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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +436 -58
- package/dist/lib.js +437 -59
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/auth-log.ts +205 -0
- package/src/connector-auth.ts +265 -8
- package/src/connector-runtime.ts +764 -79
- package/src/google-probe.ts +113 -0
- package/src/google-token.ts +181 -15
- package/src/runner.ts +67 -2
- package/src/timestamps.ts +7 -0
- package/src/tools/auth.ts +8 -2
- package/src/tools/utils.ts +70 -5
- package/src/worker.ts +33 -3
- package/tests/auth-log.test.ts +530 -0
- package/tests/connector-auth.test.ts +539 -8
- package/tests/connector-runtime.test.ts +1121 -2
- package/tests/google-probe.test.ts +116 -0
- package/tests/google-token.test.ts +125 -4
- package/tests/runner.test.ts +21 -1
- package/tests/timestamps.test.ts +52 -0
- package/tests/tools/auth-401-context.test.ts +42 -0
- package/tests/tools/auth-401-shapes.test.ts +50 -0
- package/tests/tools/auth.test.ts +28 -0
- package/tests/tools/utils.test.ts +55 -2
- package/tests/worker.test.ts +33 -8
package/src/connector-runtime.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// The
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|