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/auth-log.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { redactSecrets } from './runner.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One line per auth-state transition, on the paths where a Google credential is
|
|
5
|
+
* minted, served, evicted, replaced or refused.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this exists
|
|
8
|
+
*
|
|
9
|
+
* The incident that produced this branch could not be investigated. `gog`
|
|
10
|
+
* connectors told a user all session to re-authorize a Google account whose
|
|
11
|
+
* grant was healthy, and nothing anywhere had recorded a single auth-state
|
|
12
|
+
* transition: not the runner's transport 401, not a mint, not a cache hit, not
|
|
13
|
+
* a dead grant. `wrangler.jsonc` has set `observability.enabled = true` since
|
|
14
|
+
* the Worker shipped, so a Workers Logs sink was live the whole time with
|
|
15
|
+
* nothing writing to it.
|
|
16
|
+
*
|
|
17
|
+
* The other three fixes on this branch made those outcomes DISTINGUISHABLE —
|
|
18
|
+
* a transport 401 is now a typed RunnerTransportError, a rejected access token
|
|
19
|
+
* is evicted and re-minted, `invalid_grant` is separated from a token that
|
|
20
|
+
* merely expired. This one makes them VISIBLE, which is what turns "the user
|
|
21
|
+
* says it was broken yesterday" into a query.
|
|
22
|
+
*
|
|
23
|
+
* ## Why console, and why never console.log
|
|
24
|
+
*
|
|
25
|
+
* Workers Logs captures `console.*` and nothing else — there is no other sink
|
|
26
|
+
* available to a Worker without adding a dependency and a network hop to the
|
|
27
|
+
* request path.
|
|
28
|
+
*
|
|
29
|
+
* But this module also loads in the stdio servers (`useRemoteGogRunner` wires
|
|
30
|
+
* the same executor and the same token source into a plain Node process), and
|
|
31
|
+
* there STDOUT IS THE JSON-RPC CHANNEL. Node routes `console.log`, `.info`,
|
|
32
|
+
* `.debug` and `.trace` to fd 1, so any one of them would interleave a log line
|
|
33
|
+
* with the protocol frames and break the session. Only `.warn` and `.error` go
|
|
34
|
+
* to fd 2. That is the whole reason routine transitions are emitted at `warn`
|
|
35
|
+
* rather than at `info` where their severity belongs: `warn` is the least-severe
|
|
36
|
+
* console method that Node does not send down the wire. The record carries its
|
|
37
|
+
* own `event` name, so a log consumer classifies on that rather than on level.
|
|
38
|
+
*
|
|
39
|
+
* ## Why the whole line is redacted
|
|
40
|
+
*
|
|
41
|
+
* `reason` is built from text this layer did not author — gog's stderr, Google's
|
|
42
|
+
* error body, a rejected fetch's message — and any of those can quote a token
|
|
43
|
+
* verbatim. Redacting per-field would leave the next field someone adds
|
|
44
|
+
* unprotected, so the SERIALIZED record goes through the repo's existing
|
|
45
|
+
* `redactSecrets` (the shared mcp-utils redactor plus this repo's Google
|
|
46
|
+
* `ya29.…` / `1//…` shapes) as one string. A credential can therefore only
|
|
47
|
+
* appear in a log line if it survives the same redactor that guards every error
|
|
48
|
+
* this repo returns to a client.
|
|
49
|
+
*
|
|
50
|
+
* Credentials are never IDENTIFIED by value either: `credentialTag` derives the
|
|
51
|
+
* identifier from the SHA-256 that already keys the token cache, which is the
|
|
52
|
+
* hash-keying precedent google-token.ts set for exactly this reason.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What changed. Named after the transition rather than after the code site, so
|
|
57
|
+
* a query reads as a story about a credential rather than about a call stack.
|
|
58
|
+
*/
|
|
59
|
+
export type AuthTransition =
|
|
60
|
+
/** A fresh access token was obtained from the refresh token. */
|
|
61
|
+
| 'token.minted'
|
|
62
|
+
/** An unexpired cached access token was served without contacting Google. */
|
|
63
|
+
| 'token.cache-hit'
|
|
64
|
+
/** The exchange failed for a reason that is not a dead grant. */
|
|
65
|
+
| 'token.mint-failed'
|
|
66
|
+
/** A rejected access token was dropped, so the next read mints a new one. */
|
|
67
|
+
| 'token.evicted'
|
|
68
|
+
/** Nothing was dropped: the credential holds no token, or a different one. */
|
|
69
|
+
| 'token.evict-noop'
|
|
70
|
+
/** The REFRESH token is gone. Nothing can be minted; a human must re-enrol. */
|
|
71
|
+
| 'grant.dead'
|
|
72
|
+
/** Google refused our token, but this call is not one we may replay. */
|
|
73
|
+
| 'replay.declined'
|
|
74
|
+
/** Replaying the call once with a freshly minted token. */
|
|
75
|
+
| 'replay.attempted'
|
|
76
|
+
/** The replay succeeded — the caller saw no error at all. */
|
|
77
|
+
| 'replay.succeeded'
|
|
78
|
+
/** The replay failed too; the original failure reaches the caller. */
|
|
79
|
+
| 'replay.failed'
|
|
80
|
+
/** The RUNNER rejected our bearer. gog never ran; no Google credential read. */
|
|
81
|
+
| 'runner.auth-failed'
|
|
82
|
+
/** Enrolment: the runner refused the connector key itself (401/403). Layer 1. */
|
|
83
|
+
| 'connect.key-rejected'
|
|
84
|
+
/**
|
|
85
|
+
* Enrolment: the runner never answered, so the key was never judged. A user
|
|
86
|
+
* who saw this was previously told their key was invalid — see
|
|
87
|
+
* `connector-auth.ts`. It is filed separately because the two demand opposite
|
|
88
|
+
* actions from the user: fetch a new key, versus wait and retry.
|
|
89
|
+
*/
|
|
90
|
+
| 'connect.runner-unreachable'
|
|
91
|
+
/** Connect time: the Google layer was measured live and answered healthy. */
|
|
92
|
+
| 'connect.google-ok'
|
|
93
|
+
/**
|
|
94
|
+
* Connect time: the probe reached a VERDICT and the verdict is bad — Google
|
|
95
|
+
* refused the credential, or there is none. The user is connected anyway.
|
|
96
|
+
* Emitted only when the runner asserts `measured:true`; see google-probe.ts.
|
|
97
|
+
*/
|
|
98
|
+
| 'connect.google-unhealthy'
|
|
99
|
+
/** Connect time: the Google layer could NOT be measured. Not evidence of anything. */
|
|
100
|
+
| 'connect.google-unmeasured'
|
|
101
|
+
/**
|
|
102
|
+
* Google refused a call, and a live check taken at that moment says the
|
|
103
|
+
* credential is HEALTHY. The refusal is therefore unexplained.
|
|
104
|
+
*/
|
|
105
|
+
| 'refusal.google-ok'
|
|
106
|
+
/**
|
|
107
|
+
* Google refused a call, and a live check that REACHED A VERDICT
|
|
108
|
+
* (`measured:true`) confirms the credential is refused too.
|
|
109
|
+
*/
|
|
110
|
+
| 'refusal.google-unhealthy'
|
|
111
|
+
/** Google refused a call and the live check could not be taken. Not evidence of anything. */
|
|
112
|
+
| 'refusal.google-unmeasured';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Which credential, which service, which backend, and why — every field
|
|
116
|
+
* optional because each call site honestly knows a different subset. A
|
|
117
|
+
* transport 401 has no credential to name; a mint has no service.
|
|
118
|
+
*/
|
|
119
|
+
export interface AuthContext {
|
|
120
|
+
/** `credentialTag` of the hash that keys the token cache. Never a token. */
|
|
121
|
+
credential?: string;
|
|
122
|
+
/** The `gog` service word (gmail, drive, sheets…), when there is one. */
|
|
123
|
+
service?: string;
|
|
124
|
+
/** The gog-runner base URL. Configuration, not a secret. */
|
|
125
|
+
endpoint?: string;
|
|
126
|
+
/** Prose. Redacted with everything else — see the module note. */
|
|
127
|
+
reason?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Transitions that describe something going WRONG, routed to `console.error`.
|
|
132
|
+
* Everything else is routine and goes to `console.warn` (see the module note on
|
|
133
|
+
* why not `console.info`).
|
|
134
|
+
*
|
|
135
|
+
* `token.evicted` is deliberately NOT here: an eviction is the repair working.
|
|
136
|
+
* `replay.declined` is not either — declining is usually the correct, safe
|
|
137
|
+
* answer (a write, a superseded token), and only reads as a failure alongside
|
|
138
|
+
* the record that follows it.
|
|
139
|
+
*/
|
|
140
|
+
const FAILURES: ReadonlySet<AuthTransition> = new Set<AuthTransition>([
|
|
141
|
+
'token.mint-failed',
|
|
142
|
+
'grant.dead',
|
|
143
|
+
'replay.failed',
|
|
144
|
+
'runner.auth-failed',
|
|
145
|
+
'connect.key-rejected',
|
|
146
|
+
// An enrolment that could not proceed is a failure even though nobody is at
|
|
147
|
+
// fault: it is the only trace a half-enrolled connector leaves behind, and
|
|
148
|
+
// the absence of exactly this record is why DEFECT 4 could not be explained.
|
|
149
|
+
'connect.runner-unreachable',
|
|
150
|
+
'connect.google-unhealthy',
|
|
151
|
+
'refusal.google-unhealthy',
|
|
152
|
+
// The loudest record on this branch, and the only one that means "we cannot
|
|
153
|
+
// explain this". Google refused a real call while a live check of the same
|
|
154
|
+
// credential, taken seconds later, succeeded — so neither the 7-day cliff nor
|
|
155
|
+
// a revoked grant accounts for it. It is filed as a failure precisely because
|
|
156
|
+
// it is the record nobody may scroll past: it is the only evidence that could
|
|
157
|
+
// ever justify building something on the hosted path, and its absence over
|
|
158
|
+
// time is what retires that theory for good.
|
|
159
|
+
'refusal.google-ok',
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
// `connect.google-unmeasured` and `refusal.google-unmeasured` are deliberately
|
|
163
|
+
// NOT failures, and the distinction
|
|
164
|
+
// is the entire point of the connect-time probe. "Google was asked and said no"
|
|
165
|
+
// is a fact about the user's credential; "the probe did not run" is a fact about
|
|
166
|
+
// the probe. Filing the second under the first would rebuild the defect being
|
|
167
|
+
// fixed — a claim about health that nothing measured — with the alarm inverted.
|
|
168
|
+
//
|
|
169
|
+
// That is not a distinction a call site may re-derive by eye: it was got wrong
|
|
170
|
+
// once, at both call sites, by reading the runner's `ok` field alone. It is now
|
|
171
|
+
// decided in exactly one place — `google-probe.ts`, which reads the runner's
|
|
172
|
+
// `measured` field first — and the events below are only names for its verdicts.
|
|
173
|
+
|
|
174
|
+
/** Marker so one `grep gog-auth` finds every record and nothing else. */
|
|
175
|
+
const PREFIX = 'gog-auth ';
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How many hex characters of the cache key identify a credential in a log.
|
|
179
|
+
*
|
|
180
|
+
* 12 hex characters is 48 bits — far past collision risk for the handful of
|
|
181
|
+
* credentials one deployment holds, and short enough to read across lines. The
|
|
182
|
+
* input is already a SHA-256 of (clientId, refreshToken), so truncating it can
|
|
183
|
+
* only ever remove information.
|
|
184
|
+
*/
|
|
185
|
+
const TAG_CHARS = 12;
|
|
186
|
+
|
|
187
|
+
/** The log-safe name for a credential, from the hash that already keys it. */
|
|
188
|
+
export function credentialTag(cacheKeyHash: string): string {
|
|
189
|
+
return cacheKeyHash.slice(0, TAG_CHARS);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Emit one record. Deliberately synchronous, allocation-light and
|
|
194
|
+
* exception-free by construction: this sits on the request path, and an
|
|
195
|
+
* observability call that can fail or block would be worse than the silence it
|
|
196
|
+
* replaces.
|
|
197
|
+
*/
|
|
198
|
+
export function logAuthTransition(event: AuthTransition, context: AuthContext): void {
|
|
199
|
+
// `at` and `event` first so the line reads left to right; JSON.stringify
|
|
200
|
+
// drops the context keys whose value is undefined, which is why an absent
|
|
201
|
+
// credential produces no `"credential":null` noise.
|
|
202
|
+
const record = JSON.stringify({ at: new Date().toISOString(), event, ...context });
|
|
203
|
+
const write = FAILURES.has(event) ? console.error : console.warn;
|
|
204
|
+
write(PREFIX + redactSecrets(record));
|
|
205
|
+
}
|
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
|
};
|