@suveren/gateway 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -185,6 +185,42 @@ See `suveren-gateway config help` for the full command reference.
185
185
 
186
186
  ---
187
187
 
188
+ ## Simulation mode — block every real system
189
+
190
+ A mandate is bound to a *profile* (e.g. "sales"), not to a specific connector. If a real
191
+ connector (e.g. a live ERP account) and a simulated one (the built-in ERP/CRM/email
192
+ simulators) share a profile on one gateway, a mandate meant only for testing also
193
+ authorizes the real connector — nothing about the mandate says which one it's for.
194
+
195
+ Simulation mode closes that gap gateway-wide: turn it on and **every connector without a
196
+ manifest `simulation` marker is refused to even start** (the built-in ERP, CRM, and email
197
+ simulators all declare one; a connected Gmail or live ERP account does not), and every
198
+ connector that does declare one has its mode forced to `simulation`, overriding whatever
199
+ credential value is on file. Mandates and profiles are completely unaffected, and the
200
+ Authority Server never learns this is on — it's a purely local, gateway-side switch.
201
+
202
+ ```bash
203
+ # Start fresh with real systems blocked:
204
+ suveren-gateway start --simulation
205
+
206
+ # Or flip it on an existing install (takes effect on the next start/restart):
207
+ suveren-gateway simulation on
208
+ suveren-gateway restart
209
+
210
+ # Check the mode (the SAVED setting and, if the gateway is running, the LIVE one):
211
+ suveren-gateway simulation status
212
+
213
+ # Turning it off makes real systems reachable again, so it requires typed
214
+ # confirmation (or --confirm live for scripts) — turning it ON does not:
215
+ suveren-gateway simulation off
216
+ suveren-gateway restart
217
+ ```
218
+
219
+ `suveren-gateway status` also shows the current mode. See `suveren-gateway simulation help`
220
+ for the full command reference.
221
+
222
+ ---
223
+
188
224
  ## Technical Documentation
189
225
 
190
226
  | Document | Contents |
@@ -17,7 +17,8 @@ import { homedir, platform, userInfo } from 'node:os';
17
17
  import { dirname, join, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { buildLaunchAgentPlist, buildMacLauncher, buildSystemdUnit, buildWindowsTaskXml } from '../lib/autostart-templates.mjs';
20
- import { DEFAULT_AS_URL, readConfig, writeConfig, validateAsUrl, validateCaFile, validatePinTls, resolveAsUrl, resolvePinTls, resolvePinTlsExpectedFingerprint } from '../lib/config.mjs';
20
+ import { DEFAULT_AS_URL, readConfig, writeConfig, validateAsUrl, validateCaFile, validatePinTls, resolveAsUrl, resolvePinTls, resolvePinTlsExpectedFingerprint, resolveSimulation } from '../lib/config.mjs';
21
+ import { createInterface } from 'node:readline/promises';
21
22
  import { readPairing as readAsPairing, recordTlsPin, formatFingerprint, normalizeFingerprint } from '../lib/as-pairing.mjs';
22
23
 
23
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -210,6 +211,13 @@ async function start(args) {
210
211
  pinTlsToSave = true;
211
212
  }
212
213
 
214
+ // --simulation: the safe direction (turning OFF real systems), so — unlike
215
+ // `simulation off` — this needs no confirmation. Only ever turns it ON;
216
+ // there is no `start --no-simulation` (use `simulation off` for that, which
217
+ // DOES require confirmation, since that direction makes real systems
218
+ // reachable again).
219
+ const simulationFlag = args.includes('--simulation');
220
+
213
221
  if (await isAlreadyRunning()) {
214
222
  console.error(`suveren-gateway is already running (pid ${readPid()}). Use \`suveren-gateway stop\` first or \`suveren-gateway restart\`.`);
215
223
  process.exit(1);
@@ -269,6 +277,10 @@ async function start(args) {
269
277
  : ` Staged — the first sign-in's challenge must match it (refused otherwise); it then becomes the permanent pin.`,
270
278
  );
271
279
  }
280
+ if (simulationFlag) {
281
+ writeConfig(DATA_DIR, { simulation: true });
282
+ console.log(`Simulation mode: ON (saved) — every connector without a manifest "simulation" marker will be blocked.`);
283
+ }
272
284
 
273
285
  if (detach) {
274
286
  const out = openSync(LOG_FILE, 'a');
@@ -414,6 +426,7 @@ async function status() {
414
426
  console.log('suveren-gateway: running (managed by the login service)');
415
427
  console.log(` UI: http://localhost:${SUVEREN_PORT}`);
416
428
  console.log(` Vault: ${body.vaultUnlocked ? 'unlocked' : 'locked'}`);
429
+ console.log(` Simulation: ${body.simulation ? 'on — real systems are blocked' : 'off'}`);
417
430
  console.log(` Version: ${body.version ?? 'unknown'} (running)`);
418
431
  console.log(` Service: suveren-gateway service status`);
419
432
  return;
@@ -438,6 +451,7 @@ async function status() {
438
451
  console.log(`suveren-gateway: running (pid ${pid})`);
439
452
  console.log(` UI: http://localhost:${SUVEREN_PORT}`);
440
453
  console.log(` Vault: ${body.vaultUnlocked ? 'unlocked' : 'locked'}`);
454
+ console.log(` Simulation: ${body.simulation ? 'on — real systems are blocked' : 'off'}`);
441
455
  console.log(` Version: ${body.version ?? 'unknown'} (running)`);
442
456
  if (CLI_VERSION) console.log(` ${CLI_VERSION} (installed CLI)`);
443
457
  if (CLI_VERSION && body.version && body.version !== CLI_VERSION && body.version !== 'dev') {
@@ -634,6 +648,102 @@ async function config(args) {
634
648
  if (sub !== undefined && sub !== 'help' && sub !== '--help' && sub !== '-h') process.exit(2);
635
649
  }
636
650
 
651
+ // ─── simulation: gateway-wide switch that blocks every real system ──────
652
+ //
653
+ // A mandate is bound to a PROFILE, not a connector — see suveren-gateway's
654
+ // tool-proxy.ts (`profileMatches`). Put a real connector and a simulated one
655
+ // on the same profile, and one mandate authorizes both. Simulation mode
656
+ // closes that generically: ON blocks every connector whose manifest carries
657
+ // no `simulation` marker, and forces every connector that DOES have one into
658
+ // its simulated mode, regardless of what credential value is on file.
659
+ // Mandates and profiles are unaffected; the Authority Server never learns
660
+ // about this — it is a purely local, gateway-side switch. See
661
+ // apps/mcp-server/src/lib/simulation-mode.ts for enforcement.
662
+
663
+ function printSimulationHelp() {
664
+ console.log(`suveren-gateway simulation — gateway-wide switch that blocks every real system
665
+
666
+ Usage:
667
+ suveren-gateway simulation on Block every real connector (no confirmation — the safe direction)
668
+ suveren-gateway simulation off Make real systems reachable again (requires confirmation)
669
+ [--confirm live] Non-interactive confirmation
670
+ suveren-gateway simulation status Show the saved setting and (if running) the live one
671
+
672
+ Changes take effect on the next \`suveren-gateway start\` / \`restart\`.
673
+ `);
674
+ }
675
+
676
+ async function printSimulationStatus() {
677
+ const saved = resolveSimulation(DATA_DIR);
678
+ console.log(`Saved: ${saved ? 'on' : 'off'}`);
679
+ try {
680
+ const res = await fetch(`http://localhost:${SUVEREN_PORT}/health`, { signal: AbortSignal.timeout(3000) });
681
+ if (res.ok) {
682
+ const body = await res.json();
683
+ const running = body.simulation === true;
684
+ console.log(`Running: ${running ? 'on' : 'off'}${running !== saved ? ' (restart to apply the saved value)' : ''}`);
685
+ }
686
+ } catch {
687
+ // Not reachable — the saved value is all there is to report.
688
+ }
689
+ }
690
+
691
+ /** Read one line from stdin, or undefined if there is no TTY to prompt on
692
+ * (so a non-interactive caller gets a clean abort instead of a hang). */
693
+ async function promptLine(question) {
694
+ if (!process.stdin.isTTY) return undefined;
695
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
696
+ try {
697
+ return (await rl.question(question)).trim();
698
+ } finally {
699
+ rl.close();
700
+ }
701
+ }
702
+
703
+ async function simulation(args) {
704
+ const sub = args[0] ?? 'status';
705
+
706
+ if (sub === 'help' || sub === '--help' || sub === '-h') { printSimulationHelp(); return; }
707
+
708
+ if (sub === 'status') {
709
+ await printSimulationStatus();
710
+ return;
711
+ }
712
+
713
+ if (sub === 'on') {
714
+ // The safe direction — blocking real systems needs no confirmation.
715
+ writeConfig(DATA_DIR, { simulation: true });
716
+ console.log('Simulation mode: ON (saved) — every connector without a manifest "simulation" marker will be blocked.');
717
+ console.log('Restart to pick it up: suveren-gateway restart');
718
+ return;
719
+ }
720
+
721
+ if (sub === 'off') {
722
+ console.error('Turning simulation mode OFF makes every real system reachable again: any connector');
723
+ console.error('without a manifest "simulation" marker (e.g. a live Gmail or ERP account) will spawn');
724
+ console.error('and execute for real the next time this gateway starts.');
725
+ console.error('');
726
+ let typed = flagValue(args, '--confirm');
727
+ if (typed === undefined) {
728
+ typed = await promptLine('Type "live" to confirm, or anything else to abort: ');
729
+ }
730
+ if (typed !== 'live') {
731
+ console.error(typed === undefined
732
+ ? 'No TTY to confirm on and no --confirm given — aborted. Simulation mode unchanged.'
733
+ : 'Aborted — simulation mode unchanged.');
734
+ process.exit(1);
735
+ }
736
+ writeConfig(DATA_DIR, { simulation: false });
737
+ console.log('Simulation mode: OFF (saved) — real systems become reachable on next restart.');
738
+ console.log('Restart to pick it up: suveren-gateway restart');
739
+ return;
740
+ }
741
+
742
+ console.error(`Unknown: simulation ${sub}\n`);
743
+ printSimulationHelp();
744
+ process.exit(2);
745
+ }
746
+
637
747
  // ─── service: install autostart-on-login (survives reboot) ──────────────
638
748
  //
639
749
  // Keeps the gateway PROCESS always running (starts on login, restarts on
@@ -1139,9 +1249,10 @@ Usage:
1139
1249
  [--as-url <url>] Authority Server URL (saved for next time)
1140
1250
  [--ca-file <path>] Internal CA bundle (saved for next time)
1141
1251
  [--pin-tls --expect-fingerprint <hex>] Pin the AS TLS certificate (saved; https:// only — see below)
1252
+ [--simulation] Block every real system (saved — see \`simulation help\`)
1142
1253
  suveren-gateway stop Stop a detached gateway
1143
1254
  suveren-gateway restart Stop, then start --detach
1144
- suveren-gateway status Show running state + health
1255
+ suveren-gateway status Show running state + health (incl. simulation mode)
1145
1256
  suveren-gateway logs [--tail] Print or tail ~/.suveren/gateway.log
1146
1257
  suveren-gateway service <cmd> Run as a login service that survives reboot
1147
1258
  (install | uninstall | status)
@@ -1152,6 +1263,8 @@ Usage:
1152
1263
  Pin the Authority Server's TLS certificate
1153
1264
  (see \`suveren-gateway config help\` for the fingerprint check)
1154
1265
  suveren-gateway config set pin-tls off Stop enforcing the TLS certificate pin
1266
+ suveren-gateway simulation on|off|status Block (or unblock) every real system
1267
+ (see \`suveren-gateway simulation help\`)
1155
1268
  suveren-gateway help Print this help
1156
1269
 
1157
1270
  Environment:
@@ -1159,6 +1272,7 @@ Environment:
1159
1272
  SUVEREN_MCP_PORT MCP server port (default 3430)
1160
1273
  SUVEREN_DATA_DIR Data directory (default ~/.suveren)
1161
1274
  SUVEREN_AS_URL Authority Server URL — overrides the saved as-url
1275
+ SUVEREN_SIMULATION 1 to force simulation mode for this run — overrides the saved setting
1162
1276
 
1163
1277
  Authority Server resolution order: --as-url flag > SUVEREN_AS_URL env >
1164
1278
  saved as-url (\`config set as-url\`) > default (${DEFAULT_AS_URL}).
@@ -1181,6 +1295,7 @@ async function main() {
1181
1295
  case 'logs': await logs(argv.slice(1)); break;
1182
1296
  case 'service': await service(argv.slice(1)); break;
1183
1297
  case 'config': await config(argv.slice(1)); break;
1298
+ case 'simulation': await simulation(argv.slice(1)); break;
1184
1299
  case 'help':
1185
1300
  case '--help':
1186
1301
  case '-h':
@@ -137,7 +137,8 @@
137
137
  "contact_type": "customer",
138
138
  "action_type": "setup"
139
139
  },
140
- "actionLabel": "Test data loaded"
140
+ "actionLabel": "Test data loaded",
141
+ "hideUnlessAuthorized": true
141
142
  }
142
143
  }
143
144
  },
@@ -133,7 +133,8 @@
133
133
  "staticExecution": {
134
134
  "action_type": "setup"
135
135
  },
136
- "actionLabel": "Test data loaded"
136
+ "actionLabel": "Test data loaded",
137
+ "hideUnlessAuthorized": true
137
138
  }
138
139
  }
139
140
  },
@@ -81,7 +81,8 @@
81
81
  "staticExecution": {
82
82
  "action_type": "setup"
83
83
  },
84
- "actionLabel": "Test data loaded"
84
+ "actionLabel": "Test data loaded",
85
+ "hideUnlessAuthorized": true
85
86
  }
86
87
  }
87
88
  },
@@ -3545,6 +3545,11 @@ function selectDenials(records, opts = {}) {
3545
3545
  return { count, records: out };
3546
3546
  }
3547
3547
 
3548
+ // src/lib/simulation-mode.ts
3549
+ function isSimulationMode() {
3550
+ return process.env.SUVEREN_SIMULATION === "1";
3551
+ }
3552
+
3548
3553
  // src/index.ts
3549
3554
  (function migrateDataDir() {
3550
3555
  if (process.env.SUVEREN_DATA_DIR) return;
@@ -4159,6 +4164,9 @@ app.get("/health", async (req, res) => {
4159
4164
  updateAvailable: update.updateAvailable,
4160
4165
  installMethod: INSTALL_METHOD,
4161
4166
  spUrl: SP_URL,
4167
+ // Purely local — the Authority Server never learns this. UI reads it to
4168
+ // show a gateway-wide banner ("real systems are blocked").
4169
+ simulation: isSimulationMode(),
4162
4170
  session: buildSessionHealth(vault),
4163
4171
  security: {
4164
4172
  note: "Gateway secures tool execution. Agent host isolation is the user's responsibility."
@@ -2599,10 +2599,62 @@ function clampAgeFloor(requested, maxAgeDays, nowMs, format = "iso") {
2599
2599
  }
2600
2600
 
2601
2601
  // src/lib/tool-proxy.ts
2602
- import { getProfile as getProfile3, ContentBindingError } from "@hap/core";
2602
+ import { getProfile as getProfile3, ContentBindingError, boundActionTypes as boundActionTypes2 } from "@hap/core";
2603
2603
  import { readFile } from "fs/promises";
2604
2604
  import { extname } from "path";
2605
2605
  import { randomUUID } from "crypto";
2606
+
2607
+ // src/lib/manifest-loader.ts
2608
+ import { readFileSync as readFileSync12, existsSync as existsSync11 } from "fs";
2609
+ import { join as join11, resolve } from "path";
2610
+ var manifests = /* @__PURE__ */ new Map();
2611
+ function loadManifests(integrationsDir) {
2612
+ const dir = resolve(
2613
+ integrationsDir ?? process.env.SUVEREN_MANIFESTS_DIR ?? join11(import.meta.dirname ?? __dirname, "..", "..", "..", "..", "content", "integrations")
2614
+ );
2615
+ const indexPath = join11(dir, "index.json");
2616
+ if (!existsSync11(indexPath)) {
2617
+ console.error(`[ManifestLoader] No index.json found at ${indexPath}, skipping manifest loading`);
2618
+ return 0;
2619
+ }
2620
+ let index;
2621
+ try {
2622
+ index = JSON.parse(readFileSync12(indexPath, "utf-8"));
2623
+ } catch (err) {
2624
+ console.error(`[ManifestLoader] Failed to parse ${indexPath}:`, err);
2625
+ return 0;
2626
+ }
2627
+ let loaded = 0;
2628
+ for (const [id, relativePath] of Object.entries(index.integrations)) {
2629
+ const manifestPath = join11(dir, relativePath);
2630
+ try {
2631
+ const manifest = JSON.parse(readFileSync12(manifestPath, "utf-8"));
2632
+ manifests.set(id, manifest);
2633
+ loaded++;
2634
+ } catch (err) {
2635
+ console.error(`[ManifestLoader] Failed to load manifest ${id} from ${manifestPath}:`, err);
2636
+ }
2637
+ }
2638
+ console.error(`[ManifestLoader] Loaded ${loaded} integration manifest(s) from ${dir}`);
2639
+ return loaded;
2640
+ }
2641
+ function getManifest(id) {
2642
+ return manifests.get(id);
2643
+ }
2644
+ function getAllManifests() {
2645
+ return Array.from(manifests.values());
2646
+ }
2647
+
2648
+ // src/lib/simulation-mode.ts
2649
+ function isSimulationMode() {
2650
+ return process.env.SUVEREN_SIMULATION === "1";
2651
+ }
2652
+ function manifestIsSimulated(manifest) {
2653
+ return Boolean(manifest?.simulation);
2654
+ }
2655
+ var SIMULATION_BLOCK_REASON = "blocked: simulation mode \u2014 real systems are off";
2656
+
2657
+ // src/lib/tool-proxy.ts
2606
2658
  var IMAGE_MIME = {
2607
2659
  ".jpg": "image/jpeg",
2608
2660
  ".jpeg": "image/jpeg",
@@ -2662,6 +2714,38 @@ function applyMapping(m, value, execution) {
2662
2714
  function profileMatches(profileId, shortName) {
2663
2715
  return profileId === shortName || profileId.includes("/" + shortName + "@") || profileId.endsWith("/" + shortName);
2664
2716
  }
2717
+ function countBoundsFor(profileId, actionType) {
2718
+ const fields = getProfile3(profileId)?.boundsSchema?.fields;
2719
+ if (!fields) return null;
2720
+ return Object.entries(fields).filter(([, def]) => def.boundType?.kind === "cumulative_count").filter(([name, def]) => {
2721
+ const applies = boundActionTypes2(name, def);
2722
+ return applies === void 0 || applies.includes(actionType);
2723
+ }).map(([name]) => name);
2724
+ }
2725
+ function zeroCappedBound(profileId, bounds, actionType) {
2726
+ if (typeof actionType !== "string") return null;
2727
+ const names = countBoundsFor(profileId, actionType);
2728
+ if (!names) return null;
2729
+ for (const n of names) {
2730
+ const v = bounds?.[n];
2731
+ if (v !== void 0 && v !== "" && Number(v) <= 0) return n;
2732
+ }
2733
+ return null;
2734
+ }
2735
+ function toolIsAuthorizedForDisplay(tool, matchingAuths) {
2736
+ if (!tool.gating?.hideUnlessAuthorized) return true;
2737
+ if (matchingAuths.length === 0) return false;
2738
+ const profileId = tool.gating.profile;
2739
+ const actionType = tool.gating.staticExecution?.action_type;
2740
+ if (!profileId || typeof actionType !== "string") return false;
2741
+ const requiredFields = countBoundsFor(profileId, actionType);
2742
+ if (!requiredFields) return false;
2743
+ if (requiredFields.length === 0) return true;
2744
+ return matchingAuths.some((a) => {
2745
+ const bounds = a.bounds ?? a.frame;
2746
+ return requiredFields.every((f) => Number(bounds?.[f] ?? 0) > 0);
2747
+ });
2748
+ }
2665
2749
  function parseFirstJson(result) {
2666
2750
  const text = result.content?.find((c) => c.type === "text")?.text;
2667
2751
  if (typeof text !== "string") return void 0;
@@ -2687,6 +2771,15 @@ function denyRead(state2, tool, reason, detail, target) {
2687
2771
  return { content: [{ type: "text", text: `Read blocked by Gatekeeper: ${detail}` }], isError: true };
2688
2772
  }
2689
2773
  function createGatedToolHandler(tool, integrationManager2, state2) {
2774
+ if (isSimulationMode() && !manifestIsSimulated(getManifest(tool.integrationId))) {
2775
+ return async () => ({
2776
+ content: [{
2777
+ type: "text",
2778
+ text: `Refused: "${tool.namespacedName}" talks to a real system, but this gateway is running in simulation mode (real systems are off). No ticket was requested.`
2779
+ }],
2780
+ isError: true
2781
+ });
2782
+ }
2690
2783
  const gated = createGatedToolHandlerInner(tool, integrationManager2, state2);
2691
2784
  const inner = async (args) => gated(normalizeIncomingArgs(tool, args));
2692
2785
  const blocked = tool.gating?.blockedArgs ?? [];
@@ -2962,7 +3055,10 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
2962
3055
  bounds: candidate.bounds,
2963
3056
  context: candidate.context
2964
3057
  });
2965
- if (result.approved) {
3058
+ const capped = result.approved ? zeroCappedBound(candidate.profileId, candidate.bounds ?? candidate.frame, typeof execution.action_type === "string" ? execution.action_type : void 0) : null;
3059
+ if (result.approved && capped) {
3060
+ errors.push(`${candidate.path}: ${capped} is 0 \u2014 this mandate does not allow "${execution.action_type}"`);
3061
+ } else if (result.approved) {
2966
3062
  passers.push(candidate);
2967
3063
  } else {
2968
3064
  const reasons = result.errors.map((e) => {
@@ -3998,9 +4094,10 @@ function createMcpServer(state2, integrationManager2) {
3998
4094
  const description = buildProxiedToolDescription(tool, state2);
3999
4095
  registered.update({ description });
4000
4096
  if (tool.gating?.profile) {
4001
- const hasAuth = auths.some(
4097
+ const matchingAuths = auths.filter(
4002
4098
  (a) => a.complete && profileMatches(a.profileId, tool.gating.profile)
4003
4099
  );
4100
+ const hasAuth = matchingAuths.length > 0 && toolIsAuthorizedForDisplay(tool, matchingAuths);
4004
4101
  if (hasAuth) registered.enable();
4005
4102
  else registered.disable();
4006
4103
  } else {
@@ -4041,16 +4138,16 @@ function verifyGateContentHashes(content, auth) {
4041
4138
  }
4042
4139
 
4043
4140
  // src/lib/integration-registry.ts
4044
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync9, existsSync as existsSync11 } from "fs";
4141
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync9, existsSync as existsSync12 } from "fs";
4045
4142
  import { homedir as homedir8 } from "os";
4046
- import { dirname as dirname9, join as join11 } from "path";
4047
- var DEFAULT_DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join11(homedir8(), ".suveren");
4143
+ import { dirname as dirname9, join as join12 } from "path";
4144
+ var DEFAULT_DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join12(homedir8(), ".suveren");
4048
4145
  var IntegrationRegistry = class {
4049
4146
  integrations = /* @__PURE__ */ new Map();
4050
4147
  filePath;
4051
4148
  constructor(dataDir2) {
4052
4149
  const dir = dataDir2 ?? DEFAULT_DATA_DIR;
4053
- this.filePath = join11(dir, "integrations.json");
4150
+ this.filePath = join12(dir, "integrations.json");
4054
4151
  this.load();
4055
4152
  }
4056
4153
  // ─── CRUD ───────────────────────────────────────────────────────────────
@@ -4081,12 +4178,12 @@ var IntegrationRegistry = class {
4081
4178
  }
4082
4179
  // ─── Load / Save ────────────────────────────────────────────────────────
4083
4180
  load() {
4084
- if (!existsSync11(this.filePath)) {
4181
+ if (!existsSync12(this.filePath)) {
4085
4182
  mkdirSync9(dirname9(this.filePath), { recursive: true });
4086
4183
  return;
4087
4184
  }
4088
4185
  try {
4089
- const raw = readFileSync12(this.filePath, "utf-8");
4186
+ const raw = readFileSync13(this.filePath, "utf-8");
4090
4187
  const data = JSON.parse(raw);
4091
4188
  const migrated = data.integrations.map((i) => {
4092
4189
  if ("toolGating" in i && !("profile" in i)) {
@@ -4129,47 +4226,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4129
4226
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4130
4227
  import { getProfile as getProfile5 } from "@hap/core";
4131
4228
 
4132
- // src/lib/manifest-loader.ts
4133
- import { readFileSync as readFileSync13, existsSync as existsSync12 } from "fs";
4134
- import { join as join12, resolve } from "path";
4135
- var manifests = /* @__PURE__ */ new Map();
4136
- function loadManifests(integrationsDir) {
4137
- const dir = resolve(
4138
- integrationsDir ?? process.env.SUVEREN_MANIFESTS_DIR ?? join12(import.meta.dirname ?? __dirname, "..", "..", "..", "..", "content", "integrations")
4139
- );
4140
- const indexPath = join12(dir, "index.json");
4141
- if (!existsSync12(indexPath)) {
4142
- console.error(`[ManifestLoader] No index.json found at ${indexPath}, skipping manifest loading`);
4143
- return 0;
4144
- }
4145
- let index;
4146
- try {
4147
- index = JSON.parse(readFileSync13(indexPath, "utf-8"));
4148
- } catch (err) {
4149
- console.error(`[ManifestLoader] Failed to parse ${indexPath}:`, err);
4150
- return 0;
4151
- }
4152
- let loaded = 0;
4153
- for (const [id, relativePath] of Object.entries(index.integrations)) {
4154
- const manifestPath = join12(dir, relativePath);
4155
- try {
4156
- const manifest = JSON.parse(readFileSync13(manifestPath, "utf-8"));
4157
- manifests.set(id, manifest);
4158
- loaded++;
4159
- } catch (err) {
4160
- console.error(`[ManifestLoader] Failed to load manifest ${id} from ${manifestPath}:`, err);
4161
- }
4162
- }
4163
- console.error(`[ManifestLoader] Loaded ${loaded} integration manifest(s) from ${dir}`);
4164
- return loaded;
4165
- }
4166
- function getManifest(id) {
4167
- return manifests.get(id);
4168
- }
4169
- function getAllManifests() {
4170
- return Array.from(manifests.values());
4171
- }
4172
-
4173
4229
  // src/lib/remote-auth-preflight.ts
4174
4230
  var AUTH_HEADER_RE = /^authorization:\s*(.+)$/i;
4175
4231
  function remotePreflightTarget(command, args) {
@@ -4477,10 +4533,21 @@ var IntegrationManager = class {
4477
4533
  if (opts.skipIfRunning) return existing.tools;
4478
4534
  await this.stopIntegrationLocked(config.id);
4479
4535
  }
4536
+ if (isSimulationMode() && !manifestIsSimulated(getManifest(config.id))) {
4537
+ throw new Error(`${config.id}: ${SIMULATION_BLOCK_REASON}`);
4538
+ }
4480
4539
  if (config.npmPackage) {
4481
4540
  await this.ensureInstalled(config.npmPackage);
4482
4541
  }
4483
4542
  const env = this.resolveEnvKeys(config);
4543
+ if (isSimulationMode()) {
4544
+ const manifest = getManifest(config.id);
4545
+ const simDecl = manifest?.simulation;
4546
+ if (simDecl) {
4547
+ const envVar = Object.entries(manifest.credentials.envMapping).find(([, credKey]) => credKey === simDecl.field)?.[0];
4548
+ if (envVar) env[envVar] = "simulation";
4549
+ }
4550
+ }
4484
4551
  const interpolate = (s) => s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => env[name] ?? process.env[name] ?? "");
4485
4552
  const args = config.args.map(interpolate);
4486
4553
  const remote = remotePreflightTarget(config.command, args);
@@ -4655,7 +4722,15 @@ var IntegrationManager = class {
4655
4722
  name: config.name,
4656
4723
  running: false,
4657
4724
  toolCount: 0,
4658
- readAgeDays: readAgeOf(config)
4725
+ readAgeDays: readAgeOf(config),
4726
+ // Computed fresh, not cached: true whenever simulation mode is on
4727
+ // AND this connector has no manifest `simulation` marker, which is
4728
+ // exactly the condition startIntegrationLocked refuses under — so
4729
+ // the UI always reflects why a real connector never started,
4730
+ // without needing a separate error-tracking path for this one
4731
+ // reason. Unrelated start failures still surface via the
4732
+ // `warning` string returned by /internal/add-integration.
4733
+ error: isSimulationMode() && !manifestIsSimulated(getManifest(config.id)) ? SIMULATION_BLOCK_REASON : void 0
4659
4734
  });
4660
4735
  }
4661
4736
  }
@@ -4749,7 +4824,8 @@ var IntegrationManager = class {
4749
4824
  read: ext.read,
4750
4825
  readGovernance: ext.readGovernance,
4751
4826
  readGovernanceReason: ext.readGovernanceReason,
4752
- blockedArgs: ext.blockedArgs
4827
+ blockedArgs: ext.blockedArgs,
4828
+ hideUnlessAuthorized: ext.hideUnlessAuthorized
4753
4829
  };
4754
4830
  }
4755
4831
  return {
@@ -4763,7 +4839,8 @@ var IntegrationManager = class {
4763
4839
  contentField: ext.contentField,
4764
4840
  blockedArgs: ext.blockedArgs,
4765
4841
  argEncoding: ext.argEncoding,
4766
- argNormalization: ext.argNormalization
4842
+ argNormalization: ext.argNormalization,
4843
+ hideUnlessAuthorized: ext.hideUnlessAuthorized
4767
4844
  };
4768
4845
  }
4769
4846
  return {