gogcli-mcp 3.0.0 → 4.0.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +474 -947
- package/dist/lib.js +575 -983
- package/manifest.json +2 -2
- package/mint.yaml +41 -37
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/attachments.ts +28 -34
- package/src/blob-upload.ts +165 -134
- package/src/blob-urls.ts +3 -5
- package/src/bootstrap-auth.ts +97 -0
- package/src/index.ts +3 -4
- package/src/lib.ts +14 -9
- package/src/runner.ts +27 -176
- package/src/tools/appscript.ts +1 -1
- package/src/tools/auth.ts +5 -5
- package/src/tools/drive.ts +1 -3
- package/src/tools/gmail.ts +9 -9
- package/src/tools/utils.ts +12 -58
- package/tests/attachments.test.ts +11 -14
- package/tests/blob-upload.test.ts +235 -160
- package/tests/bootstrap-auth.test.ts +245 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +53 -96
- package/tests/sdk-single-copy.test.ts +4 -12
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +2 -2
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +6 -14
- package/tsconfig.json +1 -2
- package/vitest.config.ts +2 -12
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -319
- package/src/connector-login.ts +0 -87
- package/src/connector-runtime.ts +0 -910
- package/src/google-probe.ts +0 -113
- package/src/google-token.ts +0 -391
- package/src/remote-runner.ts +0 -77
- package/src/worker.ts +0 -117
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-login.test.ts +0 -151
- package/tests/connector-runtime.test.ts +0 -1664
- package/tests/google-probe.test.ts +0 -116
- package/tests/google-token.test.ts +0 -425
- package/tests/remote-runner.test.ts +0 -202
- package/tests/worker.test.ts +0 -167
package/src/google-probe.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How this side reads the runner's `GET /health/google` answer.
|
|
3
|
-
*
|
|
4
|
-
* ## Why this is a module and not two `if` statements
|
|
5
|
-
*
|
|
6
|
-
* There are two callers — the connect-time probe in `connector-auth.ts` and the
|
|
7
|
-
* post-refusal probe in `connector-runtime.ts` — and they used to make this
|
|
8
|
-
* judgement separately. That is exactly how the defect below survived review of
|
|
9
|
-
* both: each site branched on `body.ok === true` alone, so both inherited the
|
|
10
|
-
* same wrong reading, and fixing one would have left the other. The judgement
|
|
11
|
-
* now exists once; the call sites only translate a verdict into their own event
|
|
12
|
-
* names.
|
|
13
|
-
*
|
|
14
|
-
* ## The defect this deletes
|
|
15
|
-
*
|
|
16
|
-
* `ok` answers "is the Google layer healthy". It does NOT answer "did anything
|
|
17
|
-
* find out" — and `server.mjs` reports `ok:false` for causes that are facts
|
|
18
|
-
* about the PROBE rather than about Google: it timed out, it could not be run at
|
|
19
|
-
* all (no `gog` on PATH, no `credentials.json` on the volume), its output could
|
|
20
|
-
* not be parsed, or gog declined to state validity. Reading `ok !== true` as
|
|
21
|
-
* "Google refused" filed every one of those at error level under an event whose
|
|
22
|
-
* documented meaning is "Google was asked and **refused**". An operator grepping
|
|
23
|
-
* event names would conclude the refresh token was dead and close the incident
|
|
24
|
-
* on evidence that was never gathered — the original defect (status claiming
|
|
25
|
-
* health nothing measured) with the alarm merely inverted.
|
|
26
|
-
*
|
|
27
|
-
* So the runner now states `measured` explicitly, and this module reads it
|
|
28
|
-
* FIRST. `ok` is only consulted once a measurement is established.
|
|
29
|
-
*
|
|
30
|
-
* ## Which way "unknown" resolves
|
|
31
|
-
*
|
|
32
|
-
* Toward `unmeasured`, always. The two errors are not symmetrical: filing a real
|
|
33
|
-
* refusal as unmeasured under-claims, and the runner's own cause string still
|
|
34
|
-
* rides along on the same log line, so nothing is lost. Filing a non-measurement
|
|
35
|
-
* as a refusal invents evidence. A runner that does not say whether it measured
|
|
36
|
-
* therefore gets no verdict about Google extracted from it — including the
|
|
37
|
-
* incoherent `ok:true, measured:false` and the merely silent `ok:true`, neither
|
|
38
|
-
* of which can license a health claim.
|
|
39
|
-
*
|
|
40
|
-
* `ok:true` is worth spelling out, because it is where this rule is easiest to
|
|
41
|
-
* talk yourself out of: an affirmative-sounding field feels self-licensing, and
|
|
42
|
-
* the harm of believing it looks small. It is not. The GOOD verdict is what the
|
|
43
|
-
* refusal path compares Google's live 401 against, so a `kind:'ok'` built on
|
|
44
|
-
* silence becomes `refusal.google-ok` — the record documented as "the one
|
|
45
|
-
* record that means we cannot explain this", logged at error level, and the
|
|
46
|
-
* only evidence that could ever justify building automatic recovery on the
|
|
47
|
-
* hosted path. Raised from a measurement nobody took, it is precisely the
|
|
48
|
-
* defect this branch exists to delete: a health claim with nothing behind it.
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
/** The verdict, in the three states the log's event names already distinguish. */
|
|
52
|
-
export type GoogleProbeVerdict =
|
|
53
|
-
/** Measured, and the credential works. */
|
|
54
|
-
| { kind: 'ok'; reason?: undefined }
|
|
55
|
-
/** Measured, and it does not. `reason` is the runner's classification. */
|
|
56
|
-
| { kind: 'unhealthy'; reason: string }
|
|
57
|
-
/** Nothing was learned about Google. Not evidence of anything. */
|
|
58
|
-
| { kind: 'unmeasured'; reason: string };
|
|
59
|
-
|
|
60
|
-
/** Read a field only if it really is a boolean — a proxy may put anything here. */
|
|
61
|
-
const bool = (value: unknown): boolean | undefined =>
|
|
62
|
-
typeof value === 'boolean' ? value : undefined;
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The runner's cause, if it sent a usable one.
|
|
66
|
-
*
|
|
67
|
-
* Non-strings are dropped rather than stringified: this value reaches a log
|
|
68
|
-
* aggregator, and `[object Object]` is worse than the honest fallback sentence.
|
|
69
|
-
* Every legitimate value is a literal from `PROBE_CAUSES` in `server.mjs`.
|
|
70
|
-
*/
|
|
71
|
-
const cause = (value: unknown): string | undefined =>
|
|
72
|
-
typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Turn a `/health/google` body into a verdict. Total: every input, including a
|
|
76
|
-
* proxy's HTML page parsed into a string and a body of `null`, yields one of the
|
|
77
|
-
* three kinds and never throws.
|
|
78
|
-
*/
|
|
79
|
-
export function readGoogleProbe(body: unknown): GoogleProbeVerdict {
|
|
80
|
-
const record = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>;
|
|
81
|
-
const measured = bool(record.measured);
|
|
82
|
-
const reported = cause(record.error);
|
|
83
|
-
|
|
84
|
-
// FIRST, and before `ok` is consulted at all: a runner that says it could not
|
|
85
|
-
// measure has told us nothing about the credential, however alarming its cause
|
|
86
|
-
// string reads.
|
|
87
|
-
if (measured === false) {
|
|
88
|
-
return {
|
|
89
|
-
kind: 'unmeasured',
|
|
90
|
-
reason: reported ?? 'the runner reported it could not measure the Google layer',
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
// `ok` is read ONLY here, inside the established measurement. Health is a
|
|
94
|
-
// claim, not a fact that states itself: `ok:true` on its own is a runner
|
|
95
|
-
// asserting a verdict about a credential without saying anything asked.
|
|
96
|
-
if (measured === true) {
|
|
97
|
-
if (bool(record.ok) === true) return { kind: 'ok' };
|
|
98
|
-
return {
|
|
99
|
-
kind: 'unhealthy',
|
|
100
|
-
reason: reported ?? 'the runner reported the Google layer unhealthy with no cause',
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
// No `measured` field at all — whatever `ok` says. Silence is not a
|
|
104
|
-
// measurement, so no claim about the credential may be built on it, in either
|
|
105
|
-
// direction; but whatever the runner did say is carried through, because the
|
|
106
|
-
// operator reading this line needs it.
|
|
107
|
-
return {
|
|
108
|
-
kind: 'unmeasured',
|
|
109
|
-
reason: reported
|
|
110
|
-
? `the runner did not report whether it measured the Google layer; it said: ${reported}`
|
|
111
|
-
: 'the runner did not report whether it measured the Google layer',
|
|
112
|
-
};
|
|
113
|
-
}
|
package/src/google-token.ts
DELETED
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
-
import { credentialTag, logAuthTransition } from './auth-log.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Mint short-lived Google access tokens from a long-lived refresh token, so a
|
|
6
|
-
* hosted gog's identity belongs to the REGISTRATION rather than to the machine
|
|
7
|
-
* the binary runs on (#241).
|
|
8
|
-
*
|
|
9
|
-
* The shape of the problem: `gog` reads credentials from a keyring at
|
|
10
|
-
* `GOG_HOME`, on the box where it executes — which is why one Fly volume ended
|
|
11
|
-
* up being every registration's identity. But `gog --access-token` bypasses the
|
|
12
|
-
* keyring entirely, and #235 already carries such a token to the box per
|
|
13
|
-
* request. The only missing piece was that an access token lives about an hour,
|
|
14
|
-
* so it cannot be the thing you STORE. A refresh token can.
|
|
15
|
-
*
|
|
16
|
-
* So the refresh token stays here, in the child's environment, and only a
|
|
17
|
-
* one-hour access token ever crosses the wire. That is strictly better than the
|
|
18
|
-
* arrangement it replaces, where a permanent credential sat on a shared volume.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Replace a token this long before it actually expires. A token that dies
|
|
25
|
-
* mid-flight is a failure the caller can do nothing about, and the exchange is
|
|
26
|
-
* cheap next to a failed tool call.
|
|
27
|
-
*/
|
|
28
|
-
const EXPIRY_MARGIN_MS = 120_000;
|
|
29
|
-
|
|
30
|
-
interface CachedToken {
|
|
31
|
-
accessToken: string;
|
|
32
|
-
expiresAt: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Keyed by the CREDENTIAL, never a single "current token".
|
|
37
|
-
*
|
|
38
|
-
* A module-level current-token would be correct for one stdio process and
|
|
39
|
-
* silently wrong everywhere else: a Worker isolate serves many callers, so the
|
|
40
|
-
* first caller's identity would be handed to everyone after them. That is the
|
|
41
|
-
* same failure as the captured executor in #235 and the ambient store in #233 —
|
|
42
|
-
* three bugs, one shape, which is why this one is keyed from the start.
|
|
43
|
-
*
|
|
44
|
-
* The key is a hash rather than the token itself so that nothing which dumps or
|
|
45
|
-
* iterates this map (a heap snapshot, a debugger, a future logging line) puts a
|
|
46
|
-
* live credential in front of someone.
|
|
47
|
-
*/
|
|
48
|
-
const cache = new Map<string, CachedToken>();
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Exchanges currently in flight, so concurrent callers share ONE of them.
|
|
52
|
-
*
|
|
53
|
-
* Without this, `get` → `await exchange` → `set` has an await between the miss
|
|
54
|
-
* and the fill: every caller that arrives during that window also misses, and
|
|
55
|
-
* they all hit Google's token endpoint together. One process per caller hides
|
|
56
|
-
* it, but a Worker isolate serving many callers — or simply several tool calls
|
|
57
|
-
* in flight — turns a single refresh into a stampede, and being rate-limited
|
|
58
|
-
* for it produces exactly the intermittent auth failures this was meant to end.
|
|
59
|
-
*
|
|
60
|
-
* Keyed identically to `cache`, so two different credentials never wait on each
|
|
61
|
-
* other's exchange.
|
|
62
|
-
*/
|
|
63
|
-
const inFlight = new Map<string, Promise<CachedToken>>();
|
|
64
|
-
|
|
65
|
-
/** Test seam: both maps are process-wide, so they do not unwind between tests. */
|
|
66
|
-
export function clearAccessTokenCache(): void {
|
|
67
|
-
cache.clear();
|
|
68
|
-
inFlight.clear();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* WebCrypto rather than `node:crypto`: this module is reachable from the Worker
|
|
73
|
-
* build, which has no node builtins. Both runtimes expose `crypto.subtle`.
|
|
74
|
-
*/
|
|
75
|
-
async function cacheKey(refreshToken: string, clientId: string): Promise<string> {
|
|
76
|
-
// NUL-separated, spelled as an escape so this source file stays text: it
|
|
77
|
-
// keeps a (clientId, refreshToken) pair from colliding with a different
|
|
78
|
-
// pair whose concatenation happens to match. Neither value can contain a
|
|
79
|
-
// NUL, which is what makes the boundary unambiguous.
|
|
80
|
-
const data = new TextEncoder().encode(`${clientId}\u0000${refreshToken}`);
|
|
81
|
-
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
82
|
-
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* What a token source is: something that answers "who is this call acting as",
|
|
87
|
-
* or throws trying. It never answers `undefined` after being configured —
|
|
88
|
-
* see the failure note below.
|
|
89
|
-
*
|
|
90
|
-
* It also answers a second question, which is what makes a rejected token
|
|
91
|
-
* recoverable: `invalidate(rejected)` drops that token if it is still the one
|
|
92
|
-
* cached for this credential, and REPORTS whether it dropped anything.
|
|
93
|
-
*
|
|
94
|
-
* `true` means the next read mints something new, so a replay is worth making;
|
|
95
|
-
* `false` means a concurrent caller has already replaced the entry. Those are
|
|
96
|
-
* the only two answers, and a source with nothing to mint from gives neither —
|
|
97
|
-
* it omits the method entirely (see below).
|
|
98
|
-
*/
|
|
99
|
-
export interface AccessTokenSource {
|
|
100
|
-
(): Promise<string | undefined>;
|
|
101
|
-
/**
|
|
102
|
-
* Evict `rejected` if it is still this credential's cached token; answer
|
|
103
|
-
* whether anything was evicted.
|
|
104
|
-
*
|
|
105
|
-
* ABSENT exactly when this source holds nothing mintable — a directly-supplied
|
|
106
|
-
* GOG_ACCESS_TOKEN, or a refresh token with no OAuth client beside it. The
|
|
107
|
-
* absence has to be the signal, because an always-false return is not
|
|
108
|
-
* distinguishable from the false a real cache gives when a concurrent caller
|
|
109
|
-
* beat you to the refresh, and the connector records those as different
|
|
110
|
-
* causes: "nothing here can mint a replacement" is the one configuration
|
|
111
|
-
* where re-authorizing genuinely IS the repair, and it must not be logged as
|
|
112
|
-
* somebody else's concurrent write.
|
|
113
|
-
*
|
|
114
|
-
* Scoped to ONE credential on purpose. `clearAccessTokenCache()` drops every
|
|
115
|
-
* entry and exists only as a test seam — using it here would mean one
|
|
116
|
-
* caller's dead token forced a re-mint on every other caller sharing the
|
|
117
|
-
* isolate, which is the same shared-identity mistake this module is built
|
|
118
|
-
* around, wearing a different hat.
|
|
119
|
-
*
|
|
120
|
-
* Matched by VALUE, not just by key. A concurrent caller may already have
|
|
121
|
-
* replaced the entry while this call was in flight; evicting that fresh token
|
|
122
|
-
* would waste a mint and let two callers keep undoing each other's work.
|
|
123
|
-
*/
|
|
124
|
-
invalidate?: (rejected: string) => Promise<boolean>;
|
|
125
|
-
/**
|
|
126
|
-
* The log-safe name of the credential behind this source, for correlating
|
|
127
|
-
* this module's records with the connector's (auth-log.ts).
|
|
128
|
-
*
|
|
129
|
-
* Optional, and absent exactly when there is no mintable credential to name.
|
|
130
|
-
* A directly-supplied GOG_ACCESS_TOKEN emits no records here at all — nothing
|
|
131
|
-
* is minted, cached or evicted — so a tag for it would identify a story that
|
|
132
|
-
* is never told. Answering `undefined` says that honestly rather than
|
|
133
|
-
* inventing an identifier for a credential this module does not hold.
|
|
134
|
-
*/
|
|
135
|
-
credentialId?: () => Promise<string>;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export interface TokenEnv {
|
|
139
|
-
GOG_ACCESS_TOKEN?: string;
|
|
140
|
-
GOG_REFRESH_TOKEN?: string;
|
|
141
|
-
GOG_CLIENT_ID?: string;
|
|
142
|
-
GOG_CLIENT_SECRET?: string;
|
|
143
|
-
[key: string]: string | undefined;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Build the token source for this environment, or `undefined` when nothing is
|
|
148
|
-
* configured — which leaves the backend acting as itself, exactly as every
|
|
149
|
-
* registration did before this existed.
|
|
150
|
-
*
|
|
151
|
-
* Precedence puts a directly-supplied `GOG_ACCESS_TOKEN` first: someone who
|
|
152
|
-
* already holds a token should not need an OAuth client to use it, and it keeps
|
|
153
|
-
* the #230 path working untouched.
|
|
154
|
-
*/
|
|
155
|
-
export function makeAccessTokenSource(env: TokenEnv): AccessTokenSource | undefined {
|
|
156
|
-
// A token handed to us whole. Nothing minted it here, so nothing here can
|
|
157
|
-
// mint another — hence no `invalidate` at all, which is how the caller tells
|
|
158
|
-
// this apart from a cache that merely lost a race. When THIS is the token
|
|
159
|
-
// Google rejects, re-authorization really is the repair.
|
|
160
|
-
const direct = readEnvVar('GOG_ACCESS_TOKEN', { env });
|
|
161
|
-
if (direct) return async () => direct;
|
|
162
|
-
|
|
163
|
-
const refreshToken = readEnvVar('GOG_REFRESH_TOKEN', { env });
|
|
164
|
-
if (!refreshToken) return undefined;
|
|
165
|
-
|
|
166
|
-
const clientId = readEnvVar('GOG_CLIENT_ID', { env });
|
|
167
|
-
const clientSecret = readEnvVar('GOG_CLIENT_SECRET', { env });
|
|
168
|
-
|
|
169
|
-
// A refresh token with no OAuth client cannot mint anything, and the WRONG
|
|
170
|
-
// repair is to treat it as unconfigured: that falls back to the backend's own
|
|
171
|
-
// identity, which is the precise confusion this feature exists to remove. So
|
|
172
|
-
// the source exists and throws when used — `tools/list` still works, the
|
|
173
|
-
// server still starts, and the first tool call says what is missing.
|
|
174
|
-
if (!clientId || !clientSecret) {
|
|
175
|
-
const missing = [!clientId && 'GOG_CLIENT_ID', !clientSecret && 'GOG_CLIENT_SECRET']
|
|
176
|
-
.filter(Boolean)
|
|
177
|
-
.join(' and ');
|
|
178
|
-
return async () => {
|
|
179
|
-
throw new Error(
|
|
180
|
-
`GOG_REFRESH_TOKEN is set but ${missing} is not, so no access token can be minted. ` +
|
|
181
|
-
'Set the OAuth client alongside the refresh token, or unset GOG_REFRESH_TOKEN to use the backend’s own identity.',
|
|
182
|
-
);
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Hashed ONCE per source rather than once per call. `read` and `invalidate`
|
|
187
|
-
// each used to recompute the SHA-256 on every invocation, which is work this
|
|
188
|
-
// module was already paying for on the request path; memoizing it also gives
|
|
189
|
-
// the log records a credential tag for free. Lazy rather than eager so a
|
|
190
|
-
// source that is never used never starts a promise nobody awaits.
|
|
191
|
-
let keyPromise: Promise<string> | undefined;
|
|
192
|
-
const key = (): Promise<string> => (keyPromise ??= cacheKey(refreshToken, clientId));
|
|
193
|
-
|
|
194
|
-
// Every other transition in the set is an EVENT; a cache hit is the absence
|
|
195
|
-
// of one. Narrating it writes a line per gog invocation — a Workers Logs line
|
|
196
|
-
// (and its cost) for every tool call in healthy operation on the Worker, and
|
|
197
|
-
// a stderr line per call in the MCP host's server log on stdio — which turns
|
|
198
|
-
// the `gog-auth` stream from a log of transitions into a request log.
|
|
199
|
-
//
|
|
200
|
-
// It stays available for the investigation that has to prove WHICH token a
|
|
201
|
-
// call was served (the shape the original incident took), behind a flag
|
|
202
|
-
// nobody sets in normal operation. Read once per source rather than per call:
|
|
203
|
-
// the env cannot change under a running process.
|
|
204
|
-
const logCacheHits = parseBoolEnv('GOG_AUTH_LOG_CACHE_HITS', { env });
|
|
205
|
-
|
|
206
|
-
const read = async (): Promise<string | undefined> => {
|
|
207
|
-
const k = await key();
|
|
208
|
-
const hit = cache.get(k);
|
|
209
|
-
if (hit && hit.expiresAt - EXPIRY_MARGIN_MS > Date.now()) {
|
|
210
|
-
if (logCacheHits) logAuthTransition('token.cache-hit', { credential: credentialTag(k) });
|
|
211
|
-
return hit.accessToken;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Join the exchange already running for this credential, or start the one
|
|
215
|
-
// everyone else will join.
|
|
216
|
-
let pending = inFlight.get(k);
|
|
217
|
-
if (!pending) {
|
|
218
|
-
pending = exchange(refreshToken, clientId, clientSecret)
|
|
219
|
-
.then((minted) => {
|
|
220
|
-
cache.set(k, minted);
|
|
221
|
-
logAuthTransition('token.minted', {
|
|
222
|
-
credential: credentialTag(k),
|
|
223
|
-
reason: `valid for ${Math.round((minted.expiresAt - Date.now()) / 1000)}s`,
|
|
224
|
-
});
|
|
225
|
-
return minted;
|
|
226
|
-
})
|
|
227
|
-
// Only the caller that STARTED the exchange records it, because only one
|
|
228
|
-
// exchange happened; the callers that joined it would otherwise turn one
|
|
229
|
-
// mint into a burst of identical lines.
|
|
230
|
-
//
|
|
231
|
-
// Typed as TokenExchangeError rather than `unknown`: `exchange` catches
|
|
232
|
-
// the fetch rejection and the JSON parse itself, so this is the only
|
|
233
|
-
// thing that can arrive here, and pretending otherwise would add an arm
|
|
234
|
-
// no test could ever reach.
|
|
235
|
-
.catch((err: TokenExchangeError): never => {
|
|
236
|
-
logAuthTransition(err.grantDead ? 'grant.dead' : 'token.mint-failed', {
|
|
237
|
-
credential: credentialTag(k),
|
|
238
|
-
reason: err.message,
|
|
239
|
-
});
|
|
240
|
-
throw err;
|
|
241
|
-
})
|
|
242
|
-
// Dropped whether it resolved OR threw. Keeping a rejected promise here
|
|
243
|
-
// would make one transient failure permanent for every later caller —
|
|
244
|
-
// the opposite of the "failures are not cached" rule above.
|
|
245
|
-
.finally(() => inFlight.delete(k));
|
|
246
|
-
inFlight.set(k, pending);
|
|
247
|
-
}
|
|
248
|
-
const minted = await pending;
|
|
249
|
-
return minted.accessToken;
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Google rejected `rejected`; make sure the next read does not serve it again.
|
|
254
|
-
*
|
|
255
|
-
* The read guard above is purely about TIME, so without this a token Google
|
|
256
|
-
* has already answered 401 to is re-served until its nominal expiry — up to
|
|
257
|
-
* ~58 minutes of every call failing identically, which is exactly the incident
|
|
258
|
-
* this closes. `inFlight` is deliberately untouched: an exchange that is
|
|
259
|
-
* already running was started to produce a NEW token, and cancelling it would
|
|
260
|
-
* only make the callers waiting on it mint again.
|
|
261
|
-
*/
|
|
262
|
-
const invalidate = async (rejected: string): Promise<boolean> => {
|
|
263
|
-
const k = await key();
|
|
264
|
-
const hit = cache.get(k);
|
|
265
|
-
if (!hit || hit.accessToken !== rejected) {
|
|
266
|
-
// The two "nothing happened" cases are worth telling apart in a log: one
|
|
267
|
-
// says a concurrent caller has already repaired this credential, the
|
|
268
|
-
// other says the rejected token was never ours to begin with.
|
|
269
|
-
logAuthTransition('token.evict-noop', {
|
|
270
|
-
credential: credentialTag(k),
|
|
271
|
-
reason: hit
|
|
272
|
-
? 'a concurrent caller had already replaced this credential’s token'
|
|
273
|
-
: 'no token was cached for this credential',
|
|
274
|
-
});
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
|
-
cache.delete(k);
|
|
278
|
-
logAuthTransition('token.evicted', {
|
|
279
|
-
credential: credentialTag(k),
|
|
280
|
-
reason: 'Google rejected this access token; the next read will mint a new one',
|
|
281
|
-
});
|
|
282
|
-
return true;
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
return Object.assign(read, {
|
|
286
|
-
invalidate,
|
|
287
|
-
credentialId: async () => credentialTag(await key()),
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Exchange refresh -> access.
|
|
293
|
-
*
|
|
294
|
-
* THROWS on every failure, and never returns `undefined`. Returning nothing
|
|
295
|
-
* would let the call proceed as the backend's identity, and the caller would
|
|
296
|
-
* read someone else's mailbox while everything looked like success — the same
|
|
297
|
-
* reasoning that made a malformed token a 400 rather than an ignore in #235.
|
|
298
|
-
*
|
|
299
|
-
* Nothing here is cached on failure either, so a transient Google outage does
|
|
300
|
-
* not become a sticky one.
|
|
301
|
-
*/
|
|
302
|
-
class TokenExchangeError extends Error {
|
|
303
|
-
/**
|
|
304
|
-
* The REFRESH token is dead (Google's `invalid_grant`), not merely the access
|
|
305
|
-
* token. Carried as a flag rather than re-read from the message, because
|
|
306
|
-
* inferring the author of a failure from prose several authors can produce is
|
|
307
|
-
* precisely the mistake this branch exists to undo. `instanceof` is safe: the
|
|
308
|
-
* class is thrown and caught inside this one module.
|
|
309
|
-
*/
|
|
310
|
-
readonly grantDead: boolean;
|
|
311
|
-
|
|
312
|
-
constructor(message: string, grantDead: boolean) {
|
|
313
|
-
super(message);
|
|
314
|
-
this.grantDead = grantDead;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function exchange(refreshToken: string, clientId: string, clientSecret: string): Promise<CachedToken> {
|
|
319
|
-
let res: Response;
|
|
320
|
-
try {
|
|
321
|
-
res = await fetch(TOKEN_ENDPOINT, {
|
|
322
|
-
method: 'POST',
|
|
323
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
324
|
-
body: new URLSearchParams({
|
|
325
|
-
grant_type: 'refresh_token',
|
|
326
|
-
refresh_token: refreshToken,
|
|
327
|
-
client_id: clientId,
|
|
328
|
-
client_secret: clientSecret,
|
|
329
|
-
}).toString(),
|
|
330
|
-
});
|
|
331
|
-
} catch (err) {
|
|
332
|
-
throw new TokenExchangeError(
|
|
333
|
-
`the Google token exchange could not be reached: ${err instanceof Error ? err.message : String(err)}`,
|
|
334
|
-
false,
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const body = (await res.json().catch(() => ({}))) as {
|
|
339
|
-
access_token?: string;
|
|
340
|
-
expires_in?: number;
|
|
341
|
-
error?: string;
|
|
342
|
-
error_description?: string;
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
if (!res.ok) {
|
|
346
|
-
// invalid_grant is the one worth naming, because it is not a bug and not
|
|
347
|
-
// transient: the credential is gone and a human has to enrol again. Google
|
|
348
|
-
// expires refresh tokens after 7 days while a consent screen is still in
|
|
349
|
-
// "Testing" mode, which is how this fleet has usually met it.
|
|
350
|
-
//
|
|
351
|
-
// The literal `invalid_grant` is in the message ON PURPOSE, and is load
|
|
352
|
-
// bearing rather than decoration. This mint is a first-class error surface
|
|
353
|
-
// — it propagates in place of gog's 401 (connector-runtime.ts) — and
|
|
354
|
-
// tools/utils.ts picks INVALID_GRANT_HINT, the one that names the 7-day
|
|
355
|
-
// Testing-mode cause and the gog_auth_add_url/gog_auth_add_complete pair,
|
|
356
|
-
// by matching that exact token. Prose alone does not qualify: the previous
|
|
357
|
-
// wording ("token has expired or been revoked") missed
|
|
358
|
-
// INVALID_GRANT_PATTERN's second alternative ("token has been expired or
|
|
359
|
-
// revoked") by one word, so a dead refresh token surfaced here earned only
|
|
360
|
-
// the generic "authentication may have expired" advice while the identical
|
|
361
|
-
// failure reported BY gog earned the specific guidance.
|
|
362
|
-
if (body.error === 'invalid_grant') {
|
|
363
|
-
throw new TokenExchangeError(
|
|
364
|
-
'the stored refresh token was rejected (invalid_grant): it has expired or been revoked, so ' +
|
|
365
|
-
'this account must be re-authorized ' +
|
|
366
|
-
'(commonly the 7-day limit on OAuth consent screens still in "Testing" mode). ' +
|
|
367
|
-
'Re-enrol with gog_auth_add_url + gog_auth_add_complete and store the new refresh token.',
|
|
368
|
-
true,
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
// The refresh token is deliberately absent from this message — it is a
|
|
372
|
-
// long-lived credential and an error string travels into logs and model
|
|
373
|
-
// context.
|
|
374
|
-
throw new TokenExchangeError(
|
|
375
|
-
`the access token could not be refreshed (HTTP ${res.status}${body.error ? `, ${body.error}` : ''})`,
|
|
376
|
-
false,
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
if (!body.access_token) {
|
|
381
|
-
throw new TokenExchangeError(
|
|
382
|
-
'the access token could not be refreshed: Google returned no access_token',
|
|
383
|
-
false,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Default to an hour if Google omits expires_in; the margin above covers the
|
|
388
|
-
// difference between that guess and reality.
|
|
389
|
-
const expiresInMs = (body.expires_in ?? 3600) * 1000;
|
|
390
|
-
return { accessToken: body.access_token, expiresAt: Date.now() + expiresInMs };
|
|
391
|
-
}
|
package/src/remote-runner.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
-
import { setDefaultGogExecutor } from './runner.js';
|
|
3
|
-
import { makeAccessTokenSource } from './google-token.js';
|
|
4
|
-
import { makeFlyExecutor } from './connector-runtime.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Let a stdio server run `gog` on the Fly backend instead of spawning it.
|
|
8
|
-
*
|
|
9
|
-
* The default stdio path shells out to the `gog` binary (`runner.ts`,
|
|
10
|
-
* `spawn(GOG_PATH ?? 'gog')`), which is right on a laptop and impossible
|
|
11
|
-
* anywhere the binary is not installed — notably mcp-host, whose runner image
|
|
12
|
-
* is deliberately Node + git + tar and nothing else. Baking a Go binary into a
|
|
13
|
-
* generic runner for one MCP's sake, or curling an unpinned release tarball
|
|
14
|
-
* inside an install, are both worse than using the seam that already exists:
|
|
15
|
-
* `makeFlyExecutor` has forwarded arg-arrays to `<runner>/run` for the
|
|
16
|
-
* Cloudflare connector since that connector shipped, and it touches nothing
|
|
17
|
-
* Worker-only.
|
|
18
|
-
*
|
|
19
|
-
* So this is wiring, not new machinery. Set both variables and the process
|
|
20
|
-
* executes remotely; leave either unset and nothing changes, which is what
|
|
21
|
-
* keeps every existing local install on the binary it already has.
|
|
22
|
-
*
|
|
23
|
-
* ## Why a process-wide default and not the AsyncLocalStorage
|
|
24
|
-
*
|
|
25
|
-
* `runExecutor` is an AsyncLocalStorage, and the Worker wraps each REQUEST in
|
|
26
|
-
* `runExecutor.run(...)` because one isolate serves many callers whose backend
|
|
27
|
-
* credentials differ. A stdio process is the opposite: one backend for its
|
|
28
|
-
* whole life.
|
|
29
|
-
*
|
|
30
|
-
* This used to reach for `enterWith` on the theory that it "sets the store for
|
|
31
|
-
* the whole process". It does not. `enterWith` sets the store on the async
|
|
32
|
-
* resource that is CURRENT when it runs — here, module evaluation — and the
|
|
33
|
-
* tool calls arrive later as I/O events on the transport's own resources, which
|
|
34
|
-
* do not descend from that. So `getStore()` was undefined at exactly the moment
|
|
35
|
-
* `run()` asked, and the seam reverted to spawning a binary the host does not
|
|
36
|
-
* have. Every hosted gog MCP answered "gog executable not found" while pointed
|
|
37
|
-
* at a healthy backend, and the unit test missed it because a test that calls
|
|
38
|
-
* this function itself awaits inside the resource it just mutated.
|
|
39
|
-
*
|
|
40
|
-
* A process-lifetime value is not a scoped value, so it does not live in a
|
|
41
|
-
* scope: `setDefaultGogExecutor` holds it, and a per-request store still beats
|
|
42
|
-
* it (runner.ts `activeExecutor`) so the Worker path is unchanged.
|
|
43
|
-
*
|
|
44
|
-
* Call before the server starts, so no tool can be serviced ahead of it.
|
|
45
|
-
*/
|
|
46
|
-
export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
47
|
-
// The shared reader, not a local trim: it already treats blanks, unexpanded
|
|
48
|
-
// `${...}` placeholders AND the literal strings "undefined"/"null" as unset.
|
|
49
|
-
// Those last two are what a hand-rolled check misses, and they arrive whenever
|
|
50
|
-
// a host stringifies a missing value into an env block.
|
|
51
|
-
const endpoint = readEnvVar('GOG_RUNNER_URL', { env });
|
|
52
|
-
const key = readEnvVar('GOG_RUNNER_KEY', { env });
|
|
53
|
-
// Both or neither. A URL with no key would send unauthenticated requests the
|
|
54
|
-
// runner rejects, and a key with no URL is a credential configured for
|
|
55
|
-
// nothing — either alone is a misconfiguration, and silently spawning
|
|
56
|
-
// instead would hide it until someone wondered why the binary was needed.
|
|
57
|
-
if (!endpoint || !key) return false;
|
|
58
|
-
// Whose Google identity this process acts as (#230). The backend holds ONE
|
|
59
|
-
// identity on its volume, so without this every caller of a hosted gog acts
|
|
60
|
-
// as whoever seeded it. Under mcp-host's `perUserChild` this process belongs
|
|
61
|
-
// to a single caller and its environment carries that caller's token, so
|
|
62
|
-
// forwarding it is the whole of "act as the person calling you".
|
|
63
|
-
//
|
|
64
|
-
// Read per call rather than captured here, because the executor outlives any
|
|
65
|
-
// one request and the claim being made is about a request. Absent when unset,
|
|
66
|
-
// which is every registration that predates per-caller auth.
|
|
67
|
-
//
|
|
68
|
-
// The source also covers the case where the registration stores a REFRESH
|
|
69
|
-
// token instead (#241) — the identity then belongs to the registration rather
|
|
70
|
-
// than to the backend's volume, and the short-lived token it mints is the
|
|
71
|
-
// only thing that crosses the wire. `undefined` when neither is configured,
|
|
72
|
-
// which leaves the backend acting as itself exactly as before.
|
|
73
|
-
setDefaultGogExecutor(
|
|
74
|
-
makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, makeAccessTokenSource(env)),
|
|
75
|
-
);
|
|
76
|
-
return true;
|
|
77
|
-
}
|