@0xmaxma/claude-gateway 1.8.7 → 1.8.9

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 (77) hide show
  1. package/README.md +96 -0
  2. package/dist/agent/runner.d.ts +47 -1
  3. package/dist/agent/runner.d.ts.map +1 -1
  4. package/dist/agent/runner.js +116 -17
  5. package/dist/agent/runner.js.map +1 -1
  6. package/dist/api/connectors-router.d.ts +50 -0
  7. package/dist/api/connectors-router.d.ts.map +1 -0
  8. package/dist/api/connectors-router.js +650 -0
  9. package/dist/api/connectors-router.js.map +1 -0
  10. package/dist/api/gateway-router.d.ts +4 -0
  11. package/dist/api/gateway-router.d.ts.map +1 -1
  12. package/dist/api/gateway-router.js +26 -0
  13. package/dist/api/gateway-router.js.map +1 -1
  14. package/dist/api/oauth-connectors-router.d.ts +43 -0
  15. package/dist/api/oauth-connectors-router.d.ts.map +1 -0
  16. package/dist/api/oauth-connectors-router.js +384 -0
  17. package/dist/api/oauth-connectors-router.js.map +1 -0
  18. package/dist/api/router.d.ts.map +1 -1
  19. package/dist/api/router.js +101 -13
  20. package/dist/api/router.js.map +1 -1
  21. package/dist/apps/agent-manager.d.ts +6 -2
  22. package/dist/apps/agent-manager.d.ts.map +1 -1
  23. package/dist/apps/agent-manager.js +10 -18
  24. package/dist/apps/agent-manager.js.map +1 -1
  25. package/dist/config/config-write-lock.d.ts +45 -0
  26. package/dist/config/config-write-lock.d.ts.map +1 -0
  27. package/dist/config/config-write-lock.js +164 -0
  28. package/dist/config/config-write-lock.js.map +1 -0
  29. package/dist/config/watcher.d.ts.map +1 -1
  30. package/dist/config/watcher.js +29 -0
  31. package/dist/config/watcher.js.map +1 -1
  32. package/dist/connectors/custom-connectors-store.d.ts +54 -0
  33. package/dist/connectors/custom-connectors-store.d.ts.map +1 -0
  34. package/dist/connectors/custom-connectors-store.js +204 -0
  35. package/dist/connectors/custom-connectors-store.js.map +1 -0
  36. package/dist/connectors/custom.d.ts +69 -0
  37. package/dist/connectors/custom.d.ts.map +1 -0
  38. package/dist/connectors/custom.js +158 -0
  39. package/dist/connectors/custom.js.map +1 -0
  40. package/dist/connectors/mcp-oauth.d.ts +152 -0
  41. package/dist/connectors/mcp-oauth.d.ts.map +1 -0
  42. package/dist/connectors/mcp-oauth.js +522 -0
  43. package/dist/connectors/mcp-oauth.js.map +1 -0
  44. package/dist/connectors/oauth-refresh-sweep.d.ts +71 -0
  45. package/dist/connectors/oauth-refresh-sweep.d.ts.map +1 -0
  46. package/dist/connectors/oauth-refresh-sweep.js +337 -0
  47. package/dist/connectors/oauth-refresh-sweep.js.map +1 -0
  48. package/dist/connectors/pending-oauth-store.d.ts +39 -0
  49. package/dist/connectors/pending-oauth-store.d.ts.map +1 -0
  50. package/dist/connectors/pending-oauth-store.js +50 -0
  51. package/dist/connectors/pending-oauth-store.js.map +1 -0
  52. package/dist/connectors/resolve.d.ts +52 -0
  53. package/dist/connectors/resolve.d.ts.map +1 -0
  54. package/dist/connectors/resolve.js +176 -0
  55. package/dist/connectors/resolve.js.map +1 -0
  56. package/dist/connectors/token-env.d.ts +93 -0
  57. package/dist/connectors/token-env.d.ts.map +1 -0
  58. package/dist/connectors/token-env.js +323 -0
  59. package/dist/connectors/token-env.js.map +1 -0
  60. package/dist/connectors/types.d.ts +126 -0
  61. package/dist/connectors/types.d.ts.map +1 -0
  62. package/dist/connectors/types.js +13 -0
  63. package/dist/connectors/types.js.map +1 -0
  64. package/dist/index.js +32 -1
  65. package/dist/index.js.map +1 -1
  66. package/dist/session/process.d.ts +23 -0
  67. package/dist/session/process.d.ts.map +1 -1
  68. package/dist/session/process.js +78 -2
  69. package/dist/session/process.js.map +1 -1
  70. package/dist/types.d.ts +46 -1
  71. package/dist/types.d.ts.map +1 -1
  72. package/mcp/instructions.ts +14 -1
  73. package/mcp/server.ts +11 -5
  74. package/mcp/tools/image/module.ts +2 -70
  75. package/mcp/tools/shared/media.ts +76 -0
  76. package/mcp/tools/video/module.ts +676 -0
  77. package/package.json +1 -1
@@ -0,0 +1,650 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createConnectorsRouter = createConnectorsRouter;
4
+ const express_1 = require("express");
5
+ const auth_1 = require("./auth");
6
+ const resolve_1 = require("../connectors/resolve");
7
+ const token_env_1 = require("../connectors/token-env");
8
+ const custom_1 = require("../connectors/custom");
9
+ const custom_connectors_store_1 = require("../connectors/custom-connectors-store");
10
+ const oauth_refresh_sweep_1 = require("../connectors/oauth-refresh-sweep");
11
+ /**
12
+ * Connector management API. The gateway acts as connector registry + secret manager
13
+ * + config injector: connecting a connector stores its secret in mcp-token.env —
14
+ * that store alone is authoritative for "connected" (see resolve.ts's
15
+ * listConnectorStatus). The actual MCP server is injected into each session by
16
+ * SessionProcess (see resolveEnabledConnectors).
17
+ *
18
+ * Routes (mounted under /api):
19
+ * GET /v1/connectors — every connector, with connected state
20
+ * GET /v1/connectors/:id/status — connected boolean (for polling)
21
+ * POST /v1/connectors/:id/connect — store a secret (admin) into a
22
+ * single-secret customConnectors entry
23
+ * (e.g. reconnecting a paste-token
24
+ * connector after DELETE
25
+ * soft-disconnected it) — see the handler
26
+ * POST /v1/connectors/:id/oauth/receive — store a pushed access_token (admin);
27
+ * always writes credentialOwner 'external'
28
+ * DELETE /v1/connectors/:id — clear a secret (admin); a 'static' or
29
+ * 'gateway' connector keeps its
30
+ * entry (config/label intact, just
31
+ * disconnected), a 'none' or 'external'
32
+ * one is removed outright — see the handler below
33
+ * POST /v1/connectors/custom — add a user-pasted connector (admin)
34
+ *
35
+ * (Removal of a custom connector is NOT a separate route — it's the same
36
+ * DELETE /v1/connectors/:id above. See the note at the bottom of this file for
37
+ * why the old dedicated /custom/:id route was retired.)
38
+ *
39
+ * Externally-owned connectors (Gmail/Drive/Calendar) never do the actual OAuth
40
+ * dance here — an external control plane the deployer runs owns the
41
+ * client_secret, the token exchange, and the refresh loop (this gateway runs
42
+ * inside the user's own VM, reachable by that user's own shell/SSH, so a
43
+ * shared client_secret can't live here safely — see ConnectorCredentialOwner's
44
+ * doc comment). That control plane pushes the resulting short-lived
45
+ * access_token here via /oauth/receive, over the internal network,
46
+ * authenticated the same way any other admin API caller is. A connector this
47
+ * gateway signs in for itself is 'gateway' instead, and lives in
48
+ * oauth-connectors-router.ts.
49
+ *
50
+ * `agents` (all live AgentRunners) is what lets a route that changes a connector's
51
+ * secrets restart the sessions already using it — every route works without it
52
+ * (e.g. in tests), it just leaves running sessions on their stale MCP config
53
+ * until they next respawn on their own.
54
+ */
55
+ function createConnectorsRouter(apiKeys, configPath, agents, customConnectorsStore) {
56
+ const router = (0, express_1.Router)();
57
+ if (apiKeys?.length)
58
+ router.use((0, auth_1.createApiAuthMiddleware)(apiKeys));
59
+ const store = customConnectorsStore ?? (0, custom_connectors_store_1.createCustomConnectorsStore)(configPath);
60
+ function requireAdmin(req, res) {
61
+ if (!apiKeys?.length)
62
+ return true; // no auth configured — allow
63
+ if (!(0, auth_1.isAdmin)(req.apiKey)) {
64
+ res.status(403).json({ error: 'Connector management requires an admin API key' });
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+ // Every `:id` in this router is used as a config.json object key and interpolated
70
+ // into mcp-token.env key names, so it is validated once here rather than in each
71
+ // handler. /oauth/receive is why this has to be a shape check and not a lookup: it
72
+ // legitimately names a connector that does not exist yet, so there is nothing to
73
+ // validate the id against except its own grammar.
74
+ router.param('id', (req, res, next, id) => {
75
+ if (!(0, custom_1.isValidConnectorId)(id)) {
76
+ res.status(400).json({ error: `Invalid connector id '${id}'` });
77
+ return;
78
+ }
79
+ next();
80
+ });
81
+ /**
82
+ * Restart every live session that resolves `id`, after its secrets or its entry
83
+ * changed.
84
+ *
85
+ * A session's MCP subprocess reads its config once, at spawn (see
86
+ * session/process.ts's writeMcpConfig): connecting a connector while a session is
87
+ * running therefore does nothing visible until that session restarts. Without this
88
+ * a status poller flips to "connected" while the agent the user is talking to still
89
+ * has no such tool, for as long as the session lives.
90
+ *
91
+ * `overlay` carries an entry this route has just written but the config watcher may
92
+ * not have propagated to the runners yet. Never throws — a restart failure must not
93
+ * turn a successful connect into a 500.
94
+ *
95
+ * Called on the delete paths too, after the secrets are gone: "this connector now
96
+ * resolves to nothing" is itself the change each session is compared against (see
97
+ * AgentRunner.restartSessionsUsingConnector). The hard-delete branch passes
98
+ * `{ [id]: null }`, the overlay's way of saying the entry was removed — a
99
+ * `'none'`-owner connector has no secrets whose absence would signal the change on
100
+ * its own, so against the runners' not-yet-refreshed config snapshot it would
101
+ * otherwise still resolve identically and nothing would restart.
102
+ */
103
+ async function restartSessionsUsing(id, opts) {
104
+ await Promise.all((agents ? [...agents.values()] : []).map((runner) => runner
105
+ .restartSessionsUsingConnector(id, { overlay: opts?.overlay })
106
+ .catch((err) => {
107
+ console.error(`connectors-router: restart for connector=${id} failed: ${err.message}`);
108
+ })));
109
+ }
110
+ // Every connector, with its connected state.
111
+ //
112
+ // Wrapped because this is an `async` handler and the app runs Express 4, which
113
+ // does not catch a rejected handler promise: it escapes to the process-wide
114
+ // `unhandledRejection` hook in index.ts, which runs emergencyShutdown and
115
+ // exits. A read failure inside this one route therefore used to take down every
116
+ // agent and every channel on the box — and since callers poll this route, the
117
+ // restarted gateway got killed again on the next poll. Answering 500 keeps the
118
+ // blast radius to the client that asked.
119
+ router.get('/v1/connectors', async (_req, res) => {
120
+ try {
121
+ res.json({ connectors: (0, resolve_1.listConnectorStatus)(await store.read()) });
122
+ }
123
+ catch (err) {
124
+ console.error(`connectors-router: listing connectors failed: ${err.message}`);
125
+ res.status(500).json({ error: 'Connector configuration could not be read' });
126
+ }
127
+ });
128
+ // Single connector status (used by the web to poll)
129
+ router.get('/v1/connectors/:id/status', async (req, res) => {
130
+ const custom = (await store.read())[req.params.id];
131
+ if (!custom) {
132
+ res.status(404).json({ error: `Unknown connector '${req.params.id}'` });
133
+ return;
134
+ }
135
+ // One snapshot for both fields. Read separately, `connected` and `refresh`
136
+ // could come from different versions of mcp-token.env — the sweep rewrites
137
+ // it whole — and report a connector as connected with no refresh trouble
138
+ // when in fact the sweep had just cleared its credentials between the reads.
139
+ //
140
+ // Wrapped for the same reason listConnectorStatus wraps each entry:
141
+ // `secretNames` is typed but never validated at read time, and this is an
142
+ // `async` handler on Express 4, which does not catch rejections — an entry
143
+ // missing it would leave the poller's request hanging with no response
144
+ // rather than answering "not connected".
145
+ try {
146
+ const tokenEnv = (0, token_env_1.readTokenEnv)();
147
+ const connected = custom.secretNames.every((name) => !!tokenEnv[(0, custom_1.customSecretKey)(req.params.id, name)]);
148
+ // Same caveat the list endpoint carries: a transiently-failing refresh
149
+ // leaves the (possibly expired) access_token in place, so `connected`
150
+ // alone would keep polling clients green over a dead connector.
151
+ // 'gateway' only, for the reason listConnectorStatus gives: this gateway
152
+ // holds a refresh_token for no other owner, so the sweep never touches
153
+ // them and every counter would read a constant 0.
154
+ const refresh = custom.credentialOwner === 'gateway' ? (0, resolve_1.refreshStatusOf)(req.params.id, tokenEnv) : undefined;
155
+ res.json({ id: req.params.id, connected, ...(refresh ? { refresh } : {}) });
156
+ }
157
+ catch (err) {
158
+ console.error(`connectors-router: status for connector=${req.params.id} failed: ${err.message}`);
159
+ res.status(500).json({ error: `Connector '${req.params.id}' has an unreadable configuration` });
160
+ }
161
+ });
162
+ // Connect — store the secret into a customConnectors entry with exactly one
163
+ // secret name. That is what makes reconnecting a paste-token custom connector
164
+ // (e.g. Stripe) actually work after DELETE soft-disconnects it — the entry
165
+ // survives disconnect, so it must be reconnectable here, not only via
166
+ // re-adding it from scratch through POST /v1/connectors/custom.
167
+ router.post('/v1/connectors/:id/connect', async (req, res) => {
168
+ if (!requireAdmin(req, res))
169
+ return;
170
+ const id = req.params.id;
171
+ // Body validation happens inside the locked callback below, in the position it
172
+ // always had — after the entry checks, so an unknown id still answers 404 rather
173
+ // than "token is required". It is pure CPU, so it costs the lock nothing.
174
+ const token = req.body?.token;
175
+ // Everything below, validation included, sits inside the try. `withEntry`
176
+ // does no shape validation — `secretNames` is required by the TypeScript type
177
+ // and by nothing at runtime — so an entry hand-written into config.json
178
+ // without it turns the checks below into a TypeError, and on Express 4 a
179
+ // rejected async handler escapes to index.ts's `unhandledRejection` hook,
180
+ // which calls emergencyShutdown(). The read paths were already hardened for
181
+ // exactly this (see token-env.ts and custom-connectors-store.ts); a
182
+ // malformed entry must cost the caller a 500, not every agent on the box.
183
+ try {
184
+ // `withEntry`, not `read()`: the entry decides both whether a pasted token
185
+ // belongs here and WHICH secret name it is filed under, and /oauth/receive
186
+ // rewrites exactly those two fields on an existing id. Read outside the lock,
187
+ // a push landing in between left this route storing the token under a name
188
+ // the entry no longer declares — nothing ever reads it back, and no route
189
+ // clears it either, since every delete path enumerates the CURRENT
190
+ // secretNames. Deciding inside the lock makes that ordering impossible.
191
+ const outcome = await store.withEntry(id, ({ entry }) => {
192
+ if (!entry)
193
+ return { status: 404, error: `Unknown connector '${id}'` };
194
+ // Only a 'static' connector has a credential a human is supposed to paste.
195
+ // The other two owners each have their own way in, and naming it is the whole
196
+ // value of this branch — a bare "not allowed here" leaves the caller guessing.
197
+ if (entry.credentialOwner === 'gateway' || entry.credentialOwner === 'external') {
198
+ return {
199
+ status: 400,
200
+ error: entry.credentialOwner === 'external'
201
+ ? `Connector '${id}' has its credential owned externally — its token is pushed via POST /v1/connectors/${id}/oauth/receive, not set here.`
202
+ : `Connector '${id}' uses OAuth sign-in — start it via POST /v1/connectors/custom/${id}/oauth/start instead`,
203
+ };
204
+ }
205
+ if (entry.secretNames.length !== 1) {
206
+ return {
207
+ status: 400,
208
+ error: entry.secretNames.length === 0
209
+ ? `Connector '${id}' has no secrets to set — nothing to connect here.`
210
+ : `Connector '${id}' needs ${entry.secretNames.length} secrets (${entry.secretNames.join(', ')}) — this route only accepts a single value. Remove and re-add it with every value via POST /v1/connectors/custom.`,
211
+ };
212
+ }
213
+ if (typeof token !== 'string' || !token.trim()) {
214
+ return { status: 400, error: 'token is required and must be a non-empty string' };
215
+ }
216
+ (0, token_env_1.setSecret)((0, custom_1.customSecretKey)(id, entry.secretNames[0]), token.trim());
217
+ return { status: 200, entry };
218
+ });
219
+ if (outcome.status !== 200) {
220
+ res.status(outcome.status).json({ error: outcome.error });
221
+ return;
222
+ }
223
+ // Outside the lock: restarting sessions is slow, needs nothing from the
224
+ // entry beyond the copy taken above, and a runner that rewrites config.json
225
+ // while we still held it would deadlock (see withEntry's doc).
226
+ await restartSessionsUsing(id, { overlay: { [id]: outcome.entry } });
227
+ res.json({ id, connected: true });
228
+ }
229
+ catch (err) {
230
+ res.status(500).json({ error: err.message });
231
+ }
232
+ });
233
+ // Receive a fresh access_token + full connector shape pushed by an external
234
+ // control plane that owns the sign-in for this connector (github/gmail/
235
+ // google-drive/google-calendar, say) — this is how those connect (and stay
236
+ // fresh on refresh) now. Admin-gated exactly like /connect; the caller is that
237
+ // control plane itself, reaching this over the internal network with the
238
+ // same admin API key any other admin caller would use.
239
+ //
240
+ // The entry is written into gateway.customConnectors — the same storage a
241
+ // user-pasted connector uses — with `credentialOwner: 'external'` recording
242
+ // that the credential is that control plane's to renew, not this gateway's
243
+ // (see ConnectorCredentialOwner). `secretNames` is never trusted from the
244
+ // request body — derived from `config` via extractPlaceholders, the same
245
+ // helper /custom's add route uses, and required to be exactly
246
+ // ['access_token'] (this route only ever manages one pushed secret).
247
+ router.post('/v1/connectors/:id/oauth/receive', async (req, res) => {
248
+ if (!requireAdmin(req, res))
249
+ return;
250
+ const id = req.params.id;
251
+ // The one route that takes a connector id verbatim rather than minting it
252
+ // through slugify(), so it is also the only one that can name a server the
253
+ // session writer generates itself — which would silently drop the entry at
254
+ // injection time while every status surface still reported "Connected ✓".
255
+ if ((0, custom_1.isReservedConnectorId)(id)) {
256
+ res.status(400).json({
257
+ error: `Connector id '${id}' is reserved by the gateway's own MCP servers`,
258
+ });
259
+ return;
260
+ }
261
+ const body = req.body;
262
+ const accessToken = body?.access_token;
263
+ if (typeof accessToken !== 'string' || !accessToken.trim()) {
264
+ res.status(400).json({ error: 'access_token is required and must be a non-empty string' });
265
+ return;
266
+ }
267
+ if (typeof body.label !== 'string' || !body.label.trim()) {
268
+ res.status(400).json({ error: 'label is required and must be a non-empty string' });
269
+ return;
270
+ }
271
+ if (typeof body.config !== 'object' || body.config === null || Array.isArray(body.config)) {
272
+ res.status(400).json({ error: 'config is required and must be a JSON object' });
273
+ return;
274
+ }
275
+ if (body.description !== undefined && typeof body.description !== 'string') {
276
+ res.status(400).json({ error: 'description must be a string' });
277
+ return;
278
+ }
279
+ if (body.sourceUrl !== undefined && typeof body.sourceUrl !== 'string') {
280
+ res.status(400).json({ error: 'sourceUrl must be a string' });
281
+ return;
282
+ }
283
+ const placeholders = (0, custom_1.extractPlaceholders)(body.config);
284
+ if (placeholders.length !== 1 || placeholders[0] !== 'access_token') {
285
+ res.status(400).json({
286
+ error: "config must contain exactly one {access_token} placeholder and no others",
287
+ });
288
+ return;
289
+ }
290
+ const entry = {
291
+ label: body.label.trim(),
292
+ description: typeof body.description === 'string' ? body.description : undefined,
293
+ config: body.config,
294
+ secretNames: ['access_token'],
295
+ sourceUrl: typeof body.sourceUrl === 'string' && body.sourceUrl.trim()
296
+ ? body.sourceUrl.trim()
297
+ : undefined,
298
+ credentialOwner: 'external',
299
+ };
300
+ // This route always writes credentialOwner 'external', so pushing onto an id
301
+ // that was 'gateway'-owned is a handover: from here on nothing refreshes it —
302
+ // the sweep only ever acts on 'gateway' entries — and DELETE only clears the
303
+ // sweep's internal keys under that same `credentialOwner === 'gateway'` guard,
304
+ // which no longer holds. The refresh_token, cached DCR client_id and failure
305
+ // bookkeeping left behind would therefore outlive every path that could remove
306
+ // them, sitting in mcp-token.env as a live credential belonging to a grant the
307
+ // gateway has stopped managing. Cleared here, in the same write that stores the
308
+ // pushed token, so the handover is atomic rather than a two-step that can be
309
+ // interrupted between halves.
310
+ //
311
+ // The same argument applies to the previous entry's OWN secrets, under names
312
+ // this route does not reuse: it overwrites `secretNames` with ['access_token'],
313
+ // and every path that could remove a custom secret enumerates the CURRENT
314
+ // secretNames — DELETE maps over them, /connect is closed to 'external'. A
315
+ // 'static' connector's pasted `{api_key}` would sit in the 0600 store as a live
316
+ // third-party key with no supported operation left that clears it.
317
+ // Captured INSIDE store.mutate, not from a `read()` before it. The two are
318
+ // the same value only if nothing writes config.json in between, and the
319
+ // whole reason `mutate` exists is that things do: an unlocked read here
320
+ // races the entry this very route is about to overwrite. Lose that race —
321
+ // an admin completes a browser sign-in on this id, or /connect stores a
322
+ // pasted token, between the read and the locked write — and `previousOwner`
323
+ // says 'static' for an entry that is now 'gateway'-owned, so the
324
+ // internalSecretKeysOf() removal below is skipped and the fresh
325
+ // refresh_token survives a handover to 'external'. Nothing collects it
326
+ // afterwards: the sweep ignores non-'gateway' entries and DELETE clears
327
+ // internal keys only under that same guard, leaving a live credential in
328
+ // mcp-token.env for a grant this gateway no longer manages — precisely the
329
+ // orphan the comment above exists to prevent. `mutate` reads the file under
330
+ // the write lock, so the entry it hands us is the one being replaced.
331
+ //
332
+ // Left at their defaults when there is no persistence target (tests), where
333
+ // `mutate` returns before invoking this callback — same as the `read()` that
334
+ // returned {} there.
335
+ let previousOwner;
336
+ let staleSecretKeys = [];
337
+ try {
338
+ // config.json first, secrets second — matching the /custom add route below.
339
+ // If the second write fails, `listConnectorStatus` finds no secret and reports
340
+ // the row disconnected, which is honest and re-pushable. The other order
341
+ // fails to an orphaned secret under an id that DELETE 404s on, so nothing
342
+ // can ever clear it.
343
+ await store.mutate((c) => {
344
+ const previous = c[id];
345
+ previousOwner = previous?.credentialOwner;
346
+ staleSecretKeys = (previous?.secretNames ?? [])
347
+ .filter((name) => !entry.secretNames.includes(name))
348
+ .map((name) => (0, custom_1.customSecretKey)(id, name));
349
+ c[id] = entry;
350
+ });
351
+ (0, token_env_1.updateSecrets)({ [(0, custom_1.customSecretKey)(id, 'access_token')]: accessToken.trim() }, [
352
+ ...staleSecretKeys,
353
+ ...(previousOwner === 'gateway' ? (0, oauth_refresh_sweep_1.internalSecretKeysOf)(id) : []),
354
+ ]);
355
+ // A live session already resolved this connector with the stale (or
356
+ // absent) token baked into its MCP subprocess's env — restart it so the
357
+ // next spawn picks up the fresh one (session/process.ts resolves
358
+ // connector secrets fresh on every spawn, but a running subprocess
359
+ // can't be hot-patched). The entry is passed as an overlay because the
360
+ // runners' own view of config.json is refreshed by a file watcher that
361
+ // has almost certainly not fired yet for the write just above — on a
362
+ // first push, without it, no runner would consider itself a user of a
363
+ // connector it is about to have.
364
+ await restartSessionsUsing(id, { overlay: { [id]: entry } });
365
+ res.json({ id, connected: true });
366
+ }
367
+ catch (err) {
368
+ res.status(500).json({ error: err.message });
369
+ }
370
+ });
371
+ // Disconnect — clear the secret + wiring. Checks admin before "does the id
372
+ // exist" (this route used to do it the other way round, which leaked whether
373
+ // an id exists to a non-admin caller).
374
+ router.delete('/v1/connectors/:id', async (req, res) => {
375
+ if (!requireAdmin(req, res))
376
+ return;
377
+ const id = req.params.id;
378
+ try {
379
+ // `withEntry`, not `read()`. Every decision this route makes comes off the
380
+ // entry — `credentialOwner` picks soft-disconnect vs hard-delete AND whether
381
+ // the sweep's internal keys are cleared, `secretNames` picks which of its own
382
+ // secrets go — and /oauth/receive rewrites all three of those fields on an
383
+ // existing id in a single locked write. Read outside the lock, a push landing
384
+ // in between left this route clearing secrets under names the entry no longer
385
+ // declares, skipping the internal-key removal for an owner that had just
386
+ // stopped being 'gateway' (or performing it for one that had just become
387
+ // 'external'), and keeping an entry whose new owner says remove it. Every one
388
+ // of those leaves a live credential in mcp-token.env that no later route can
389
+ // reach. Deciding inside the lock is what /oauth/receive itself was fixed to
390
+ // do; this is the other side of the same race.
391
+ const outcome = await store.withEntry(id, ({ entry, remove }) => {
392
+ if (!entry)
393
+ return { found: false };
394
+ // Which of the entry's own secrets Disconnect clears depends on which of
395
+ // them a reconnect can put back.
396
+ //
397
+ // 'gateway': only `access_token` is the gateway's to mint — the OAuth
398
+ // callback writes that one key and nothing else from `secretNames` (see
399
+ // oauth-connectors-router.ts's updateSecrets call). Any OTHER placeholder
400
+ // on a `oauth: true` connector — a `{workspace_id}`, a `{team_domain}` —
401
+ // came from the `secrets` map pasted into POST /v1/connectors/custom, and
402
+ // no route can restore it: /connect is closed to 'gateway' owners and the
403
+ // add route mints a NEW id via slugify(). Clearing them all therefore made
404
+ // one Disconnect permanent for any multi-placeholder OAuth connector —
405
+ // sign-in succeeded, `secretNames.every(hasSecret)` stayed false because of
406
+ // the placeholder nothing had refilled, and the row read "Not connected"
407
+ // forever with no recovery short of hand-editing config.json. Only the
408
+ // credential the user asked to revoke is cleared; the rest is inert
409
+ // configuration that costs nothing to keep and is the difference between
410
+ // "reconnect is one sign-in" and "remove and re-add from scratch".
411
+ //
412
+ // Every other owner: `secretNames` is exactly what a reconnect re-supplies
413
+ // (one /connect paste for 'static', one /oauth/receive push for
414
+ // 'external', nothing at all for 'none'), so all of it goes.
415
+ const toDelete = entry.credentialOwner === 'gateway'
416
+ ? [(0, custom_1.customSecretKey)(id, 'access_token')]
417
+ : entry.secretNames.map((name) => (0, custom_1.customSecretKey)(id, name));
418
+ // A 'gateway'-owned entry's refresh_token/client_id/expiry (and any
419
+ // recorded failure backoff, generation counter or cached DCR
420
+ // registration) live outside secretNames — they're sweep-internal
421
+ // bookkeeping, not a {placeholder} from the pasted config (see
422
+ // oauth-refresh-sweep.ts's storage note), so the names above never cover
423
+ // them. Left alone, the still-valid refresh_token would let
424
+ // refreshExpiringOAuthConnectors silently mint a fresh access_token and
425
+ // resurrect a connector the user just disconnected.
426
+ //
427
+ // Enumerated by `internalSecretKeysOf` rather than listed here, because
428
+ // this list drifted: `__dcr_client_id` and `__client_redirect_uri` were
429
+ // added to the OAuth start path and never added to any delete path, so a
430
+ // provider-side registration that had been deleted stayed cached forever.
431
+ // Disconnect-and-reconnect — the one recovery a user can perform from the
432
+ // UI — read the dead client back out, saw its redirect_uri still matched,
433
+ // skipped re-registration, and failed again every time.
434
+ if (entry.credentialOwner === 'gateway')
435
+ toDelete.push(...(0, oauth_refresh_sweep_1.internalSecretKeysOf)(id));
436
+ (0, token_env_1.deleteSecrets)(toDelete);
437
+ // Whether "Disconnect" keeps the entry or removes it follows from who owns
438
+ // the credential, which is the same question as "is there anything here
439
+ // worth preserving for a reconnect".
440
+ //
441
+ // 'static' and 'gateway': the definition IS this entry — the config the
442
+ // user pasted, its label and description exist nowhere else. Wiping it
443
+ // would make Disconnect silently discard all of that, with no way back
444
+ // short of re-adding from scratch. Clear only the secret and leave the row
445
+ // in place as "not connected", so reconnecting costs one paste or one
446
+ // sign-in.
447
+ //
448
+ // 'none': there is no secret to clear. listConnectorStatus's
449
+ // `secretNames.every(...)` is vacuously true on an empty array, so the row
450
+ // would report connected forever no matter what this route did — a
451
+ // soft disconnect is a no-op that looks like a bug (click Disconnect, row
452
+ // stays "Connected"). Nothing to preserve for a reconnect either.
453
+ //
454
+ // 'external': the definition lives in the control plane that pushed it,
455
+ // which the caller can re-push in full via /oauth/receive at any time.
456
+ if (entry.credentialOwner === 'static' || entry.credentialOwner === 'gateway') {
457
+ return { found: true, hard: false };
458
+ }
459
+ // The entry AND the per-agent enablement flags that reference it, in this
460
+ // one locked write: with the entry gone nothing would ever come back for
461
+ // orphaned flags, so splitting this in two would reintroduce exactly the
462
+ // orphan it exists to prevent (see dropConnector's doc). A soft
463
+ // disconnect above keeps the entry, so it keeps its enablement too.
464
+ remove();
465
+ return { found: true, hard: true };
466
+ });
467
+ if (!outcome.found) {
468
+ res.status(404).json({ error: `Unknown connector '${id}'` });
469
+ return;
470
+ }
471
+ // `null` is the overlay's way of saying "this entry is gone", and the
472
+ // hard-delete branch is the one place that needs it. A 'none'-owner
473
+ // connector has no secret whose disappearance would change its resolved
474
+ // shape, and the runners' config snapshot is refreshed by a file watcher
475
+ // that has almost certainly not fired for the write above — so without the
476
+ // overlay every session's spawn-time fingerprint still matches, nothing
477
+ // restarts, and agents keep talking to a connector the API has just
478
+ // reported deleted until something unrelated restarts them.
479
+ //
480
+ // After the lock, not inside it: a restart can take seconds, and a runner
481
+ // that rewrites config.json on the way would deadlock against it.
482
+ await restartSessionsUsing(id, outcome.hard ? { overlay: { [id]: null } } : undefined);
483
+ res.json({ id, connected: false });
484
+ }
485
+ catch (err) {
486
+ res.status(500).json({ error: err.message });
487
+ }
488
+ });
489
+ // Add a custom (user-pasted) connector — raw mcpServers-entry JSON with
490
+ // {placeholder} tokens standing in for secrets. Admin-trusted, NOT
491
+ // code-reviewed (see CustomConnectorEntry's doc comment for the tradeoff).
492
+ router.post('/v1/connectors/custom', async (req, res) => {
493
+ if (!requireAdmin(req, res))
494
+ return;
495
+ const body = req.body;
496
+ if (typeof body.label !== 'string' || !body.label.trim()) {
497
+ res.status(400).json({ error: 'label is required and must be a non-empty string' });
498
+ return;
499
+ }
500
+ if (typeof body.config !== 'object' ||
501
+ body.config === null ||
502
+ Array.isArray(body.config)) {
503
+ res.status(400).json({ error: 'config is required and must be a JSON object' });
504
+ return;
505
+ }
506
+ if (body.oauth !== undefined && typeof body.oauth !== 'boolean') {
507
+ res.status(400).json({ error: 'oauth must be a boolean' });
508
+ return;
509
+ }
510
+ if (body.description !== undefined && typeof body.description !== 'string') {
511
+ res.status(400).json({ error: 'description must be a string' });
512
+ return;
513
+ }
514
+ if (body.sourceUrl !== undefined && typeof body.sourceUrl !== 'string') {
515
+ res.status(400).json({ error: 'sourceUrl must be a string' });
516
+ return;
517
+ }
518
+ let secrets = {};
519
+ if (body.secrets !== undefined) {
520
+ if (typeof body.secrets !== 'object' || body.secrets === null || Array.isArray(body.secrets)) {
521
+ res.status(400).json({ error: 'secrets must be an object of string values' });
522
+ return;
523
+ }
524
+ for (const v of Object.values(body.secrets)) {
525
+ if (typeof v !== 'string') {
526
+ res.status(400).json({ error: 'secrets values must all be strings' });
527
+ return;
528
+ }
529
+ }
530
+ secrets = body.secrets;
531
+ }
532
+ const oauth = body.oauth === true;
533
+ if (oauth && typeof body.config.url !== 'string') {
534
+ res.status(400).json({ error: 'config.url is required when oauth is true' });
535
+ return;
536
+ }
537
+ const existing = await store.read();
538
+ const secretNames = (0, custom_1.extractPlaceholders)(body.config);
539
+ // `__`-prefixed names are the gateway's own (oauth-refresh-sweep.ts). They can no
540
+ // longer collide with it — internalSecretKey() uses a prefix customSecretKey()
541
+ // can't produce — but they would silently resolve to the empty string, so say so.
542
+ const reserved = secretNames.filter(custom_1.isReservedPlaceholder);
543
+ if (reserved.length) {
544
+ res.status(400).json({
545
+ error: `Placeholder names starting with "__" are reserved by the gateway: ${reserved.join(', ')}`,
546
+ });
547
+ return;
548
+ }
549
+ if (oauth && !secretNames.includes('access_token')) {
550
+ res.status(400).json({
551
+ error: 'oauth connectors need an {access_token} placeholder in config (e.g. headers.Authorization: "Bearer {access_token}")',
552
+ });
553
+ return;
554
+ }
555
+ if (oauth && secrets.access_token !== undefined) {
556
+ // A pasted access_token on a connector that says "the gateway signs this in"
557
+ // is a state nothing here can maintain. `oauth: true` makes the entry
558
+ // 'gateway'-owned, and the refresh sweep renews a 'gateway' token from the
559
+ // refresh_token the sign-in stores — which a paste cannot supply, since the
560
+ // gateway never saw the exchange it came out of.
561
+ //
562
+ // Accepted, the row read "connected" off `secretNames.every(hasSecret)`
563
+ // immediately and stayed that way: the sweep skips a connector with no
564
+ // refresh_token, so it records no failure, and `unrefreshable` could not
565
+ // catch it either because it keyed on a stored expiry that only a real
566
+ // sign-in ever writes. The connector then simply stopped working whenever
567
+ // the pasted token aged out, with a green checkmark still on it and nothing
568
+ // in any log — the exact state oauth-refresh-sweep.ts's module comment says
569
+ // it exists to prevent.
570
+ //
571
+ // Both real intents remain reachable: sign in through /oauth/start, or
572
+ // re-send this request without `oauth` to get a 'static' connector, which is
573
+ // what a hand-held token actually is. Other placeholders on an oauth
574
+ // connector are untouched — a {workspace_id} is inert configuration the
575
+ // sign-in neither writes nor can supply.
576
+ res.status(400).json({
577
+ error: 'access_token cannot be pasted into an oauth connector — the gateway mints it at' +
578
+ ' POST /v1/connectors/custom/:id/oauth/start and needs the refresh_token from that' +
579
+ ' exchange to keep it alive. Omit `oauth` to store a hand-held token as a static' +
580
+ ' connector instead.',
581
+ });
582
+ return;
583
+ }
584
+ // A secret whose name is not a placeholder in `config` can never be read back:
585
+ // resolution only ever looks up `entry.secretNames`. Silently dropping it means
586
+ // the caller pasted a value it believes is stored — a typo in a placeholder name
587
+ // then looks like "the connector just doesn't work", with the real token sitting
588
+ // in a file nothing reads. Report it instead.
589
+ const unknownSecrets = Object.keys(secrets).filter((name) => !secretNames.includes(name));
590
+ if (unknownSecrets.length) {
591
+ res.status(400).json({
592
+ error: `secrets contains ${unknownSecrets.join(', ')}, which ${unknownSecrets.length === 1 ? 'is not a' : 'are not'} {placeholder} in config — expected ${secretNames.length ? secretNames.join(', ') : 'none'}`,
593
+ });
594
+ return;
595
+ }
596
+ try {
597
+ const entry = {
598
+ label: body.label.trim(),
599
+ description: typeof body.description === 'string' ? body.description : undefined,
600
+ config: body.config,
601
+ secretNames,
602
+ sourceUrl: typeof body.sourceUrl === 'string' && body.sourceUrl.trim()
603
+ ? body.sourceUrl.trim()
604
+ : undefined,
605
+ // The request field stays `oauth: boolean` — an instruction ("run the
606
+ // sign-in flow on this gateway"), not a report of state, so it has none
607
+ // of the ambiguity the stored flags had. It is resolved to an owner
608
+ // exactly once, here: this route can only ever produce these three.
609
+ // 'external' is written by /oauth/receive alone, because only a caller
610
+ // that already holds a token can claim to own one.
611
+ credentialOwner: oauth ? 'gateway' : secretNames.length ? 'static' : 'none',
612
+ };
613
+ // The id is picked inside the write lock, against the map actually being
614
+ // written. Choosing it from the `read()` above instead left a window in
615
+ // which two concurrent adds of the same label both saw the id as free and
616
+ // the second silently overwrote the first — including pointing it at the
617
+ // first one's already-stored secrets. `store.mutate` is a no-op when there
618
+ // is no config file to persist to (tests), so the pre-computed value below
619
+ // stands in for that case.
620
+ let id = (0, custom_1.slugify)(body.label, Object.keys(existing));
621
+ const label = body.label;
622
+ await store.mutate((c) => {
623
+ id = (0, custom_1.slugify)(label, Object.keys(c));
624
+ c[id] = entry;
625
+ });
626
+ // Secrets go in after the id is final — writing them first would key them
627
+ // to an id the lock might not hand us.
628
+ const values = {};
629
+ for (const name of secretNames) {
630
+ const value = secrets[name];
631
+ if (value?.trim())
632
+ values[(0, custom_1.customSecretKey)(id, name)] = value.trim();
633
+ }
634
+ if (Object.keys(values).length)
635
+ (0, token_env_1.setSecrets)(values);
636
+ const connected = secretNames.every((name) => (0, token_env_1.hasSecret)((0, custom_1.customSecretKey)(id, name)));
637
+ if (connected)
638
+ await restartSessionsUsing(id, { overlay: { [id]: entry } });
639
+ res.json({ id, label: entry.label, connected });
640
+ }
641
+ catch (err) {
642
+ res.status(500).json({ error: err.message });
643
+ }
644
+ });
645
+ // Custom connector removal is now handled by `DELETE /v1/connectors/:id`
646
+ // above — this used to be a separate `/custom/:id` route; retired in favor
647
+ // of one delete path.
648
+ return router;
649
+ }
650
+ //# sourceMappingURL=connectors-router.js.map