gogcli-mcp 2.19.2 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +134 -30
- package/dist/lib.js +95 -10
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/connector-runtime.ts +27 -2
- package/src/google-token.ts +225 -0
- package/src/remote-runner.ts +19 -1
- package/src/tools/auth.ts +6 -1
- package/src/tools/utils.ts +25 -3
- package/src/worker.ts +1 -1
- package/tests/connector-runtime.test.ts +51 -0
- package/tests/google-token.test.ts +304 -0
- package/tests/remote-runner.test.ts +41 -0
- package/tests/sdk-single-copy.test.ts +48 -0
- package/tests/tools/auth.test.ts +21 -0
- package/tests/tools/utils.test.ts +44 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mint short-lived Google access tokens from a long-lived refresh token, so a
|
|
5
|
+
* hosted gog's identity belongs to the REGISTRATION rather than to the machine
|
|
6
|
+
* the binary runs on (#241).
|
|
7
|
+
*
|
|
8
|
+
* The shape of the problem: `gog` reads credentials from a keyring at
|
|
9
|
+
* `GOG_HOME`, on the box where it executes — which is why one Fly volume ended
|
|
10
|
+
* up being every registration's identity. But `gog --access-token` bypasses the
|
|
11
|
+
* keyring entirely, and #235 already carries such a token to the box per
|
|
12
|
+
* request. The only missing piece was that an access token lives about an hour,
|
|
13
|
+
* so it cannot be the thing you STORE. A refresh token can.
|
|
14
|
+
*
|
|
15
|
+
* So the refresh token stays here, in the child's environment, and only a
|
|
16
|
+
* one-hour access token ever crosses the wire. That is strictly better than the
|
|
17
|
+
* arrangement it replaces, where a permanent credential sat on a shared volume.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Replace a token this long before it actually expires. A token that dies
|
|
24
|
+
* mid-flight is a failure the caller can do nothing about, and the exchange is
|
|
25
|
+
* cheap next to a failed tool call.
|
|
26
|
+
*/
|
|
27
|
+
const EXPIRY_MARGIN_MS = 120_000;
|
|
28
|
+
|
|
29
|
+
interface CachedToken {
|
|
30
|
+
accessToken: string;
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Keyed by the CREDENTIAL, never a single "current token".
|
|
36
|
+
*
|
|
37
|
+
* A module-level current-token would be correct for one stdio process and
|
|
38
|
+
* silently wrong everywhere else: a Worker isolate serves many callers, so the
|
|
39
|
+
* first caller's identity would be handed to everyone after them. That is the
|
|
40
|
+
* same failure as the captured executor in #235 and the ambient store in #233 —
|
|
41
|
+
* three bugs, one shape, which is why this one is keyed from the start.
|
|
42
|
+
*
|
|
43
|
+
* The key is a hash rather than the token itself so that nothing which dumps or
|
|
44
|
+
* iterates this map (a heap snapshot, a debugger, a future logging line) puts a
|
|
45
|
+
* live credential in front of someone.
|
|
46
|
+
*/
|
|
47
|
+
const cache = new Map<string, CachedToken>();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exchanges currently in flight, so concurrent callers share ONE of them.
|
|
51
|
+
*
|
|
52
|
+
* Without this, `get` → `await exchange` → `set` has an await between the miss
|
|
53
|
+
* and the fill: every caller that arrives during that window also misses, and
|
|
54
|
+
* they all hit Google's token endpoint together. One process per caller hides
|
|
55
|
+
* it, but a Worker isolate serving many callers — or simply several tool calls
|
|
56
|
+
* in flight — turns a single refresh into a stampede, and being rate-limited
|
|
57
|
+
* for it produces exactly the intermittent auth failures this was meant to end.
|
|
58
|
+
*
|
|
59
|
+
* Keyed identically to `cache`, so two different credentials never wait on each
|
|
60
|
+
* other's exchange.
|
|
61
|
+
*/
|
|
62
|
+
const inFlight = new Map<string, Promise<CachedToken>>();
|
|
63
|
+
|
|
64
|
+
/** Test seam: both maps are process-wide, so they do not unwind between tests. */
|
|
65
|
+
export function clearAccessTokenCache(): void {
|
|
66
|
+
cache.clear();
|
|
67
|
+
inFlight.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* WebCrypto rather than `node:crypto`: this module is reachable from the Worker
|
|
72
|
+
* build, which has no node builtins. Both runtimes expose `crypto.subtle`.
|
|
73
|
+
*/
|
|
74
|
+
async function cacheKey(refreshToken: string, clientId: string): Promise<string> {
|
|
75
|
+
// NUL-separated, spelled as an escape so this source file stays text: it
|
|
76
|
+
// keeps a (clientId, refreshToken) pair from colliding with a different
|
|
77
|
+
// pair whose concatenation happens to match. Neither value can contain a
|
|
78
|
+
// NUL, which is what makes the boundary unambiguous.
|
|
79
|
+
const data = new TextEncoder().encode(`${clientId}\u0000${refreshToken}`);
|
|
80
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
81
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a token source is: something that answers "who is this call acting as",
|
|
86
|
+
* or throws trying. It never answers `undefined` after being configured —
|
|
87
|
+
* see the failure note below.
|
|
88
|
+
*/
|
|
89
|
+
export type AccessTokenSource = () => Promise<string | undefined>;
|
|
90
|
+
|
|
91
|
+
export interface TokenEnv {
|
|
92
|
+
GOG_ACCESS_TOKEN?: string;
|
|
93
|
+
GOG_REFRESH_TOKEN?: string;
|
|
94
|
+
GOG_CLIENT_ID?: string;
|
|
95
|
+
GOG_CLIENT_SECRET?: string;
|
|
96
|
+
[key: string]: string | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the token source for this environment, or `undefined` when nothing is
|
|
101
|
+
* configured — which leaves the backend acting as itself, exactly as every
|
|
102
|
+
* registration did before this existed.
|
|
103
|
+
*
|
|
104
|
+
* Precedence puts a directly-supplied `GOG_ACCESS_TOKEN` first: someone who
|
|
105
|
+
* already holds a token should not need an OAuth client to use it, and it keeps
|
|
106
|
+
* the #230 path working untouched.
|
|
107
|
+
*/
|
|
108
|
+
export function makeAccessTokenSource(env: TokenEnv): AccessTokenSource | undefined {
|
|
109
|
+
const direct = readEnvVar('GOG_ACCESS_TOKEN', { env });
|
|
110
|
+
if (direct) return async () => direct;
|
|
111
|
+
|
|
112
|
+
const refreshToken = readEnvVar('GOG_REFRESH_TOKEN', { env });
|
|
113
|
+
if (!refreshToken) return undefined;
|
|
114
|
+
|
|
115
|
+
const clientId = readEnvVar('GOG_CLIENT_ID', { env });
|
|
116
|
+
const clientSecret = readEnvVar('GOG_CLIENT_SECRET', { env });
|
|
117
|
+
|
|
118
|
+
// A refresh token with no OAuth client cannot mint anything, and the WRONG
|
|
119
|
+
// repair is to treat it as unconfigured: that falls back to the backend's own
|
|
120
|
+
// identity, which is the precise confusion this feature exists to remove. So
|
|
121
|
+
// the source exists and throws when used — `tools/list` still works, the
|
|
122
|
+
// server still starts, and the first tool call says what is missing.
|
|
123
|
+
if (!clientId || !clientSecret) {
|
|
124
|
+
const missing = [!clientId && 'GOG_CLIENT_ID', !clientSecret && 'GOG_CLIENT_SECRET']
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join(' and ');
|
|
127
|
+
return async () => {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`GOG_REFRESH_TOKEN is set but ${missing} is not, so no access token can be minted. ` +
|
|
130
|
+
'Set the OAuth client alongside the refresh token, or unset GOG_REFRESH_TOKEN to use the backend’s own identity.',
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return async () => {
|
|
136
|
+
const key = await cacheKey(refreshToken, clientId);
|
|
137
|
+
const hit = cache.get(key);
|
|
138
|
+
if (hit && hit.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return hit.accessToken;
|
|
139
|
+
|
|
140
|
+
// Join the exchange already running for this credential, or start the one
|
|
141
|
+
// everyone else will join.
|
|
142
|
+
let pending = inFlight.get(key);
|
|
143
|
+
if (!pending) {
|
|
144
|
+
pending = exchange(refreshToken, clientId, clientSecret)
|
|
145
|
+
.then((minted) => {
|
|
146
|
+
cache.set(key, minted);
|
|
147
|
+
return minted;
|
|
148
|
+
})
|
|
149
|
+
// Dropped whether it resolved OR threw. Keeping a rejected promise here
|
|
150
|
+
// would make one transient failure permanent for every later caller —
|
|
151
|
+
// the opposite of the "failures are not cached" rule above.
|
|
152
|
+
.finally(() => inFlight.delete(key));
|
|
153
|
+
inFlight.set(key, pending);
|
|
154
|
+
}
|
|
155
|
+
const minted = await pending;
|
|
156
|
+
return minted.accessToken;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Exchange refresh -> access.
|
|
162
|
+
*
|
|
163
|
+
* THROWS on every failure, and never returns `undefined`. Returning nothing
|
|
164
|
+
* would let the call proceed as the backend's identity, and the caller would
|
|
165
|
+
* read someone else's mailbox while everything looked like success — the same
|
|
166
|
+
* reasoning that made a malformed token a 400 rather than an ignore in #235.
|
|
167
|
+
*
|
|
168
|
+
* Nothing here is cached on failure either, so a transient Google outage does
|
|
169
|
+
* not become a sticky one.
|
|
170
|
+
*/
|
|
171
|
+
async function exchange(refreshToken: string, clientId: string, clientSecret: string): Promise<CachedToken> {
|
|
172
|
+
let res: Response;
|
|
173
|
+
try {
|
|
174
|
+
res = await fetch(TOKEN_ENDPOINT, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
177
|
+
body: new URLSearchParams({
|
|
178
|
+
grant_type: 'refresh_token',
|
|
179
|
+
refresh_token: refreshToken,
|
|
180
|
+
client_id: clientId,
|
|
181
|
+
client_secret: clientSecret,
|
|
182
|
+
}).toString(),
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`the Google token exchange could not be reached: ${err instanceof Error ? err.message : String(err)}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
191
|
+
access_token?: string;
|
|
192
|
+
expires_in?: number;
|
|
193
|
+
error?: string;
|
|
194
|
+
error_description?: string;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
// invalid_grant is the one worth naming, because it is not a bug and not
|
|
199
|
+
// transient: the credential is gone and a human has to enrol again. Google
|
|
200
|
+
// expires refresh tokens after 7 days while a consent screen is still in
|
|
201
|
+
// "Testing" mode, which is how this fleet has usually met it.
|
|
202
|
+
if (body.error === 'invalid_grant') {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'the stored refresh token has expired or been revoked, so this account must be re-authorized ' +
|
|
205
|
+
'(commonly the 7-day limit on OAuth consent screens still in "Testing" mode). ' +
|
|
206
|
+
'Re-enrol with gog_auth_add_url + gog_auth_add_complete and store the new refresh token.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
// The refresh token is deliberately absent from this message — it is a
|
|
210
|
+
// long-lived credential and an error string travels into logs and model
|
|
211
|
+
// context.
|
|
212
|
+
throw new Error(
|
|
213
|
+
`the access token could not be refreshed (HTTP ${res.status}${body.error ? `, ${body.error}` : ''})`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!body.access_token) {
|
|
218
|
+
throw new Error('the access token could not be refreshed: Google returned no access_token');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Default to an hour if Google omits expires_in; the margin above covers the
|
|
222
|
+
// difference between that guess and reality.
|
|
223
|
+
const expiresInMs = (body.expires_in ?? 3600) * 1000;
|
|
224
|
+
return { accessToken: body.access_token, expiresAt: Date.now() + expiresInMs };
|
|
225
|
+
}
|
package/src/remote-runner.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
2
|
import { setDefaultGogExecutor } from './runner.js';
|
|
3
|
+
import { makeAccessTokenSource } from './google-token.js';
|
|
3
4
|
import { makeFlyExecutor } from './connector-runtime.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -54,6 +55,23 @@ export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolea
|
|
|
54
55
|
// nothing — either alone is a misconfiguration, and silently spawning
|
|
55
56
|
// instead would hide it until someone wondered why the binary was needed.
|
|
56
57
|
if (!endpoint || !key) return false;
|
|
57
|
-
|
|
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
|
+
);
|
|
58
76
|
return true;
|
|
59
77
|
}
|
package/src/tools/auth.ts
CHANGED
|
@@ -14,7 +14,12 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
14
14
|
`Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request ` +
|
|
15
15
|
`with invalid_scope.`;
|
|
16
16
|
server.registerTool('gog_auth_list', {
|
|
17
|
-
description:
|
|
17
|
+
description:
|
|
18
|
+
'List the Google accounts stored in gogcli, with their scopes. This reads local ' +
|
|
19
|
+
'configuration only — it does not contact Google and does NOT tell you whether an account ' +
|
|
20
|
+
'still works: a signed-out account whose refresh token expired or was revoked is listed here ' +
|
|
21
|
+
'exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account ' +
|
|
22
|
+
'can actually authenticate.',
|
|
18
23
|
annotations: { readOnlyHint: true },
|
|
19
24
|
inputSchema: {},
|
|
20
25
|
}, async () => {
|
package/src/tools/utils.ts
CHANGED
|
@@ -135,7 +135,18 @@ export function errorText(err: unknown): string {
|
|
|
135
135
|
return err instanceof Error ? `Error: ${err.message}` : String(err);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// Google saying "not authenticated" in its own words. Definitive: a retry
|
|
139
|
+
// cannot turn a 401 into a success, so this outranks the transient signal below.
|
|
140
|
+
const DEFINITE_AUTH_PATTERN = /\b(401|unauthorized|invalid_grant)\b/i;
|
|
141
|
+
|
|
142
|
+
// A message that TALKS about an expired token. Suggestive, not definitive — and
|
|
143
|
+
// it used to be `/token.*(expired|revoked)/`, whose greedy `.*` matched a token
|
|
144
|
+
// mentioned anywhere and an expiry mentioned anywhere later in the same line
|
|
145
|
+
// ("page token accepted; the export link has expired"). Now the two words must
|
|
146
|
+
// actually be about each other.
|
|
147
|
+
const STALE_TOKEN_PATTERN = /\b(?:access[ _-]?)?token\b[^.;\n]{0,40}\b(?:has\s+)?(?:been\s+)?(?:expired|revoked)\b|\b(?:expired|revoked)\s+(?:access[ _-]?)?token\b/i;
|
|
148
|
+
|
|
149
|
+
const AUTH_ERROR_PATTERN = new RegExp(`${DEFINITE_AUTH_PATTERN.source}|${STALE_TOKEN_PATTERN.source}`, 'i');
|
|
139
150
|
|
|
140
151
|
// A DEAD refresh token — the whole account is signed out, not just a stale
|
|
141
152
|
// access token that would refresh silently. This is the recurring account-wide
|
|
@@ -207,8 +218,19 @@ export function formatAccountList(raw: string): string {
|
|
|
207
218
|
export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
208
219
|
const errText = errorText(err);
|
|
209
220
|
const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
|
|
210
|
-
|
|
211
|
-
|
|
221
|
+
|
|
222
|
+
// Precedence, and the reason for it. Reporting needs-auth is EXPENSIVE to be
|
|
223
|
+
// wrong about: re-authorization is a manual, human step, and it does not fix
|
|
224
|
+
// a 429. So a transient signal beats a merely SUGGESTIVE auth signal (a
|
|
225
|
+
// message that mentions an expired token) — the reported bug was servers
|
|
226
|
+
// flapping into needs-auth and working again seconds later, which is what
|
|
227
|
+
// being told to re-auth over a rate-limit looks like.
|
|
228
|
+
//
|
|
229
|
+
// It does NOT beat a definitive one. A literal 401 or invalid_grant is Google
|
|
230
|
+
// saying the credential will not work, and calling that "retry" would loop a
|
|
231
|
+
// caller forever against a request that can never succeed.
|
|
232
|
+
const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
233
|
+
const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
|
|
212
234
|
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
213
235
|
const hint = isInvalidGrant
|
|
214
236
|
? INVALID_GRANT_HINT
|
package/src/worker.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { gogAuth, type GogProps } from './connector-auth.js';
|
|
|
38
38
|
// connector with all ~360 tools at once. Add whichever paths you want as separate
|
|
39
39
|
// connectors in claude.ai (each authorizes with the same connector key).
|
|
40
40
|
|
|
41
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.21.0'; // x-release-please-version
|
|
42
42
|
|
|
43
43
|
// Build an McpAgent subclass whose init() registers `registrars` onto its server,
|
|
44
44
|
// each handler wrapped in the ALS scope carrying the per-session Fly executor.
|
|
@@ -120,6 +120,57 @@ describe('makeFlyExecutor', () => {
|
|
|
120
120
|
expect(init.body).toBe(JSON.stringify({ args: ['sheets', 'get', 'A1'] }));
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
// A hosted gog authenticates as whoever seeded the Fly volume, not as the
|
|
124
|
+
// person calling it (#230). mcp-host can give each caller their own child
|
|
125
|
+
// carrying their own GOG_ACCESS_TOKEN, so the missing link is the child
|
|
126
|
+
// handing that token to the backend for ITS call only — never as something
|
|
127
|
+
// ambient on the box, which would be the same shared identity from the other
|
|
128
|
+
// direction.
|
|
129
|
+
describe('per-request access token', () => {
|
|
130
|
+
function okFetch() {
|
|
131
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ stdout: 'ok' }) }));
|
|
132
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
133
|
+
return fetchMock;
|
|
134
|
+
}
|
|
135
|
+
function bodyOf(fetchMock: { mock: { calls: unknown[][] } }, i = 0): Record<string, unknown> {
|
|
136
|
+
const [, init] = fetchMock.mock.calls[i] as [string, RequestInit];
|
|
137
|
+
return JSON.parse(init.body as string) as Record<string, unknown>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it('sends the token with the request when one is available', async () => {
|
|
141
|
+
const fetchMock = okFetch();
|
|
142
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, () => 'ya29.caller-token');
|
|
143
|
+
await exec(['auth', 'status'], {});
|
|
144
|
+
expect(bodyOf(fetchMock)).toEqual({ args: ['auth', 'status'], accessToken: 'ya29.caller-token' });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('omits the field entirely when there is no token', async () => {
|
|
148
|
+
// Absent, not null or "": the backend distinguishes "act as the caller"
|
|
149
|
+
// from "act as the box", and a present-but-empty field would be a third
|
|
150
|
+
// state neither side has a meaning for.
|
|
151
|
+
const fetchMock = okFetch();
|
|
152
|
+
for (const provider of [undefined, () => undefined, () => '']) {
|
|
153
|
+
vi.clearAllMocks();
|
|
154
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, provider as (() => string | undefined) | undefined);
|
|
155
|
+
await exec(['auth', 'status'], {});
|
|
156
|
+
expect(bodyOf(fetchMock)).toEqual({ args: ['auth', 'status'] });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reads the token per CALL, not once when the executor is built', async () => {
|
|
161
|
+
// The whole contract is "this token belongs to this request". Reading it
|
|
162
|
+
// once at construction would pin the first caller's identity onto an
|
|
163
|
+
// executor that outlives them — exactly the bug this closes, rebuilt.
|
|
164
|
+
const fetchMock = okFetch();
|
|
165
|
+
const tokens = ['first', 'second'];
|
|
166
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, () => tokens.shift());
|
|
167
|
+
await exec(['auth', 'status'], {});
|
|
168
|
+
await exec(['auth', 'status'], {});
|
|
169
|
+
expect(bodyOf(fetchMock, 0).accessToken).toBe('first');
|
|
170
|
+
expect(bodyOf(fetchMock, 1).accessToken).toBe('second');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
123
174
|
// Everything below is the file-arg wire contract. The Worker has no filesystem
|
|
124
175
|
// and no gog binary, so a GogFileArg must cross the wire STRUCTURED and be
|
|
125
176
|
// materialized on the Fly runner. If this layer ever flattened it back into an
|