@0xmaxma/claude-gateway 1.8.7 → 1.8.8
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 +96 -0
- package/dist/agent/runner.d.ts +47 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +116 -17
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/connectors-router.d.ts +50 -0
- package/dist/api/connectors-router.d.ts.map +1 -0
- package/dist/api/connectors-router.js +650 -0
- package/dist/api/connectors-router.js.map +1 -0
- package/dist/api/gateway-router.d.ts +4 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +26 -0
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/oauth-connectors-router.d.ts +43 -0
- package/dist/api/oauth-connectors-router.d.ts.map +1 -0
- package/dist/api/oauth-connectors-router.js +384 -0
- package/dist/api/oauth-connectors-router.js.map +1 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +101 -13
- package/dist/api/router.js.map +1 -1
- package/dist/apps/agent-manager.d.ts +6 -2
- package/dist/apps/agent-manager.d.ts.map +1 -1
- package/dist/apps/agent-manager.js +10 -18
- package/dist/apps/agent-manager.js.map +1 -1
- package/dist/config/config-write-lock.d.ts +45 -0
- package/dist/config/config-write-lock.d.ts.map +1 -0
- package/dist/config/config-write-lock.js +164 -0
- package/dist/config/config-write-lock.js.map +1 -0
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +29 -0
- package/dist/config/watcher.js.map +1 -1
- package/dist/connectors/custom-connectors-store.d.ts +54 -0
- package/dist/connectors/custom-connectors-store.d.ts.map +1 -0
- package/dist/connectors/custom-connectors-store.js +204 -0
- package/dist/connectors/custom-connectors-store.js.map +1 -0
- package/dist/connectors/custom.d.ts +69 -0
- package/dist/connectors/custom.d.ts.map +1 -0
- package/dist/connectors/custom.js +158 -0
- package/dist/connectors/custom.js.map +1 -0
- package/dist/connectors/mcp-oauth.d.ts +152 -0
- package/dist/connectors/mcp-oauth.d.ts.map +1 -0
- package/dist/connectors/mcp-oauth.js +522 -0
- package/dist/connectors/mcp-oauth.js.map +1 -0
- package/dist/connectors/oauth-refresh-sweep.d.ts +71 -0
- package/dist/connectors/oauth-refresh-sweep.d.ts.map +1 -0
- package/dist/connectors/oauth-refresh-sweep.js +337 -0
- package/dist/connectors/oauth-refresh-sweep.js.map +1 -0
- package/dist/connectors/pending-oauth-store.d.ts +39 -0
- package/dist/connectors/pending-oauth-store.d.ts.map +1 -0
- package/dist/connectors/pending-oauth-store.js +50 -0
- package/dist/connectors/pending-oauth-store.js.map +1 -0
- package/dist/connectors/resolve.d.ts +52 -0
- package/dist/connectors/resolve.d.ts.map +1 -0
- package/dist/connectors/resolve.js +176 -0
- package/dist/connectors/resolve.js.map +1 -0
- package/dist/connectors/token-env.d.ts +93 -0
- package/dist/connectors/token-env.d.ts.map +1 -0
- package/dist/connectors/token-env.js +323 -0
- package/dist/connectors/token-env.js.map +1 -0
- package/dist/connectors/types.d.ts +126 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +13 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/index.js +32 -1
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts +23 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +78 -2
- package/dist/session/process.js.map +1 -1
- package/dist/types.d.ts +46 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Connector resolution — the single source of truth shared by the session spawner
|
|
4
|
+
* and the HTTP API.
|
|
5
|
+
*
|
|
6
|
+
* resolveEnabledConnectors(): for the injection point in SessionProcess.writeMcpConfig.
|
|
7
|
+
* listConnectorStatus(): for GET /v1/connectors.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.resolveEnabledConnectors = resolveEnabledConnectors;
|
|
11
|
+
exports.refreshStatusOf = refreshStatusOf;
|
|
12
|
+
exports.listConnectorStatus = listConnectorStatus;
|
|
13
|
+
const token_env_1 = require("./token-env");
|
|
14
|
+
const custom_1 = require("./custom");
|
|
15
|
+
const oauth_refresh_sweep_1 = require("./oauth-refresh-sweep");
|
|
16
|
+
/**
|
|
17
|
+
* Build the mcpServers entries for every connector that is (a) in customConnectors,
|
|
18
|
+
* (b) enabled for this agent, and (c) connected (every `{placeholder}` secret it
|
|
19
|
+
* declares is present). Returns a map keyed by connector id, ready to merge into
|
|
20
|
+
* mcp-config.json.
|
|
21
|
+
*
|
|
22
|
+
* Enablement defaults to opt-OUT: a globally-connected connector is available to
|
|
23
|
+
* every agent the moment it's connected — an agent only misses it if explicitly
|
|
24
|
+
* disabled (`{enabled: false}`). Connecting a connector at all is the security gate;
|
|
25
|
+
* per-agent is a refinement, not a second required step.
|
|
26
|
+
*
|
|
27
|
+
* `defaultEnabled: false` (gateway.connectorsDefaultEnabled in config.json) flips
|
|
28
|
+
* that to opt-IN. The default suits the common single-operator install, but a
|
|
29
|
+
* gateway hosting agents for several different people is a different situation:
|
|
30
|
+
* there, connecting one connector hands its credential to every agent on the box,
|
|
31
|
+
* including agents whose chat users are not the person who connected it. Opting in
|
|
32
|
+
* per agent is the safer posture for that deployment, so it is available — as a
|
|
33
|
+
* deliberate choice, not a silent change of meaning for existing installs.
|
|
34
|
+
*/
|
|
35
|
+
function resolveEnabledConnectors(agentConfig, customConnectors = {}, defaultEnabled = true) {
|
|
36
|
+
const enabled = agentConfig.connectors ?? {};
|
|
37
|
+
const tokenEnv = (0, token_env_1.readTokenEnv)();
|
|
38
|
+
// Null-prototype for the reason the `secrets` map below is one, one level up:
|
|
39
|
+
// `restartSessionsUsingConnector` asks this map what a single id resolves to
|
|
40
|
+
// (`resolved[connectorId]`), and a connector id may legitimately be `constructor`
|
|
41
|
+
// or `valueOf`. On a plain object that lookup answers with an inherited Function
|
|
42
|
+
// for an id that resolves to nothing — a "changed" fingerprint that restarts every
|
|
43
|
+
// session on the box, forever, since it never matches what a spawn recorded.
|
|
44
|
+
const out = Object.create(null);
|
|
45
|
+
const isEnabled = (id) => enabled[id]?.enabled ?? defaultEnabled;
|
|
46
|
+
for (const [id, entry] of Object.entries(customConnectors)) {
|
|
47
|
+
if (!isEnabled(id))
|
|
48
|
+
continue; // opted out (or not opted in)
|
|
49
|
+
try {
|
|
50
|
+
// Null-prototype for the same reason token-env.ts's parse() uses one: the
|
|
51
|
+
// names come from a pasted config, and PLACEHOLDER_RE happily matches
|
|
52
|
+
// `{constructor}` or `{toString}`.
|
|
53
|
+
const secrets = Object.create(null);
|
|
54
|
+
let allPresent = true;
|
|
55
|
+
for (const name of entry.secretNames) {
|
|
56
|
+
const value = tokenEnv[(0, custom_1.customSecretKey)(id, name)];
|
|
57
|
+
if (!value) {
|
|
58
|
+
allPresent = false;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
secrets[name] = value;
|
|
62
|
+
}
|
|
63
|
+
if (!allPresent)
|
|
64
|
+
continue; // enabled but not fully connected — skip
|
|
65
|
+
out[id] = (0, custom_1.substitutePlaceholders)(entry.config, secrets);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// A malformed pasted config (see CustomConnectorEntry's doc comment:
|
|
69
|
+
// admin-trusted, not code-reviewed) must not take down every other
|
|
70
|
+
// connector's resolution — or worse, the whole session spawn — for
|
|
71
|
+
// every user of this agent. Skip just this one.
|
|
72
|
+
console.error(`resolveEnabledConnectors: connector=${id} resolve failed: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The `refresh` block of ConnectorStatus, or undefined when the connector's
|
|
79
|
+
* background refresh is healthy (or was never failing). Reads the sweep's own
|
|
80
|
+
* bookkeeping out of an already-loaded token env rather than re-reading the file
|
|
81
|
+
* per connector.
|
|
82
|
+
*
|
|
83
|
+
* Both failure streaks are reported, not just the transient one. A connector two
|
|
84
|
+
* refusals into the three-strike permanent count is one tick away from having its
|
|
85
|
+
* credentials deleted — the most actionable state this block can describe, and the
|
|
86
|
+
* one a caller most needs to see before it happens.
|
|
87
|
+
*
|
|
88
|
+
* `unrefreshable` covers the cases with no streak at all. A provider that issues no
|
|
89
|
+
* refresh_token (an AS advertising scopes that don't include `offline_access` — see
|
|
90
|
+
* the scope fallback in mcp-oauth.ts's `resolveScope`) leaves the sweep nothing to
|
|
91
|
+
* refresh with, so it skips the connector on every tick and never records a failure.
|
|
92
|
+
* Without this flag that connector keeps a green checkmark forever over an
|
|
93
|
+
* access_token that expired an hour in, which is the exact state the sweep's module
|
|
94
|
+
* comment says it exists to prevent.
|
|
95
|
+
*/
|
|
96
|
+
function refreshStatusOf(id, tokenEnv) {
|
|
97
|
+
// A hand-edited or truncated mcp-token.env can hold anything, and the sweep floors
|
|
98
|
+
// the very same values before deciding whether to delete credentials — `parseCounter`
|
|
99
|
+
// is that floor, shared, so display and decision cannot drift apart.
|
|
100
|
+
const consecutiveFailures = (0, token_env_1.parseCounter)(tokenEnv[(0, oauth_refresh_sweep_1.refreshTransientCountSecretKey)(id)]);
|
|
101
|
+
const permanentFailures = (0, token_env_1.parseCounter)(tokenEnv[(0, oauth_refresh_sweep_1.refreshFailCountSecretKey)(id)]);
|
|
102
|
+
// Derived, not stored: the sweep never writes a marker for this, and shouldn't —
|
|
103
|
+
// it is a fact about what is already in the file, and computing it here means it
|
|
104
|
+
// cannot drift out of sync with the tokens it describes.
|
|
105
|
+
const expiresAt = (0, token_env_1.parseCounter)(tokenEnv[(0, oauth_refresh_sweep_1.expiresAtSecretKey)(id)]);
|
|
106
|
+
// Three states, one flag: there is a token here, nothing can renew it, and it is
|
|
107
|
+
// not currently known-good.
|
|
108
|
+
//
|
|
109
|
+
// `expiresAt === 0` is the third one, and it is not the "not signed in yet" case —
|
|
110
|
+
// that has no access_token at all, and the row already reports `connected: false`
|
|
111
|
+
// without help from here. A 'gateway' connector that HAS an access_token and no
|
|
112
|
+
// recorded expiry is one whose token this gateway never minted: every path that
|
|
113
|
+
// writes one writes an expiry alongside it, defaulting to an hour when the AS
|
|
114
|
+
// omits `expires_in` (see the callback and the sweep). The way to get here is to
|
|
115
|
+
// paste an access_token into an `oauth: true` add — now rejected at that route,
|
|
116
|
+
// but rows already in this state predate the check and are exactly the ones that
|
|
117
|
+
// need saying out loud, since they will simply stop working at a moment nothing
|
|
118
|
+
// recorded.
|
|
119
|
+
const unrefreshable = !!tokenEnv[(0, custom_1.customSecretKey)(id, 'access_token')] &&
|
|
120
|
+
!tokenEnv[(0, oauth_refresh_sweep_1.refreshTokenSecretKey)(id)] &&
|
|
121
|
+
(expiresAt === 0 || expiresAt <= Date.now());
|
|
122
|
+
if (!consecutiveFailures && !permanentFailures && !unrefreshable)
|
|
123
|
+
return undefined;
|
|
124
|
+
return {
|
|
125
|
+
consecutiveFailures,
|
|
126
|
+
permanentFailures,
|
|
127
|
+
nextAttemptAt: (0, token_env_1.parseCounter)(tokenEnv[(0, oauth_refresh_sweep_1.refreshBackoffUntilSecretKey)(id)]),
|
|
128
|
+
...(unrefreshable ? { unrefreshable: true } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Connector list + connected state for the API. `connected` reflects secret presence. */
|
|
132
|
+
function listConnectorStatus(customConnectors = {}) {
|
|
133
|
+
const tokenEnv = (0, token_env_1.readTokenEnv)();
|
|
134
|
+
return Object.entries(customConnectors).map(([id, entry]) => {
|
|
135
|
+
try {
|
|
136
|
+
const connected = entry.secretNames.every((name) => !!tokenEnv[(0, custom_1.customSecretKey)(id, name)]);
|
|
137
|
+
// Omitted rather than set to undefined when refresh is healthy: the single-status
|
|
138
|
+
// route omits it too, and a caller doing `'refresh' in status` should get the same
|
|
139
|
+
// answer from both.
|
|
140
|
+
//
|
|
141
|
+
// 'gateway' only. An 'external' entry is refreshed by the control plane that
|
|
142
|
+
// pushed it — this gateway holds no refresh_token for it, so the sweep skips it
|
|
143
|
+
// and every counter here would read a constant 0.
|
|
144
|
+
const refresh = entry.credentialOwner === 'gateway' ? refreshStatusOf(id, tokenEnv) : undefined;
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
label: entry.label,
|
|
148
|
+
description: entry.description,
|
|
149
|
+
credentialOwner: entry.credentialOwner,
|
|
150
|
+
connected,
|
|
151
|
+
repoUrl: entry.sourceUrl,
|
|
152
|
+
...(refresh ? { refresh } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
// Same per-entry isolation resolveEnabledConnectors uses above, and for a
|
|
157
|
+
// sharper reason: `entry.secretNames` is typed but never validated at read
|
|
158
|
+
// time, so one hand-edited (or older-build) config.json entry missing it
|
|
159
|
+
// throws here — inside an `async` Express 4 handler, which does not catch
|
|
160
|
+
// rejections. The request then never gets a response at all and the whole
|
|
161
|
+
// connector panel hangs, rather than one row degrading.
|
|
162
|
+
console.error(`listConnectorStatus: connector=${id} status failed: ${err.message}`);
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
label: typeof entry?.label === 'string' ? entry.label : id,
|
|
166
|
+
description: 'This connector’s configuration could not be read.',
|
|
167
|
+
// Not the entry's own value, even if it has a readable one: this row
|
|
168
|
+
// describes a connector nothing can be done with, and every other owner
|
|
169
|
+
// would invite a caller to offer a connect action that cannot work.
|
|
170
|
+
credentialOwner: 'none',
|
|
171
|
+
connected: false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../src/connectors/resolve.ts"],"names":[],"mappings":";AAAA;;;;;;GAMG;;AAiCH,4DA8CC;AAqBD,0CAqCC;AAGD,kDA+CC;AAvLD,2CAAyD;AACzD,qCAAmE;AACnE,+DAM+B;AAE/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,wBAAwB,CACtC,WAA4C,EAC5C,mBAAyD,EAAE,EAC3D,cAAc,GAAG,IAAI;IAErB,MAAM,OAAO,GAAG,WAAW,CAAC,UAAU,IAAI,EAAE,CAAC;IAC7C,MAAM,QAAQ,GAAG,IAAA,wBAAY,GAAE,CAAC;IAChC,8EAA8E;IAC9E,6EAA6E;IAC7E,kFAAkF;IAClF,iFAAiF;IACjF,mFAAmF;IACnF,6EAA6E;IAC7E,MAAM,GAAG,GAA4B,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACzD,MAAM,SAAS,GAAG,CAAC,EAAU,EAAW,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,OAAO,IAAI,cAAc,CAAC;IAElF,KAAK,MAAM,CAAC,EAAE,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAAE,CAAC;QAC3D,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YAAE,SAAS,CAAC,8BAA8B;QAE5D,IAAI,CAAC;YACH,0EAA0E;YAC1E,sEAAsE;YACtE,mCAAmC;YACnC,MAAM,OAAO,GAA2B,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YAC5D,IAAI,UAAU,GAAG,IAAI,CAAC;YACtB,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,WAAW,EAAE,CAAC;gBACrC,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAA,wBAAe,EAAC,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC;gBAClD,IAAI,CAAC,KAAK,EAAE,CAAC;oBACX,UAAU,GAAG,KAAK,CAAC;oBACnB,MAAM;gBACR,CAAC;gBACD,OAAO,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;YACxB,CAAC;YACD,IAAI,CAAC,UAAU;gBAAE,SAAS,CAAC,yCAAyC;YAEpE,GAAG,CAAC,EAAE,CAAC,GAAG,IAAA,+BAAsB,EAAC,KAAK,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC1D,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,qEAAqE;YACrE,mEAAmE;YACnE,mEAAmE;YACnE,gDAAgD;YAChD,OAAO,CAAC,KAAK,CAAC,uCAAuC,EAAE,oBAAqB,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;QACvG,CAAC;IACH,CAAC;IAED,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,eAAe,CAC7B,EAAU,EACV,QAAgC;IAEhC,mFAAmF;IACnF,sFAAsF;IACtF,qEAAqE;IACrE,MAAM,mBAAmB,GAAG,IAAA,wBAAY,EAAC,QAAQ,CAAC,IAAA,oDAA8B,EAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACvF,MAAM,iBAAiB,GAAG,IAAA,wBAAY,EAAC,QAAQ,CAAC,IAAA,+CAAyB,EAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAChF,iFAAiF;IACjF,iFAAiF;IACjF,yDAAyD;IACzD,MAAM,SAAS,GAAG,IAAA,wBAAY,EAAC,QAAQ,CAAC,IAAA,wCAAkB,EAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACjE,iFAAiF;IACjF,4BAA4B;IAC5B,EAAE;IACF,mFAAmF;IACnF,kFAAkF;IAClF,gFAAgF;IAChF,gFAAgF;IAChF,8EAA8E;IAC9E,iFAAiF;IACjF,gFAAgF;IAChF,iFAAiF;IACjF,gFAAgF;IAChF,YAAY;IACZ,MAAM,aAAa,GACjB,CAAC,CAAC,QAAQ,CAAC,IAAA,wBAAe,EAAC,EAAE,EAAE,cAAc,CAAC,CAAC;QAC/C,CAAC,QAAQ,CAAC,IAAA,2CAAqB,EAAC,EAAE,CAAC,CAAC;QACpC,CAAC,SAAS,KAAK,CAAC,IAAI,SAAS,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC/C,IAAI,CAAC,mBAAmB,IAAI,CAAC,iBAAiB,IAAI,CAAC,aAAa;QAAE,OAAO,SAAS,CAAC;IACnF,OAAO;QACL,mBAAmB;QACnB,iBAAiB;QACjB,aAAa,EAAE,IAAA,wBAAY,EAAC,QAAQ,CAAC,IAAA,kDAA4B,EAAC,EAAE,CAAC,CAAC,CAAC;QACvE,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAClD,CAAC;AACJ,CAAC;AAED,0FAA0F;AAC1F,SAAgB,mBAAmB,CACjC,mBAAyD,EAAE;IAE3D,MAAM,QAAQ,GAAG,IAAA,wBAAY,GAAE,CAAC;IAEhC,OAAO,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE;QAC1D,IAAI,CAAC;YACH,MAAM,SAAS,GAAG,KAAK,CAAC,WAAW,CAAC,KAAK,CACvC,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAA,wBAAe,EAAC,EAAE,EAAE,IAAI,CAAC,CAAC,CACxD,CAAC;YACF,kFAAkF;YAClF,mFAAmF;YACnF,oBAAoB;YACpB,EAAE;YACF,6EAA6E;YAC7E,gFAAgF;YAChF,kDAAkD;YAClD,MAAM,OAAO,GAAG,KAAK,CAAC,eAAe,KAAK,SAAS,CAAC,CAAC,CAAC,eAAe,CAAC,EAAE,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;YAChG,OAAO;gBACL,EAAE;gBACF,KAAK,EAAE,KAAK,CAAC,KAAK;gBAClB,WAAW,EAAE,KAAK,CAAC,WAAW;gBAC9B,eAAe,EAAE,KAAK,CAAC,eAAe;gBACtC,SAAS;gBACT,OAAO,EAAE,KAAK,CAAC,SAAS;gBACxB,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACb,CAAC;QACvB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,0EAA0E;YAC1E,2EAA2E;YAC3E,yEAAyE;YACzE,0EAA0E;YAC1E,0EAA0E;YAC1E,wDAAwD;YACxD,OAAO,CAAC,KAAK,CAAC,kCAAkC,EAAE,mBAAoB,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;YAC/F,OAAO;gBACL,EAAE;gBACF,KAAK,EAAE,OAAO,KAAK,EAAE,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE;gBAC1D,WAAW,EAAE,mDAAmD;gBAChE,qEAAqE;gBACrE,wEAAwE;gBACxE,oEAAoE;gBACpE,eAAe,EAAE,MAAM;gBACvB,SAAS,EAAE,KAAK;aACE,CAAC;QACvB,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connector secret storage — ~/.claude-gateway/mcp-token.env
|
|
3
|
+
*
|
|
4
|
+
* A plain dotenv file (mode 0600) of `KEY=value` lines, one per connector secret
|
|
5
|
+
* (e.g. GITHUB_TOKEN=ghp_...). This mirrors the gateway's existing secret posture
|
|
6
|
+
* (~/.claude-gateway/.env holds bot tokens in plaintext); config.json only ever holds
|
|
7
|
+
* the env-var NAME, never the value.
|
|
8
|
+
*
|
|
9
|
+
* The file is parsed fresh on every read so a web "connect" takes effect on the next
|
|
10
|
+
* session spawn with no daemon restart. Reads fail soft (an unreadable file reads as
|
|
11
|
+
* "nothing is connected"); writes do not, because a write that fails soft would erase
|
|
12
|
+
* what it could not read — see `readTokenEnvForUpdate` vs `readTokenEnv`.
|
|
13
|
+
*
|
|
14
|
+
* Nothing outside this module parses the file — it is never handed to a shell or
|
|
15
|
+
* `--env-file` — so the quoting rules are ours and both sides of the round-trip live
|
|
16
|
+
* here. Secret VALUES are not ours, though: a provider's token endpoint decides what
|
|
17
|
+
* `access_token` looks like, and a value containing a newline would otherwise be
|
|
18
|
+
* written as a second `KEY=value` line and read back as a *different* connector's
|
|
19
|
+
* secret. See `encodeValue`/`decodeValue`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isValidSecretKey(key: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Read all connector secrets for a caller that only intends to LOOK at them.
|
|
24
|
+
* Returns {} when the file does not exist — or cannot be read.
|
|
25
|
+
*
|
|
26
|
+
* Degrading here is the whole point: every caller asks "is this connected / when does
|
|
27
|
+
* it expire", and the honest answer for an unreadable file is "as far as we can tell,
|
|
28
|
+
* no". The throw it replaced propagated out of `GET /v1/connectors` — an `async`
|
|
29
|
+
* handler on Express 4, which does not catch rejections — into index.ts's
|
|
30
|
+
* `unhandledRejection` hook, which shuts the gateway down. A root-owned mcp-token.env
|
|
31
|
+
* (one `sudo`, or a restored volume) therefore killed every agent and channel on the
|
|
32
|
+
* box, and the panel's next poll after restart killed it again. Failures are still
|
|
33
|
+
* logged, because silence is its own harm.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readTokenEnv(): Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* `getSecret` against an ALREADY-loaded snapshot, for a caller reading several keys
|
|
38
|
+
* at once (`refreshOne`'s due-check, `refreshStatusOf`). Same "an empty value means
|
|
39
|
+
* absent" rule, in one place: it is the difference between "connected" and "not" on
|
|
40
|
+
* every status surface, and it was previously re-stated at each call site.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getSecretFrom(map: Record<string, string>, envName: string): string | null;
|
|
43
|
+
/** Get a single secret value, or null if absent (or unreadable — see `readTokenEnv`). */
|
|
44
|
+
export declare function getSecret(envName: string): string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Coerce a raw value from this file into a non-negative, finite counter/timestamp.
|
|
47
|
+
*
|
|
48
|
+
* Every numeric thing stored here — the refresh failure streaks, the backoff deadline,
|
|
49
|
+
* the token expiry — is hand-editable text, and a NaN reached from it poisons whatever
|
|
50
|
+
* reads it: `NaN >= MAX_CONSECUTIVE_FAILURES` is false forever, so the sweep's give-up
|
|
51
|
+
* branch becomes unreachable and a NaN backoff never blocks its skip guard, while the
|
|
52
|
+
* same value serialized into a status response arrives as `null` and makes a caller's
|
|
53
|
+
* `failures >= 3` test quietly false. One floor, used by both the code that decides
|
|
54
|
+
* whether to delete credentials and the code that displays the decision, so the two
|
|
55
|
+
* cannot disagree about what the file says.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseCounter(raw: string | null | undefined): number;
|
|
58
|
+
/** True when the secret is present and non-empty. */
|
|
59
|
+
export declare function hasSecret(envName: string): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Upsert a secret line (creates the file if needed).
|
|
62
|
+
*
|
|
63
|
+
* Throws on a malformed key — unlike the read path, this one cannot fail soft:
|
|
64
|
+
* silently dropping a write would leave the caller believing a secret was stored.
|
|
65
|
+
*/
|
|
66
|
+
export declare function setSecret(envName: string, value: string): void;
|
|
67
|
+
/**
|
|
68
|
+
* Upsert several secrets in ONE read-modify-write.
|
|
69
|
+
*
|
|
70
|
+
* Not a race fix — each `setSecret` is already atomic against other JS (read, mutate
|
|
71
|
+
* and rename are all synchronous). It fixes *partial* application: storing an OAuth
|
|
72
|
+
* result used to be five separate rewrites, and a crash between them left an
|
|
73
|
+
* access_token with no expiry, which the sweep then treats as "due now" forever. One
|
|
74
|
+
* rename means the whole group lands or none of it does; every key is validated before
|
|
75
|
+
* anything is written, so a bad key aborts the batch rather than half-committing.
|
|
76
|
+
*/
|
|
77
|
+
export declare function setSecrets(entries: Record<string, string>): void;
|
|
78
|
+
/**
|
|
79
|
+
* Upsert some secrets and remove others in ONE read-modify-write.
|
|
80
|
+
*
|
|
81
|
+
* `setSecrets` then `deleteSecrets` is two renames, reopening the partial-application
|
|
82
|
+
* hole `setSecrets` closes. The sweep needs this: recording one kind of failure clears
|
|
83
|
+
* the other kind's counter, and a crash between the writes leaves both set — which
|
|
84
|
+
* status reports as a backoff that is not in effect.
|
|
85
|
+
*
|
|
86
|
+
* Removals are applied before upserts, so a key appearing in both is written, not
|
|
87
|
+
* dropped. Validation covers every upserted key before anything is written; removals
|
|
88
|
+
* need none, since removing a key that could never have been written is a no-op.
|
|
89
|
+
*/
|
|
90
|
+
export declare function updateSecrets(entries: Record<string, string>, removals?: readonly string[]): void;
|
|
91
|
+
/** Remove several secret lines in one rewrite. No-op for names that aren't present. */
|
|
92
|
+
export declare function deleteSecrets(envNames: readonly string[]): void;
|
|
93
|
+
//# sourceMappingURL=token-env.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-env.d.ts","sourceRoot":"","sources":["../../src/connectors/token-env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAqCH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAErD;AA0ED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAcrD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGzF;AAED,yFAAyF;AACzF,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAExD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAGnE;AAED,qDAAqD;AACrD,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAElD;AA+BD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAE9D;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAEhE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,QAAQ,GAAE,SAAS,MAAM,EAAO,GAC/B,IAAI,CAUN;AAED,uFAAuF;AACvF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAS/D"}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Connector secret storage — ~/.claude-gateway/mcp-token.env
|
|
4
|
+
*
|
|
5
|
+
* A plain dotenv file (mode 0600) of `KEY=value` lines, one per connector secret
|
|
6
|
+
* (e.g. GITHUB_TOKEN=ghp_...). This mirrors the gateway's existing secret posture
|
|
7
|
+
* (~/.claude-gateway/.env holds bot tokens in plaintext); config.json only ever holds
|
|
8
|
+
* the env-var NAME, never the value.
|
|
9
|
+
*
|
|
10
|
+
* The file is parsed fresh on every read so a web "connect" takes effect on the next
|
|
11
|
+
* session spawn with no daemon restart. Reads fail soft (an unreadable file reads as
|
|
12
|
+
* "nothing is connected"); writes do not, because a write that fails soft would erase
|
|
13
|
+
* what it could not read — see `readTokenEnvForUpdate` vs `readTokenEnv`.
|
|
14
|
+
*
|
|
15
|
+
* Nothing outside this module parses the file — it is never handed to a shell or
|
|
16
|
+
* `--env-file` — so the quoting rules are ours and both sides of the round-trip live
|
|
17
|
+
* here. Secret VALUES are not ours, though: a provider's token endpoint decides what
|
|
18
|
+
* `access_token` looks like, and a value containing a newline would otherwise be
|
|
19
|
+
* written as a second `KEY=value` line and read back as a *different* connector's
|
|
20
|
+
* secret. See `encodeValue`/`decodeValue`.
|
|
21
|
+
*/
|
|
22
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
25
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
26
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
27
|
+
}
|
|
28
|
+
Object.defineProperty(o, k2, desc);
|
|
29
|
+
}) : (function(o, m, k, k2) {
|
|
30
|
+
if (k2 === undefined) k2 = k;
|
|
31
|
+
o[k2] = m[k];
|
|
32
|
+
}));
|
|
33
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35
|
+
}) : function(o, v) {
|
|
36
|
+
o["default"] = v;
|
|
37
|
+
});
|
|
38
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
39
|
+
var ownKeys = function(o) {
|
|
40
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
41
|
+
var ar = [];
|
|
42
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
43
|
+
return ar;
|
|
44
|
+
};
|
|
45
|
+
return ownKeys(o);
|
|
46
|
+
};
|
|
47
|
+
return function (mod) {
|
|
48
|
+
if (mod && mod.__esModule) return mod;
|
|
49
|
+
var result = {};
|
|
50
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
51
|
+
__setModuleDefault(result, mod);
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
exports.isValidSecretKey = isValidSecretKey;
|
|
57
|
+
exports.readTokenEnv = readTokenEnv;
|
|
58
|
+
exports.getSecretFrom = getSecretFrom;
|
|
59
|
+
exports.getSecret = getSecret;
|
|
60
|
+
exports.parseCounter = parseCounter;
|
|
61
|
+
exports.hasSecret = hasSecret;
|
|
62
|
+
exports.setSecret = setSecret;
|
|
63
|
+
exports.setSecrets = setSecrets;
|
|
64
|
+
exports.updateSecrets = updateSecrets;
|
|
65
|
+
exports.deleteSecrets = deleteSecrets;
|
|
66
|
+
const fs = __importStar(require("fs"));
|
|
67
|
+
const os = __importStar(require("os"));
|
|
68
|
+
const path = __importStar(require("path"));
|
|
69
|
+
const paths_1 = require("../utils/paths");
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the mcp-token.env path (override via GATEWAY_MCP_TOKEN_ENV_PATH, used by
|
|
72
|
+
* tests and by operators who keep secrets off the default volume).
|
|
73
|
+
*
|
|
74
|
+
* Tilde-expanded, because .env.example ships the override commented out as
|
|
75
|
+
* `~/.claude-gateway/mcp-token.env` and index.ts does the same for its sibling
|
|
76
|
+
* GATEWAY_CONFIG. A shell does not expand `~` inside a .env file, so without this
|
|
77
|
+
* an operator who uncomments the documented line gets a literal `~` directory
|
|
78
|
+
* created under the gateway's cwd by ensureDir() below — every existing connector
|
|
79
|
+
* then reads as disconnected and freshly minted OAuth tokens are written to the
|
|
80
|
+
* wrong file, with nothing logged to say so.
|
|
81
|
+
*/
|
|
82
|
+
function tokenEnvPath() {
|
|
83
|
+
const override = process.env.GATEWAY_MCP_TOKEN_ENV_PATH;
|
|
84
|
+
return override
|
|
85
|
+
? (0, paths_1.expandHome)(override)
|
|
86
|
+
: path.join(os.homedir(), '.claude-gateway', 'mcp-token.env');
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Keys are env-var names, and the only structural delimiter this format has. Anything
|
|
90
|
+
* outside this shape (a newline, `=`, a space, a quote) could forge extra entries, so
|
|
91
|
+
* it is rejected rather than escaped — every key the gateway writes is
|
|
92
|
+
* `customSecretKey()`/`internalSecretKey()` output, so a rejection means a caller
|
|
93
|
+
* passed unvalidated user input. `-` is allowed because connector ids are slugs
|
|
94
|
+
* (`CUSTOM__google-calendar__…`); these are map keys here, never real env vars.
|
|
95
|
+
*/
|
|
96
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
97
|
+
function isValidSecretKey(key) {
|
|
98
|
+
return KEY_RE.test(key);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Encode a value for one `KEY=value` line. Values that survive a raw round-trip (the
|
|
102
|
+
* common case — tokens are base64url/JWT) are written bare so the file stays greppable;
|
|
103
|
+
* anything else is double-quoted and backslash-escaped, which `decodeValue` reverses.
|
|
104
|
+
*/
|
|
105
|
+
function encodeValue(value) {
|
|
106
|
+
const needsQuoting = value !== value.trim() || /["\\\n\r]/.test(value) || value.startsWith('#');
|
|
107
|
+
if (!needsQuoting)
|
|
108
|
+
return value;
|
|
109
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
|
|
110
|
+
}
|
|
111
|
+
/** Reverse of `encodeValue`. Bare values are returned trimmed, as they always were. */
|
|
112
|
+
function decodeValue(raw) {
|
|
113
|
+
const v = raw.trim();
|
|
114
|
+
if (v.length < 2 || !v.startsWith('"') || !v.endsWith('"'))
|
|
115
|
+
return v;
|
|
116
|
+
const inner = v.slice(1, -1);
|
|
117
|
+
let out = '';
|
|
118
|
+
for (let i = 0; i < inner.length; i++) {
|
|
119
|
+
if (inner[i] !== '\\') {
|
|
120
|
+
out += inner[i];
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const next = inner[++i];
|
|
124
|
+
out += next === 'n' ? '\n' : next === 'r' ? '\r' : (next ?? '');
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/** Parse a dotenv file body into a flat map. Ignores blank lines and `#` comments. */
|
|
129
|
+
function parse(body) {
|
|
130
|
+
// Null-prototype so a hostile `__proto__` key can never reach Object.prototype.
|
|
131
|
+
const out = Object.create(null);
|
|
132
|
+
for (const rawLine of body.split('\n')) {
|
|
133
|
+
const line = rawLine.trim();
|
|
134
|
+
if (!line || line.startsWith('#'))
|
|
135
|
+
continue;
|
|
136
|
+
const eq = line.indexOf('=');
|
|
137
|
+
if (eq <= 0)
|
|
138
|
+
continue;
|
|
139
|
+
const key = line.slice(0, eq).trim();
|
|
140
|
+
if (!isValidSecretKey(key))
|
|
141
|
+
continue;
|
|
142
|
+
out[key] = decodeValue(line.slice(eq + 1));
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The read half of a read-modify-write. Only ENOENT means "empty" — every other
|
|
148
|
+
* errno is rethrown, on purpose.
|
|
149
|
+
*
|
|
150
|
+
* `setSecrets`, `updateSecrets` and `deleteSecrets` rewrite the file whole from
|
|
151
|
+
* whatever this returns. Swallowing an EMFILE or a momentary EACCES here turned that
|
|
152
|
+
* into "write only the new entries", silently destroying every other connector's token
|
|
153
|
+
* with no error anywhere. Failing the write is strictly better — the file stays intact.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately NOT exported: the strictness is right only when about to overwrite the
|
|
156
|
+
* file, and wrong for a read-only caller (see `readTokenEnv`).
|
|
157
|
+
*/
|
|
158
|
+
function readTokenEnvForUpdate() {
|
|
159
|
+
try {
|
|
160
|
+
return parse(fs.readFileSync(tokenEnvPath(), 'utf-8'));
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (err.code === 'ENOENT')
|
|
164
|
+
return {};
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// An unreadable file stays unreadable, and status pollers hit this every couple of
|
|
169
|
+
// seconds. Logging every failure buries the log; logging none is how an EACCES goes
|
|
170
|
+
// unnoticed for a week. One line per minute keeps the signal.
|
|
171
|
+
const READ_FAILURE_LOG_INTERVAL_MS = 60 * 1000;
|
|
172
|
+
let lastReadFailureLog = 0;
|
|
173
|
+
/**
|
|
174
|
+
* Read all connector secrets for a caller that only intends to LOOK at them.
|
|
175
|
+
* Returns {} when the file does not exist — or cannot be read.
|
|
176
|
+
*
|
|
177
|
+
* Degrading here is the whole point: every caller asks "is this connected / when does
|
|
178
|
+
* it expire", and the honest answer for an unreadable file is "as far as we can tell,
|
|
179
|
+
* no". The throw it replaced propagated out of `GET /v1/connectors` — an `async`
|
|
180
|
+
* handler on Express 4, which does not catch rejections — into index.ts's
|
|
181
|
+
* `unhandledRejection` hook, which shuts the gateway down. A root-owned mcp-token.env
|
|
182
|
+
* (one `sudo`, or a restored volume) therefore killed every agent and channel on the
|
|
183
|
+
* box, and the panel's next poll after restart killed it again. Failures are still
|
|
184
|
+
* logged, because silence is its own harm.
|
|
185
|
+
*/
|
|
186
|
+
function readTokenEnv() {
|
|
187
|
+
try {
|
|
188
|
+
return readTokenEnvForUpdate();
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (now - lastReadFailureLog >= READ_FAILURE_LOG_INTERVAL_MS) {
|
|
193
|
+
lastReadFailureLog = now;
|
|
194
|
+
console.error(`token-env: cannot read ${tokenEnvPath()} (${err.code ?? 'unknown'})` +
|
|
195
|
+
` — treating every connector as not connected until it is readable: ${err.message}`);
|
|
196
|
+
}
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* `getSecret` against an ALREADY-loaded snapshot, for a caller reading several keys
|
|
202
|
+
* at once (`refreshOne`'s due-check, `refreshStatusOf`). Same "an empty value means
|
|
203
|
+
* absent" rule, in one place: it is the difference between "connected" and "not" on
|
|
204
|
+
* every status surface, and it was previously re-stated at each call site.
|
|
205
|
+
*/
|
|
206
|
+
function getSecretFrom(map, envName) {
|
|
207
|
+
const v = map[envName];
|
|
208
|
+
return v === undefined || v === '' ? null : v;
|
|
209
|
+
}
|
|
210
|
+
/** Get a single secret value, or null if absent (or unreadable — see `readTokenEnv`). */
|
|
211
|
+
function getSecret(envName) {
|
|
212
|
+
return getSecretFrom(readTokenEnv(), envName);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Coerce a raw value from this file into a non-negative, finite counter/timestamp.
|
|
216
|
+
*
|
|
217
|
+
* Every numeric thing stored here — the refresh failure streaks, the backoff deadline,
|
|
218
|
+
* the token expiry — is hand-editable text, and a NaN reached from it poisons whatever
|
|
219
|
+
* reads it: `NaN >= MAX_CONSECUTIVE_FAILURES` is false forever, so the sweep's give-up
|
|
220
|
+
* branch becomes unreachable and a NaN backoff never blocks its skip guard, while the
|
|
221
|
+
* same value serialized into a status response arrives as `null` and makes a caller's
|
|
222
|
+
* `failures >= 3` test quietly false. One floor, used by both the code that decides
|
|
223
|
+
* whether to delete credentials and the code that displays the decision, so the two
|
|
224
|
+
* cannot disagree about what the file says.
|
|
225
|
+
*/
|
|
226
|
+
function parseCounter(raw) {
|
|
227
|
+
const n = Number(raw ?? '0');
|
|
228
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
229
|
+
}
|
|
230
|
+
/** True when the secret is present and non-empty. */
|
|
231
|
+
function hasSecret(envName) {
|
|
232
|
+
return getSecret(envName) !== null;
|
|
233
|
+
}
|
|
234
|
+
/** Atomically rewrite the file (mode 0600) from a map. */
|
|
235
|
+
function writeAll(map) {
|
|
236
|
+
const file = tokenEnvPath();
|
|
237
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
238
|
+
const body = Object.entries(map)
|
|
239
|
+
.map(([k, v]) => `${k}=${encodeValue(v)}`)
|
|
240
|
+
.join('\n') + '\n';
|
|
241
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
|
|
242
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
243
|
+
try {
|
|
244
|
+
fs.renameSync(tmp, file);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
// `tmp` holds every secret this process knows. A rename that fails — a full
|
|
248
|
+
// disk, the directory replaced underneath us, EACCES after a permissions
|
|
249
|
+
// change — leaves that complete copy sitting next to the real file under a
|
|
250
|
+
// name nothing will ever clean up, and no later write reuses the path
|
|
251
|
+
// (pid+timestamp), so it accumulates one leaked credential dump per failure.
|
|
252
|
+
// Best-effort: the original error is what the caller needs to see, so a
|
|
253
|
+
// failed unlink must not replace it.
|
|
254
|
+
try {
|
|
255
|
+
fs.unlinkSync(tmp);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
/* nothing better to do — surface the rename failure below */
|
|
259
|
+
}
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Upsert a secret line (creates the file if needed).
|
|
265
|
+
*
|
|
266
|
+
* Throws on a malformed key — unlike the read path, this one cannot fail soft:
|
|
267
|
+
* silently dropping a write would leave the caller believing a secret was stored.
|
|
268
|
+
*/
|
|
269
|
+
function setSecret(envName, value) {
|
|
270
|
+
setSecrets({ [envName]: value });
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Upsert several secrets in ONE read-modify-write.
|
|
274
|
+
*
|
|
275
|
+
* Not a race fix — each `setSecret` is already atomic against other JS (read, mutate
|
|
276
|
+
* and rename are all synchronous). It fixes *partial* application: storing an OAuth
|
|
277
|
+
* result used to be five separate rewrites, and a crash between them left an
|
|
278
|
+
* access_token with no expiry, which the sweep then treats as "due now" forever. One
|
|
279
|
+
* rename means the whole group lands or none of it does; every key is validated before
|
|
280
|
+
* anything is written, so a bad key aborts the batch rather than half-committing.
|
|
281
|
+
*/
|
|
282
|
+
function setSecrets(entries) {
|
|
283
|
+
updateSecrets(entries, []);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Upsert some secrets and remove others in ONE read-modify-write.
|
|
287
|
+
*
|
|
288
|
+
* `setSecrets` then `deleteSecrets` is two renames, reopening the partial-application
|
|
289
|
+
* hole `setSecrets` closes. The sweep needs this: recording one kind of failure clears
|
|
290
|
+
* the other kind's counter, and a crash between the writes leaves both set — which
|
|
291
|
+
* status reports as a backoff that is not in effect.
|
|
292
|
+
*
|
|
293
|
+
* Removals are applied before upserts, so a key appearing in both is written, not
|
|
294
|
+
* dropped. Validation covers every upserted key before anything is written; removals
|
|
295
|
+
* need none, since removing a key that could never have been written is a no-op.
|
|
296
|
+
*/
|
|
297
|
+
function updateSecrets(entries, removals = []) {
|
|
298
|
+
for (const key of Object.keys(entries)) {
|
|
299
|
+
if (!isValidSecretKey(key)) {
|
|
300
|
+
throw new Error(`Invalid secret key '${key}' — expected /${KEY_RE.source}/`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const map = readTokenEnvForUpdate();
|
|
304
|
+
for (const name of removals)
|
|
305
|
+
delete map[name];
|
|
306
|
+
for (const [key, value] of Object.entries(entries))
|
|
307
|
+
map[key] = value;
|
|
308
|
+
writeAll(map);
|
|
309
|
+
}
|
|
310
|
+
/** Remove several secret lines in one rewrite. No-op for names that aren't present. */
|
|
311
|
+
function deleteSecrets(envNames) {
|
|
312
|
+
const map = readTokenEnvForUpdate();
|
|
313
|
+
let changed = false;
|
|
314
|
+
for (const name of envNames) {
|
|
315
|
+
if (!(name in map))
|
|
316
|
+
continue;
|
|
317
|
+
delete map[name];
|
|
318
|
+
changed = true;
|
|
319
|
+
}
|
|
320
|
+
if (changed)
|
|
321
|
+
writeAll(map);
|
|
322
|
+
}
|
|
323
|
+
//# sourceMappingURL=token-env.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-env.js","sourceRoot":"","sources":["../../src/connectors/token-env.ts"],"names":[],"mappings":"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"}
|