@suveren/gateway 0.35.0 → 0.35.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.
@@ -6,7 +6,7 @@
6
6
  "icon": "users",
7
7
  "profile": "customers",
8
8
  "npmPackage": "@humanagencyp/crm-mcp",
9
- "npmVersion": "1.5.0",
9
+ "npmVersion": "1.6.0",
10
10
  "mcp": {
11
11
  "command": "crm-mcp",
12
12
  "args": []
@@ -26,17 +26,33 @@
26
26
  "type": "text",
27
27
  "placeholder": "simulation (default) — live needs an adapter for your CRM",
28
28
  "optional": true
29
+ },
30
+ {
31
+ "key": "liveAdapter",
32
+ "label": "Live adapter (optional)",
33
+ "type": "text",
34
+ "placeholder": "local — your own company's CRM data. Leave empty unless Mode is \"live\"",
35
+ "optional": true
29
36
  }
30
37
  ],
31
38
  "envMapping": {
32
39
  "DATABASE_URL": "databaseUrl",
33
- "CRM_MODE": "mode"
40
+ "CRM_MODE": "mode",
41
+ "CRM_LIVE_ADAPTER": "liveAdapter"
34
42
  }
35
43
  },
36
44
  "simulation": {
37
45
  "field": "mode",
38
46
  "default": "simulation"
39
47
  },
48
+ "modeLabels": {
49
+ "simulation": "Test data",
50
+ "live": { "local": "Local" }
51
+ },
52
+ "modeNotes": {
53
+ "live": { "local": "On this computer — no shared access" }
54
+ },
55
+ "adapterField": "liveAdapter",
40
56
  "personalDefault": true,
41
57
  "oauth": null,
42
58
  "context": {
@@ -6,7 +6,7 @@
6
6
  "icon": "briefcase",
7
7
  "profile": "sales",
8
8
  "npmPackage": "@humanagencyp/erp-mcp",
9
- "npmVersion": "0.9.0",
9
+ "npmVersion": "0.9.1",
10
10
  "mcp": {
11
11
  "command": "erp-mcp",
12
12
  "args": [],
@@ -62,6 +62,14 @@
62
62
  "field": "mode",
63
63
  "default": "simulation"
64
64
  },
65
+ "modeLabels": {
66
+ "simulation": "Test data",
67
+ "live": { "local": "Local" }
68
+ },
69
+ "modeNotes": {
70
+ "live": { "local": "On this computer — no shared access" }
71
+ },
72
+ "adapterField": "liveAdapter",
65
73
  "personalDefault": true,
66
74
  "oauth": null,
67
75
  "documents": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "mail",
3
- "name": "Email (simulation)",
3
+ "name": "Email",
4
4
  "version": "1.0.0",
5
5
  "description": "Simulated inbox for testing an agent's email replies — nothing leaves the laptop",
6
6
  "icon": "mail",
@@ -40,6 +40,9 @@
40
40
  "field": "mode",
41
41
  "default": "simulation"
42
42
  },
43
+ "modeLabels": {
44
+ "simulation": "Test mailbox"
45
+ },
43
46
  "personalDefault": false,
44
47
  "oauth": null,
45
48
  "attachments": {
@@ -3530,8 +3530,11 @@ function isSimulationMode() {
3530
3530
  if (policySimulation !== void 0) return policySimulation;
3531
3531
  return process.env.SUVEREN_SIMULATION === "1";
3532
3532
  }
3533
- function manifestIsSimulated(manifest) {
3534
- return Boolean(manifest?.simulation);
3533
+ function connectorCurrentlySimulated(manifest, fields) {
3534
+ const decl = manifest?.simulation;
3535
+ if (!decl) return false;
3536
+ const value = (fields?.[decl.field] ?? "").trim().toLowerCase() || decl.default.trim().toLowerCase();
3537
+ return value === "simulation";
3535
3538
  }
3536
3539
  var SIMULATION_BLOCK_REASON = "blocked: simulation mode \u2014 real systems are off";
3537
3540
 
@@ -4415,6 +4418,39 @@ function invalidManifestReadGateReason(manifest) {
4415
4418
  }
4416
4419
  return null;
4417
4420
  }
4421
+ function invalidModeLabelsReason(manifest) {
4422
+ if (!manifest.modeLabels) return null;
4423
+ if (manifest.modeLabels === null || typeof manifest.modeLabels !== "object" || Array.isArray(manifest.modeLabels)) {
4424
+ return "modeLabels must be an object mapping mode values to labels";
4425
+ }
4426
+ if (!manifest.simulation) {
4427
+ return 'modeLabels is set but this manifest declares no "simulation" marker \u2014 mode labels need a mode field to key off';
4428
+ }
4429
+ const fieldKeys = new Set(manifest.credentials?.fields?.map((f) => f.key) ?? []);
4430
+ let needsAdapterField = false;
4431
+ for (const [mode, label] of Object.entries(manifest.modeLabels)) {
4432
+ if (typeof label === "string") {
4433
+ if (label.length === 0) return `modeLabels.${mode} must be a non-empty string`;
4434
+ continue;
4435
+ }
4436
+ if (label === null || typeof label !== "object" || Array.isArray(label)) {
4437
+ return `modeLabels.${mode} must be a non-empty string or an object keyed by adapter value`;
4438
+ }
4439
+ needsAdapterField = true;
4440
+ for (const [adapter, adapterLabel] of Object.entries(label)) {
4441
+ if (typeof adapterLabel !== "string" || adapterLabel.length === 0) {
4442
+ return `modeLabels.${mode}.${adapter} must be a non-empty string`;
4443
+ }
4444
+ }
4445
+ }
4446
+ if (needsAdapterField && !manifest.adapterField) {
4447
+ return 'modeLabels has an adapter-keyed entry but "adapterField" is missing \u2014 name the credential field holding the adapter';
4448
+ }
4449
+ if (manifest.adapterField && !fieldKeys.has(manifest.adapterField)) {
4450
+ return `adapterField "${manifest.adapterField}" does not name a declared credential field`;
4451
+ }
4452
+ return null;
4453
+ }
4418
4454
  var manifests = /* @__PURE__ */ new Map();
4419
4455
  function loadManifests(integrationsDir) {
4420
4456
  const dir = resolve(
@@ -4482,6 +4518,11 @@ function loadManifests(integrationsDir) {
4482
4518
  console.error(`[ManifestLoader] Refusing manifest ${id} (${manifestPath}): ${valuesReason}`);
4483
4519
  continue;
4484
4520
  }
4521
+ const modeLabelsReason = invalidModeLabelsReason(manifest) ?? (manifest.modeNotes ? invalidModeLabelsReason({ ...manifest, modeLabels: manifest.modeNotes })?.replace(/modeLabels/g, "modeNotes") ?? null : null);
4522
+ if (modeLabelsReason) {
4523
+ console.error(`[ManifestLoader] Refusing manifest ${id} (${manifestPath}): ${modeLabelsReason}`);
4524
+ continue;
4525
+ }
4485
4526
  manifests.set(id, manifest);
4486
4527
  loaded++;
4487
4528
  } catch (err) {
@@ -4612,7 +4653,7 @@ function isAttachmentSourceAllowed(profileId, profile, allowedSourcesValue, ref)
4612
4653
  if (!attachmentSourcesFieldDeclared(profile)) {
4613
4654
  return {
4614
4655
  allowed: false,
4615
- reason: `Profile "${profileId}" does not yet declare "${ATTACHMENT_SOURCES_FIELD}" \u2014 no mandate can authorize an attachment until it does. Refusing by default (suveren-invoicing-plan.md \xA76).`
4656
+ reason: `Profile "${profileId}" does not yet declare "${ATTACHMENT_SOURCES_FIELD}" \u2014 no mandate can authorize an attachment \u2014 a mandate on a newer email profile that lists the source is needed.`
4616
4657
  };
4617
4658
  }
4618
4659
  const allowedSet = new Set(
@@ -4627,6 +4668,22 @@ function isAttachmentSourceAllowed(profileId, profile, allowedSourcesValue, ref)
4627
4668
  }
4628
4669
  return { allowed: true, reason: "" };
4629
4670
  }
4671
+ function candidatesAllowingAttachments(candidates, refs, profileOf) {
4672
+ if (refs.length === 0) return { allowed: candidates };
4673
+ let refusal;
4674
+ const allowed = candidates.filter((c) => {
4675
+ const value = typeof c.context?.[ATTACHMENT_SOURCES_FIELD] === "string" ? c.context[ATTACHMENT_SOURCES_FIELD] : void 0;
4676
+ for (const ref of refs) {
4677
+ const check = isAttachmentSourceAllowed(c.profileId, profileOf(c.profileId), value, ref);
4678
+ if (!check.allowed) {
4679
+ refusal ??= check.reason;
4680
+ return false;
4681
+ }
4682
+ }
4683
+ return true;
4684
+ });
4685
+ return { allowed, refusal: allowed.length ? void 0 : refusal };
4686
+ }
4630
4687
 
4631
4688
  // src/lib/preview.ts
4632
4689
  import { computeContentHash } from "@hap/core";
@@ -5692,18 +5749,19 @@ async function finalizeAttachmentsForExecution(integrationManager2, integrationI
5692
5749
  contentBase64: d.contentBase64
5693
5750
  }));
5694
5751
  } else {
5695
- const to = toStringArray(outgoing.to);
5696
- const cc = toStringArray(outgoing.cc);
5697
- const subject = typeof outgoing.subject === "string" ? outgoing.subject : "";
5698
- const bodyText = typeof outgoing.body === "string" ? outgoing.body : "";
5699
- const mime = buildMimeMessage({
5700
- to,
5701
- cc,
5702
- subject,
5703
- bodyText,
5704
- attachments: resolvedDocs.map((d) => ({ filename: d.filename, contentType: d.contentType, contentBase64: d.contentBase64 }))
5752
+ const arg = transport.arg;
5753
+ const attachments = resolvedDocs.map((d) => ({ filename: d.filename, contentType: d.contentType, contentBase64: d.contentBase64 }));
5754
+ const withRaw = (a) => ({
5755
+ ...a,
5756
+ [arg]: toBase64Url(buildMimeMessage({
5757
+ to: toStringArray(a.to),
5758
+ cc: toStringArray(a.cc),
5759
+ subject: typeof a.subject === "string" ? a.subject : "",
5760
+ bodyText: typeof a.body === "string" ? a.body : "",
5761
+ attachments
5762
+ }))
5705
5763
  });
5706
- outgoing[transport.arg] = toBase64Url(mime);
5764
+ return { ok: true, args: withRaw(outgoing), rebuildTransport: withRaw };
5707
5765
  }
5708
5766
  }
5709
5767
  return { ok: true, args: outgoing };
@@ -6632,7 +6690,20 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
6632
6690
  };
6633
6691
  }
6634
6692
  const contextKeys = getProfile5(passers[0].profileId)?.scopeSchema?.keyOrder ?? [];
6635
- return attemptWithCandidates(passers);
6693
+ let pool = passers;
6694
+ if (tool.gating?.attachmentsArg) {
6695
+ const raw = args[tool.gating.attachmentsArg];
6696
+ const refs = Array.isArray(raw) ? raw.filter((r) => !!r && typeof r === "object" && typeof r.connector === "string" && typeof r.kind === "string") : [];
6697
+ const narrowed = candidatesAllowingAttachments(passers, refs, (id) => getProfile5(id));
6698
+ if (narrowed.allowed.length === 0) {
6699
+ return {
6700
+ content: [{ type: "text", text: `Refused: attachment not allowed by any ${tool.gating.profile ?? ""} mandate. ${narrowed.refusal ?? ""}`.trim() }],
6701
+ isError: true
6702
+ };
6703
+ }
6704
+ pool = narrowed.allowed;
6705
+ }
6706
+ return attemptWithCandidates(pool);
6636
6707
  async function attemptWithCandidates(candidates) {
6637
6708
  const selection = selectAuthorization(
6638
6709
  contextKeys,
@@ -6725,12 +6796,14 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
6725
6796
  }
6726
6797
  }
6727
6798
  let finalArgs = callArgs;
6799
+ let rebuildTransport;
6728
6800
  if (tool.gating?.attachmentsArg || tool.gating?.insertFields?.length) {
6729
6801
  const finalized = await finalizeAttachmentsForExecution(integrationManager2, tool.integrationId, callArgs);
6730
6802
  if (!finalized.ok) {
6731
6803
  return { content: [{ type: "text", text: finalized.message }], isError: true };
6732
6804
  }
6733
6805
  finalArgs = finalized.args;
6806
+ rebuildTransport = finalized.rebuildTransport;
6734
6807
  }
6735
6808
  let receiptId;
6736
6809
  try {
@@ -6961,6 +7034,7 @@ Proposal ID: ${proposal.id}. Use check-pending-commitments to track status.` + a
6961
7034
  });
6962
7035
  let outgoingArgs = shouldAttachFooter() && receiptId ? appendVerificationFooter(tool, finalArgs, receiptId, auth.subjects?.[0]) : finalArgs;
6963
7036
  if (receiptId) outgoingArgs = attachTicketId(tool, outgoingArgs, receiptId);
7037
+ if (rebuildTransport) outgoingArgs = rebuildTransport(outgoingArgs);
6964
7038
  outgoingArgs = encodeOutgoingArgs(tool, outgoingArgs);
6965
7039
  outgoingArgs = stripSourceLink(outgoingArgs);
6966
7040
  outgoingArgs = stripAttachmentsSealedMeta(outgoingArgs);
@@ -8584,6 +8658,9 @@ function withoutBlockedArgs(schema, blocked) {
8584
8658
  }
8585
8659
  return out;
8586
8660
  }
8661
+ function stableEnvKey(env) {
8662
+ return JSON.stringify(Object.keys(env).sort().map((k) => [k, env[k]]));
8663
+ }
8587
8664
  var RESPAWN_DELAYS = [2e3, 4e3, 6e3];
8588
8665
  var MAX_RESPAWN_ATTEMPTS = 3;
8589
8666
  var CONNECT_TIMEOUT_MS = 3e4;
@@ -8899,7 +8976,7 @@ var IntegrationManager = class {
8899
8976
  if (opts.skipIfRunning) return existing.tools;
8900
8977
  await this.stopIntegrationLocked(config.id);
8901
8978
  }
8902
- if (isSimulationMode() && !manifestIsSimulated(getManifest(config.id))) {
8979
+ if (isSimulationMode() && !this.isSimulationSafe(config.id)) {
8903
8980
  throw new Error(`${config.id}: ${SIMULATION_BLOCK_REASON}`);
8904
8981
  }
8905
8982
  if (config.npmPackage) {
@@ -8925,6 +9002,7 @@ var IntegrationManager = class {
8925
9002
  );
8926
9003
  }
8927
9004
  const env = this.resolveEnvKeys(config);
9005
+ const credentialEnv = stableEnvKey(env);
8928
9006
  if (isSimulationMode()) {
8929
9007
  const manifest = getManifest(config.id);
8930
9008
  const simDecl = manifest?.simulation;
@@ -8998,7 +9076,8 @@ var IntegrationManager = class {
8998
9076
  client,
8999
9077
  transport,
9000
9078
  tools,
9001
- respawnAttempts: 0
9079
+ respawnAttempts: 0,
9080
+ credentialEnv
9002
9081
  };
9003
9082
  this.running.set(config.id, entry);
9004
9083
  transport.onclose = () => {
@@ -9119,13 +9198,24 @@ var IntegrationManager = class {
9119
9198
  return this.builtins.get(id)?.def.alwaysListed === true;
9120
9199
  }
9121
9200
  /**
9122
- * Whether `id` may run while simulation mode is on: a connector whose manifest
9123
- * carries the `simulation` marker, or a built-in that declares `simulation: true`.
9201
+ * Whether `id` may run while simulation mode is on: a built-in that
9202
+ * declares `simulation: true`, or a connector whose CURRENT setting (the
9203
+ * credential/setting field its manifest's `simulation.field` names,
9204
+ * defaulting per `simulation.default` when unset) resolves to simulated —
9205
+ * NOT merely a connector whose manifest supports a simulated mode at all.
9206
+ * A connector set to a real mode (e.g. ERP's `mode=live`) is refused here
9207
+ * exactly like one with no simulation capability at all.
9208
+ *
9209
+ * Reads `serviceCredentials` directly, keyed by `id` — the same convention
9210
+ * `resolveEnvKeys`'s manifest-mapping fallback and `getServiceCredentials`
9211
+ * already rely on: a connector's OWN credential fields (mode, liveAdapter,
9212
+ * …) are pushed to this gateway under its own id (control-plane's
9213
+ * `pushServiceCredentials(id, fields)`), never a shared/other service id.
9124
9214
  */
9125
9215
  isSimulationSafe(id) {
9126
9216
  const builtin = this.builtins.get(id);
9127
9217
  if (builtin) return builtin.def.simulation === true;
9128
- return manifestIsSimulated(getManifest(id));
9218
+ return connectorCurrentlySimulated(getManifest(id), this.serviceCredentials.get(id));
9129
9219
  }
9130
9220
  /**
9131
9221
  * The built-in's own refusal for this call, checked before the gate (no proposal,
@@ -9287,15 +9377,17 @@ var IntegrationManager = class {
9287
9377
  toolCount: 0,
9288
9378
  readAgeDays: readAgeOf(config),
9289
9379
  // Computed fresh, not cached: true whenever simulation mode is on
9290
- // AND this connector has no manifest `simulation` marker, which is
9291
- // exactly the condition startIntegrationLocked refuses under — so
9292
- // the UI always reflects why a real connector never started,
9293
- // without needing a separate tracking path for this one reason.
9294
- // This is `paused`, NOT `error`: it is not a problem, it's the
9295
- // mode working as designed (see IntegrationStatus.paused). Genuine
9380
+ // AND this connector is not currently simulation-safe (see
9381
+ // `isSimulationSafe` — no capability at all, OR capability
9382
+ // declared but currently SET to a real mode), which is exactly
9383
+ // the condition startIntegrationLocked refuses under — so the UI
9384
+ // always reflects why a real connector never started, without
9385
+ // needing a separate tracking path for this one reason. This is
9386
+ // `paused`, NOT `error`: it is not a problem, it's the mode
9387
+ // working as designed (see IntegrationStatus.paused). Genuine
9296
9388
  // start failures still surface via the one-shot `warning` string
9297
9389
  // returned by /internal/add-integration — they never land here.
9298
- paused: isSimulationMode() && !manifestIsSimulated(getManifest(config.id)) ? "simulation" : void 0
9390
+ paused: isSimulationMode() && !this.isSimulationSafe(config.id) ? "simulation" : void 0
9299
9391
  });
9300
9392
  }
9301
9393
  }
@@ -9308,6 +9400,21 @@ var IntegrationManager = class {
9308
9400
  isRunning(id) {
9309
9401
  return this.running.has(id);
9310
9402
  }
9403
+ /**
9404
+ * True when the integration is running but the vault credentials would now
9405
+ * resolve to a different env than the one its process was started with.
9406
+ *
9407
+ * Why: at boot the gateway starts connectors whose settings are all optional
9408
+ * (ERP, CRM — mode/adapter/company file) BEFORE the vault is unlocked, so they
9409
+ * come up on their defaults (simulation). The settings arrive on unlock, and
9410
+ * without a restart the process keeps running on the wrong mode — the user's
9411
+ * live data silently disappears from view.
9412
+ */
9413
+ credentialsChangedSinceStart(config) {
9414
+ const entry = this.running.get(config.id);
9415
+ if (!entry) return false;
9416
+ return stableEnvKey(this.resolveEnvKeys(config)) !== entry.credentialEnv;
9417
+ }
9311
9418
  /**
9312
9419
  * Check if all required env keys for an integration can be resolved.
9313
9420
  */
@@ -9474,7 +9581,8 @@ var IntegrationManager = class {
9474
9581
  client: null,
9475
9582
  transport: null,
9476
9583
  tools: [],
9477
- respawnAttempts: attempts + 1
9584
+ respawnAttempts: attempts + 1,
9585
+ credentialEnv: ""
9478
9586
  };
9479
9587
  this.running.set(id, fakeEntry);
9480
9588
  this.handleCrash(id);
@@ -10354,8 +10462,9 @@ function briefRefusal(args) {
10354
10462
  function connectedSystems(im) {
10355
10463
  const byId = /* @__PURE__ */ new Map();
10356
10464
  for (const t of im.getAllTools()) {
10357
- if (im.isBuiltin(t.integrationId) || !t.gating?.profile || t.gating.category === "disabled") continue;
10358
- const s = byId.get(t.integrationId) ?? { id: t.integrationId, profile: t.gating.profile, actionTypes: [], writeTools: [], readTools: [] };
10465
+ if (im.isBuiltin(t.integrationId) || t.internal || !t.gating?.profile || t.gating.category === "disabled") continue;
10466
+ const key = `${t.integrationId}|${t.gating.profile}`;
10467
+ const s = byId.get(key) ?? { id: t.integrationId, profile: t.gating.profile, actionTypes: [], writeTools: [], readTools: [] };
10359
10468
  if (t.gating.category === "read") {
10360
10469
  s.readTools.push(t.originalName);
10361
10470
  } else {
@@ -10363,9 +10472,9 @@ function connectedSystems(im) {
10363
10472
  const at = t.gating.staticExecution?.action_type;
10364
10473
  if (typeof at === "string" && !s.actionTypes.includes(at)) s.actionTypes.push(at);
10365
10474
  }
10366
- byId.set(t.integrationId, s);
10475
+ byId.set(key, s);
10367
10476
  }
10368
- return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
10477
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id) || a.profile.localeCompare(b.profile));
10369
10478
  }
10370
10479
  function shortName(idOrName) {
10371
10480
  const stripped = idOrName.replace(/@.*$/, "").split("/").pop() ?? idOrName;
@@ -10586,7 +10695,7 @@ function setupBuiltin(deps) {
10586
10695
  const guides = loadGuides();
10587
10696
  const systems = connectedSystems(deps.integrationManager);
10588
10697
  const running = new Set(systems.map((s) => s.id));
10589
- const inactive = getAllManifests().filter((m) => manifestIsSimulated(m) && !running.has(m.id)).map((m) => m.name);
10698
+ const inactive = getAllManifests().filter((m) => deps.integrationManager.isSimulationSafe(m.id) && !running.has(m.id)).map((m) => m.name);
10590
10699
  const header = guideHeader(systems, inactive);
10591
10700
  const topic = typeof args.topic === "string" ? args.topic.trim().toLowerCase() : "";
10592
10701
  if (!topic) {
@@ -11624,11 +11733,16 @@ async function startIntegrationForService(serviceId) {
11624
11733
  (c) => c.id === serviceId || Object.values(c.envKeys ?? {}).some((ref) => typeof ref === "string" && ref.startsWith(`${serviceId}.`))
11625
11734
  );
11626
11735
  for (const config of candidates) {
11736
+ if (integrationManager.isRunning(config.id) && integrationManager.credentialsChangedSinceStart(config)) {
11737
+ console.error(`[Suveren MCP] ${config.id}: credentials changed since start \u2014 restarting`);
11738
+ await startOneIntegration(config, { restart: true });
11739
+ continue;
11740
+ }
11627
11741
  await startOneIntegration(config);
11628
11742
  }
11629
11743
  }
11630
- async function startOneIntegration(config) {
11631
- if (integrationManager.isRunning(config.id)) return;
11744
+ async function startOneIntegration(config, opts = {}) {
11745
+ if (!opts.restart && integrationManager.isRunning(config.id)) return;
11632
11746
  const needsCreds = Object.keys(config.envKeys ?? {}).length > 0;
11633
11747
  if (needsCreds && !integrationManager.canResolveEnvKeys(config)) {
11634
11748
  const missing = [];
@@ -11647,7 +11761,7 @@ async function startOneIntegration(config) {
11647
11761
  const manifest = getManifest(config.id);
11648
11762
  const effectiveConfig = manifest ? { ...config, toolGating: manifest.toolGating, npmPackage: manifest.npmPackage ?? config.npmPackage } : config;
11649
11763
  try {
11650
- await integrationManager.startIntegration(effectiveConfig, { skipIfRunning: true });
11764
+ await integrationManager.startIntegration(effectiveConfig, { skipIfRunning: !opts.restart });
11651
11765
  } catch (err) {
11652
11766
  console.error(`[Suveren MCP] Failed to start integration ${config.id}:`, err);
11653
11767
  }