gogcli-mcp 2.20.0 → 2.21.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.
@@ -1,5 +1,14 @@
1
- import { runExecutor } from './runner.js';
1
+ import { runExecutor, RunnerTransportError } from './runner.js';
2
2
  import type { GogArg, GogExecutor } from './runner.js';
3
+ import { logAuthTransition } from './auth-log.js';
4
+
5
+ // Re-exported so the runner-transport error type reads as part of THIS module's
6
+ // surface — this is the layer that authors these failures. It is DEFINED in
7
+ // runner.js because runner.js also has to preserve the type across its
8
+ // redacting rewrap, and importing it from here would make the two modules
9
+ // circular.
10
+ export { RunnerTransportError, isRunnerTransportError } from './runner.js';
11
+ export type { RunnerFailureKind } from './runner.js';
3
12
 
4
13
  // Runtime helpers for the Cloudflare connector (worker.ts), split out here so
5
14
  // they can be unit-tested under the node pool — worker.ts itself imports the
@@ -30,6 +39,19 @@ const DEFAULT_TIMEOUT_MS = 30_000;
30
39
  // than to inflate the grace, so the runner still gets to answer first.
31
40
  const DEADLINE_GRACE_MS = 5_000;
32
41
 
42
+ // The smallest remaining budget worth spending on a replay.
43
+ //
44
+ // The replay shares the ORIGINAL call's deadline rather than getting a fresh
45
+ // copy of it (see `deadlineAt` in makeFlyExecutor), so a first attempt that ran
46
+ // long leaves the second one very little. Below this floor the replay can only
47
+ // end in an abort, and that TimeoutError would REPLACE gog's own 401 — trading
48
+ // the error that names the problem for one that names nothing.
49
+ //
50
+ // Declining costs only the automatic repair, never the durable one: the
51
+ // eviction has already happened by the time this is consulted, so the caller's
52
+ // own next call mints a fresh token.
53
+ const MIN_REPLAY_BUDGET_MS = 1_000;
54
+
33
55
  // Status codes the Fly runner uses to classify its OWN failures. These must stay
34
56
  // in sync with fly-gog-runner/server.mjs — they are the contract that lets this
35
57
  // side tell "gog ran and failed" apart from "the request never arrived", without
@@ -38,8 +60,330 @@ const DEADLINE_GRACE_MS = 5_000;
38
60
  // 422: `gog` executed and exited non-zero. Deterministic — never retry.
39
61
  // 503: the runner is draining (SIGINT from Fly's autostop). Transient — retry.
40
62
  // Any other non-2xx: infrastructure, i.e. Fly's edge, not us.
63
+ // 400: the runner refused the request shape before `gog` was reached.
64
+ // 401: the runner rejected OUR bearer token — its own transport auth.
65
+ //
66
+ // Both are runner-authored: they are surfaced as RunnerTransportError so the
67
+ // diagnosing layer classifies them by TYPE. Reading them as prose is what made
68
+ // a key mismatch look like a dead Google grant: the runner answers a bad bearer
69
+ // with the single word "unauthorized", which is also exactly how Google phrases
70
+ // a rejected credential.
41
71
  const RUNNER_GOG_FAILED = 422;
42
72
  const RUNNER_DRAINING = 503;
73
+ const RUNNER_BAD_REQUEST = 400;
74
+ const RUNNER_BAD_KEY = 401;
75
+
76
+ // How this module reads a token source. Structurally the plain
77
+ // `() => token` of the #230 wiring, plus the optional `invalidate` that
78
+ // google-token.ts's minting source provides — optional precisely so a source
79
+ // that cannot re-mint says so by not having it.
80
+ export type FlyAccessTokenSource = {
81
+ (): string | undefined | Promise<string | undefined>;
82
+ invalidate?: (rejected: string) => boolean | Promise<boolean>;
83
+ /**
84
+ * The credential's log-safe name (google-token.ts `credentialTag`), so a
85
+ * record written here names the same credential as the records written
86
+ * there. Optional for the same reason `invalidate` is: a source holding a
87
+ * directly-supplied token has no mintable credential to name.
88
+ */
89
+ credentialId?: () => Promise<string>;
90
+ };
91
+
92
+ // `gog` ran on the backend and exited non-zero (the runner's 422). Kept as an
93
+ // ORDINARY Error subclass — its `name` stays 'Error' and its message is still
94
+ // gog's own words — because tools/utils.ts must go on reading that prose. The
95
+ // only thing added is a place to keep gog's stderr APART from the command line
96
+ // the runner echoed alongside it, and a type this module can recognise.
97
+ //
98
+ // Why a type rather than re-reading the message: the re-mint below must fire
99
+ // only for a failure that actually reached Google. Deciding that by pattern
100
+ // would put us back where defect 1 started — inferring the author of a failure
101
+ // from words that several different authors can produce.
102
+ //
103
+ // `instanceof` is safe here (unlike RunnerTransportError, which is branded)
104
+ // because this class is thrown and caught inside this one module: there is no
105
+ // boundary a second copy of it could be created across.
106
+ class GogFailedError extends Error {
107
+ /** gog's stderr alone, with no echoed argv mixed in. */
108
+ readonly stderr: string;
109
+
110
+ constructor(message: string, stderr: string) {
111
+ super(message);
112
+ this.stderr = stderr;
113
+ }
114
+ }
115
+
116
+ // Google saying, through gog, that the ACCESS token it was given is no good.
117
+ //
118
+ // Matched against gog's stderr only, never against the runner's `error` field:
119
+ // that field is Node's execFile message, which embeds the entire command line,
120
+ // so a caller's own text (`--subject "invoice 401"`) would otherwise decide
121
+ // whether we replay their call.
122
+ //
123
+ // Narrow on purpose, and narrower than tools/utils.ts. That module picks a
124
+ // HINT, where being over-eager costs a sentence; here it would cost a REPLAYED
125
+ // request, so this only matches the shapes Google and gog actually emit.
126
+ // (tools/utils.ts no longer uses a bare /\b401\b/ — 58d3e5b made it require a
127
+ // status word, and #246 widened the separator so `Google API error (401 …)`,
128
+ // the very shape below, still matches there too.)
129
+ const GOOGLE_TOKEN_REJECTED_PATTERN =
130
+ /Google API error \(401\b|invalid[ _]authentication[ _]credentials|\bACCESS_TOKEN_EXPIRED\b|\binvalid_token\b/i;
131
+
132
+ // The refresh token is dead, not the access token. Nothing can be minted from
133
+ // it, so there is nothing to replay — a human has to re-authorize. This
134
+ // outranks the pattern above, because gog reports both in one stderr when the
135
+ // grant is gone.
136
+ const REFRESH_TOKEN_DEAD_PATTERN = /\binvalid_grant\b/i;
137
+
138
+ // `gog` subcommands that only READ. Everything outside this set is treated as a
139
+ // write, which is the conservative direction: a read that is missing here loses
140
+ // nothing but the automatic replay — the eviction happens BEFORE this set is
141
+ // consulted (see the write rule below), so the caller's own next call mints a
142
+ // fresh token and succeeds — while a write that crept in could be applied
143
+ // twice.
144
+ //
145
+ // Only verbs that cannot plausibly grow mutating children are listed.
146
+ // Namespace-shaped words are deliberately absent: `gog gmail labels list`
147
+ // arrives here as the subcommand `labels`, and if `labels` were listed then a
148
+ // later `labels create` would inherit the replay. Reading `labels` as unsafe
149
+ // costs one message, and nothing else.
150
+ //
151
+ // `lists` used to be here, and was the counter-example that proves the rule is
152
+ // not hypothetical: `gog tasks lists` is a NAMESPACE, and `tasks lists create
153
+ // <title> ...` exists in gog v0.34.1 today — variadic, so one invocation makes
154
+ // N Google calls and a 401 on the second means the first already landed. It is
155
+ // reachable without any new gog, through the `gog_tasks_run` escape hatch
156
+ // (`{subcommand: 'lists', args: ['create', 'A', 'B']}`), so listing the
157
+ // namespace word handed a real double-apply the replay. Its absence costs
158
+ // `gog_tasks_lists` (tools/tasks.ts, argv `tasks lists list`) one automatic
159
+ // replay and nothing more: the eviction runs BEFORE this set is consulted, so
160
+ // that caller's next call still mints a fresh token.
161
+ const READ_ONLY_SUBCOMMANDS = new Set([
162
+ 'cat',
163
+ 'describe',
164
+ 'get',
165
+ 'info',
166
+ 'list',
167
+ 'list-slides',
168
+ 'ls',
169
+ 'metadata',
170
+ 'read-slide',
171
+ 'search',
172
+ 'services',
173
+ 'status',
174
+ 'structure',
175
+ ]);
176
+
177
+ // The gog SERVICE and SUBCOMMAND inside a fully-assembled arg list, either of
178
+ // which may be absent.
179
+ //
180
+ // `run()` (runner.ts assembleArgs) puts global flags in front of the service and
181
+ // subcommand: --json, --color=never, --no-input, --readonly, and --account,
182
+ // which is the only one carrying a SEPARATE value. So the subcommand is the
183
+ // second bare word once flags (and --account's value) are skipped.
184
+ //
185
+ // If a future global flag with a value is added and not handled here, the scan
186
+ // mistakes that value for the service and returns the wrong word — which will
187
+ // almost certainly not be in the read-only set, i.e. it degrades toward NOT
188
+ // replaying. That is the direction a mistake here has to fail in.
189
+ // The service is returned as well as the subcommand because it is what a log
190
+ // record needs: the whole fleet shares one backend and one Google credential,
191
+ // so "which service was this" is the first question asked of an auth record,
192
+ // and re-scanning argv a second time to answer it would be waste on a path that
193
+ // is already handling a failure.
194
+ function gogTarget(args: GogArg[]): { service?: string; subcommand?: string } {
195
+ // A GogFileArg is a payload, never a verb; dropping the non-strings first
196
+ // keeps the scan below about words only.
197
+ const words = args.filter((arg): arg is string => typeof arg === 'string');
198
+ let service: string | undefined;
199
+ for (let i = 0; i < words.length; i += 1) {
200
+ const word = words[i];
201
+ if (word.startsWith('-')) {
202
+ if (word === '--account') i += 1;
203
+ continue;
204
+ }
205
+ if (service === undefined) {
206
+ service = word;
207
+ continue;
208
+ }
209
+ return { service, subcommand: word };
210
+ }
211
+ return { service };
212
+ }
213
+
214
+ // A replay that has been authorized: the freshly minted token to send, plus the
215
+ // identifiers the records around it are tagged with. Carried together rather
216
+ // than recomputed by the caller so the token and the credential a log line
217
+ // names can never come from two different decisions.
218
+ interface Replay {
219
+ token: string;
220
+ /**
221
+ * What is LEFT of the one deadline this tool call was given, in ms. Carried
222
+ * rather than recomputed by the caller so the budget that was judged
223
+ * sufficient is exactly the budget that gets spent.
224
+ */
225
+ budgetMs: number;
226
+ /**
227
+ * The same eviction the rejected token got, carried so the caller can apply
228
+ * it to THIS token if Google refuses it too — see the symmetric eviction
229
+ * below. Carried rather than re-read from the source because by the time an
230
+ * authorization exists it has already been proved present, and re-deriving it
231
+ * would mean re-asking a question whose answer is what this object IS.
232
+ */
233
+ invalidate: NonNullable<FlyAccessTokenSource['invalidate']>;
234
+ credential: string | undefined;
235
+ service: string | undefined;
236
+ }
237
+
238
+ // Decide whether this failure earns exactly one replay with a freshly minted
239
+ // token, and produce that token if so. `undefined` means "do not replay".
240
+ //
241
+ // Every condition is a separate refusal because each is refusing for its own
242
+ // reason, and collapsing them would make the log of WHY unreadable.
243
+ async function remintAfterGoogleRejection(
244
+ err: unknown,
245
+ used: string | undefined,
246
+ args: GogArg[],
247
+ readAccessToken: FlyAccessTokenSource | undefined,
248
+ deadlineAt: number,
249
+ ): Promise<Replay | undefined> {
250
+ // Only a failure gog itself authored can carry Google's verdict. A runner
251
+ // transport failure never reached Google, and the runner's own 401 is about
252
+ // OUR bearer — minting a Google token for either is pure waste.
253
+ if (!(err instanceof GogFailedError)) return undefined;
254
+
255
+ // THE RECORDING GATE, and the reason the two pattern tests moved up here from
256
+ // the middle of the ladder.
257
+ //
258
+ // Everything below this line is a refusal worth WRITING DOWN, because by here
259
+ // Google has demonstrably refused a credential. Everything above it is an
260
+ // ordinary gog failure — a bad attachment id, an --out path that does not
261
+ // exist on the box — and an auth log that also carries those is an auth log
262
+ // nobody reads.
263
+ //
264
+ // Reordering is safe precisely because every check in this function is a pure
265
+ // predicate whose failure returns `undefined`: none has a side effect until
266
+ // the `invalidate` call at the bottom, so the answer cannot depend on the
267
+ // order they are asked in.
268
+ const grantDead = REFRESH_TOKEN_DEAD_PATTERN.test(err.stderr);
269
+ if (!grantDead && !GOOGLE_TOKEN_REJECTED_PATTERN.test(err.stderr)) return undefined;
270
+
271
+ const { service, subcommand } = gogTarget(args);
272
+ const credential = await readAccessToken?.credentialId?.();
273
+ const where = { credential, service };
274
+
275
+ // The refresh token is gone: no mint can succeed, and replaying would loop a
276
+ // caller against a credential that can never work. This is the ONE outcome on
277
+ // this path that legitimately ends in "a human must re-authorize", which is
278
+ // exactly why it gets its own transition rather than a declined-replay note.
279
+ if (grantDead) {
280
+ logAuthTransition('grant.dead', {
281
+ ...where,
282
+ reason:
283
+ 'gog reported invalid_grant: the stored refresh token is dead, so no token can be minted ' +
284
+ 'and this account must be re-authorized',
285
+ });
286
+ return undefined;
287
+ }
288
+
289
+ // Only a token WE supplied is ours to replace. Without one, gog acted as
290
+ // whatever identity the backend volume holds and only an operator can change
291
+ // that.
292
+ if (!used) {
293
+ logAuthTransition('replay.declined', {
294
+ ...where,
295
+ reason:
296
+ 'no access token was supplied with the call, so gog acted as the backend volume’s own identity',
297
+ });
298
+ return undefined;
299
+ }
300
+
301
+ // A source with no cache behind it (a directly-supplied GOG_ACCESS_TOKEN)
302
+ // would hand back the identical rejected string.
303
+ if (!readAccessToken?.invalidate) {
304
+ logAuthTransition('replay.declined', {
305
+ ...where,
306
+ reason: 'this token source cannot mint a replacement, so a replay would resend the rejected token',
307
+ });
308
+ return undefined;
309
+ }
310
+
311
+ // EVICT FIRST, and unconditionally — every remaining check decides whether to
312
+ // REPLAY, which is a different question with a different answer.
313
+ //
314
+ // A replay re-runs the call and can double-apply; an eviction only drops a
315
+ // string Google has already refused, and the cache's own read guard is purely
316
+ // about TIME, so a token left in it is re-served until its nominal expiry.
317
+ // Gating the eviction behind the replay rules is therefore not a conservative
318
+ // choice but the caching half of the original defect: after Google rejected
319
+ // the token, every write (and every read whose subcommand is outside the
320
+ // allow-list) re-sent that same rejected token for up to ~58 minutes, and
321
+ // only a reconnect — a fresh isolate with an empty cache — appeared to help.
322
+ //
323
+ // Safe to do before the decision because it is idempotent and value-matched:
324
+ // `invalidate` drops the entry only if it still holds exactly this string, so
325
+ // a concurrent caller's fresher token is never the casualty, and a second
326
+ // call for the same token changes nothing.
327
+ const evicted = await readAccessToken.invalidate(used);
328
+
329
+ // THE WRITE RULE. A replay re-runs the whole gog invocation. For a read that
330
+ // is free; for a write it is only safe if nothing was applied before the
331
+ // failure, and this layer cannot know that — gog may make several Google
332
+ // calls in one invocation, and a 401 on a later one would mean the earlier
333
+ // ones already landed. Re-sending an email is not a cost worth paying for
334
+ // hiding one error message, so writes get the eviction above and nothing more.
335
+ if (subcommand === undefined || !READ_ONLY_SUBCOMMANDS.has(subcommand)) {
336
+ logAuthTransition('replay.declined', {
337
+ ...where,
338
+ reason: `not replayable: '${subcommand ?? '(none)'}' is not a known read-only subcommand and a write could double-apply`,
339
+ });
340
+ return undefined;
341
+ }
342
+
343
+ // Did the eviction actually drop this token? False means a concurrent caller
344
+ // already replaced it, so the token a replay would send is the one already in
345
+ // use and the replay proves nothing.
346
+ if (!evicted) {
347
+ logAuthTransition('replay.declined', {
348
+ ...where,
349
+ reason: 'the rejected token was already superseded, so the cache holds the token a replay would send',
350
+ });
351
+ return undefined;
352
+ }
353
+
354
+ // The mint can throw (invalid_grant, Google unreachable). That error is
355
+ // allowed to propagate in place of gog's 401, because it is strictly more
356
+ // actionable: it names the credential that is actually dead and the step that
357
+ // repairs it, where gog's 401 only says a token was refused. The mint's own
358
+ // outcome is recorded by google-token.ts, so nothing is logged for it here.
359
+ const fresh = await readAccessToken();
360
+
361
+ // A source that minted a moment ago and now answers nothing must NOT be
362
+ // replayed without a token: the backend would run the call as its own
363
+ // identity and hand this caller someone else's account.
364
+ if (!fresh) {
365
+ logAuthTransition('replay.declined', {
366
+ ...where,
367
+ reason: 'the token source produced no token after eviction; replaying without one would act as the backend',
368
+ });
369
+ return undefined;
370
+ }
371
+
372
+ // Whatever is LEFT of the one deadline this call was given — the mint above
373
+ // spends from it too. A replay with no budget can only abort, and that
374
+ // timeout would land on the caller in place of gog's own 401.
375
+ const budgetMs = deadlineAt - Date.now();
376
+ if (budgetMs < MIN_REPLAY_BUDGET_MS) {
377
+ logAuthTransition('replay.declined', {
378
+ ...where,
379
+ reason:
380
+ `only ${budgetMs}ms of the call’s deadline remained, so a replay could only time out; ` +
381
+ 'the rejected token was evicted, so the next call mints a fresh one',
382
+ });
383
+ return undefined;
384
+ }
385
+ return { token: fresh, budgetMs, invalidate: readAccessToken.invalidate, ...where };
386
+ }
43
387
 
44
388
  // Build a GogExecutor that forwards a fully-assembled `gog` arg-array to the Fly
45
389
  // backend's `/run` endpoint.
@@ -78,96 +422,257 @@ const RUNNER_DRAINING = 503;
78
422
  export function makeFlyExecutor(
79
423
  endpoint: string,
80
424
  key: string,
81
- readAccessToken?: () => string | undefined,
425
+ readAccessToken?: FlyAccessTokenSource,
82
426
  ): GogExecutor {
83
427
  return async (args: GogArg[], opts) => {
84
428
  const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
85
- const accessToken = readAccessToken?.();
86
- let res: Response;
429
+ // Awaited, because the token may have to be MINTED (#241): a refresh token
430
+ // is what a registration stores, and the access token it yields lives about
431
+ // an hour. Deliberately NOT caught here — if the source throws, the call
432
+ // fails, because the alternative is running it as the backend's identity
433
+ // and handing this caller someone else's account.
434
+ const accessToken = await readAccessToken?.();
435
+
436
+ // ONE deadline for the whole tool call, fixed before the first attempt
437
+ // rather than re-derived per attempt. A replay is a second `fetch`, and
438
+ // handing it a fresh copy of the budget made the worst case two full
439
+ // budgets — ~70s of wall clock for one tool call, which can outlast the MCP
440
+ // client's own request timeout and turn a self-healing read into a
441
+ // client-side hang.
442
+ const deadlineAt = Date.now() + deadlineMs;
443
+
87
444
  try {
88
- res = await fetch(endpoint + '/run', {
89
- method: 'POST',
90
- headers: {
91
- Authorization: 'Bearer ' + key,
92
- 'Content-Type': 'application/json',
93
- },
94
- body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
95
- signal: AbortSignal.timeout(deadlineMs),
96
- });
445
+ return await attempt(endpoint, key, args, accessToken, deadlineMs);
97
446
  } catch (err) {
98
- // AbortSignal.timeout rejects with a TimeoutError; a caller-supplied abort
99
- // surfaces as AbortError. Either way the bare message ("The operation was
100
- // aborted") says nothing about which backend failed to answer.
101
- const name = err instanceof Error ? err.name : '';
102
- if (name === 'TimeoutError' || name === 'AbortError') {
103
- throw new Error(
104
- `gog-runner did not respond within ${deadlineMs}ms (${endpoint}) — the Fly backend may be cold or wedged`,
105
- );
447
+ // ONE replay, and only when the token we sent is the reason it failed.
448
+ // Not a retry loop: an unbounded one against a genuinely dead credential
449
+ // is exactly the behaviour the "retry the same call" hints already
450
+ // produced elsewhere, and it never terminates. `remintAfterGoogleRejection`
451
+ // both decides and mints, so the decision cannot drift from the token.
452
+ const replay = await remintAfterGoogleRejection(
453
+ err,
454
+ accessToken,
455
+ args,
456
+ readAccessToken,
457
+ deadlineAt,
458
+ );
459
+ if (replay === undefined) throw err;
460
+
461
+ const where = { credential: replay.credential, service: replay.service, endpoint };
462
+ logAuthTransition('replay.attempted', {
463
+ ...where,
464
+ reason: 'Google rejected the access token; replaying this read once with a freshly minted one',
465
+ });
466
+ try {
467
+ const stdout = await attempt(endpoint, key, args, replay.token, replay.budgetMs);
468
+ // The interesting record of the pair: it says the caller saw a clean
469
+ // success where they used to see an hour of identical 401s.
470
+ logAuthTransition('replay.succeeded', where);
471
+ return stdout;
472
+ } catch (replayErr) {
473
+ // `String`, not `instanceof Error ? .message : …` — a rethrown non-Error
474
+ // rejection is a real possibility here (see attempt's abort handling)
475
+ // and stringifying uniformly avoids an arm that no test could reach.
476
+ logAuthTransition('replay.failed', { ...where, reason: String(replayErr) });
477
+ // SYMMETRY with the eviction that got us here, under the SAME predicate
478
+ // — a token is dropped because GOOGLE refused it, never merely because
479
+ // a call carrying it failed.
480
+ //
481
+ // The gate is the point. A replay can fail without Google ever seeing
482
+ // the token: the Machine drains between the two attempts, the
483
+ // client-side deadline fires, the runner's bearer rotates mid-call.
484
+ // Evicting on those would discard a token nothing has refused and emit
485
+ // `token.evicted` reading "Google rejected this access token" about a
486
+ // service that was never consulted — the same misattribution this
487
+ // branch exists to delete, moved from the user's screen to the
488
+ // operator's query. So the second eviction asks exactly what authorized
489
+ // the first: is this a gog failure whose stderr shows Google refusing
490
+ // the credential?
491
+ //
492
+ // WHAT THIS BUYS, measured rather than assumed. Under a sustained
493
+ // Google-side refusal that is not invalid_grant (a revoked scope, say),
494
+ // a steady-state call costs 2 /run round-trips either way — the first
495
+ // attempt and the replay both always happen — and this eviction in fact
496
+ // costs one EXTRA mint per call (2 instead of 1), because it empties the
497
+ // cache the next call would otherwise have hit. It is not a round-trip
498
+ // saving and must not be justified as one.
499
+ //
500
+ // It is worth keeping because it bounds how long a KNOWN-REFUSED token
501
+ // can be handed out. Left cached, `ya29.fresh` is re-served for the rest
502
+ // of its nominal hour, and the caller that suffers most is a WRITE: a
503
+ // write gets the eviction and no replay, so it would be sent with the
504
+ // token that just failed twice and fail on contact — even after the
505
+ // underlying refusal has cleared. Trading a mint for that is the right
506
+ // side of the deal.
507
+ //
508
+ // Value-matched and idempotent like the first eviction, so a concurrent
509
+ // caller's fresher token is never the casualty. Not wrapped in its own
510
+ // catch: `invalidate` is a map lookup behind a digest, the first
511
+ // eviction is already un-guarded on this same path, and a guard here
512
+ // would add an arm no test can reach.
513
+ if (replayErr instanceof GogFailedError && GOOGLE_TOKEN_REJECTED_PATTERN.test(replayErr.stderr)) {
514
+ await replay.invalidate(replay.token);
515
+ }
516
+ throw replayErr;
106
517
  }
107
- throw err;
108
518
  }
109
- if (!res.ok) {
110
- // Two very different failures arrive as non-2xx, and collapsing them (as
111
- // this used to) is what made a real bug look like random flakiness:
112
- //
113
- // a) The runner answered with its own JSON — `gog` actually ran on the
114
- // box and failed. Deterministic: the same call will fail the same way.
115
- // b) The body is NOT the runner's JSON (Fly's HTML error page, or empty).
116
- // Then the request never reached `gog` at all; Fly's edge proxy is
117
- // reporting that it could not reach the Machine — typically because
118
- // the Machine was starting from scale-to-zero, or was mid-shutdown.
119
- // Genuinely transient, and the only case worth retrying.
120
- const body = (await res.json().catch(() => null)) as
121
- | { error?: string; stderr?: string; retryable?: boolean }
122
- | null;
123
- const detail =
124
- body && typeof body.error === 'string'
125
- ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim()
126
- ? `${body.error}\n${body.stderr}`
127
- : body.error
128
- : '';
129
-
130
- // 422 is the runner's "gog ran and exited non-zero" status. It is only
131
- // ever produced by our own handler, so reaching here proves the request
132
- // was delivered and executed. Deterministic — say so, and say nothing
133
- // that invites a retry.
134
- if (res.status === RUNNER_GOG_FAILED) {
135
- throw new Error(detail || 'gog failed on the runner (no detail supplied)');
136
- }
519
+ };
520
+ }
137
521
 
138
- // The runner's drain response: it is up, but deliberately refusing new
139
- // work while it shuts down. The one runner-authored failure worth retrying.
140
- if (res.status === RUNNER_DRAINING || body?.retryable === true) {
141
- throw new Error(
142
- `gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ''}`,
143
- );
144
- }
522
+ // One request to the runner: send the args (and, when we have one, the identity
523
+ // to act as), and turn whatever comes back into either stdout or a classified
524
+ // failure. Everything about WHICH failure this is lives here; the caller above
525
+ // decides only whether to run it a second time.
526
+ async function attempt(
527
+ endpoint: string,
528
+ key: string,
529
+ args: GogArg[],
530
+ accessToken: string | undefined,
531
+ deadlineMs: number,
532
+ ): Promise<string> {
533
+ let res: Response;
534
+ try {
535
+ res = await fetch(endpoint + '/run', {
536
+ method: 'POST',
537
+ headers: {
538
+ Authorization: 'Bearer ' + key,
539
+ 'Content-Type': 'application/json',
540
+ },
541
+ body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
542
+ signal: AbortSignal.timeout(deadlineMs),
543
+ });
544
+ } catch (err) {
545
+ // AbortSignal.timeout rejects with a TimeoutError; a caller-supplied abort
546
+ // surfaces as AbortError. Either way the bare message ("The operation was
547
+ // aborted") says nothing about which backend failed to answer.
548
+ const name = err instanceof Error ? err.name : '';
549
+ if (name === 'TimeoutError' || name === 'AbortError') {
550
+ // No status: nothing answered. Retryable — the usual cause is the Fly
551
+ // machine waking from scale-to-zero, which succeeds on the next call.
552
+ throw new RunnerTransportError(
553
+ `gog-runner did not respond within ${deadlineMs}ms (${endpoint}) — the Fly backend may be cold or wedged`,
554
+ 'transport-retryable',
555
+ );
556
+ }
557
+ throw err;
558
+ }
559
+ if (!res.ok) {
560
+ // Two very different failures arrive as non-2xx, and collapsing them (as
561
+ // this used to) is what made a real bug look like random flakiness:
562
+ //
563
+ // a) The runner answered with its own JSON — `gog` actually ran on the
564
+ // box and failed. Deterministic: the same call will fail the same way.
565
+ // b) The body is NOT the runner's JSON (Fly's HTML error page, or empty).
566
+ // Then the request never reached `gog` at all; Fly's edge proxy is
567
+ // reporting that it could not reach the Machine — typically because
568
+ // the Machine was starting from scale-to-zero, or was mid-shutdown.
569
+ // Genuinely transient, and the only case worth retrying.
570
+ const body = (await res.json().catch(() => null)) as
571
+ | { error?: string; stderr?: string; retryable?: boolean }
572
+ | null;
573
+ const detail =
574
+ body && typeof body.error === 'string'
575
+ ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim()
576
+ ? `${body.error}\n${body.stderr}`
577
+ : body.error
578
+ : '';
145
579
 
146
- // Anything else non-2xx is infrastructure: Fly's edge could not reach the
147
- // Machine, or the Machine answered with something that is not ours. Only
148
- // claim the request never arrived when there is genuinely no runner body
149
- // — a runner that did answer deserves to have its own words repeated.
150
- //
151
- // The status is deliberately NOT interpolated here. A runner body proves
152
- // gog ran, so this is a deterministic failure; embedding the literal
153
- // status would put "502" into the message, which matches
154
- // TRANSIENT_ERROR_PATTERN (/\b5\d\d\b/) in tools/utils.ts and re-attaches
155
- // the very "this is transient, retry the same call" hint this change
156
- // exists to remove reintroducing the bug during the rollout window this
157
- // branch exists to cover. Anything genuinely transient in gog's own text
158
- // (a Google 5xx, say) still matches on its own merits, which is correct.
159
- if (detail) {
160
- throw new Error(detail);
161
- }
162
- throw new Error(
163
- `gog-runner HTTP ${res.status}: the response did not come from the runner, ` +
164
- 'so the request never reached gog. The backend Machine was most likely starting ' +
165
- 'or shutting down — this is transient, retry the same call.',
580
+ // 422 is the runner's "gog ran and exited non-zero" status. It is only
581
+ // ever produced by our own handler, so reaching here proves the request
582
+ // was delivered and executed. Deterministic say so, and say nothing
583
+ // that invites a retry.
584
+ if (res.status === RUNNER_GOG_FAILED) {
585
+ // The message is unchanged gog's words, exactly as before. `stderr` is
586
+ // carried alongside rather than folded in, so the replay decision can
587
+ // read what GOG said without also reading the argv the runner echoed
588
+ // back inside `error`.
589
+ throw new GogFailedError(
590
+ detail || 'gog failed on the runner (no detail supplied)',
591
+ typeof body?.stderr === 'string' ? body.stderr : '',
166
592
  );
167
593
  }
168
- const { stdout } = (await res.json()) as { stdout: string };
169
- return stdout;
170
- };
594
+
595
+ // The runner's OWN bearer auth failed: the key the Worker sent is not the
596
+ // key the Fly app expects. `gog` never ran, Google was never contacted,
597
+ // and no stored credential was even read — so the one thing this must not
598
+ // do is send the caller to re-authorize an account that is fine.
599
+ //
600
+ // The runner's body (the bare word "unauthorized") is deliberately
601
+ // DROPPED rather than repeated, and the status is deliberately not
602
+ // interpolated, for the same reason the 5xx fallback below omits its own:
603
+ // both `unauthorized` and `401` match DEFINITE_AUTH_PATTERN in
604
+ // tools/utils.ts. The type is what classifies this error now, but the
605
+ // prose must not be able to re-create the old misdiagnosis at any future
606
+ // boundary where the type could be lost (a serialized error, a log line a
607
+ // human reads).
608
+ if (res.status === RUNNER_BAD_KEY) {
609
+ // The record a human reads must not re-create defect 1 either, so it
610
+ // states the negative facts explicitly instead of repeating the runner's
611
+ // bare "unauthorized".
612
+ logAuthTransition('runner.auth-failed', {
613
+ service: gogTarget(args).service,
614
+ endpoint,
615
+ reason:
616
+ 'the gog-runner rejected the connector’s bearer token, so gog never ran and no Google ' +
617
+ 'credential was read; GOG_RUNNER_KEY does not match the Fly app’s RUNNER_KEY',
618
+ });
619
+ throw new RunnerTransportError(
620
+ "gog-runner rejected the connector's bearer token, so the request never reached gog and no " +
621
+ 'Google credential was involved. The Worker secret GOG_RUNNER_KEY no longer matches RUNNER_KEY ' +
622
+ 'on the Fly app; set them to the same value (wrangler secret put GOG_RUNNER_KEY / fly secrets ' +
623
+ 'set RUNNER_KEY) and retry.',
624
+ 'transport-auth',
625
+ res.status,
626
+ );
627
+ }
628
+
629
+ // The runner refused the request shape (oversized arg, unparseable JSON,
630
+ // a malformed access token). Its words are about OUR request, never about
631
+ // Google — deterministic, and no hint applies.
632
+ if (res.status === RUNNER_BAD_REQUEST) {
633
+ throw new RunnerTransportError(
634
+ detail || 'gog-runner rejected the request (no detail supplied)',
635
+ 'transport-request',
636
+ res.status,
637
+ );
638
+ }
639
+
640
+ // The runner's drain response: it is up, but deliberately refusing new
641
+ // work while it shuts down. The one runner-authored failure worth retrying.
642
+ if (res.status === RUNNER_DRAINING || body?.retryable === true) {
643
+ throw new RunnerTransportError(
644
+ `gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ''}`,
645
+ 'transport-retryable',
646
+ res.status,
647
+ );
648
+ }
649
+
650
+ // Anything else non-2xx is infrastructure: Fly's edge could not reach the
651
+ // Machine, or the Machine answered with something that is not ours. Only
652
+ // claim the request never arrived when there is genuinely no runner body
653
+ // — a runner that did answer deserves to have its own words repeated.
654
+ //
655
+ // The status is deliberately NOT interpolated here. A runner body proves
656
+ // gog ran, so this is a deterministic failure; embedding the literal
657
+ // status would put "502" into the message, which matches
658
+ // TRANSIENT_ERROR_PATTERN (/\b5\d\d\b/) in tools/utils.ts and re-attaches
659
+ // the very "this is transient, retry the same call" hint this change
660
+ // exists to remove — reintroducing the bug during the rollout window this
661
+ // branch exists to cover. Anything genuinely transient in gog's own text
662
+ // (a Google 5xx, say) still matches on its own merits, which is correct.
663
+ if (detail) {
664
+ throw new Error(detail);
665
+ }
666
+ throw new RunnerTransportError(
667
+ `gog-runner HTTP ${res.status}: the response did not come from the runner, ` +
668
+ 'so the request never reached gog. The backend Machine was most likely starting ' +
669
+ 'or shutting down — this is transient, retry the same call.',
670
+ 'transport-retryable',
671
+ res.status,
672
+ );
673
+ }
674
+ const { stdout } = (await res.json()) as { stdout: string };
675
+ return stdout;
171
676
  }
172
677
 
173
678
  // Wrap an McpServer in a Proxy whose `registerTool` (and `tool`, if any