@provos/ironcurtain 0.8.0 → 0.9.1
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 +11 -10
- package/dist/auth/auth-command.d.ts +15 -0
- package/dist/auth/auth-command.js +424 -0
- package/dist/auth/auth-command.js.map +1 -0
- package/dist/auth/oauth-flow.d.ts +46 -0
- package/dist/auth/oauth-flow.js +279 -0
- package/dist/auth/oauth-flow.js.map +1 -0
- package/dist/auth/oauth-provider.d.ts +55 -0
- package/dist/auth/oauth-provider.js +73 -0
- package/dist/auth/oauth-provider.js.map +1 -0
- package/dist/auth/oauth-registry.d.ts +34 -0
- package/dist/auth/oauth-registry.js +69 -0
- package/dist/auth/oauth-registry.js.map +1 -0
- package/dist/auth/oauth-token-provider.d.ts +52 -0
- package/dist/auth/oauth-token-provider.js +159 -0
- package/dist/auth/oauth-token-provider.js.map +1 -0
- package/dist/auth/oauth-token-store.d.ts +30 -0
- package/dist/auth/oauth-token-store.js +93 -0
- package/dist/auth/oauth-token-store.js.map +1 -0
- package/dist/auth/providers/google-scopes.d.ts +35 -0
- package/dist/auth/providers/google-scopes.js +183 -0
- package/dist/auth/providers/google-scopes.js.map +1 -0
- package/dist/auth/providers/google.d.ts +2 -0
- package/dist/auth/providers/google.js +28 -0
- package/dist/auth/providers/google.js.map +1 -0
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/config-command.js +39 -1
- package/dist/config/config-command.js.map +1 -1
- package/dist/config/constitution-readonly.md +14 -11
- package/dist/config/constitution-user-base.md +15 -0
- package/dist/config/generated/test-scenarios.json +1 -1
- package/dist/config/generated/tool-annotations.json +10617 -296
- package/dist/config/generated-readonly/compiled-policy.json +787 -244
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/mcp-servers.json +23 -0
- package/dist/config/paths.d.ts +15 -0
- package/dist/config/paths.js +35 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/types.d.ts +8 -0
- package/dist/config/user-config.d.ts +2 -0
- package/dist/config/user-config.js +2 -0
- package/dist/config/user-config.js.map +1 -1
- package/dist/cron/headless-transport.d.ts +4 -0
- package/dist/cron/headless-transport.js +1 -1
- package/dist/cron/headless-transport.js.map +1 -1
- package/dist/daemon/ironcurtain-daemon.js +6 -1
- package/dist/daemon/ironcurtain-daemon.js.map +1 -1
- package/dist/docker/docker-agent-session.d.ts +3 -1
- package/dist/docker/docker-agent-session.js +49 -28
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-infrastructure.js +39 -11
- package/dist/docker/docker-infrastructure.js.map +1 -1
- package/dist/docker/docker-manager.d.ts +2 -0
- package/dist/docker/docker-manager.js +9 -1
- package/dist/docker/docker-manager.js.map +1 -1
- package/dist/docker/mitm-proxy.d.ts +50 -0
- package/dist/docker/mitm-proxy.js +220 -31
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/oauth-credentials.d.ts +45 -6
- package/dist/docker/oauth-credentials.js +151 -24
- package/dist/docker/oauth-credentials.js.map +1 -1
- package/dist/docker/oauth-token-manager.d.ts +15 -16
- package/dist/docker/oauth-token-manager.js +23 -17
- package/dist/docker/oauth-token-manager.js.map +1 -1
- package/dist/docker/platform.d.ts +6 -6
- package/dist/docker/platform.js +8 -6
- package/dist/docker/platform.js.map +1 -1
- package/dist/docker/proxy-tools.d.ts +80 -0
- package/dist/docker/proxy-tools.js +248 -0
- package/dist/docker/proxy-tools.js.map +1 -0
- package/dist/docker/pty-session.js +48 -29
- package/dist/docker/pty-session.js.map +1 -1
- package/dist/docker/pty-types.d.ts +4 -0
- package/dist/docker/pty-types.js.map +1 -1
- package/dist/escalation/escalation-watcher.d.ts +3 -1
- package/dist/escalation/escalation-watcher.js +6 -2
- package/dist/escalation/escalation-watcher.js.map +1 -1
- package/dist/escalation/listener-command.js +24 -10
- package/dist/escalation/listener-command.js.map +1 -1
- package/dist/escalation/listener-lock.d.ts +5 -0
- package/dist/escalation/listener-lock.js +14 -0
- package/dist/escalation/listener-lock.js.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/memory/auto-save.d.ts +25 -0
- package/dist/memory/auto-save.js +89 -0
- package/dist/memory/auto-save.js.map +1 -0
- package/dist/mux/mux-app.d.ts +4 -0
- package/dist/mux/mux-app.js +186 -21
- package/dist/mux/mux-app.js.map +1 -1
- package/dist/mux/mux-command.d.ts +2 -2
- package/dist/mux/mux-command.js +14 -22
- package/dist/mux/mux-command.js.map +1 -1
- package/dist/mux/mux-escalation-manager.d.ts +9 -3
- package/dist/mux/mux-escalation-manager.js +69 -19
- package/dist/mux/mux-escalation-manager.js.map +1 -1
- package/dist/mux/mux-input-handler.d.ts +33 -1
- package/dist/mux/mux-input-handler.js +112 -29
- package/dist/mux/mux-input-handler.js.map +1 -1
- package/dist/mux/mux-renderer.d.ts +17 -1
- package/dist/mux/mux-renderer.js +288 -7
- package/dist/mux/mux-renderer.js.map +1 -1
- package/dist/mux/pty-bridge.d.ts +4 -0
- package/dist/mux/pty-bridge.js +13 -1
- package/dist/mux/pty-bridge.js.map +1 -1
- package/dist/mux/types.d.ts +24 -1
- package/dist/mux/types.js +10 -1
- package/dist/mux/types.js.map +1 -1
- package/dist/persona/persona-command.js +0 -5
- package/dist/persona/persona-command.js.map +1 -1
- package/dist/pipeline/annotate.js +1 -7
- package/dist/pipeline/annotate.js.map +1 -1
- package/dist/pipeline/compile.d.ts +5 -2
- package/dist/pipeline/compile.js +23 -11
- package/dist/pipeline/compile.js.map +1 -1
- package/dist/pipeline/constitution-compiler.d.ts +302 -3
- package/dist/pipeline/constitution-compiler.js +285 -23
- package/dist/pipeline/constitution-compiler.js.map +1 -1
- package/dist/pipeline/generate-with-repair.d.ts +2 -2
- package/dist/pipeline/generate-with-repair.js +2 -2
- package/dist/pipeline/handwritten-scenarios.d.ts +20 -7
- package/dist/pipeline/handwritten-scenarios.js +20 -41
- package/dist/pipeline/handwritten-scenarios.js.map +1 -1
- package/dist/pipeline/list-resolver.d.ts +18 -20
- package/dist/pipeline/list-resolver.js +28 -36
- package/dist/pipeline/list-resolver.js.map +1 -1
- package/dist/pipeline/pipeline-runner.d.ts +102 -13
- package/dist/pipeline/pipeline-runner.js +497 -237
- package/dist/pipeline/pipeline-runner.js.map +1 -1
- package/dist/pipeline/pipeline-shared.d.ts +7 -9
- package/dist/pipeline/pipeline-shared.js +8 -18
- package/dist/pipeline/pipeline-shared.js.map +1 -1
- package/dist/pipeline/policy-verifier.d.ts +32 -5
- package/dist/pipeline/policy-verifier.js +137 -8
- package/dist/pipeline/policy-verifier.js.map +1 -1
- package/dist/pipeline/proxy-mcp-connections.d.ts +25 -0
- package/dist/pipeline/proxy-mcp-connections.js +121 -0
- package/dist/pipeline/proxy-mcp-connections.js.map +1 -0
- package/dist/pipeline/refresh-lists.js +21 -13
- package/dist/pipeline/refresh-lists.js.map +1 -1
- package/dist/pipeline/scenario-generator.d.ts +29 -49
- package/dist/pipeline/scenario-generator.js +180 -143
- package/dist/pipeline/scenario-generator.js.map +1 -1
- package/dist/pipeline/scenario-schema-validator.d.ts +51 -0
- package/dist/pipeline/scenario-schema-validator.js +132 -0
- package/dist/pipeline/scenario-schema-validator.js.map +1 -0
- package/dist/pipeline/tool-annotator.d.ts +9 -8
- package/dist/pipeline/tool-annotator.js +48 -95
- package/dist/pipeline/tool-annotator.js.map +1 -1
- package/dist/pipeline/types.d.ts +31 -24
- package/dist/sandbox/index.js +20 -0
- package/dist/sandbox/index.js.map +1 -1
- package/dist/servers/fetch-server.js +10 -5
- package/dist/servers/fetch-server.js.map +1 -1
- package/dist/session/agent-session.d.ts +6 -19
- package/dist/session/agent-session.js +18 -69
- package/dist/session/agent-session.js.map +1 -1
- package/dist/session/base-transport.d.ts +17 -1
- package/dist/session/base-transport.js +25 -1
- package/dist/session/base-transport.js.map +1 -1
- package/dist/session/cli-transport.d.ts +5 -0
- package/dist/session/cli-transport.js +48 -25
- package/dist/session/cli-transport.js.map +1 -1
- package/dist/session/preflight.js +15 -16
- package/dist/session/preflight.js.map +1 -1
- package/dist/session/prompts.js +0 -1
- package/dist/session/prompts.js.map +1 -1
- package/dist/session/session-manager.d.ts +6 -0
- package/dist/session/session-manager.js +12 -0
- package/dist/session/session-manager.js.map +1 -1
- package/dist/session/types.d.ts +6 -1
- package/dist/session/types.js.map +1 -1
- package/dist/signal/signal-bot-daemon.js +24 -8
- package/dist/signal/signal-bot-daemon.js.map +1 -1
- package/dist/signal/signal-transport.d.ts +5 -2
- package/dist/signal/signal-transport.js +3 -3
- package/dist/signal/signal-transport.js.map +1 -1
- package/dist/trusted-process/approval-whitelist.d.ts +117 -0
- package/dist/trusted-process/approval-whitelist.js +237 -0
- package/dist/trusted-process/approval-whitelist.js.map +1 -0
- package/dist/trusted-process/gworkspace-credentials.d.ts +40 -0
- package/dist/trusted-process/gworkspace-credentials.js +45 -0
- package/dist/trusted-process/gworkspace-credentials.js.map +1 -0
- package/dist/trusted-process/index.d.ts +11 -1
- package/dist/trusted-process/index.js +63 -27
- package/dist/trusted-process/index.js.map +1 -1
- package/dist/trusted-process/mcp-proxy-server.d.ts +14 -0
- package/dist/trusted-process/mcp-proxy-server.js +325 -64
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/policy-engine.d.ts +8 -2
- package/dist/trusted-process/policy-engine.js +18 -22
- package/dist/trusted-process/policy-engine.js.map +1 -1
- package/dist/trusted-process/policy-types.d.ts +17 -0
- package/dist/trusted-process/sandbox-integration.d.ts +23 -0
- package/dist/trusted-process/sandbox-integration.js +115 -3
- package/dist/trusted-process/sandbox-integration.js.map +1 -1
- package/dist/trusted-process/token-file-refresher.d.ts +69 -0
- package/dist/trusted-process/token-file-refresher.js +120 -0
- package/dist/trusted-process/token-file-refresher.js.map +1 -0
- package/dist/types/argument-roles.d.ts +7 -1
- package/dist/types/argument-roles.js +50 -0
- package/dist/types/argument-roles.js.map +1 -1
- package/dist/types/audit.d.ts +11 -0
- package/docker/Dockerfile.claude-code +2 -2
- package/docker/entrypoint-claude-code.sh +15 -5
- package/package.json +3 -2
- package/src/config/constitution-user-base.md +15 -0
- package/src/config/generated/test-scenarios.json +1 -1
- package/src/config/generated/tool-annotations.json +10617 -296
- package/src/config/mcp-servers.json +23 -0
- package/dist/pipeline/mcp-connections.d.ts +0 -16
- package/dist/pipeline/mcp-connections.js +0 -61
- package/dist/pipeline/mcp-connections.js.map +0 -1
|
@@ -37,16 +37,16 @@ import { appendFileSync, existsSync, mkdtempSync, readFileSync, writeFileSync, u
|
|
|
37
37
|
import { atomicWriteJsonSync } from '../escalation/escalation-watcher.js';
|
|
38
38
|
import { join, resolve } from 'node:path';
|
|
39
39
|
import { fileURLToPath } from 'node:url';
|
|
40
|
-
import { tmpdir } from 'node:os';
|
|
40
|
+
import { tmpdir, homedir } from 'node:os';
|
|
41
41
|
import { v4 as uuidv4 } from 'uuid';
|
|
42
42
|
import { loadGeneratedPolicy, extractServerDomainAllowlists, getPackageGeneratedDir } from '../config/index.js';
|
|
43
43
|
import { PolicyEngine, extractAnnotatedPaths } from './policy-engine.js';
|
|
44
|
-
import { getPathRoles } from '../types/argument-roles.js';
|
|
44
|
+
import { getPathRoles, expandTilde } from '../types/argument-roles.js';
|
|
45
45
|
import { AuditLog } from './audit-log.js';
|
|
46
46
|
import { prepareToolArgs, rewriteResultContent } from './path-utils.js';
|
|
47
47
|
import { CONTAINER_WORKSPACE_DIR } from '../docker/agent-adapter.js';
|
|
48
48
|
import { extractPolicyRoots, toMcpRoots, directoryForPath } from './policy-roots.js';
|
|
49
|
-
import { checkSandboxAvailability, resolveSandboxConfig, writeServerSettings, wrapServerCommand, cleanupSettingsFiles, annotateSandboxViolation, } from './sandbox-integration.js';
|
|
49
|
+
import { checkSandboxAvailability, resolveSandboxConfig, writeServerSettings, wrapServerCommand, cleanupSettingsFiles, annotateSandboxViolation, discoverNodePaths, rewriteServerSettings, } from './sandbox-integration.js';
|
|
50
50
|
import { ROOTS_REFRESH_TIMEOUT_MS } from './mcp-client-manager.js';
|
|
51
51
|
import { CallCircuitBreaker } from './call-circuit-breaker.js';
|
|
52
52
|
import { autoApprove, extractArgsForAutoApprove, readUserContext } from './auto-approver.js';
|
|
@@ -59,6 +59,21 @@ import { permissiveJsonSchemaValidator } from './permissive-output-validator.js'
|
|
|
59
59
|
import { VERSION } from '../version.js';
|
|
60
60
|
import { buildTrustedServerSet } from '../memory/memory-annotations.js';
|
|
61
61
|
import { loadToolDescriptionHints, applyToolDescriptionHints } from './tool-description-hints.js';
|
|
62
|
+
import { createApprovalWhitelist, extractWhitelistCandidates, } from './approval-whitelist.js';
|
|
63
|
+
import { getProviderForServer } from '../auth/oauth-registry.js';
|
|
64
|
+
import { loadClientCredentials } from '../auth/oauth-provider.js';
|
|
65
|
+
import { OAuthTokenProvider } from '../auth/oauth-token-provider.js';
|
|
66
|
+
import { loadOAuthToken } from '../auth/oauth-token-store.js';
|
|
67
|
+
import { writeGWorkspaceCredentialFile } from './gworkspace-credentials.js';
|
|
68
|
+
import { TokenFileRefresher } from './token-file-refresher.js';
|
|
69
|
+
import { proxyAnnotations, proxyPolicyRules, proxyToolDefinitions, handleVirtualProxyTool, createControlApiClient, } from '../docker/proxy-tools.js';
|
|
70
|
+
/**
|
|
71
|
+
* Pending whitelist candidates keyed by escalation ID.
|
|
72
|
+
* Populated when a request file is written with candidates;
|
|
73
|
+
* consumed when the response comes back with a selection.
|
|
74
|
+
* Module-level state — the proxy is a singleton process.
|
|
75
|
+
*/
|
|
76
|
+
const pendingWhitelistCandidates = new Map();
|
|
62
77
|
/**
|
|
63
78
|
* Adds a root to a client's root list and waits for the server to
|
|
64
79
|
* fetch the updated list. No-op if the root URI is already present.
|
|
@@ -230,13 +245,21 @@ function cleanupEscalationFiles(requestPath, responsePath) {
|
|
|
230
245
|
}
|
|
231
246
|
/**
|
|
232
247
|
* Reads and parses the escalation response file if it exists.
|
|
233
|
-
* Returns the
|
|
248
|
+
* Returns the response data, or undefined if the file is not present.
|
|
234
249
|
*/
|
|
235
250
|
function readEscalationResponse(responsePath) {
|
|
236
251
|
if (!existsSync(responsePath))
|
|
237
252
|
return undefined;
|
|
238
|
-
const
|
|
239
|
-
|
|
253
|
+
const raw = JSON.parse(readFileSync(responsePath, 'utf-8'));
|
|
254
|
+
// Validate whitelistSelection: must be an integer if present, discard otherwise
|
|
255
|
+
let whitelistSelection;
|
|
256
|
+
if (typeof raw.whitelistSelection === 'number' && Number.isInteger(raw.whitelistSelection)) {
|
|
257
|
+
whitelistSelection = raw.whitelistSelection;
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
decision: raw.decision,
|
|
261
|
+
...(whitelistSelection !== undefined ? { whitelistSelection } : {}),
|
|
262
|
+
};
|
|
240
263
|
}
|
|
241
264
|
/**
|
|
242
265
|
* Waits for a human decision via file-based IPC.
|
|
@@ -253,17 +276,17 @@ async function waitForEscalationDecision(escalationDir, request) {
|
|
|
253
276
|
atomicWriteJsonSync(requestPath, request);
|
|
254
277
|
const deadline = Date.now() + getEscalationTimeoutMs();
|
|
255
278
|
while (Date.now() < deadline) {
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
279
|
+
const response = readEscalationResponse(responsePath);
|
|
280
|
+
if (response) {
|
|
258
281
|
cleanupEscalationFiles(requestPath, responsePath);
|
|
259
|
-
return
|
|
282
|
+
return response;
|
|
260
283
|
}
|
|
261
284
|
await new Promise((r) => setTimeout(r, ESCALATION_POLL_INTERVAL_MS));
|
|
262
285
|
}
|
|
263
286
|
// Final check -- response may have arrived between last poll and deadline
|
|
264
|
-
const
|
|
287
|
+
const lateResponse = readEscalationResponse(responsePath);
|
|
265
288
|
cleanupEscalationFiles(requestPath, responsePath);
|
|
266
|
-
return
|
|
289
|
+
return lateResponse ?? { decision: 'denied' };
|
|
267
290
|
}
|
|
268
291
|
/**
|
|
269
292
|
* Creates the auto-approve LLM model from environment variables.
|
|
@@ -332,12 +355,17 @@ export function parseProxyEnvConfig() {
|
|
|
332
355
|
const allServersConfig = JSON.parse(serversConfigJson);
|
|
333
356
|
const protectedPaths = JSON.parse(protectedPathsJson);
|
|
334
357
|
// When SERVER_FILTER is set, only connect to that single backend server.
|
|
358
|
+
// Special case: SERVER_FILTER=proxy with no matching backend enters virtual-only mode.
|
|
335
359
|
const serverFilter = process.env.SERVER_FILTER;
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
360
|
+
const isVirtualOnly = serverFilter === 'proxy' && !allServersConfig[serverFilter];
|
|
361
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- isVirtualOnly is defensive; TS can't prove it's false at runtime
|
|
362
|
+
const serversConfig = isVirtualOnly
|
|
363
|
+
? {}
|
|
364
|
+
: serverFilter
|
|
365
|
+
? { [serverFilter]: allServersConfig[serverFilter] }
|
|
366
|
+
: allServersConfig;
|
|
339
367
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive: Record index may be undefined at runtime
|
|
340
|
-
if (serverFilter && !allServersConfig[serverFilter]) {
|
|
368
|
+
if (serverFilter && !allServersConfig[serverFilter] && !isVirtualOnly) {
|
|
341
369
|
process.stderr.write(`SERVER_FILTER: unknown server "${serverFilter}"\n`);
|
|
342
370
|
process.exit(1);
|
|
343
371
|
}
|
|
@@ -405,6 +433,28 @@ export function resolveServerSandboxConfigs(serversConfig, allowedDirectory, san
|
|
|
405
433
|
}
|
|
406
434
|
return { resolvedSandboxConfigs, settingsDir, serverCwdPaths };
|
|
407
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* Validates that all argument keys exist in the tool's inputSchema.
|
|
438
|
+
* Returns null if valid, or an error message listing unknown and valid keys.
|
|
439
|
+
* Skips validation when the schema has no properties or allows additional properties.
|
|
440
|
+
*/
|
|
441
|
+
export function validateToolArguments(args, inputSchema) {
|
|
442
|
+
const properties = inputSchema.properties;
|
|
443
|
+
if (!properties || typeof properties !== 'object')
|
|
444
|
+
return null;
|
|
445
|
+
if (inputSchema.additionalProperties)
|
|
446
|
+
return null;
|
|
447
|
+
const validKeys = new Set(Object.keys(properties));
|
|
448
|
+
const unknownKeys = Object.keys(args).filter((k) => !validKeys.has(k));
|
|
449
|
+
if (unknownKeys.length === 0)
|
|
450
|
+
return null;
|
|
451
|
+
const unknownList = unknownKeys.map((k) => `"${k}"`).join(', ');
|
|
452
|
+
const validList = [...validKeys]
|
|
453
|
+
.sort()
|
|
454
|
+
.map((k) => `"${k}"`)
|
|
455
|
+
.join(', ');
|
|
456
|
+
return `Unknown argument(s): ${unknownList}. Valid parameters are: ${validList}`;
|
|
457
|
+
}
|
|
408
458
|
/** Builds a lookup map from tool name to ProxiedTool for routing. */
|
|
409
459
|
export function buildToolMap(allTools) {
|
|
410
460
|
const toolMap = new Map();
|
|
@@ -430,6 +480,8 @@ export function buildAuditEntry(request, argsForTransport, policyDecision, resul
|
|
|
430
480
|
durationMs,
|
|
431
481
|
sandboxed: options.sandboxed || undefined,
|
|
432
482
|
autoApproved: options.autoApproved || undefined,
|
|
483
|
+
whitelistApproved: options.whitelistApproved || undefined,
|
|
484
|
+
whitelistPatternId: options.whitelistPatternId,
|
|
433
485
|
};
|
|
434
486
|
}
|
|
435
487
|
/**
|
|
@@ -462,6 +514,27 @@ export async function handleCallTool(toolName, rawArgs, deps) {
|
|
|
462
514
|
isError: true,
|
|
463
515
|
};
|
|
464
516
|
}
|
|
517
|
+
// Validate argument names against the tool's schema.
|
|
518
|
+
// Skip for trusted servers (check directly, not via isTrusted which also requires missing annotation).
|
|
519
|
+
if (!deps.policyEngine.isTrustedServer(toolInfo.serverName)) {
|
|
520
|
+
const validationError = validateToolArguments(rawArgs, toolInfo.inputSchema);
|
|
521
|
+
if (validationError) {
|
|
522
|
+
deps.auditLog.log({
|
|
523
|
+
timestamp: new Date().toISOString(),
|
|
524
|
+
requestId: uuidv4(),
|
|
525
|
+
serverName: toolInfo.serverName,
|
|
526
|
+
toolName: toolInfo.name,
|
|
527
|
+
arguments: rawArgs,
|
|
528
|
+
policyDecision: { status: 'deny', rule: 'invalid-arguments', reason: validationError },
|
|
529
|
+
result: { status: 'denied', error: validationError },
|
|
530
|
+
durationMs: 0,
|
|
531
|
+
});
|
|
532
|
+
return {
|
|
533
|
+
content: [{ type: 'text', text: validationError }],
|
|
534
|
+
isError: true,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}
|
|
465
538
|
let argsForTransport;
|
|
466
539
|
let argsForPolicy;
|
|
467
540
|
if (isTrusted) {
|
|
@@ -516,6 +589,8 @@ export async function handleCallTool(toolName, rawArgs, deps) {
|
|
|
516
589
|
};
|
|
517
590
|
let escalationResult;
|
|
518
591
|
let autoApproved = false;
|
|
592
|
+
let whitelistApproved = false;
|
|
593
|
+
let whitelistPatternId;
|
|
519
594
|
let rootsExpanded = false;
|
|
520
595
|
const serverSandboxConfig = deps.resolvedSandboxConfigs.get(toolInfo.serverName);
|
|
521
596
|
const serverIsSandboxed = serverSandboxConfig?.sandboxed === true;
|
|
@@ -524,63 +599,102 @@ export async function handleCallTool(toolName, rawArgs, deps) {
|
|
|
524
599
|
escalationResult: overrideEscalation ?? escalationResult,
|
|
525
600
|
sandboxed: serverIsSandboxed,
|
|
526
601
|
autoApproved,
|
|
602
|
+
whitelistApproved: whitelistApproved || undefined,
|
|
603
|
+
whitelistPatternId,
|
|
527
604
|
});
|
|
528
605
|
deps.auditLog.log(entry);
|
|
529
606
|
}
|
|
530
607
|
if (evaluation.decision === 'escalate') {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
// Try auto-approve before falling through to human escalation
|
|
544
|
-
if (deps.autoApproveModel) {
|
|
545
|
-
const userContext = readUserContext(deps.escalationDir);
|
|
546
|
-
if (userContext) {
|
|
547
|
-
const isPtySession = process.env.IRONCURTAIN_PTY_SESSION === '1';
|
|
548
|
-
if (isUserContextTrusted(userContext, isPtySession)) {
|
|
549
|
-
const autoResult = await autoApprove({
|
|
550
|
-
userMessage: userContext.userMessage,
|
|
551
|
-
toolName: `${toolInfo.serverName}/${toolInfo.name}`,
|
|
552
|
-
escalationReason: evaluation.reason,
|
|
553
|
-
arguments: extractArgsForAutoApprove(argsForPolicy, annotation),
|
|
554
|
-
}, deps.autoApproveModel);
|
|
555
|
-
if (autoResult.decision === 'approve') {
|
|
556
|
-
autoApproved = true;
|
|
557
|
-
escalationResult = 'approved';
|
|
558
|
-
policyDecision.status = 'allow';
|
|
559
|
-
policyDecision.reason = `Auto-approved: ${autoResult.reasoning}`;
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
}
|
|
608
|
+
// Annotation is guaranteed non-null here: trusted servers always allow (never escalate),
|
|
609
|
+
// and missing-annotation returns early above. Narrow the type for TypeScript.
|
|
610
|
+
const resolvedAnnotation = annotation;
|
|
611
|
+
const whitelistMatch = deps.whitelist.match(toolInfo.serverName, toolInfo.name, argsForPolicy, resolvedAnnotation);
|
|
612
|
+
if (whitelistMatch.matched) {
|
|
613
|
+
whitelistApproved = true;
|
|
614
|
+
whitelistPatternId = whitelistMatch.patternId;
|
|
615
|
+
escalationResult = 'approved';
|
|
616
|
+
policyDecision.status = 'allow';
|
|
617
|
+
policyDecision.reason = `Whitelist-approved: ${whitelistMatch.pattern.description}`;
|
|
563
618
|
}
|
|
564
|
-
if (!
|
|
565
|
-
|
|
566
|
-
const decision = await waitForEscalationDecision(deps.escalationDir, {
|
|
567
|
-
escalationId,
|
|
568
|
-
serverName: request.serverName,
|
|
569
|
-
toolName: request.toolName,
|
|
570
|
-
arguments: argsForTransport,
|
|
571
|
-
reason: evaluation.reason,
|
|
572
|
-
context: formatServerContext(deps.serverContextMap, toolInfo.serverName),
|
|
573
|
-
});
|
|
574
|
-
if (decision === 'denied') {
|
|
619
|
+
if (!whitelistApproved) {
|
|
620
|
+
if (!deps.escalationDir) {
|
|
575
621
|
logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
|
|
576
622
|
return {
|
|
577
|
-
content: [
|
|
623
|
+
content: [
|
|
624
|
+
{
|
|
625
|
+
type: 'text',
|
|
626
|
+
text: `ESCALATION REQUIRED: ${evaluation.reason}. Action denied (no escalation handler).`,
|
|
627
|
+
},
|
|
628
|
+
],
|
|
578
629
|
isError: true,
|
|
579
630
|
};
|
|
580
631
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
632
|
+
const escalationId = uuidv4();
|
|
633
|
+
// Try auto-approve before falling through to human escalation
|
|
634
|
+
if (deps.autoApproveModel) {
|
|
635
|
+
const userContext = readUserContext(deps.escalationDir);
|
|
636
|
+
if (userContext) {
|
|
637
|
+
const isPtySession = process.env.IRONCURTAIN_PTY_SESSION === '1';
|
|
638
|
+
if (isUserContextTrusted(userContext, isPtySession)) {
|
|
639
|
+
const autoResult = await autoApprove({
|
|
640
|
+
userMessage: userContext.userMessage,
|
|
641
|
+
toolName: `${toolInfo.serverName}/${toolInfo.name}`,
|
|
642
|
+
escalationReason: evaluation.reason,
|
|
643
|
+
arguments: extractArgsForAutoApprove(argsForPolicy, annotation),
|
|
644
|
+
}, deps.autoApproveModel);
|
|
645
|
+
if (autoResult.decision === 'approve') {
|
|
646
|
+
autoApproved = true;
|
|
647
|
+
escalationResult = 'approved';
|
|
648
|
+
policyDecision.status = 'allow';
|
|
649
|
+
policyDecision.reason = `Auto-approved: ${autoResult.reasoning}`;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (!autoApproved) {
|
|
655
|
+
// Extract whitelist candidates just before human escalation (avoids
|
|
656
|
+
// wasted work when auto-approve succeeds).
|
|
657
|
+
const { patterns: candidatePatterns, ipcs: candidateIpcs } = extractWhitelistCandidates(toolInfo.serverName, toolInfo.name, argsForPolicy, resolvedAnnotation, evaluation.escalatedRoles, escalationId, evaluation.reason);
|
|
658
|
+
if (candidatePatterns.length > 0) {
|
|
659
|
+
// Store full patterns in proxy memory, keyed by escalation ID
|
|
660
|
+
pendingWhitelistCandidates.set(escalationId, candidatePatterns);
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const response = await waitForEscalationDecision(deps.escalationDir, {
|
|
664
|
+
escalationId,
|
|
665
|
+
serverName: request.serverName,
|
|
666
|
+
toolName: request.toolName,
|
|
667
|
+
arguments: argsForTransport,
|
|
668
|
+
reason: evaluation.reason,
|
|
669
|
+
context: formatServerContext(deps.serverContextMap, toolInfo.serverName),
|
|
670
|
+
whitelistCandidates: candidateIpcs.length > 0 ? candidateIpcs : undefined,
|
|
671
|
+
});
|
|
672
|
+
if (response.decision === 'denied') {
|
|
673
|
+
logAudit({ status: 'denied', error: evaluation.reason }, 0, 'denied');
|
|
674
|
+
return {
|
|
675
|
+
content: [{ type: 'text', text: `ESCALATION DENIED: ${evaluation.reason}` }],
|
|
676
|
+
isError: true,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
escalationResult = 'approved';
|
|
680
|
+
policyDecision.status = 'allow';
|
|
681
|
+
policyDecision.reason = 'Approved by human during escalation';
|
|
682
|
+
// Handle whitelist selection from the response
|
|
683
|
+
if (response.whitelistSelection !== undefined) {
|
|
684
|
+
const storedPatterns = pendingWhitelistCandidates.get(escalationId);
|
|
685
|
+
if (storedPatterns &&
|
|
686
|
+
response.whitelistSelection >= 0 &&
|
|
687
|
+
response.whitelistSelection < storedPatterns.length) {
|
|
688
|
+
const selectedPattern = storedPatterns[response.whitelistSelection];
|
|
689
|
+
deps.whitelist.add(selectedPattern);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
// Issue 12: ensure cleanup even if waitForEscalationDecision throws
|
|
695
|
+
pendingWhitelistCandidates.delete(escalationId);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
584
698
|
}
|
|
585
699
|
// Expand roots for approved path arguments only (skip URLs, opaques)
|
|
586
700
|
const state = deps.clientStates.get(toolInfo.serverName);
|
|
@@ -612,6 +726,20 @@ export async function handleCallTool(toolName, rawArgs, deps) {
|
|
|
612
726
|
isError: true,
|
|
613
727
|
};
|
|
614
728
|
}
|
|
729
|
+
// Virtual proxy tools: handle locally, no backend forwarding
|
|
730
|
+
if (toolInfo.serverName === 'proxy' && deps.controlApiClient) {
|
|
731
|
+
const startTime = Date.now();
|
|
732
|
+
try {
|
|
733
|
+
const result = await handleVirtualProxyTool(toolInfo.name, argsForTransport, deps.controlApiClient);
|
|
734
|
+
logAudit({ status: 'success' }, Date.now() - startTime);
|
|
735
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
736
|
+
}
|
|
737
|
+
catch (err) {
|
|
738
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
739
|
+
logAudit({ status: 'error', error: errorMessage }, Date.now() - startTime);
|
|
740
|
+
return { content: [{ type: 'text', text: `Error: ${errorMessage}` }], isError: true };
|
|
741
|
+
}
|
|
742
|
+
}
|
|
615
743
|
// Policy allows -- forward to the real MCP server with transport args
|
|
616
744
|
const startTime = Date.now();
|
|
617
745
|
try {
|
|
@@ -692,6 +820,13 @@ async function main() {
|
|
|
692
820
|
toolAnnotationsDir,
|
|
693
821
|
fallbackDir: getPackageGeneratedDir(),
|
|
694
822
|
});
|
|
823
|
+
// Merge proxy tool annotations and policy rules into the loaded artifacts
|
|
824
|
+
// so the PolicyEngine can evaluate proxy tool calls.
|
|
825
|
+
toolAnnotations.servers.proxy = {
|
|
826
|
+
inputHash: 'hardcoded',
|
|
827
|
+
tools: proxyAnnotations,
|
|
828
|
+
};
|
|
829
|
+
compiledPolicy.rules = [...proxyPolicyRules, ...compiledPolicy.rules];
|
|
695
830
|
const serverDomainAllowlists = extractServerDomainAllowlists(serversConfig);
|
|
696
831
|
const trustedServers = buildTrustedServerSet(serversConfig);
|
|
697
832
|
const policyEngine = new PolicyEngine(compiledPolicy, toolAnnotations, protectedPaths, allowedDirectory, serverDomainAllowlists, dynamicLists, trustedServers);
|
|
@@ -703,9 +838,12 @@ async function main() {
|
|
|
703
838
|
// ── Sandbox availability & config resolution ──────────────────────
|
|
704
839
|
const { sandboxAvailable } = validateSandboxAvailability(sandboxPolicy, sessionLogPath, process.platform);
|
|
705
840
|
const { resolvedSandboxConfigs, settingsDir, serverCwdPaths } = resolveServerSandboxConfigs(serversConfig, allowedDirectory, sandboxAvailable, sandboxPolicy);
|
|
841
|
+
// Discover node/npm paths once for all sandboxed servers with denyRead: ["~"]
|
|
842
|
+
const dynamicNodePaths = discoverNodePaths();
|
|
706
843
|
// ── Connect to real MCP servers ───────────────────────────────────
|
|
707
844
|
const clientStates = new Map();
|
|
708
845
|
const allTools = [];
|
|
846
|
+
const tokenRefreshers = new Map();
|
|
709
847
|
for (const [serverName, config] of Object.entries(serversConfig)) {
|
|
710
848
|
// Skip servers whose args reference env vars (Docker -e VAR_NAME syntax)
|
|
711
849
|
// that aren't set — the server will fail to start without them.
|
|
@@ -717,14 +855,110 @@ async function main() {
|
|
|
717
855
|
logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
|
|
718
856
|
continue;
|
|
719
857
|
}
|
|
858
|
+
// ── OAuth token injection for servers backed by OAuth providers ──
|
|
859
|
+
const oauthEnv = {};
|
|
860
|
+
const oauthProvider = getProviderForServer(serverName);
|
|
861
|
+
if (oauthProvider) {
|
|
862
|
+
const clientCreds = loadClientCredentials(oauthProvider);
|
|
863
|
+
if (!clientCreds) {
|
|
864
|
+
const warning = `Skipping MCP server "${serverName}": no OAuth credentials. Run 'ironcurtain auth import ${oauthProvider.id} <file>'`;
|
|
865
|
+
process.stderr.write(`WARNING: ${warning}\n`);
|
|
866
|
+
if (sessionLogPath)
|
|
867
|
+
logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
const tokenProvider = new OAuthTokenProvider(oauthProvider, clientCreds);
|
|
871
|
+
if (!tokenProvider.isAuthorized()) {
|
|
872
|
+
const warning = `Skipping MCP server "${serverName}": not authorized. Run 'ironcurtain auth ${oauthProvider.id}'`;
|
|
873
|
+
process.stderr.write(`WARNING: ${warning}\n`);
|
|
874
|
+
if (sessionLogPath)
|
|
875
|
+
logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const accessToken = await tokenProvider.getValidAccessToken();
|
|
880
|
+
const storedToken = loadOAuthToken(oauthProvider.id);
|
|
881
|
+
if (!storedToken) {
|
|
882
|
+
throw new Error(`Token file disappeared after validation for "${oauthProvider.id}"`);
|
|
883
|
+
}
|
|
884
|
+
// Create per-session credential directory
|
|
885
|
+
const credsDir = join(settingsDir, `${serverName}-creds`);
|
|
886
|
+
writeGWorkspaceCredentialFile(credsDir, accessToken, storedToken.expiresAt, storedToken.scopes);
|
|
887
|
+
// Inject env vars for the MCP server
|
|
888
|
+
oauthEnv.GWORKSPACE_CREDS_DIR = credsDir;
|
|
889
|
+
oauthEnv.CLIENT_ID = clientCreds.clientId;
|
|
890
|
+
oauthEnv.CLIENT_SECRET = clientCreds.clientSecret;
|
|
891
|
+
// Redirect npm cache into the credential directory for npx sandbox compatibility
|
|
892
|
+
oauthEnv.npm_config_cache = join(credsDir, '.npm-cache');
|
|
893
|
+
// Start proactive token refresh
|
|
894
|
+
const refresher = new TokenFileRefresher({
|
|
895
|
+
providerId: oauthProvider.id,
|
|
896
|
+
getAccessToken: async () => {
|
|
897
|
+
// Force refresh: match the refresher's 10-min threshold
|
|
898
|
+
// (getValidAccessToken's 5-min threshold would skip near-expiry tokens)
|
|
899
|
+
const token = await tokenProvider.forceRefresh();
|
|
900
|
+
const stored = loadOAuthToken(oauthProvider.id);
|
|
901
|
+
if (!stored) {
|
|
902
|
+
throw new Error(`Token file missing for "${oauthProvider.id}"`);
|
|
903
|
+
}
|
|
904
|
+
return { accessToken: token, expiresAt: stored.expiresAt, scopes: stored.scopes };
|
|
905
|
+
},
|
|
906
|
+
writeCredentialFile: (token, expiry, scopes) => {
|
|
907
|
+
writeGWorkspaceCredentialFile(credsDir, token, expiry, scopes);
|
|
908
|
+
},
|
|
909
|
+
logToSession: sessionLogPath ? (msg) => logToSessionFile(sessionLogPath, msg) : undefined,
|
|
910
|
+
}, storedToken.expiresAt);
|
|
911
|
+
refresher.start();
|
|
912
|
+
tokenRefreshers.set(serverName, refresher);
|
|
913
|
+
// Rewrite srt settings to allow the credential directory and
|
|
914
|
+
// node paths (for denyRead: ["~"] servers) in a single call
|
|
915
|
+
const resolvedOAuth = resolvedSandboxConfigs.get(serverName);
|
|
916
|
+
if (resolvedOAuth?.sandboxed) {
|
|
917
|
+
const hasDenyHome = resolvedOAuth.config.denyRead.some((p) => expandTilde(p) === homedir());
|
|
918
|
+
const extraAllowRead = hasDenyHome ? [...dynamicNodePaths, credsDir] : [credsDir];
|
|
919
|
+
const settingsPath = join(settingsDir, `${serverName}.srt-settings.json`);
|
|
920
|
+
rewriteServerSettings(settingsPath, {
|
|
921
|
+
allowRead: extraAllowRead,
|
|
922
|
+
allowWrite: [credsDir],
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
if (sessionLogPath) {
|
|
926
|
+
logToSessionFile(sessionLogPath, `[proxy] OAuth token prepared for "${serverName}" (provider: ${oauthProvider.id})`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
catch (err) {
|
|
930
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
931
|
+
const warning = `Skipping MCP server "${serverName}": OAuth token error: ${message}. Run 'ironcurtain auth ${oauthProvider.id}'`;
|
|
932
|
+
process.stderr.write(`WARNING: ${warning}\n`);
|
|
933
|
+
if (sessionLogPath)
|
|
934
|
+
logToSessionFile(sessionLogPath, `[proxy] ${warning}`);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
720
938
|
const resolved = resolvedSandboxConfigs.get(serverName);
|
|
721
939
|
if (!resolved)
|
|
722
940
|
throw new Error(`Missing sandbox config for server "${serverName}"`);
|
|
941
|
+
// For non-OAuth sandboxed servers with denyRead: ["~"], inject node paths
|
|
942
|
+
if (!oauthProvider && resolved.sandboxed && dynamicNodePaths.length > 0) {
|
|
943
|
+
const hasDenyHome = resolved.config.denyRead.some((p) => expandTilde(p) === homedir());
|
|
944
|
+
if (hasDenyHome) {
|
|
945
|
+
const settingsPath = join(settingsDir, `${serverName}.srt-settings.json`);
|
|
946
|
+
rewriteServerSettings(settingsPath, { allowRead: dynamicNodePaths });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
723
949
|
const wrapped = wrapServerCommand(serverName, config.command, config.args, resolved, settingsDir);
|
|
724
950
|
const transport = new StdioClientTransport({
|
|
725
951
|
command: wrapped.command,
|
|
726
952
|
args: wrapped.args,
|
|
727
|
-
env: {
|
|
953
|
+
env: {
|
|
954
|
+
...process.env,
|
|
955
|
+
// Strip NODE_OPTIONS for sandboxed servers to prevent IDE debugger preloads
|
|
956
|
+
// (e.g., Cursor/VS Code) from referencing paths under ~ that denyRead blocks.
|
|
957
|
+
...(resolved.sandboxed ? { NODE_OPTIONS: '' } : {}),
|
|
958
|
+
...(config.env ?? {}),
|
|
959
|
+
...serverCredentials,
|
|
960
|
+
...oauthEnv,
|
|
961
|
+
},
|
|
728
962
|
stderr: 'pipe',
|
|
729
963
|
// Sandboxed servers use a per-server temp dir as CWD (not the sandbox)
|
|
730
964
|
// to prevent srt/bwrap ghost dotfiles from polluting the sandbox directory.
|
|
@@ -738,7 +972,7 @@ async function main() {
|
|
|
738
972
|
if (sessionLogPath) {
|
|
739
973
|
const lines = text.trimEnd();
|
|
740
974
|
if (lines) {
|
|
741
|
-
const redacted = redactCredentials(lines, serverCredentials);
|
|
975
|
+
const redacted = redactCredentials(redactCredentials(lines, serverCredentials), oauthEnv);
|
|
742
976
|
logToSessionFile(sessionLogPath, `[mcp:${serverName}] ${redacted}`);
|
|
743
977
|
}
|
|
744
978
|
}
|
|
@@ -776,6 +1010,26 @@ async function main() {
|
|
|
776
1010
|
});
|
|
777
1011
|
}
|
|
778
1012
|
}
|
|
1013
|
+
// In virtual-only mode (SERVER_FILTER=proxy), register proxy tool definitions
|
|
1014
|
+
// and create the control API client for communicating with the MITM proxy.
|
|
1015
|
+
const isVirtualOnlyMode = process.env.SERVER_FILTER === 'proxy' && Object.keys(serversConfig).length === 0;
|
|
1016
|
+
let controlApiClient = null;
|
|
1017
|
+
if (isVirtualOnlyMode) {
|
|
1018
|
+
for (const toolDef of proxyToolDefinitions) {
|
|
1019
|
+
allTools.push({
|
|
1020
|
+
serverName: 'proxy',
|
|
1021
|
+
name: toolDef.name,
|
|
1022
|
+
description: toolDef.description,
|
|
1023
|
+
inputSchema: toolDef.inputSchema,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
const mitmControlAddr = process.env.MITM_CONTROL_ADDR;
|
|
1027
|
+
if (!mitmControlAddr) {
|
|
1028
|
+
throw new Error('MITM_CONTROL_ADDR must be set when running in virtual-only proxy mode ' +
|
|
1029
|
+
'(SERVER_FILTER=proxy with no other MCP servers).');
|
|
1030
|
+
}
|
|
1031
|
+
controlApiClient = createControlApiClient(mitmControlAddr);
|
|
1032
|
+
}
|
|
779
1033
|
const toolMap = buildToolMap(allTools);
|
|
780
1034
|
const toolDescriptionHints = loadToolDescriptionHints();
|
|
781
1035
|
const hintedTools = applyToolDescriptionHints(allTools, toolDescriptionHints);
|
|
@@ -802,6 +1056,8 @@ async function main() {
|
|
|
802
1056
|
escalationDir,
|
|
803
1057
|
autoApproveModel,
|
|
804
1058
|
serverContextMap,
|
|
1059
|
+
whitelist: createApprovalWhitelist(),
|
|
1060
|
+
controlApiClient,
|
|
805
1061
|
};
|
|
806
1062
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
807
1063
|
return handleCallTool(req.params.name, req.params.arguments ?? {}, callToolDeps);
|
|
@@ -829,6 +1085,11 @@ async function main() {
|
|
|
829
1085
|
await server.connect(transport);
|
|
830
1086
|
// ── Shutdown handler ──────────────────────────────────────────────
|
|
831
1087
|
async function shutdown() {
|
|
1088
|
+
// Stop token refreshers before closing clients
|
|
1089
|
+
for (const refresher of tokenRefreshers.values()) {
|
|
1090
|
+
refresher.stop();
|
|
1091
|
+
}
|
|
1092
|
+
tokenRefreshers.clear();
|
|
832
1093
|
for (const state of clientStates.values()) {
|
|
833
1094
|
try {
|
|
834
1095
|
await state.client.close();
|