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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +119 -8
- package/dist/lib.js +119 -8
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/auth-log.ts +59 -1
- package/src/connector-auth.ts +265 -8
- package/src/connector-runtime.ts +186 -1
- package/src/google-probe.ts +113 -0
- package/src/timestamps.ts +7 -0
- package/src/tools/auth.ts +8 -2
- package/src/worker.ts +25 -6
- package/tests/auth-log.test.ts +24 -2
- package/tests/connector-auth.test.ts +539 -8
- package/tests/connector-runtime.test.ts +447 -1
- package/tests/google-probe.test.ts +116 -0
- package/tests/timestamps.test.ts +52 -0
- package/tests/tools/auth.test.ts +28 -0
- package/tests/worker.test.ts +33 -8
package/src/connector-auth.ts
CHANGED
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
};
|
package/src/connector-runtime.ts
CHANGED
|
@@ -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',
|