gogcli-mcp 3.0.0 → 4.0.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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +273 -748
  4. package/dist/lib.js +375 -785
  5. package/manifest.json +2 -2
  6. package/mint.yaml +37 -33
  7. package/package.json +3 -3
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/index.ts +3 -4
  14. package/src/lib.ts +14 -9
  15. package/src/runner.ts +23 -175
  16. package/src/tools/appscript.ts +1 -1
  17. package/src/tools/auth.ts +5 -5
  18. package/src/tools/drive.ts +1 -3
  19. package/src/tools/gmail.ts +9 -9
  20. package/src/tools/utils.ts +12 -58
  21. package/tests/attachments.test.ts +11 -14
  22. package/tests/blob-upload.test.ts +235 -160
  23. package/tests/bootstrap-auth.test.ts +245 -0
  24. package/tests/runner-file-args.test.ts +1 -13
  25. package/tests/runner.test.ts +8 -95
  26. package/tests/sdk-single-copy.test.ts +4 -12
  27. package/tests/tools/auth-401-shapes.test.ts +2 -3
  28. package/tests/tools/auth.test.ts +5 -4
  29. package/tests/tools/drive.test.ts +11 -3
  30. package/tests/tools/gmail.test.ts +2 -2
  31. package/tests/tools/utils.test.ts +1 -50
  32. package/tests/zod-single-copy.test.ts +6 -14
  33. package/tsconfig.json +1 -2
  34. package/vitest.config.ts +2 -12
  35. package/src/auth-log.ts +0 -205
  36. package/src/connector-auth.ts +0 -319
  37. package/src/connector-login.ts +0 -87
  38. package/src/connector-runtime.ts +0 -910
  39. package/src/google-probe.ts +0 -113
  40. package/src/google-token.ts +0 -391
  41. package/src/remote-runner.ts +0 -77
  42. package/src/worker.ts +0 -117
  43. package/tests/auth-log.test.ts +0 -530
  44. package/tests/connector-auth.test.ts +0 -559
  45. package/tests/connector-login.test.ts +0 -151
  46. package/tests/connector-runtime.test.ts +0 -1664
  47. package/tests/google-probe.test.ts +0 -116
  48. package/tests/google-token.test.ts +0 -425
  49. package/tests/remote-runner.test.ts +0 -202
  50. package/tests/worker.test.ts +0 -167
@@ -1,910 +0,0 @@
1
- import { runExecutor, RunnerTransportError } from './runner.js';
2
- import type { GogArg, GogExecutor } from './runner.js';
3
- import { logAuthTransition, type AuthTransition } from './auth-log.js';
4
- import { readGoogleProbe } from './google-probe.js';
5
-
6
- // Re-exported so the runner-transport error type reads as part of THIS module's
7
- // surface — this is the layer that authors these failures. It is DEFINED in
8
- // runner.js because runner.js also has to preserve the type across its
9
- // redacting rewrap, and importing it from here would make the two modules
10
- // circular.
11
- export { RunnerTransportError, isRunnerTransportError } from './runner.js';
12
- export type { RunnerFailureKind } from './runner.js';
13
-
14
- // Runtime helpers for the Cloudflare connector (worker.ts), split out here so
15
- // they can be unit-tested under the node pool — worker.ts itself imports the
16
- // Worker-only `agents` runtime and cannot load in
17
- // node. These helpers touch only the `runExecutor` seam and global `fetch`.
18
-
19
- // Mirrors runner.ts's TIMEOUT_MS: the budget the stdio path gives a `gog` call
20
- // before it kills the child. Duplicated rather than imported to keep this module
21
- // free of the spawn-side surface.
22
- const DEFAULT_TIMEOUT_MS = 30_000;
23
-
24
- // Headroom on top of the backend's own budget. The Fly runner kills `gog` and
25
- // returns a real error; we only want the client deadline to fire when the
26
- // backend cannot answer at all (scale-to-zero cold start that never wakes, or a
27
- // wedged machine). Firing first would turn a useful "gog exited 1" into an
28
- // opaque timeout.
29
- //
30
- // This grace is deliberately NOT widened for GogFileArg payloads. The deadline
31
- // now covers a request body that carries the payload inline, so upload happens
32
- // before `gog` starts — but the payloads that take this path are text (a mail
33
- // body, slide notes), bounded by the MCP request that carried them, and the hop
34
- // is Cloudflare edge → Fly datacenter, so upload is milliseconds, not seconds.
35
- // Widening the grace would weaken the property this constant exists to protect:
36
- // the backend must lose the race only when it genuinely cannot answer, and every
37
- // extra second is a second of an opaque client timeout replacing a real error.
38
- // If a payload class ever appears that does NOT upload in well under 5s, the fix
39
- // is to raise the caller's `opts.timeout` (which this deadline tracks) rather
40
- // than to inflate the grace, so the runner still gets to answer first.
41
- const DEADLINE_GRACE_MS = 5_000;
42
-
43
- // The smallest remaining budget worth spending on a replay.
44
- //
45
- // The replay shares the ORIGINAL call's deadline rather than getting a fresh
46
- // copy of it (see `deadlineAt` in makeFlyExecutor), so a first attempt that ran
47
- // long leaves the second one very little. Below this floor the replay can only
48
- // end in an abort, and that TimeoutError would REPLACE gog's own 401 — trading
49
- // the error that names the problem for one that names nothing.
50
- //
51
- // Declining costs only the automatic repair, never the durable one: the
52
- // eviction has already happened by the time this is consulted, so the caller's
53
- // own next call mints a fresh token.
54
- const MIN_REPLAY_BUDGET_MS = 1_000;
55
-
56
- // --- The Google-layer measurement taken when a hosted call is refused --------
57
- //
58
- // DEFECT 3 was "the hosted path has no automatic recovery, by design", and the
59
- // instinct was to build one. It should not be built. `gog` is spawned fresh per
60
- // `/run` and re-reads the keyring every time, so there is no cross-spawn
61
- // in-memory token that could go stale: a Google 401 on this path means the
62
- // STORED credential was refused, and no retry can repair that. What was missing
63
- // was never a retry — it was an answer to the question nobody could answer after
64
- // the incident: at the moment Google refused that call, was the refresh token on
65
- // the volume alive or dead? `replay.declined` records only that WE did nothing.
66
- //
67
- // So this asks, once, using the probe the runner now exposes, and writes down
68
- // what it heard. It never decides anything, never alters the caller's error and
69
- // never throws.
70
-
71
- /**
72
- * How long the refusal probe may take.
73
- *
74
- * Deliberately much shorter than the runner's own budget for the same probe
75
- * (`GOOGLE_PROBE_TIMEOUT_MS` in server.mjs): the caller is already holding a
76
- * failed tool call, and every millisecond spent here delays the error they
77
- * actually need. Timing out is a fine outcome — it records "not measured".
78
- */
79
- const REFUSAL_PROBE_TIMEOUT_MS = 4_000;
80
-
81
- /**
82
- * The smallest slice of the call's REMAINING deadline worth spending on a
83
- * diagnostic. Mirrors MIN_REPLAY_BUDGET_MS and for the same reason: below this
84
- * the probe can only abort, so it would buy nothing and cost the caller's error
85
- * a delay. Skipping is recorded, not silent.
86
- */
87
- const MIN_PROBE_BUDGET_MS = 1_000;
88
-
89
- /**
90
- * How rarely one executor will re-measure the same backend.
91
- *
92
- * `/health/google` spawns a real `gog auth list --check`, which costs a Google
93
- * API call and takes the keyring's EXCLUSIVE flock. That adjective was
94
- * challenged in review as read-only rhetoric, so it is sourced: `auth list`
95
- * reaches the keyring through `listAuthTokensWithFallback` →
96
- * `store.ListTokens()` (gogcli `internal/cmd/auth_list_helpers.go`), and
97
- * `ListTokens` wraps its read in `withWriteLock`, not `withReadLock`
98
- * (`internal/secrets/token.go`) → `withFileLock(true, …)` →
99
- * `unix.LOCK_EX | LOCK_NB` (`internal/secrets/keyring_lock_unix.go`). Verified
100
- * against v0.34.1, the tag this deployment's Dockerfile pins. The shared lock
101
- * exists but is taken by `Keys()`, which `auth list` reaches only on the
102
- * fallback path after `ListTokens` has already failed.
103
- *
104
- * A model retrying a call against a genuinely refused credential would
105
- * otherwise turn one diagnostic into a queue of them, on the box that is
106
- * already failing. One measurement a minute is plenty: the fact being measured
107
- * changes on the order of days.
108
- */
109
- const PROBE_INTERVAL_MS = 60_000;
110
-
111
- // Status codes the Fly runner uses to classify its OWN failures. These must stay
112
- // in sync with fly-gog-runner/server.mjs — they are the contract that lets this
113
- // side tell "gog ran and failed" apart from "the request never arrived", without
114
- // having to guess from the response body.
115
- //
116
- // 422: `gog` executed and exited non-zero. Deterministic — never retry.
117
- // 503: the runner is draining (SIGINT from Fly's autostop). Transient — retry.
118
- // Any other non-2xx: infrastructure, i.e. Fly's edge, not us.
119
- // 400: the runner refused the request shape before `gog` was reached.
120
- // 401: the runner rejected OUR bearer token — its own transport auth.
121
- //
122
- // Both are runner-authored: they are surfaced as RunnerTransportError so the
123
- // diagnosing layer classifies them by TYPE. Reading them as prose is what made
124
- // a key mismatch look like a dead Google grant: the runner answers a bad bearer
125
- // with the single word "unauthorized", which is also exactly how Google phrases
126
- // a rejected credential.
127
- const RUNNER_GOG_FAILED = 422;
128
- const RUNNER_DRAINING = 503;
129
- const RUNNER_BAD_REQUEST = 400;
130
- const RUNNER_BAD_KEY = 401;
131
-
132
- // How this module reads a token source. Structurally the plain
133
- // `() => token` of the #230 wiring, plus the optional `invalidate` that
134
- // google-token.ts's minting source provides — optional precisely so a source
135
- // that cannot re-mint says so by not having it.
136
- export type FlyAccessTokenSource = {
137
- (): string | undefined | Promise<string | undefined>;
138
- invalidate?: (rejected: string) => boolean | Promise<boolean>;
139
- /**
140
- * The credential's log-safe name (google-token.ts `credentialTag`), so a
141
- * record written here names the same credential as the records written
142
- * there. Optional for the same reason `invalidate` is: a source holding a
143
- * directly-supplied token has no mintable credential to name.
144
- */
145
- credentialId?: () => Promise<string>;
146
- };
147
-
148
- // `gog` ran on the backend and exited non-zero (the runner's 422). Kept as an
149
- // ORDINARY Error subclass — its `name` stays 'Error' and its message is still
150
- // gog's own words — because tools/utils.ts must go on reading that prose. The
151
- // only thing added is a place to keep gog's stderr APART from the command line
152
- // the runner echoed alongside it, and a type this module can recognise.
153
- //
154
- // Why a type rather than re-reading the message: the re-mint below must fire
155
- // only for a failure that actually reached Google. Deciding that by pattern
156
- // would put us back where defect 1 started — inferring the author of a failure
157
- // from words that several different authors can produce.
158
- //
159
- // `instanceof` is safe here (unlike RunnerTransportError, which is branded)
160
- // because this class is thrown and caught inside this one module: there is no
161
- // boundary a second copy of it could be created across.
162
- class GogFailedError extends Error {
163
- /** gog's stderr alone, with no echoed argv mixed in. */
164
- readonly stderr: string;
165
-
166
- constructor(message: string, stderr: string) {
167
- super(message);
168
- this.stderr = stderr;
169
- }
170
- }
171
-
172
- // Google saying, through gog, that the ACCESS token it was given is no good.
173
- //
174
- // Matched against gog's stderr only, never against the runner's `error` field:
175
- // that field is Node's execFile message, which embeds the entire command line,
176
- // so a caller's own text (`--subject "invoice 401"`) would otherwise decide
177
- // whether we replay their call.
178
- //
179
- // Narrow on purpose, and narrower than tools/utils.ts. That module picks a
180
- // HINT, where being over-eager costs a sentence; here it would cost a REPLAYED
181
- // request, so this only matches the shapes Google and gog actually emit.
182
- // (tools/utils.ts no longer uses a bare /\b401\b/ — 58d3e5b made it require a
183
- // status word, and #246 widened the separator so `Google API error (401 …)`,
184
- // the very shape below, still matches there too.)
185
- const GOOGLE_TOKEN_REJECTED_PATTERN =
186
- /Google API error \(401\b|invalid[ _]authentication[ _]credentials|\bACCESS_TOKEN_EXPIRED\b|\binvalid_token\b/i;
187
-
188
- // The refresh token is dead, not the access token. Nothing can be minted from
189
- // it, so there is nothing to replay — a human has to re-authorize. This
190
- // outranks the pattern above, because gog reports both in one stderr when the
191
- // grant is gone.
192
- const REFRESH_TOKEN_DEAD_PATTERN = /\binvalid_grant\b/i;
193
-
194
- // `gog` subcommands that only READ. Everything outside this set is treated as a
195
- // write, which is the conservative direction: a read that is missing here loses
196
- // nothing but the automatic replay — the eviction happens BEFORE this set is
197
- // consulted (see the write rule below), so the caller's own next call mints a
198
- // fresh token and succeeds — while a write that crept in could be applied
199
- // twice.
200
- //
201
- // Only verbs that cannot plausibly grow mutating children are listed.
202
- // Namespace-shaped words are deliberately absent: `gog gmail labels list`
203
- // arrives here as the subcommand `labels`, and if `labels` were listed then a
204
- // later `labels create` would inherit the replay. Reading `labels` as unsafe
205
- // costs one message, and nothing else.
206
- //
207
- // `lists` used to be here, and was the counter-example that proves the rule is
208
- // not hypothetical: `gog tasks lists` is a NAMESPACE, and `tasks lists create
209
- // <title> ...` exists in gog v0.34.1 today — variadic, so one invocation makes
210
- // N Google calls and a 401 on the second means the first already landed. It is
211
- // reachable without any new gog, through the `gog_tasks_run` escape hatch
212
- // (`{subcommand: 'lists', args: ['create', 'A', 'B']}`), so listing the
213
- // namespace word handed a real double-apply the replay. Its absence costs
214
- // `gog_tasks_lists` (tools/tasks.ts, argv `tasks lists list`) one automatic
215
- // replay and nothing more: the eviction runs BEFORE this set is consulted, so
216
- // that caller's next call still mints a fresh token.
217
- const READ_ONLY_SUBCOMMANDS = new Set([
218
- 'cat',
219
- 'describe',
220
- 'get',
221
- 'info',
222
- 'list',
223
- 'list-slides',
224
- 'ls',
225
- 'metadata',
226
- 'read-slide',
227
- 'search',
228
- 'services',
229
- 'status',
230
- 'structure',
231
- ]);
232
-
233
- // The gog SERVICE and SUBCOMMAND inside a fully-assembled arg list, either of
234
- // which may be absent.
235
- //
236
- // `run()` (runner.ts assembleArgs) puts global flags in front of the service and
237
- // subcommand: --json, --color=never, --no-input, --readonly, and --account,
238
- // which is the only one carrying a SEPARATE value. So the subcommand is the
239
- // second bare word once flags (and --account's value) are skipped.
240
- //
241
- // If a future global flag with a value is added and not handled here, the scan
242
- // mistakes that value for the service and returns the wrong word — which will
243
- // almost certainly not be in the read-only set, i.e. it degrades toward NOT
244
- // replaying. That is the direction a mistake here has to fail in.
245
- // The service is returned as well as the subcommand because it is what a log
246
- // record needs: the whole fleet shares one backend and one Google credential,
247
- // so "which service was this" is the first question asked of an auth record,
248
- // and re-scanning argv a second time to answer it would be waste on a path that
249
- // is already handling a failure.
250
- function gogTarget(args: GogArg[]): { service?: string; subcommand?: string } {
251
- // A GogFileArg is a payload, never a verb; dropping the non-strings first
252
- // keeps the scan below about words only.
253
- const words = args.filter((arg): arg is string => typeof arg === 'string');
254
- let service: string | undefined;
255
- for (let i = 0; i < words.length; i += 1) {
256
- const word = words[i];
257
- if (word.startsWith('-')) {
258
- if (word === '--account') i += 1;
259
- continue;
260
- }
261
- if (service === undefined) {
262
- service = word;
263
- continue;
264
- }
265
- return { service, subcommand: word };
266
- }
267
- return { service };
268
- }
269
-
270
- // A replay that has been authorized: the freshly minted token to send, plus the
271
- // identifiers the records around it are tagged with. Carried together rather
272
- // than recomputed by the caller so the token and the credential a log line
273
- // names can never come from two different decisions.
274
- interface Replay {
275
- token: string;
276
- /**
277
- * What is LEFT of the one deadline this tool call was given, in ms. Carried
278
- * rather than recomputed by the caller so the budget that was judged
279
- * sufficient is exactly the budget that gets spent.
280
- */
281
- budgetMs: number;
282
- /**
283
- * The same eviction the rejected token got, carried so the caller can apply
284
- * it to THIS token if Google refuses it too — see the symmetric eviction
285
- * below. Carried rather than re-read from the source because by the time an
286
- * authorization exists it has already been proved present, and re-deriving it
287
- * would mean re-asking a question whose answer is what this object IS.
288
- */
289
- invalidate: NonNullable<FlyAccessTokenSource['invalidate']>;
290
- credential: string | undefined;
291
- service: string | undefined;
292
- }
293
-
294
- // Decide whether this failure earns exactly one replay with a freshly minted
295
- // token, and produce that token if so. `undefined` means "do not replay".
296
- //
297
- // Every condition is a separate refusal because each is refusing for its own
298
- // reason, and collapsing them would make the log of WHY unreadable.
299
- async function remintAfterGoogleRejection(
300
- err: unknown,
301
- used: string | undefined,
302
- args: GogArg[],
303
- readAccessToken: FlyAccessTokenSource | undefined,
304
- deadlineAt: number,
305
- // Take a live reading of the Google layer. Supplied by `makeFlyExecutor` so
306
- // this function keeps knowing nothing about the endpoint, the bearer or how
307
- // often measuring is affordable — it decides only WHEN a reading is worth
308
- // taking, which is the one part of it that belongs to the replay ladder.
309
- probeGoogle: (where: { credential?: string; service?: string }) => Promise<void>,
310
- ): Promise<Replay | undefined> {
311
- // Only a failure gog itself authored can carry Google's verdict. A runner
312
- // transport failure never reached Google, and the runner's own 401 is about
313
- // OUR bearer — minting a Google token for either is pure waste.
314
- if (!(err instanceof GogFailedError)) return undefined;
315
-
316
- // THE RECORDING GATE, and the reason the two pattern tests moved up here from
317
- // the middle of the ladder.
318
- //
319
- // Everything below this line is a refusal worth WRITING DOWN, because by here
320
- // Google has demonstrably refused a credential. Everything above it is an
321
- // ordinary gog failure — a bad attachment id, an --out path that does not
322
- // exist on the box — and an auth log that also carries those is an auth log
323
- // nobody reads.
324
- //
325
- // Reordering is safe precisely because every check in this function is a pure
326
- // predicate whose failure returns `undefined`: none has a side effect until
327
- // the `invalidate` call at the bottom, so the answer cannot depend on the
328
- // order they are asked in.
329
- const grantDead = REFRESH_TOKEN_DEAD_PATTERN.test(err.stderr);
330
- if (!grantDead && !GOOGLE_TOKEN_REJECTED_PATTERN.test(err.stderr)) return undefined;
331
-
332
- const { service, subcommand } = gogTarget(args);
333
- const credential = await readAccessToken?.credentialId?.();
334
- const where = { credential, service };
335
-
336
- // The refresh token is gone: no mint can succeed, and replaying would loop a
337
- // caller against a credential that can never work. This is the ONE outcome on
338
- // this path that legitimately ends in "a human must re-authorize", which is
339
- // exactly why it gets its own transition rather than a declined-replay note.
340
- if (grantDead) {
341
- logAuthTransition('grant.dead', {
342
- ...where,
343
- reason:
344
- 'gog reported invalid_grant: the stored refresh token is dead, so no token can be minted ' +
345
- 'and this account must be re-authorized',
346
- });
347
- return undefined;
348
- }
349
-
350
- // Only a token WE supplied is ours to replace. Without one, gog acted as
351
- // whatever identity the backend volume holds and only an operator can change
352
- // that.
353
- if (!used) {
354
- // THE HOSTED PATH, and the only place a reading is worth paying for.
355
- //
356
- // We are here because Google refused a call that `gog` made as the BACKEND
357
- // VOLUME's own identity — the shape `worker.ts` produces on every hosted
358
- // connector. Nothing above this line can say whether the credential behind
359
- // that identity is dead or alive, and that is precisely the fact the
360
- // incident needed and did not have. So measure first, then record the
361
- // decision: the pair reads as "here is what Google said, here is why we did
362
- // nothing about it".
363
- //
364
- // Ordered before the `replay.declined` line rather than after it so the log
365
- // tells the story in the order it happened. Awaited rather than fired and
366
- // forgotten because a Worker may cancel unawaited work at the end of the
367
- // request — an unawaited probe is one that silently does not happen, which
368
- // is the failure mode this whole branch exists to delete.
369
- await probeGoogle(where);
370
- logAuthTransition('replay.declined', {
371
- ...where,
372
- reason:
373
- 'no access token was supplied with the call, so gog acted as the backend volume’s own identity',
374
- });
375
- return undefined;
376
- }
377
-
378
- // A source with no cache behind it (a directly-supplied GOG_ACCESS_TOKEN)
379
- // would hand back the identical rejected string.
380
- if (!readAccessToken?.invalidate) {
381
- logAuthTransition('replay.declined', {
382
- ...where,
383
- reason: 'this token source cannot mint a replacement, so a replay would resend the rejected token',
384
- });
385
- return undefined;
386
- }
387
-
388
- // EVICT FIRST, and unconditionally — every remaining check decides whether to
389
- // REPLAY, which is a different question with a different answer.
390
- //
391
- // A replay re-runs the call and can double-apply; an eviction only drops a
392
- // string Google has already refused, and the cache's own read guard is purely
393
- // about TIME, so a token left in it is re-served until its nominal expiry.
394
- // Gating the eviction behind the replay rules is therefore not a conservative
395
- // choice but the caching half of the original defect: after Google rejected
396
- // the token, every write (and every read whose subcommand is outside the
397
- // allow-list) re-sent that same rejected token for up to ~58 minutes, and
398
- // only a reconnect — a fresh isolate with an empty cache — appeared to help.
399
- //
400
- // Safe to do before the decision because it is idempotent and value-matched:
401
- // `invalidate` drops the entry only if it still holds exactly this string, so
402
- // a concurrent caller's fresher token is never the casualty, and a second
403
- // call for the same token changes nothing.
404
- const evicted = await readAccessToken.invalidate(used);
405
-
406
- // THE WRITE RULE. A replay re-runs the whole gog invocation. For a read that
407
- // is free; for a write it is only safe if nothing was applied before the
408
- // failure, and this layer cannot know that — gog may make several Google
409
- // calls in one invocation, and a 401 on a later one would mean the earlier
410
- // ones already landed. Re-sending an email is not a cost worth paying for
411
- // hiding one error message, so writes get the eviction above and nothing more.
412
- if (subcommand === undefined || !READ_ONLY_SUBCOMMANDS.has(subcommand)) {
413
- logAuthTransition('replay.declined', {
414
- ...where,
415
- reason: `not replayable: '${subcommand ?? '(none)'}' is not a known read-only subcommand and a write could double-apply`,
416
- });
417
- return undefined;
418
- }
419
-
420
- // Did the eviction actually drop this token? False means a concurrent caller
421
- // already replaced it, so the token a replay would send is the one already in
422
- // use and the replay proves nothing.
423
- if (!evicted) {
424
- logAuthTransition('replay.declined', {
425
- ...where,
426
- reason: 'the rejected token was already superseded, so the cache holds the token a replay would send',
427
- });
428
- return undefined;
429
- }
430
-
431
- // The mint can throw (invalid_grant, Google unreachable). That error is
432
- // allowed to propagate in place of gog's 401, because it is strictly more
433
- // actionable: it names the credential that is actually dead and the step that
434
- // repairs it, where gog's 401 only says a token was refused. The mint's own
435
- // outcome is recorded by google-token.ts, so nothing is logged for it here.
436
- const fresh = await readAccessToken();
437
-
438
- // A source that minted a moment ago and now answers nothing must NOT be
439
- // replayed without a token: the backend would run the call as its own
440
- // identity and hand this caller someone else's account.
441
- if (!fresh) {
442
- logAuthTransition('replay.declined', {
443
- ...where,
444
- reason: 'the token source produced no token after eviction; replaying without one would act as the backend',
445
- });
446
- return undefined;
447
- }
448
-
449
- // Whatever is LEFT of the one deadline this call was given — the mint above
450
- // spends from it too. A replay with no budget can only abort, and that
451
- // timeout would land on the caller in place of gog's own 401.
452
- const budgetMs = deadlineAt - Date.now();
453
- if (budgetMs < MIN_REPLAY_BUDGET_MS) {
454
- logAuthTransition('replay.declined', {
455
- ...where,
456
- reason:
457
- `only ${budgetMs}ms of the call’s deadline remained, so a replay could only time out; ` +
458
- 'the rejected token was evicted, so the next call mints a fresh one',
459
- });
460
- return undefined;
461
- }
462
- return { token: fresh, budgetMs, invalidate: readAccessToken.invalidate, ...where };
463
- }
464
-
465
- // Build a GogExecutor that forwards a fully-assembled `gog` arg-array to the Fly
466
- // backend's `/run` endpoint.
467
- //
468
- // The backend (and the `gog` process it spawns) own timeout/interactive
469
- // behaviour, so `opts` does not change what the backend does — but `timeout`
470
- // still has to be honoured HERE as a client-side deadline. Workers' `fetch` has
471
- // no default timeout, and the stdio path's kill lives in a child process this
472
- // path never spawns, so without an explicit signal a cold or wedged backend
473
- // hangs the MCP request indefinitely with nothing to interrupt it.
474
- //
475
- // GogFileArg elements pass through STRUCTURED, as objects inside the JSON body.
476
- // Every other executor materializes them to a temp file itself; this one cannot
477
- // and must not. A Worker has no filesystem and does not host the `gog` binary,
478
- // so there is nowhere to put a file that the process which needs it could read.
479
- // The Fly runner is the only box with both, so it owns materialization — this
480
- // layer's whole job is to hand the elements over unaltered, in order. Anything
481
- // clever here (flattening to `--flag=<inline>`, truncating, re-encoding) would
482
- // put the payload straight back into argv and re-create the size cap this whole
483
- // change exists to escape.
484
- // `readAccessToken` is how a hosted gog acts as its CALLER rather than as
485
- // whoever seeded the backend's volume (#230). The backend holds one Google
486
- // identity; a token supplied with the request overrides it for that one `gog`
487
- // invocation, and `gog` already prefers a directly-passed token over its store.
488
- //
489
- // A FUNCTION, read per call, not a string captured at construction. The whole
490
- // claim being made is "this token belongs to this request", and an executor
491
- // outlives any one request — on the Worker path a single isolate serves many
492
- // callers, so a captured token would pin the first caller's identity onto
493
- // everyone who followed. That is the same shared-identity bug this closes,
494
- // rebuilt one layer up.
495
- //
496
- // Absent when there is no token, rather than null or "": the backend has to
497
- // tell "act as this caller" from "act as the box", and an empty third state is
498
- // one neither side has a meaning for.
499
- export function makeFlyExecutor(
500
- endpoint: string,
501
- key: string,
502
- readAccessToken?: FlyAccessTokenSource,
503
- ): GogExecutor {
504
- // Throttle state for the refusal probe, held PER EXECUTOR rather than in a
505
- // module-level map.
506
- //
507
- // That is the scope the thing being throttled actually has: the stateless
508
- // Worker caches one executor per endpoint + connector key for the isolate,
509
- // while stdio builds one per process (`remote-runner.ts`). Callers sharing a
510
- // backend credential therefore share its throttle, without one credential's
511
- // retry loop suppressing the first measurement for another credential.
512
- let lastProbeAt = Number.NEGATIVE_INFINITY;
513
-
514
- /**
515
- * Ask the runner whether Google still accepts the credential on its volume,
516
- * and record the answer. Resolves in EVERY case: it can neither throw nor
517
- * return a value, because nothing may make a decision out of what it finds.
518
- * The caller's error is already decided by the time this runs.
519
- */
520
- const probeGoogleAfterRefusal = async (
521
- where: { credential?: string; service?: string },
522
- deadlineAt: number,
523
- ): Promise<void> => {
524
- const record = { ...where, endpoint };
525
-
526
- // ONE reading of the clock for both budget and throttle, so the two
527
- // decisions cannot disagree about what time it is.
528
- const now = Date.now();
529
- const remainingMs = deadlineAt - now;
530
- if (remainingMs < MIN_PROBE_BUDGET_MS) {
531
- logAuthTransition('refusal.google-unmeasured', {
532
- ...record,
533
- reason:
534
- `only ${remainingMs}ms of the call’s deadline remained, so the Google layer was not ` +
535
- 'measured rather than delay the caller’s own error',
536
- });
537
- return;
538
- }
539
- if (now - lastProbeAt < PROBE_INTERVAL_MS) {
540
- logAuthTransition('refusal.google-unmeasured', {
541
- ...record,
542
- // "attempted", not "measured". `lastProbeAt` is stamped before the
543
- // fetch and is deliberately NOT reset when the probe comes back with no
544
- // verdict (a 404 from a runner too old to have the endpoint, a timeout,
545
- // a dead socket) — the backend cost this throttle exists to bound was
546
- // paid either way, and resetting it would let a retry loop storm a
547
- // runner that is already unwell. So the timestamp stays and the sentence
548
- // has to be the true one: on this branch a log line may not assert a
549
- // measurement that never happened, and the previous probe may well have
550
- // measured nothing at all.
551
- reason:
552
- 'a Google probe was attempted recently, so another was not sent: this probe spawns ' +
553
- 'gog on the backend and takes the keyring’s exclusive lock',
554
- });
555
- return;
556
- }
557
- // Claimed BEFORE the await, so two overlapping refusals cannot both get
558
- // past the check and spawn a probe apiece.
559
- lastProbeAt = now;
560
-
561
- let event: AuthTransition;
562
- let reason: string;
563
- try {
564
- const res = await fetch(`${endpoint}/health/google`, {
565
- headers: { Authorization: `Bearer ${key}` },
566
- // Never more than the probe's own budget, never more than the call has
567
- // left. `Math.min` rather than a plain constant because the second
568
- // bound is the caller's, and it outranks ours.
569
- signal: AbortSignal.timeout(Math.min(REFUSAL_PROBE_TIMEOUT_MS, remainingMs)),
570
- });
571
- if (!res.ok) {
572
- // Includes the 404 from a runner deployed before `/health/google`
573
- // existed. "I could not ask" is never filed as "Google said no" — that
574
- // is the defect this branch exists to delete, with the alarm inverted.
575
- event = 'refusal.google-unmeasured';
576
- reason = `the runner did not answer the Google probe (HTTP ${res.status})`;
577
- } else {
578
- // `readGoogleProbe` is the ONE place that judges a probe body, shared
579
- // with the connect-time probe in connector-auth.ts. It reads the
580
- // runner's `measured` field BEFORE its `ok` field, which is what keeps a
581
- // probe that timed out or could not be run from being filed as
582
- // `-unhealthy` — the record an operator reads as "the live check agrees
583
- // the credential is refused". Its reason strings come from the runner's
584
- // CLOSED vocabulary of causes (PROBE_CAUSES in server.mjs), so they
585
- // carry a classification and never gog's own output.
586
- const verdict = readGoogleProbe(await res.json());
587
- if (verdict.kind === 'ok') {
588
- event = 'refusal.google-ok';
589
- reason =
590
- 'Google refused this call, yet a live token check on the same volume succeeded — ' +
591
- 'so a dead or expired refresh token does not explain this refusal';
592
- } else {
593
- event = verdict.kind === 'unhealthy' ? 'refusal.google-unhealthy' : 'refusal.google-unmeasured';
594
- reason = verdict.reason;
595
- }
596
- }
597
- } catch (err) {
598
- // A rejected fetch, an abort at the budget, or a body that is not JSON (a
599
- // proxy's HTML error page). None of them are facts about Google.
600
- event = 'refusal.google-unmeasured';
601
- reason = err instanceof Error ? err.message : String(err);
602
- }
603
- // `reason` can quote text this layer did not author, so the record goes
604
- // through the same redactor as every other auth log line.
605
- logAuthTransition(event, { ...record, reason });
606
- };
607
-
608
- return async (args: GogArg[], opts) => {
609
- const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
610
- // Awaited, because the token may have to be MINTED (#241): a refresh token
611
- // is what a registration stores, and the access token it yields lives about
612
- // an hour. Deliberately NOT caught here — if the source throws, the call
613
- // fails, because the alternative is running it as the backend's identity
614
- // and handing this caller someone else's account.
615
- const accessToken = await readAccessToken?.();
616
-
617
- // ONE deadline for the whole tool call, fixed before the first attempt
618
- // rather than re-derived per attempt. A replay is a second `fetch`, and
619
- // handing it a fresh copy of the budget made the worst case two full
620
- // budgets — ~70s of wall clock for one tool call, which can outlast the MCP
621
- // client's own request timeout and turn a self-healing read into a
622
- // client-side hang.
623
- const deadlineAt = Date.now() + deadlineMs;
624
-
625
- try {
626
- return await attempt(endpoint, key, args, accessToken, deadlineMs);
627
- } catch (err) {
628
- // ONE replay, and only when the token we sent is the reason it failed.
629
- // Not a retry loop: an unbounded one against a genuinely dead credential
630
- // is exactly the behaviour the "retry the same call" hints already
631
- // produced elsewhere, and it never terminates. `remintAfterGoogleRejection`
632
- // both decides and mints, so the decision cannot drift from the token.
633
- const replay = await remintAfterGoogleRejection(
634
- err,
635
- accessToken,
636
- args,
637
- readAccessToken,
638
- deadlineAt,
639
- (where) => probeGoogleAfterRefusal(where, deadlineAt),
640
- );
641
- if (replay === undefined) throw err;
642
-
643
- const where = { credential: replay.credential, service: replay.service, endpoint };
644
- logAuthTransition('replay.attempted', {
645
- ...where,
646
- reason: 'Google rejected the access token; replaying this read once with a freshly minted one',
647
- });
648
- try {
649
- const stdout = await attempt(endpoint, key, args, replay.token, replay.budgetMs);
650
- // The interesting record of the pair: it says the caller saw a clean
651
- // success where they used to see an hour of identical 401s.
652
- logAuthTransition('replay.succeeded', where);
653
- return stdout;
654
- } catch (replayErr) {
655
- // `String`, not `instanceof Error ? .message : …` — a rethrown non-Error
656
- // rejection is a real possibility here (see attempt's abort handling)
657
- // and stringifying uniformly avoids an arm that no test could reach.
658
- logAuthTransition('replay.failed', { ...where, reason: String(replayErr) });
659
- // SYMMETRY with the eviction that got us here, under the SAME predicate
660
- // — a token is dropped because GOOGLE refused it, never merely because
661
- // a call carrying it failed.
662
- //
663
- // The gate is the point. A replay can fail without Google ever seeing
664
- // the token: the Machine drains between the two attempts, the
665
- // client-side deadline fires, the runner's bearer rotates mid-call.
666
- // Evicting on those would discard a token nothing has refused and emit
667
- // `token.evicted` reading "Google rejected this access token" about a
668
- // service that was never consulted — the same misattribution this
669
- // branch exists to delete, moved from the user's screen to the
670
- // operator's query. So the second eviction asks exactly what authorized
671
- // the first: is this a gog failure whose stderr shows Google refusing
672
- // the credential?
673
- //
674
- // WHAT THIS BUYS, measured rather than assumed. Under a sustained
675
- // Google-side refusal that is not invalid_grant (a revoked scope, say),
676
- // a steady-state call costs 2 /run round-trips either way — the first
677
- // attempt and the replay both always happen — and this eviction in fact
678
- // costs one EXTRA mint per call (2 instead of 1), because it empties the
679
- // cache the next call would otherwise have hit. It is not a round-trip
680
- // saving and must not be justified as one.
681
- //
682
- // It is worth keeping because it bounds how long a KNOWN-REFUSED token
683
- // can be handed out. Left cached, `ya29.fresh` is re-served for the rest
684
- // of its nominal hour, and the caller that suffers most is a WRITE: a
685
- // write gets the eviction and no replay, so it would be sent with the
686
- // token that just failed twice and fail on contact — even after the
687
- // underlying refusal has cleared. Trading a mint for that is the right
688
- // side of the deal.
689
- //
690
- // Value-matched and idempotent like the first eviction, so a concurrent
691
- // caller's fresher token is never the casualty. Not wrapped in its own
692
- // catch: `invalidate` is a map lookup behind a digest, the first
693
- // eviction is already un-guarded on this same path, and a guard here
694
- // would add an arm no test can reach.
695
- if (replayErr instanceof GogFailedError && GOOGLE_TOKEN_REJECTED_PATTERN.test(replayErr.stderr)) {
696
- await replay.invalidate(replay.token);
697
- }
698
- throw replayErr;
699
- }
700
- }
701
- };
702
- }
703
-
704
- // One request to the runner: send the args (and, when we have one, the identity
705
- // to act as), and turn whatever comes back into either stdout or a classified
706
- // failure. Everything about WHICH failure this is lives here; the caller above
707
- // decides only whether to run it a second time.
708
- async function attempt(
709
- endpoint: string,
710
- key: string,
711
- args: GogArg[],
712
- accessToken: string | undefined,
713
- deadlineMs: number,
714
- ): Promise<string> {
715
- let res: Response;
716
- try {
717
- res = await fetch(endpoint + '/run', {
718
- method: 'POST',
719
- headers: {
720
- Authorization: 'Bearer ' + key,
721
- 'Content-Type': 'application/json',
722
- },
723
- body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
724
- signal: AbortSignal.timeout(deadlineMs),
725
- });
726
- } catch (err) {
727
- // AbortSignal.timeout rejects with a TimeoutError; a caller-supplied abort
728
- // surfaces as AbortError. Either way the bare message ("The operation was
729
- // aborted") says nothing about which backend failed to answer.
730
- const name = err instanceof Error ? err.name : '';
731
- if (name === 'TimeoutError' || name === 'AbortError') {
732
- // No status: nothing answered. Retryable — the usual cause is the Fly
733
- // machine waking from scale-to-zero, which succeeds on the next call.
734
- throw new RunnerTransportError(
735
- `gog-runner did not respond within ${deadlineMs}ms (${endpoint}) — the Fly backend may be cold or wedged`,
736
- 'transport-retryable',
737
- );
738
- }
739
- throw err;
740
- }
741
- if (!res.ok) {
742
- // Two very different failures arrive as non-2xx, and collapsing them (as
743
- // this used to) is what made a real bug look like random flakiness:
744
- //
745
- // a) The runner answered with its own JSON — `gog` actually ran on the
746
- // box and failed. Deterministic: the same call will fail the same way.
747
- // b) The body is NOT the runner's JSON (Fly's HTML error page, or empty).
748
- // Then the request never reached `gog` at all; Fly's edge proxy is
749
- // reporting that it could not reach the Machine — typically because
750
- // the Machine was starting from scale-to-zero, or was mid-shutdown.
751
- // Genuinely transient, and the only case worth retrying.
752
- const body = (await res.json().catch(() => null)) as
753
- | { error?: string; stderr?: string; retryable?: boolean }
754
- | null;
755
- const detail =
756
- body && typeof body.error === 'string'
757
- ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim()
758
- ? `${body.error}\n${body.stderr}`
759
- : body.error
760
- : '';
761
-
762
- // 422 is the runner's "gog ran and exited non-zero" status. It is only
763
- // ever produced by our own handler, so reaching here proves the request
764
- // was delivered and executed. Deterministic — say so, and say nothing
765
- // that invites a retry.
766
- if (res.status === RUNNER_GOG_FAILED) {
767
- // The message is unchanged — gog's words, exactly as before. `stderr` is
768
- // carried alongside rather than folded in, so the replay decision can
769
- // read what GOG said without also reading the argv the runner echoed
770
- // back inside `error`.
771
- throw new GogFailedError(
772
- detail || 'gog failed on the runner (no detail supplied)',
773
- typeof body?.stderr === 'string' ? body.stderr : '',
774
- );
775
- }
776
-
777
- // The runner's OWN bearer auth failed: the key the Worker sent is not the
778
- // key the Fly app expects. `gog` never ran, Google was never contacted,
779
- // and no stored credential was even read — so the one thing this must not
780
- // do is send the caller to re-authorize an account that is fine.
781
- //
782
- // The runner's body (the bare word "unauthorized") is deliberately
783
- // DROPPED rather than repeated, and the status is deliberately not
784
- // interpolated, for the same reason the 5xx fallback below omits its own:
785
- // both `unauthorized` and `401` match DEFINITE_AUTH_PATTERN in
786
- // tools/utils.ts. The type is what classifies this error now, but the
787
- // prose must not be able to re-create the old misdiagnosis at any future
788
- // boundary where the type could be lost (a serialized error, a log line a
789
- // human reads).
790
- if (res.status === RUNNER_BAD_KEY) {
791
- // The record a human reads must not re-create defect 1 either, so it
792
- // states the negative facts explicitly instead of repeating the runner's
793
- // bare "unauthorized".
794
- logAuthTransition('runner.auth-failed', {
795
- service: gogTarget(args).service,
796
- endpoint,
797
- reason:
798
- 'the gog-runner rejected the connector’s bearer token, so gog never ran and no Google ' +
799
- 'credential was read; GOG_RUNNER_KEY does not match the Fly app’s RUNNER_KEY',
800
- });
801
- throw new RunnerTransportError(
802
- "gog-runner rejected the connector's bearer token, so the request never reached gog and no " +
803
- 'Google credential was involved. The Worker secret GOG_RUNNER_KEY no longer matches RUNNER_KEY ' +
804
- 'on the Fly app; set them to the same value (wrangler secret put GOG_RUNNER_KEY / fly secrets ' +
805
- 'set RUNNER_KEY) and retry.',
806
- 'transport-auth',
807
- res.status,
808
- );
809
- }
810
-
811
- // The runner refused the request shape (oversized arg, unparseable JSON,
812
- // a malformed access token). Its words are about OUR request, never about
813
- // Google — deterministic, and no hint applies.
814
- if (res.status === RUNNER_BAD_REQUEST) {
815
- throw new RunnerTransportError(
816
- detail || 'gog-runner rejected the request (no detail supplied)',
817
- 'transport-request',
818
- res.status,
819
- );
820
- }
821
-
822
- // The runner's drain response: it is up, but deliberately refusing new
823
- // work while it shuts down. The one runner-authored failure worth retrying.
824
- if (res.status === RUNNER_DRAINING || body?.retryable === true) {
825
- throw new RunnerTransportError(
826
- `gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ''}`,
827
- 'transport-retryable',
828
- res.status,
829
- );
830
- }
831
-
832
- // Anything else non-2xx is infrastructure: Fly's edge could not reach the
833
- // Machine, or the Machine answered with something that is not ours. Only
834
- // claim the request never arrived when there is genuinely no runner body
835
- // — a runner that did answer deserves to have its own words repeated.
836
- //
837
- // The status is deliberately NOT interpolated here. A runner body proves
838
- // gog ran, so this is a deterministic failure; embedding the literal
839
- // status would put "502" into the message, which matches
840
- // TRANSIENT_ERROR_PATTERN (/\b5\d\d\b/) in tools/utils.ts and re-attaches
841
- // the very "this is transient, retry the same call" hint this change
842
- // exists to remove — reintroducing the bug during the rollout window this
843
- // branch exists to cover. Anything genuinely transient in gog's own text
844
- // (a Google 5xx, say) still matches on its own merits, which is correct.
845
- if (detail) {
846
- throw new Error(detail);
847
- }
848
- throw new RunnerTransportError(
849
- `gog-runner HTTP ${res.status}: the response did not come from the runner, ` +
850
- 'so the request never reached gog. The backend Machine was most likely starting ' +
851
- 'or shutting down — this is transient, retry the same call.',
852
- 'transport-retryable',
853
- res.status,
854
- );
855
- }
856
- const { stdout } = (await res.json()) as { stdout: string };
857
- return stdout;
858
- }
859
-
860
- /**
861
- * Cache Fly executors by endpoint and connector key for one runtime isolate.
862
- *
863
- * `makeFlyExecutor` owns the refusal-probe throttle state, so resolving a new
864
- * executor for every stateless tool call would reset that state and allow each
865
- * retry to spawn another keyring-locking Google probe.
866
- */
867
- export function createFlyExecutorResolver(
868
- factory: (endpoint: string, key: string) => GogExecutor = makeFlyExecutor,
869
- ): (endpoint: string, key: string) => GogExecutor {
870
- const byEndpoint = new Map<string, Map<string, GogExecutor>>();
871
- return (endpoint, key) => {
872
- let byKey = byEndpoint.get(endpoint);
873
- if (!byKey) {
874
- byKey = new Map();
875
- byEndpoint.set(endpoint, byKey);
876
- }
877
- let executor = byKey.get(key);
878
- if (!executor) {
879
- executor = factory(endpoint, key);
880
- byKey.set(key, executor);
881
- }
882
- return executor;
883
- };
884
- }
885
-
886
- // Wrap an McpServer in a Proxy whose `registerTool` (and `tool`, if any
887
- // registrar uses it) intercepts the tool handler so it runs inside the
888
- // `runExecutor` ALS scope. This is the crux of the connector: it lets the
889
- // UNCHANGED base registrars forward every `gog` call to the request's Fly
890
- // executor without any change to the registrars or `runner.ts` — when a
891
- // handler's `run()` looks up `runExecutor.getStore()` it finds `executor` and
892
- // forwards instead of spawning. Everything else proxies through via Reflect.
893
- export function wrapServer<T extends object>(server: T, executor: GogExecutor): T {
894
- return new Proxy(server, {
895
- get(target, prop, receiver) {
896
- if (prop === 'registerTool' || prop === 'tool') {
897
- const orig = (target as Record<string | symbol, (...a: unknown[]) => unknown>)[prop].bind(target);
898
- return (...args: unknown[]) => {
899
- const handler = args[args.length - 1];
900
- if (typeof handler === 'function') {
901
- args[args.length - 1] = (...h: unknown[]) =>
902
- runExecutor.run({ executor }, () => (handler as (...a: unknown[]) => unknown)(...h));
903
- }
904
- return orig(...args);
905
- };
906
- }
907
- return Reflect.get(target, prop, receiver);
908
- },
909
- });
910
- }