gogcli-mcp 2.21.0 → 2.22.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.
@@ -1,5 +1,15 @@
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, 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';
3
13
 
4
14
  // Runtime helpers for the Cloudflare connector (worker.ts), split out here so
5
15
  // they can be unit-tested under the node pool — worker.ts itself imports the
@@ -30,6 +40,74 @@ const DEFAULT_TIMEOUT_MS = 30_000;
30
40
  // than to inflate the grace, so the runner still gets to answer first.
31
41
  const DEADLINE_GRACE_MS = 5_000;
32
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
+
33
111
  // Status codes the Fly runner uses to classify its OWN failures. These must stay
34
112
  // in sync with fly-gog-runner/server.mjs — they are the contract that lets this
35
113
  // side tell "gog ran and failed" apart from "the request never arrived", without
@@ -38,8 +116,351 @@ const DEADLINE_GRACE_MS = 5_000;
38
116
  // 422: `gog` executed and exited non-zero. Deterministic — never retry.
39
117
  // 503: the runner is draining (SIGINT from Fly's autostop). Transient — retry.
40
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.
41
127
  const RUNNER_GOG_FAILED = 422;
42
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
+ }
43
464
 
44
465
  // Build a GogExecutor that forwards a fully-assembled `gog` arg-array to the Fly
45
466
  // backend's `/run` endpoint.
@@ -78,8 +499,115 @@ const RUNNER_DRAINING = 503;
78
499
  export function makeFlyExecutor(
79
500
  endpoint: string,
80
501
  key: string,
81
- readAccessToken?: () => string | undefined | Promise<string | undefined>,
502
+ readAccessToken?: FlyAccessTokenSource,
82
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: on the Worker one
508
+ // executor is built per agent session (`worker.ts` `init()`), on stdio one per
509
+ // process (`remote-runner.ts`). So a session that is hammering a refused
510
+ // credential rate-limits itself without a second, unrelated session's probe
511
+ // being suppressed by it — a module global would let one caller's retry loop
512
+ // silence everybody else's first and only measurement. It also means the state
513
+ // dies with the session instead of accumulating endpoints for the isolate's
514
+ // lifetime.
515
+ let lastProbeAt = Number.NEGATIVE_INFINITY;
516
+
517
+ /**
518
+ * Ask the runner whether Google still accepts the credential on its volume,
519
+ * and record the answer. Resolves in EVERY case: it can neither throw nor
520
+ * return a value, because nothing may make a decision out of what it finds.
521
+ * The caller's error is already decided by the time this runs.
522
+ */
523
+ const probeGoogleAfterRefusal = async (
524
+ where: { credential?: string; service?: string },
525
+ deadlineAt: number,
526
+ ): Promise<void> => {
527
+ const record = { ...where, endpoint };
528
+
529
+ // ONE reading of the clock for both budget and throttle, so the two
530
+ // decisions cannot disagree about what time it is.
531
+ const now = Date.now();
532
+ const remainingMs = deadlineAt - now;
533
+ if (remainingMs < MIN_PROBE_BUDGET_MS) {
534
+ logAuthTransition('refusal.google-unmeasured', {
535
+ ...record,
536
+ reason:
537
+ `only ${remainingMs}ms of the call’s deadline remained, so the Google layer was not ` +
538
+ 'measured rather than delay the caller’s own error',
539
+ });
540
+ return;
541
+ }
542
+ if (now - lastProbeAt < PROBE_INTERVAL_MS) {
543
+ logAuthTransition('refusal.google-unmeasured', {
544
+ ...record,
545
+ // "attempted", not "measured". `lastProbeAt` is stamped before the
546
+ // fetch and is deliberately NOT reset when the probe comes back with no
547
+ // verdict (a 404 from a runner too old to have the endpoint, a timeout,
548
+ // a dead socket) — the backend cost this throttle exists to bound was
549
+ // paid either way, and resetting it would let a retry loop storm a
550
+ // runner that is already unwell. So the timestamp stays and the sentence
551
+ // has to be the true one: on this branch a log line may not assert a
552
+ // measurement that never happened, and the previous probe may well have
553
+ // measured nothing at all.
554
+ reason:
555
+ 'a Google probe was attempted recently, so another was not sent: this probe spawns ' +
556
+ 'gog on the backend and takes the keyring’s exclusive lock',
557
+ });
558
+ return;
559
+ }
560
+ // Claimed BEFORE the await, so two overlapping refusals cannot both get
561
+ // past the check and spawn a probe apiece.
562
+ lastProbeAt = now;
563
+
564
+ let event: AuthTransition;
565
+ let reason: string;
566
+ try {
567
+ const res = await fetch(`${endpoint}/health/google`, {
568
+ headers: { Authorization: `Bearer ${key}` },
569
+ // Never more than the probe's own budget, never more than the call has
570
+ // left. `Math.min` rather than a plain constant because the second
571
+ // bound is the caller's, and it outranks ours.
572
+ signal: AbortSignal.timeout(Math.min(REFUSAL_PROBE_TIMEOUT_MS, remainingMs)),
573
+ });
574
+ if (!res.ok) {
575
+ // Includes the 404 from a runner deployed before `/health/google`
576
+ // existed. "I could not ask" is never filed as "Google said no" — that
577
+ // is the defect this branch exists to delete, with the alarm inverted.
578
+ event = 'refusal.google-unmeasured';
579
+ reason = `the runner did not answer the Google probe (HTTP ${res.status})`;
580
+ } else {
581
+ // `readGoogleProbe` is the ONE place that judges a probe body, shared
582
+ // with the connect-time probe in connector-auth.ts. It reads the
583
+ // runner's `measured` field BEFORE its `ok` field, which is what keeps a
584
+ // probe that timed out or could not be run from being filed as
585
+ // `-unhealthy` — the record an operator reads as "the live check agrees
586
+ // the credential is refused". Its reason strings come from the runner's
587
+ // CLOSED vocabulary of causes (PROBE_CAUSES in server.mjs), so they
588
+ // carry a classification and never gog's own output.
589
+ const verdict = readGoogleProbe(await res.json());
590
+ if (verdict.kind === 'ok') {
591
+ event = 'refusal.google-ok';
592
+ reason =
593
+ 'Google refused this call, yet a live token check on the same volume succeeded — ' +
594
+ 'so a dead or expired refresh token does not explain this refusal';
595
+ } else {
596
+ event = verdict.kind === 'unhealthy' ? 'refusal.google-unhealthy' : 'refusal.google-unmeasured';
597
+ reason = verdict.reason;
598
+ }
599
+ }
600
+ } catch (err) {
601
+ // A rejected fetch, an abort at the budget, or a body that is not JSON (a
602
+ // proxy's HTML error page). None of them are facts about Google.
603
+ event = 'refusal.google-unmeasured';
604
+ reason = err instanceof Error ? err.message : String(err);
605
+ }
606
+ // `reason` can quote text this layer did not author, so the record goes
607
+ // through the same redactor as every other auth log line.
608
+ logAuthTransition(event, { ...record, reason });
609
+ };
610
+
83
611
  return async (args: GogArg[], opts) => {
84
612
  const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
85
613
  // Awaited, because the token may have to be MINTED (#241): a refresh token
@@ -88,91 +616,248 @@ export function makeFlyExecutor(
88
616
  // fails, because the alternative is running it as the backend's identity
89
617
  // and handing this caller someone else's account.
90
618
  const accessToken = await readAccessToken?.();
91
- let res: Response;
619
+
620
+ // ONE deadline for the whole tool call, fixed before the first attempt
621
+ // rather than re-derived per attempt. A replay is a second `fetch`, and
622
+ // handing it a fresh copy of the budget made the worst case two full
623
+ // budgets — ~70s of wall clock for one tool call, which can outlast the MCP
624
+ // client's own request timeout and turn a self-healing read into a
625
+ // client-side hang.
626
+ const deadlineAt = Date.now() + deadlineMs;
627
+
92
628
  try {
93
- res = await fetch(endpoint + '/run', {
94
- method: 'POST',
95
- headers: {
96
- Authorization: 'Bearer ' + key,
97
- 'Content-Type': 'application/json',
98
- },
99
- body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
100
- signal: AbortSignal.timeout(deadlineMs),
101
- });
629
+ return await attempt(endpoint, key, args, accessToken, deadlineMs);
102
630
  } catch (err) {
103
- // AbortSignal.timeout rejects with a TimeoutError; a caller-supplied abort
104
- // surfaces as AbortError. Either way the bare message ("The operation was
105
- // aborted") says nothing about which backend failed to answer.
106
- const name = err instanceof Error ? err.name : '';
107
- if (name === 'TimeoutError' || name === 'AbortError') {
108
- throw new Error(
109
- `gog-runner did not respond within ${deadlineMs}ms (${endpoint}) — the Fly backend may be cold or wedged`,
110
- );
631
+ // ONE replay, and only when the token we sent is the reason it failed.
632
+ // Not a retry loop: an unbounded one against a genuinely dead credential
633
+ // is exactly the behaviour the "retry the same call" hints already
634
+ // produced elsewhere, and it never terminates. `remintAfterGoogleRejection`
635
+ // both decides and mints, so the decision cannot drift from the token.
636
+ const replay = await remintAfterGoogleRejection(
637
+ err,
638
+ accessToken,
639
+ args,
640
+ readAccessToken,
641
+ deadlineAt,
642
+ (where) => probeGoogleAfterRefusal(where, deadlineAt),
643
+ );
644
+ if (replay === undefined) throw err;
645
+
646
+ const where = { credential: replay.credential, service: replay.service, endpoint };
647
+ logAuthTransition('replay.attempted', {
648
+ ...where,
649
+ reason: 'Google rejected the access token; replaying this read once with a freshly minted one',
650
+ });
651
+ try {
652
+ const stdout = await attempt(endpoint, key, args, replay.token, replay.budgetMs);
653
+ // The interesting record of the pair: it says the caller saw a clean
654
+ // success where they used to see an hour of identical 401s.
655
+ logAuthTransition('replay.succeeded', where);
656
+ return stdout;
657
+ } catch (replayErr) {
658
+ // `String`, not `instanceof Error ? .message : …` — a rethrown non-Error
659
+ // rejection is a real possibility here (see attempt's abort handling)
660
+ // and stringifying uniformly avoids an arm that no test could reach.
661
+ logAuthTransition('replay.failed', { ...where, reason: String(replayErr) });
662
+ // SYMMETRY with the eviction that got us here, under the SAME predicate
663
+ // — a token is dropped because GOOGLE refused it, never merely because
664
+ // a call carrying it failed.
665
+ //
666
+ // The gate is the point. A replay can fail without Google ever seeing
667
+ // the token: the Machine drains between the two attempts, the
668
+ // client-side deadline fires, the runner's bearer rotates mid-call.
669
+ // Evicting on those would discard a token nothing has refused and emit
670
+ // `token.evicted` reading "Google rejected this access token" about a
671
+ // service that was never consulted — the same misattribution this
672
+ // branch exists to delete, moved from the user's screen to the
673
+ // operator's query. So the second eviction asks exactly what authorized
674
+ // the first: is this a gog failure whose stderr shows Google refusing
675
+ // the credential?
676
+ //
677
+ // WHAT THIS BUYS, measured rather than assumed. Under a sustained
678
+ // Google-side refusal that is not invalid_grant (a revoked scope, say),
679
+ // a steady-state call costs 2 /run round-trips either way — the first
680
+ // attempt and the replay both always happen — and this eviction in fact
681
+ // costs one EXTRA mint per call (2 instead of 1), because it empties the
682
+ // cache the next call would otherwise have hit. It is not a round-trip
683
+ // saving and must not be justified as one.
684
+ //
685
+ // It is worth keeping because it bounds how long a KNOWN-REFUSED token
686
+ // can be handed out. Left cached, `ya29.fresh` is re-served for the rest
687
+ // of its nominal hour, and the caller that suffers most is a WRITE: a
688
+ // write gets the eviction and no replay, so it would be sent with the
689
+ // token that just failed twice and fail on contact — even after the
690
+ // underlying refusal has cleared. Trading a mint for that is the right
691
+ // side of the deal.
692
+ //
693
+ // Value-matched and idempotent like the first eviction, so a concurrent
694
+ // caller's fresher token is never the casualty. Not wrapped in its own
695
+ // catch: `invalidate` is a map lookup behind a digest, the first
696
+ // eviction is already un-guarded on this same path, and a guard here
697
+ // would add an arm no test can reach.
698
+ if (replayErr instanceof GogFailedError && GOOGLE_TOKEN_REJECTED_PATTERN.test(replayErr.stderr)) {
699
+ await replay.invalidate(replay.token);
700
+ }
701
+ throw replayErr;
111
702
  }
112
- throw err;
113
703
  }
114
- if (!res.ok) {
115
- // Two very different failures arrive as non-2xx, and collapsing them (as
116
- // this used to) is what made a real bug look like random flakiness:
117
- //
118
- // a) The runner answered with its own JSON — `gog` actually ran on the
119
- // box and failed. Deterministic: the same call will fail the same way.
120
- // b) The body is NOT the runner's JSON (Fly's HTML error page, or empty).
121
- // Then the request never reached `gog` at all; Fly's edge proxy is
122
- // reporting that it could not reach the Machine — typically because
123
- // the Machine was starting from scale-to-zero, or was mid-shutdown.
124
- // Genuinely transient, and the only case worth retrying.
125
- const body = (await res.json().catch(() => null)) as
126
- | { error?: string; stderr?: string; retryable?: boolean }
127
- | null;
128
- const detail =
129
- body && typeof body.error === 'string'
130
- ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim()
131
- ? `${body.error}\n${body.stderr}`
132
- : body.error
133
- : '';
134
-
135
- // 422 is the runner's "gog ran and exited non-zero" status. It is only
136
- // ever produced by our own handler, so reaching here proves the request
137
- // was delivered and executed. Deterministic — say so, and say nothing
138
- // that invites a retry.
139
- if (res.status === RUNNER_GOG_FAILED) {
140
- throw new Error(detail || 'gog failed on the runner (no detail supplied)');
141
- }
704
+ };
705
+ }
142
706
 
143
- // The runner's drain response: it is up, but deliberately refusing new
144
- // work while it shuts down. The one runner-authored failure worth retrying.
145
- if (res.status === RUNNER_DRAINING || body?.retryable === true) {
146
- throw new Error(
147
- `gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ''}`,
148
- );
149
- }
707
+ // One request to the runner: send the args (and, when we have one, the identity
708
+ // to act as), and turn whatever comes back into either stdout or a classified
709
+ // failure. Everything about WHICH failure this is lives here; the caller above
710
+ // decides only whether to run it a second time.
711
+ async function attempt(
712
+ endpoint: string,
713
+ key: string,
714
+ args: GogArg[],
715
+ accessToken: string | undefined,
716
+ deadlineMs: number,
717
+ ): Promise<string> {
718
+ let res: Response;
719
+ try {
720
+ res = await fetch(endpoint + '/run', {
721
+ method: 'POST',
722
+ headers: {
723
+ Authorization: 'Bearer ' + key,
724
+ 'Content-Type': 'application/json',
725
+ },
726
+ body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
727
+ signal: AbortSignal.timeout(deadlineMs),
728
+ });
729
+ } catch (err) {
730
+ // AbortSignal.timeout rejects with a TimeoutError; a caller-supplied abort
731
+ // surfaces as AbortError. Either way the bare message ("The operation was
732
+ // aborted") says nothing about which backend failed to answer.
733
+ const name = err instanceof Error ? err.name : '';
734
+ if (name === 'TimeoutError' || name === 'AbortError') {
735
+ // No status: nothing answered. Retryable — the usual cause is the Fly
736
+ // machine waking from scale-to-zero, which succeeds on the next call.
737
+ throw new RunnerTransportError(
738
+ `gog-runner did not respond within ${deadlineMs}ms (${endpoint}) — the Fly backend may be cold or wedged`,
739
+ 'transport-retryable',
740
+ );
741
+ }
742
+ throw err;
743
+ }
744
+ if (!res.ok) {
745
+ // Two very different failures arrive as non-2xx, and collapsing them (as
746
+ // this used to) is what made a real bug look like random flakiness:
747
+ //
748
+ // a) The runner answered with its own JSON — `gog` actually ran on the
749
+ // box and failed. Deterministic: the same call will fail the same way.
750
+ // b) The body is NOT the runner's JSON (Fly's HTML error page, or empty).
751
+ // Then the request never reached `gog` at all; Fly's edge proxy is
752
+ // reporting that it could not reach the Machine — typically because
753
+ // the Machine was starting from scale-to-zero, or was mid-shutdown.
754
+ // Genuinely transient, and the only case worth retrying.
755
+ const body = (await res.json().catch(() => null)) as
756
+ | { error?: string; stderr?: string; retryable?: boolean }
757
+ | null;
758
+ const detail =
759
+ body && typeof body.error === 'string'
760
+ ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim()
761
+ ? `${body.error}\n${body.stderr}`
762
+ : body.error
763
+ : '';
150
764
 
151
- // Anything else non-2xx is infrastructure: Fly's edge could not reach the
152
- // Machine, or the Machine answered with something that is not ours. Only
153
- // claim the request never arrived when there is genuinely no runner body
154
- // — a runner that did answer deserves to have its own words repeated.
155
- //
156
- // The status is deliberately NOT interpolated here. A runner body proves
157
- // gog ran, so this is a deterministic failure; embedding the literal
158
- // status would put "502" into the message, which matches
159
- // TRANSIENT_ERROR_PATTERN (/\b5\d\d\b/) in tools/utils.ts and re-attaches
160
- // the very "this is transient, retry the same call" hint this change
161
- // exists to remove reintroducing the bug during the rollout window this
162
- // branch exists to cover. Anything genuinely transient in gog's own text
163
- // (a Google 5xx, say) still matches on its own merits, which is correct.
164
- if (detail) {
165
- throw new Error(detail);
166
- }
167
- throw new Error(
168
- `gog-runner HTTP ${res.status}: the response did not come from the runner, ` +
169
- 'so the request never reached gog. The backend Machine was most likely starting ' +
170
- 'or shutting down — this is transient, retry the same call.',
765
+ // 422 is the runner's "gog ran and exited non-zero" status. It is only
766
+ // ever produced by our own handler, so reaching here proves the request
767
+ // was delivered and executed. Deterministic say so, and say nothing
768
+ // that invites a retry.
769
+ if (res.status === RUNNER_GOG_FAILED) {
770
+ // The message is unchanged gog's words, exactly as before. `stderr` is
771
+ // carried alongside rather than folded in, so the replay decision can
772
+ // read what GOG said without also reading the argv the runner echoed
773
+ // back inside `error`.
774
+ throw new GogFailedError(
775
+ detail || 'gog failed on the runner (no detail supplied)',
776
+ typeof body?.stderr === 'string' ? body.stderr : '',
171
777
  );
172
778
  }
173
- const { stdout } = (await res.json()) as { stdout: string };
174
- return stdout;
175
- };
779
+
780
+ // The runner's OWN bearer auth failed: the key the Worker sent is not the
781
+ // key the Fly app expects. `gog` never ran, Google was never contacted,
782
+ // and no stored credential was even read — so the one thing this must not
783
+ // do is send the caller to re-authorize an account that is fine.
784
+ //
785
+ // The runner's body (the bare word "unauthorized") is deliberately
786
+ // DROPPED rather than repeated, and the status is deliberately not
787
+ // interpolated, for the same reason the 5xx fallback below omits its own:
788
+ // both `unauthorized` and `401` match DEFINITE_AUTH_PATTERN in
789
+ // tools/utils.ts. The type is what classifies this error now, but the
790
+ // prose must not be able to re-create the old misdiagnosis at any future
791
+ // boundary where the type could be lost (a serialized error, a log line a
792
+ // human reads).
793
+ if (res.status === RUNNER_BAD_KEY) {
794
+ // The record a human reads must not re-create defect 1 either, so it
795
+ // states the negative facts explicitly instead of repeating the runner's
796
+ // bare "unauthorized".
797
+ logAuthTransition('runner.auth-failed', {
798
+ service: gogTarget(args).service,
799
+ endpoint,
800
+ reason:
801
+ 'the gog-runner rejected the connector’s bearer token, so gog never ran and no Google ' +
802
+ 'credential was read; GOG_RUNNER_KEY does not match the Fly app’s RUNNER_KEY',
803
+ });
804
+ throw new RunnerTransportError(
805
+ "gog-runner rejected the connector's bearer token, so the request never reached gog and no " +
806
+ 'Google credential was involved. The Worker secret GOG_RUNNER_KEY no longer matches RUNNER_KEY ' +
807
+ 'on the Fly app; set them to the same value (wrangler secret put GOG_RUNNER_KEY / fly secrets ' +
808
+ 'set RUNNER_KEY) and retry.',
809
+ 'transport-auth',
810
+ res.status,
811
+ );
812
+ }
813
+
814
+ // The runner refused the request shape (oversized arg, unparseable JSON,
815
+ // a malformed access token). Its words are about OUR request, never about
816
+ // Google — deterministic, and no hint applies.
817
+ if (res.status === RUNNER_BAD_REQUEST) {
818
+ throw new RunnerTransportError(
819
+ detail || 'gog-runner rejected the request (no detail supplied)',
820
+ 'transport-request',
821
+ res.status,
822
+ );
823
+ }
824
+
825
+ // The runner's drain response: it is up, but deliberately refusing new
826
+ // work while it shuts down. The one runner-authored failure worth retrying.
827
+ if (res.status === RUNNER_DRAINING || body?.retryable === true) {
828
+ throw new RunnerTransportError(
829
+ `gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ''}`,
830
+ 'transport-retryable',
831
+ res.status,
832
+ );
833
+ }
834
+
835
+ // Anything else non-2xx is infrastructure: Fly's edge could not reach the
836
+ // Machine, or the Machine answered with something that is not ours. Only
837
+ // claim the request never arrived when there is genuinely no runner body
838
+ // — a runner that did answer deserves to have its own words repeated.
839
+ //
840
+ // The status is deliberately NOT interpolated here. A runner body proves
841
+ // gog ran, so this is a deterministic failure; embedding the literal
842
+ // status would put "502" into the message, which matches
843
+ // TRANSIENT_ERROR_PATTERN (/\b5\d\d\b/) in tools/utils.ts and re-attaches
844
+ // the very "this is transient, retry the same call" hint this change
845
+ // exists to remove — reintroducing the bug during the rollout window this
846
+ // branch exists to cover. Anything genuinely transient in gog's own text
847
+ // (a Google 5xx, say) still matches on its own merits, which is correct.
848
+ if (detail) {
849
+ throw new Error(detail);
850
+ }
851
+ throw new RunnerTransportError(
852
+ `gog-runner HTTP ${res.status}: the response did not come from the runner, ` +
853
+ 'so the request never reached gog. The backend Machine was most likely starting ' +
854
+ 'or shutting down — this is transient, retry the same call.',
855
+ 'transport-retryable',
856
+ res.status,
857
+ );
858
+ }
859
+ const { stdout } = (await res.json()) as { stdout: string };
860
+ return stdout;
176
861
  }
177
862
 
178
863
  // Wrap an McpServer in a Proxy whose `registerTool` (and `tool`, if any