gogcli-mcp 2.21.1 → 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,4 +1,7 @@
1
1
  import type { ConnectorAuth } from '@chrischall/mcp-connector';
2
+ import { logAuthTransition, type AuthTransition } from './auth-log.js';
3
+ import { readGoogleProbe } from './google-probe.js';
4
+ import { redactSecrets } from './runner.js';
2
5
 
3
6
  /**
4
7
  * OAuth props stored per user by the Cloudflare connector's OAuth provider.
@@ -22,13 +25,265 @@ export interface GogProps {
22
25
  [k: string]: unknown;
23
26
  }
24
27
 
28
+ /**
29
+ * How long the connect-time Google probe may take before it is abandoned.
30
+ *
31
+ * This sits inside the user's `/authorize` POST, so it is latency the human is
32
+ * watching and claude.ai is timing. The probe is diagnostic, never a gate, so
33
+ * the correct trade is unambiguous: give up early and record "not measured"
34
+ * rather than hold up a login that was already decided. The runner's own budget
35
+ * for the same probe (`GOOGLE_PROBE_TIMEOUT_MS` in server.mjs) is longer, so an
36
+ * abort here is this side declining to wait, not the runner failing.
37
+ */
38
+ export const GOOGLE_PROBE_TIMEOUT_MS = 4_000;
39
+
40
+ /**
41
+ * How long ONE key check may take before it is abandoned and (once) retried.
42
+ *
43
+ * `/health` runs no gog, so a healthy runner answers in milliseconds; anything
44
+ * near this bound means the Machine is booting or the proxy is holding the
45
+ * connection, which is precisely the case the retry exists for.
46
+ */
47
+ export const LOGIN_ATTEMPT_TIMEOUT_MS = 5_000;
48
+
49
+ /**
50
+ * The pause between the two key-check attempts.
51
+ *
52
+ * Sized against what it is waiting out — a Fly proxy handing a request to a
53
+ * Machine that is still coming up, or a runner a few hundred milliseconds from
54
+ * the end of its drain — and against what it is spending: latency inside the
55
+ * user's `/authorize` POST. One short pause is worth an enrolment; a backoff
56
+ * ladder would not be.
57
+ */
58
+ export const LOGIN_RETRY_DELAY_MS = 250;
59
+
60
+ /** How the failing status is named to the user. */
61
+ function describeStatus(status: unknown): string {
62
+ return typeof status === 'number' ? `HTTP ${status}` : 'no HTTP status';
63
+ }
64
+
65
+ const describeCause = (err: unknown) => (err instanceof Error ? err.message : String(err));
66
+
67
+ /**
68
+ * Everything after the specific cause. It leads with the sentence that matters:
69
+ * the user's key was never judged, so the correct action is to wait, not to go
70
+ * looking for a different key.
71
+ */
72
+ const UNREACHABLE_ADVICE =
73
+ 'Your connector key was NOT rejected — the backend never answered, so the key ' +
74
+ 'was never checked. The runner answers 503 for the whole of its drain window ' +
75
+ '(i.e. during every deploy) and Fly answers 502 while a stopped Machine boots, ' +
76
+ 'so this is usually momentary. Wait a few seconds and try again.';
77
+
78
+ /**
79
+ * Verify the connector key against the runner's `/health`, distinguishing the
80
+ * two ways that can fail. Returns on success; throws otherwise.
81
+ *
82
+ * ## The bug this replaces (DEFECT 4)
83
+ *
84
+ * The previous body was `if (!res.ok) throw new Error('Invalid connector key
85
+ * (backend rejected it)')`. Every non-2xx produced that sentence, and a rejected
86
+ * `fetch` produced no sentence at all — it escaped `login()` uncaught.
87
+ *
88
+ * But `/health` returns non-2xx for reasons that have nothing to do with the
89
+ * key. `server.mjs` answers **503 `{retryable:true}` to every request once a
90
+ * shutdown signal lands**, which is the whole of every deploy, and Fly's proxy
91
+ * answers 502 while a stopped Machine boots. A user enrolling in either window
92
+ * was told, flatly, that their key was wrong. The reasonable response to that is
93
+ * to stop and go find a better key — which leaves a connector stuck at
94
+ * `authenticate` / `complete_authentication`, exactly the state `gog_docs`,
95
+ * `gog_sheets` and `gog_drive` were observed in.
96
+ *
97
+ * ## The two rules
98
+ *
99
+ * **Only 401 and 403 mean "wrong key."** Those are the runner actually judging
100
+ * the bearer (`bearerMatches` → `{ error: 'unauthorized' }`). Everything else —
101
+ * every other status, an unparseable answer, a dead socket, our own timeout — is
102
+ * the backend failing to answer, and is reported as such.
103
+ *
104
+ * **Unknown resolves toward "try again."** The two errors are not symmetrical:
105
+ * telling a user with a good key that it is invalid ends the enrolment, while
106
+ * telling a user with a bad key to retry costs one more attempt and then tells
107
+ * them the truth. So anything unrecognised (including a response with no status
108
+ * at all) takes the transient branch.
109
+ *
110
+ * ## Why retry rather than merely report
111
+ *
112
+ * The dominant transient case is self-inflicted and self-clearing: we deploy,
113
+ * the runner drains, it returns 503 for a moment. Reporting that accurately
114
+ * still costs the user an enrolment attempt they did nothing to deserve. One
115
+ * retry absorbs it entirely. It is bounded at two attempts and one short delay
116
+ * because this runs inside the `/authorize` POST the human is watching.
117
+ *
118
+ * Note the direction: this can only turn a refusal into a success. It cannot
119
+ * strand anyone, which is what separates it from any check on the Google layer.
120
+ */
121
+ async function verifyConnectorKey(endpoint: string, key: string): Promise<void> {
122
+ let cause = '';
123
+ for (let attempt = 0; attempt < 2; attempt += 1) {
124
+ if (attempt > 0) {
125
+ await new Promise((resolve) => setTimeout(resolve, LOGIN_RETRY_DELAY_MS));
126
+ }
127
+ let res: Response;
128
+ try {
129
+ res = await fetch(`${endpoint}/health`, {
130
+ headers: { Authorization: `Bearer ${key}` },
131
+ signal: AbortSignal.timeout(LOGIN_ATTEMPT_TIMEOUT_MS),
132
+ });
133
+ } catch (err) {
134
+ cause = `could not reach the gog backend (${describeCause(err)})`;
135
+ continue;
136
+ }
137
+ if (res.ok) return;
138
+ if (res.status === 401 || res.status === 403) {
139
+ logAuthTransition('connect.key-rejected', {
140
+ endpoint,
141
+ reason: `the runner refused the connector key (HTTP ${res.status})`,
142
+ });
143
+ throw new Error('Invalid connector key (backend rejected it)');
144
+ }
145
+ cause = `the gog backend did not answer the key check (${describeStatus(res.status)})`;
146
+ }
147
+ logAuthTransition('connect.runner-unreachable', { endpoint, reason: cause });
148
+ // `cause` can quote text this layer did not author — a proxy's error body, a
149
+ // socket error that echoed the outgoing Authorization header — so the sentence
150
+ // shown on the login page goes through the same redactor as every other error
151
+ // this repo hands back.
152
+ throw new Error(redactSecrets(`${cause}. ${UNREACHABLE_ADVICE}`));
153
+ }
154
+
155
+
156
+ /**
157
+ * The MCP `instructions` every hosted agent advertises (see `worker.ts`).
158
+ *
159
+ * ## Why a connector needs to say this at all
160
+ *
161
+ * There are two independent credentials behind these tools and the client UI
162
+ * shows only the first:
163
+ *
164
+ * Layer 1 claude.ai → this Worker → the Fly runner, authenticated by the
165
+ * user's connector key (the runner's RUNNER_KEY), stored in OAUTH_KV.
166
+ * Layer 2 `gog` on the Fly machine → Google, authenticated by a refresh token
167
+ * in gog's file keyring on the /data volume. The connector never
168
+ * sees it, cannot refresh it, and is not told when it dies.
169
+ *
170
+ * "Connected" is a layer-1 fact. "Refreshed" is smaller still: `ConnectorAuth`
171
+ * exposes only a `login` hook — no `validate`, no `refresh` — so a refresh is an
172
+ * OAuth exchange inside OAUTH_KV that contacts neither Fly nor Google. Both
173
+ * words are outside this repo's control, and both get read as "your Google
174
+ * access works". They were, right up until the next Gmail call returned a Google
175
+ * 401.
176
+ *
177
+ * So the boundary we DO own says it plainly, to the one reader who can act on it
178
+ * before the user hits the error: the model holding these tools.
179
+ */
180
+ export const CONNECTOR_INSTRUCTIONS = [
181
+ 'These tools reach Google through a `gog` install on the user\'s own Fly.io machine.',
182
+ '',
183
+ 'There are TWO credentials, and this connector holds only the first:',
184
+ ' 1. the connector key, which authorizes this Worker to call that machine;',
185
+ ' 2. a Google refresh token in gog\'s keyring ON that machine, which the connector',
186
+ ' never sees and cannot refresh.',
187
+ '',
188
+ 'A "connected" or "refreshed" connector therefore proves only (1). It does NOT mean',
189
+ 'Google still accepts (2): a refresh token that expired or was revoked leaves the',
190
+ 'connector looking perfectly healthy until the first real call returns a Google 401.',
191
+ 'Nothing in the connection status measures Google — gog_auth_health is the only tool',
192
+ 'that does, because it performs a real token refresh against Google.',
193
+ '',
194
+ 'When a call fails with a Google 401 or invalid_grant, do not retry it and do not',
195
+ 'assume the connector is broken. Run gog_auth_health to confirm, then re-authorize',
196
+ 'with gog_auth_add_url followed by gog_auth_add_complete (the browser-based',
197
+ 'gog_auth_add cannot work here — there is no browser on the Fly machine).',
198
+ '',
199
+ 'If the OAuth client\'s consent screen is still in "Testing" mode, Google expires its',
200
+ 'refresh tokens exactly 7 days after issue, so this can recur weekly until the app is',
201
+ 'published. gog_auth_health reports how long ago each account was authorized.',
202
+ ].join('\n');
203
+
204
+ /**
205
+ * Ask the runner whether Google still accepts the credential on its volume, and
206
+ * record the answer. Resolves in every case; it can neither throw nor return a
207
+ * value, because nothing may make a decision out of what it finds.
208
+ *
209
+ * ## Why measuring here is worth doing, and why refusing here is not
210
+ *
211
+ * `login()` verifies the connector key against the runner's `/health`, an
212
+ * endpoint whose own comment says it "does not depend on gog". So a successful
213
+ * login has always been a layer-1 statement, presented to the user as if it
214
+ * settled both layers. This makes the connect path measure the layer it was
215
+ * silently vouching for.
216
+ *
217
+ * It must never gate the login. The tools that repair a dead Google credential
218
+ * (`gog_auth_add_url`, `gog_auth_add_complete`) are MCP tools, reachable only
219
+ * once the connector is connected — so refusing to connect on a dead credential
220
+ * would lock the user out of the only path that fixes it. The goal is that
221
+ * status never claims health it did not measure, NOT that a bad measurement
222
+ * refuses the connection.
223
+ *
224
+ * ## What it is honestly able to say
225
+ *
226
+ * Only what was true AT CONNECT TIME, and only on the connect path: claude.ai's
227
+ * later "refreshed" never reaches this code (there is no `refresh` hook to run
228
+ * it from), so the record is a fixed point in the past, not a live status. Its
229
+ * value is that the incident log finally contains what the Google layer was
230
+ * doing at the moment the UI said "connected" — which is exactly the correlation
231
+ * that could not be made when this was first reported.
232
+ */
233
+ async function recordGoogleLayerAtConnect(endpoint: string, key: string): Promise<void> {
234
+ let event: AuthTransition;
235
+ let reason: string | undefined;
236
+ try {
237
+ const res = await fetch(`${endpoint}/health/google`, {
238
+ headers: { Authorization: `Bearer ${key}` },
239
+ signal: AbortSignal.timeout(GOOGLE_PROBE_TIMEOUT_MS),
240
+ });
241
+ if (!res.ok) {
242
+ // Includes the 404 from a runner deployed before the probe endpoint
243
+ // existed. "I could not ask" is never reported as "Google said no".
244
+ event = 'connect.google-unmeasured';
245
+ reason = `the runner did not answer the Google probe (HTTP ${res.status})`;
246
+ } else {
247
+ // `readGoogleProbe` is the ONE place that judges a probe body, shared with
248
+ // the post-refusal probe in connector-runtime.ts. It reads the runner's
249
+ // `measured` field before its `ok` field, which is what keeps a probe that
250
+ // TIMED OUT or could not be RUN out of `-unhealthy` — an event whose
251
+ // documented meaning is "Google was asked and refused". The reason string
252
+ // it returns comes from the runner's closed vocabulary (PROBE_CAUSES), so
253
+ // it carries a classification and never gog's own output.
254
+ const verdict = readGoogleProbe(await res.json());
255
+ event =
256
+ verdict.kind === 'ok'
257
+ ? 'connect.google-ok'
258
+ : verdict.kind === 'unhealthy'
259
+ ? 'connect.google-unhealthy'
260
+ : 'connect.google-unmeasured';
261
+ reason = verdict.reason;
262
+ }
263
+ } catch (err) {
264
+ // A rejected fetch, an abort at GOOGLE_PROBE_TIMEOUT_MS, or a body that is
265
+ // not JSON (a proxy's HTML error page). None of them are facts about Google.
266
+ event = 'connect.google-unmeasured';
267
+ reason = err instanceof Error ? err.message : String(err);
268
+ }
269
+ // `reason` can quote text this layer did not author, so the record goes
270
+ // through the same redactor as every other auth log line.
271
+ logAuthTransition(event, { endpoint, reason });
272
+ }
273
+
25
274
  /**
26
275
  * `ConnectorAuth` for the gogcli remote connector: the login page collects the
27
276
  * user's connector key, verifies it by hitting the Fly backend's `/health`
28
- * endpoint with the key as a bearer token (a bad key makes the backend answer
29
- * non-2xx, which surfaces back on the login page), and stores `{ key }` as the
30
- * OAuth props that `worker.ts`'s `buildClient` turns into a per-session Fly
31
- * executor.
277
+ * endpoint with the key as a bearer token, and stores `{ key }` as the OAuth
278
+ * props that `worker.ts`'s `buildClient` turns into a per-session Fly executor.
279
+ *
280
+ * Only the runner judging the bearer (401/403) refuses the login; a backend that
281
+ * does not answer is retried once and then reported as unreachable, never as a
282
+ * bad key — see `verifyConnectorKey`.
283
+ *
284
+ * After the key is accepted it also measures the SECOND credential — the Google
285
+ * grant on the Fly volume — and records what it found. That measurement changes
286
+ * nothing about whether the login succeeds; see `recordGoogleLayerAtConnect`.
32
287
  */
33
288
  export const gogAuth: ConnectorAuth<GogProps> = {
34
289
  service: 'gogcli (Google Workspace)',
@@ -37,10 +292,12 @@ export const gogAuth: ConnectorAuth<GogProps> = {
37
292
  'Your connector key is stored encrypted and used only to reach your own gog backend.',
38
293
  fields: [{ name: 'key', label: 'gogcli connector key', type: 'password' }],
39
294
  async login(fields, env) {
40
- const res = await fetch(`${(env as any).FLY_ENDPOINT}/health`, {
41
- headers: { Authorization: `Bearer ${fields.key}` },
42
- });
43
- if (!res.ok) throw new Error('Invalid connector key (backend rejected it)');
295
+ const endpoint = (env as any).FLY_ENDPOINT;
296
+ // Layer 1. Throws — and only this may refuse the login.
297
+ await verifyConnectorKey(endpoint, fields.key);
298
+ // Layer 2. Records; never refuses. Deliberately after the key check, so a
299
+ // login that never happened says nothing at all about Google.
300
+ await recordGoogleLayerAtConnect(endpoint, fields.key);
44
301
  return { key: fields.key };
45
302
  },
46
303
  };
@@ -1,6 +1,7 @@
1
1
  import { runExecutor, RunnerTransportError } from './runner.js';
2
2
  import type { GogArg, GogExecutor } from './runner.js';
3
- import { logAuthTransition } from './auth-log.js';
3
+ import { logAuthTransition, type AuthTransition } from './auth-log.js';
4
+ import { readGoogleProbe } from './google-probe.js';
4
5
 
5
6
  // Re-exported so the runner-transport error type reads as part of THIS module's
6
7
  // surface — this is the layer that authors these failures. It is DEFINED in
@@ -52,6 +53,61 @@ const DEADLINE_GRACE_MS = 5_000;
52
53
  // own next call mints a fresh token.
53
54
  const MIN_REPLAY_BUDGET_MS = 1_000;
54
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
+
55
111
  // Status codes the Fly runner uses to classify its OWN failures. These must stay
56
112
  // in sync with fly-gog-runner/server.mjs — they are the contract that lets this
57
113
  // side tell "gog ran and failed" apart from "the request never arrived", without
@@ -246,6 +302,11 @@ async function remintAfterGoogleRejection(
246
302
  args: GogArg[],
247
303
  readAccessToken: FlyAccessTokenSource | undefined,
248
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>,
249
310
  ): Promise<Replay | undefined> {
250
311
  // Only a failure gog itself authored can carry Google's verdict. A runner
251
312
  // transport failure never reached Google, and the runner's own 401 is about
@@ -290,6 +351,22 @@ async function remintAfterGoogleRejection(
290
351
  // whatever identity the backend volume holds and only an operator can change
291
352
  // that.
292
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);
293
370
  logAuthTransition('replay.declined', {
294
371
  ...where,
295
372
  reason:
@@ -424,6 +501,113 @@ export function makeFlyExecutor(
424
501
  key: string,
425
502
  readAccessToken?: FlyAccessTokenSource,
426
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
+
427
611
  return async (args: GogArg[], opts) => {
428
612
  const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
429
613
  // Awaited, because the token may have to be MINTED (#241): a refresh token
@@ -455,6 +639,7 @@ export function makeFlyExecutor(
455
639
  args,
456
640
  readAccessToken,
457
641
  deadlineAt,
642
+ (where) => probeGoogleAfterRefusal(where, deadlineAt),
458
643
  );
459
644
  if (replay === undefined) throw err;
460
645
 
@@ -0,0 +1,113 @@
1
+ /**
2
+ * How this side reads the runner's `GET /health/google` answer.
3
+ *
4
+ * ## Why this is a module and not two `if` statements
5
+ *
6
+ * There are two callers — the connect-time probe in `connector-auth.ts` and the
7
+ * post-refusal probe in `connector-runtime.ts` — and they used to make this
8
+ * judgement separately. That is exactly how the defect below survived review of
9
+ * both: each site branched on `body.ok === true` alone, so both inherited the
10
+ * same wrong reading, and fixing one would have left the other. The judgement
11
+ * now exists once; the call sites only translate a verdict into their own event
12
+ * names.
13
+ *
14
+ * ## The defect this deletes
15
+ *
16
+ * `ok` answers "is the Google layer healthy". It does NOT answer "did anything
17
+ * find out" — and `server.mjs` reports `ok:false` for causes that are facts
18
+ * about the PROBE rather than about Google: it timed out, it could not be run at
19
+ * all (no `gog` on PATH, no `credentials.json` on the volume), its output could
20
+ * not be parsed, or gog declined to state validity. Reading `ok !== true` as
21
+ * "Google refused" filed every one of those at error level under an event whose
22
+ * documented meaning is "Google was asked and **refused**". An operator grepping
23
+ * event names would conclude the refresh token was dead and close the incident
24
+ * on evidence that was never gathered — the original defect (status claiming
25
+ * health nothing measured) with the alarm merely inverted.
26
+ *
27
+ * So the runner now states `measured` explicitly, and this module reads it
28
+ * FIRST. `ok` is only consulted once a measurement is established.
29
+ *
30
+ * ## Which way "unknown" resolves
31
+ *
32
+ * Toward `unmeasured`, always. The two errors are not symmetrical: filing a real
33
+ * refusal as unmeasured under-claims, and the runner's own cause string still
34
+ * rides along on the same log line, so nothing is lost. Filing a non-measurement
35
+ * as a refusal invents evidence. A runner that does not say whether it measured
36
+ * therefore gets no verdict about Google extracted from it — including the
37
+ * incoherent `ok:true, measured:false` and the merely silent `ok:true`, neither
38
+ * of which can license a health claim.
39
+ *
40
+ * `ok:true` is worth spelling out, because it is where this rule is easiest to
41
+ * talk yourself out of: an affirmative-sounding field feels self-licensing, and
42
+ * the harm of believing it looks small. It is not. The GOOD verdict is what the
43
+ * refusal path compares Google's live 401 against, so a `kind:'ok'` built on
44
+ * silence becomes `refusal.google-ok` — the record documented as "the one
45
+ * record that means we cannot explain this", logged at error level, and the
46
+ * only evidence that could ever justify building automatic recovery on the
47
+ * hosted path. Raised from a measurement nobody took, it is precisely the
48
+ * defect this branch exists to delete: a health claim with nothing behind it.
49
+ */
50
+
51
+ /** The verdict, in the three states the log's event names already distinguish. */
52
+ export type GoogleProbeVerdict =
53
+ /** Measured, and the credential works. */
54
+ | { kind: 'ok'; reason?: undefined }
55
+ /** Measured, and it does not. `reason` is the runner's classification. */
56
+ | { kind: 'unhealthy'; reason: string }
57
+ /** Nothing was learned about Google. Not evidence of anything. */
58
+ | { kind: 'unmeasured'; reason: string };
59
+
60
+ /** Read a field only if it really is a boolean — a proxy may put anything here. */
61
+ const bool = (value: unknown): boolean | undefined =>
62
+ typeof value === 'boolean' ? value : undefined;
63
+
64
+ /**
65
+ * The runner's cause, if it sent a usable one.
66
+ *
67
+ * Non-strings are dropped rather than stringified: this value reaches a log
68
+ * aggregator, and `[object Object]` is worse than the honest fallback sentence.
69
+ * Every legitimate value is a literal from `PROBE_CAUSES` in `server.mjs`.
70
+ */
71
+ const cause = (value: unknown): string | undefined =>
72
+ typeof value === 'string' && value.length > 0 ? value : undefined;
73
+
74
+ /**
75
+ * Turn a `/health/google` body into a verdict. Total: every input, including a
76
+ * proxy's HTML page parsed into a string and a body of `null`, yields one of the
77
+ * three kinds and never throws.
78
+ */
79
+ export function readGoogleProbe(body: unknown): GoogleProbeVerdict {
80
+ const record = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>;
81
+ const measured = bool(record.measured);
82
+ const reported = cause(record.error);
83
+
84
+ // FIRST, and before `ok` is consulted at all: a runner that says it could not
85
+ // measure has told us nothing about the credential, however alarming its cause
86
+ // string reads.
87
+ if (measured === false) {
88
+ return {
89
+ kind: 'unmeasured',
90
+ reason: reported ?? 'the runner reported it could not measure the Google layer',
91
+ };
92
+ }
93
+ // `ok` is read ONLY here, inside the established measurement. Health is a
94
+ // claim, not a fact that states itself: `ok:true` on its own is a runner
95
+ // asserting a verdict about a credential without saying anything asked.
96
+ if (measured === true) {
97
+ if (bool(record.ok) === true) return { kind: 'ok' };
98
+ return {
99
+ kind: 'unhealthy',
100
+ reason: reported ?? 'the runner reported the Google layer unhealthy with no cause',
101
+ };
102
+ }
103
+ // No `measured` field at all — whatever `ok` says. Silence is not a
104
+ // measurement, so no claim about the credential may be built on it, in either
105
+ // direction; but whatever the runner did say is carried through, because the
106
+ // operator reading this line needs it.
107
+ return {
108
+ kind: 'unmeasured',
109
+ reason: reported
110
+ ? `the runner did not report whether it measured the Google layer; it said: ${reported}`
111
+ : 'the runner did not report whether it measured the Google layer',
112
+ };
113
+ }
package/src/timestamps.ts CHANGED
@@ -149,6 +149,13 @@ const TIMESTAMP_KEYS = new Set([
149
149
  'date', // gog gmail message/thread listings ("2026-07-28 03:36")
150
150
  'dateTime', // Calendar event start/end
151
151
  'internalDate', // Gmail, epoch milliseconds (authoritative)
152
+ // gog >= 0.35.0 Gmail message AND thread listings. Already offset-bearing
153
+ // (RFC3339 from internalDate), so it needs no offset repair — it is
154
+ // allowlisted purely to gain a Display sibling, and to be re-rendered in
155
+ // DISPLAY_TZ like every other instant. Separately sourced from the sibling
156
+ // `date`, which is a naive re-format of the sender's Date header; the two may
157
+ // legitimately disagree. See docs/timestamps.md.
158
+ 'internalDateIso',
152
159
  'modifiedTime', // Drive
153
160
  'createdTime', // Drive
154
161
  'createTime',