gogcli-mcp 3.0.0 → 4.0.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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +273 -748
  4. package/dist/lib.js +375 -785
  5. package/manifest.json +2 -2
  6. package/mint.yaml +37 -33
  7. package/package.json +3 -3
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/index.ts +3 -4
  14. package/src/lib.ts +14 -9
  15. package/src/runner.ts +23 -175
  16. package/src/tools/appscript.ts +1 -1
  17. package/src/tools/auth.ts +5 -5
  18. package/src/tools/drive.ts +1 -3
  19. package/src/tools/gmail.ts +9 -9
  20. package/src/tools/utils.ts +12 -58
  21. package/tests/attachments.test.ts +11 -14
  22. package/tests/blob-upload.test.ts +235 -160
  23. package/tests/bootstrap-auth.test.ts +245 -0
  24. package/tests/runner-file-args.test.ts +1 -13
  25. package/tests/runner.test.ts +8 -95
  26. package/tests/sdk-single-copy.test.ts +4 -12
  27. package/tests/tools/auth-401-shapes.test.ts +2 -3
  28. package/tests/tools/auth.test.ts +5 -4
  29. package/tests/tools/drive.test.ts +11 -3
  30. package/tests/tools/gmail.test.ts +2 -2
  31. package/tests/tools/utils.test.ts +1 -50
  32. package/tests/zod-single-copy.test.ts +6 -14
  33. package/tsconfig.json +1 -2
  34. package/vitest.config.ts +2 -12
  35. package/src/auth-log.ts +0 -205
  36. package/src/connector-auth.ts +0 -319
  37. package/src/connector-login.ts +0 -87
  38. package/src/connector-runtime.ts +0 -910
  39. package/src/google-probe.ts +0 -113
  40. package/src/google-token.ts +0 -391
  41. package/src/remote-runner.ts +0 -77
  42. package/src/worker.ts +0 -117
  43. package/tests/auth-log.test.ts +0 -530
  44. package/tests/connector-auth.test.ts +0 -559
  45. package/tests/connector-login.test.ts +0 -151
  46. package/tests/connector-runtime.test.ts +0 -1664
  47. package/tests/google-probe.test.ts +0 -116
  48. package/tests/google-token.test.ts +0 -425
  49. package/tests/remote-runner.test.ts +0 -202
  50. package/tests/worker.test.ts +0 -167
package/vitest.config.ts CHANGED
@@ -1,11 +1,7 @@
1
- import { configDefaults, defineConfig } from 'vitest/config';
1
+ import { defineConfig } from 'vitest/config';
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
- // `tests/worker.test.ts` only runs under the Workers runtime pool
6
- // (root `vitest.workers.config.mts` / `npm run worker:test`), which provides
7
- // the virtual `cloudflare:test` module it imports. The node pool must skip it.
8
- exclude: [...configDefaults.exclude, 'tests/worker.test.ts'],
9
5
  // Neutralize the gog env vars for the whole suite.
10
6
  //
11
7
  // THE BUG THIS FIXES: the runner tests assert the exact argv `run()` builds,
@@ -28,13 +24,7 @@ export default defineConfig({
28
24
  coverage: {
29
25
  provider: 'v8',
30
26
  include: ['src/**/*.ts'],
31
- exclude: [
32
- 'src/index.ts',
33
- // The Worker-only entry is exercised by the Workers pool suite
34
- // (`npm run worker:test`). Its node-loadable helpers stay in this
35
- // suite and its 100% gate.
36
- 'src/worker.ts',
37
- ],
27
+ exclude: ['src/index.ts'],
38
28
  thresholds: {
39
29
  lines: 100,
40
30
  functions: 100,
package/src/auth-log.ts DELETED
@@ -1,205 +0,0 @@
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
- }
@@ -1,319 +0,0 @@
1
- import { logAuthTransition, type AuthTransition } from './auth-log.js';
2
- import { readGoogleProbe } from './google-probe.js';
3
- import { redactSecrets } from './runner.js';
4
-
5
- /** One credential field rendered by the connector authorization page. */
6
- export interface LoginField {
7
- name: string;
8
- label: string;
9
- type?: 'text' | 'password';
10
- }
11
-
12
- /** Login-page configuration consumed by the local authorization handler. */
13
- export interface ConnectorAuth<Props> {
14
- service: string;
15
- fields: LoginField[];
16
- userId?: string;
17
- login(fields: Record<string, string>, env: unknown): Promise<Props>;
18
- privacyNote?: string;
19
- accent?: string;
20
- }
21
-
22
- /**
23
- * OAuth props stored per user by the Cloudflare connector's OAuth provider.
24
- *
25
- * The gogcli remote connector authenticates each user with a single long-lived
26
- * personal "connector key" — a shared secret (the Fly backend's `RUNNER_KEY`)
27
- * that authorizes calls to that user's own `gog` backend on Fly.io. There is no
28
- * refresh cycle: `worker.ts` resolves this key to a cached Fly executor for
29
- * each stateless request. These props are encrypted at rest in `OAUTH_KV` by
30
- * the OAuth provider.
31
- *
32
- * NOTE: this is a FIELD LOGIN (a personal key), NOT Google OAuth. The Google
33
- * OAuth handshake lives entirely inside the Fly backend's `gog` install; the
34
- * connector never sees a Google token.
35
- *
36
- * The index signature keeps these props usable across the OAuth provider and
37
- * MCP request-context boundaries.
38
- */
39
- export interface GogProps {
40
- key: string;
41
- [k: string]: unknown;
42
- }
43
-
44
- /**
45
- * How long the connect-time Google probe may take before it is abandoned.
46
- *
47
- * This sits inside the user's `/authorize` POST, so it is latency the human is
48
- * watching and claude.ai is timing. The probe is diagnostic, never a gate, so
49
- * the correct trade is unambiguous: give up early and record "not measured"
50
- * rather than hold up a login that was already decided. The runner's own budget
51
- * for the same probe (`GOOGLE_PROBE_TIMEOUT_MS` in server.mjs) is longer, so an
52
- * abort here is this side declining to wait, not the runner failing.
53
- */
54
- export const GOOGLE_PROBE_TIMEOUT_MS = 4_000;
55
-
56
- /**
57
- * How long ONE key check may take before it is abandoned and (once) retried.
58
- *
59
- * `/health` runs no gog, so a healthy runner answers in milliseconds; anything
60
- * near this bound means the Machine is booting or the proxy is holding the
61
- * connection, which is precisely the case the retry exists for.
62
- */
63
- export const LOGIN_ATTEMPT_TIMEOUT_MS = 5_000;
64
-
65
- /**
66
- * The pause between the two key-check attempts.
67
- *
68
- * Sized against what it is waiting out — a Fly proxy handing a request to a
69
- * Machine that is still coming up, or a runner a few hundred milliseconds from
70
- * the end of its drain — and against what it is spending: latency inside the
71
- * user's `/authorize` POST. One short pause is worth an enrolment; a backoff
72
- * ladder would not be.
73
- */
74
- export const LOGIN_RETRY_DELAY_MS = 250;
75
-
76
- /** How the failing status is named to the user. */
77
- function describeStatus(status: unknown): string {
78
- return typeof status === 'number' ? `HTTP ${status}` : 'no HTTP status';
79
- }
80
-
81
- const describeCause = (err: unknown) => (err instanceof Error ? err.message : String(err));
82
-
83
- /**
84
- * Everything after the specific cause. It leads with the sentence that matters:
85
- * the user's key was never judged, so the correct action is to wait, not to go
86
- * looking for a different key.
87
- */
88
- const UNREACHABLE_ADVICE =
89
- 'Your connector key was NOT rejected — the backend never answered, so the key ' +
90
- 'was never checked. The runner answers 503 for the whole of its drain window ' +
91
- '(i.e. during every deploy) and Fly answers 502 while a stopped Machine boots, ' +
92
- 'so this is usually momentary. Wait a few seconds and try again.';
93
-
94
- /**
95
- * Verify the connector key against the runner's `/health`, distinguishing the
96
- * two ways that can fail. Returns on success; throws otherwise.
97
- *
98
- * ## The bug this replaces (DEFECT 4)
99
- *
100
- * The previous body was `if (!res.ok) throw new Error('Invalid connector key
101
- * (backend rejected it)')`. Every non-2xx produced that sentence, and a rejected
102
- * `fetch` produced no sentence at all — it escaped `login()` uncaught.
103
- *
104
- * But `/health` returns non-2xx for reasons that have nothing to do with the
105
- * key. `server.mjs` answers **503 `{retryable:true}` to every request once a
106
- * shutdown signal lands**, which is the whole of every deploy, and Fly's proxy
107
- * answers 502 while a stopped Machine boots. A user enrolling in either window
108
- * was told, flatly, that their key was wrong. The reasonable response to that is
109
- * to stop and go find a better key — which leaves a connector stuck at
110
- * `authenticate` / `complete_authentication`, exactly the state `gog_docs`,
111
- * `gog_sheets` and `gog_drive` were observed in.
112
- *
113
- * ## The two rules
114
- *
115
- * **Only 401 and 403 mean "wrong key."** Those are the runner actually judging
116
- * the bearer (`bearerMatches` → `{ error: 'unauthorized' }`). Everything else —
117
- * every other status, an unparseable answer, a dead socket, our own timeout — is
118
- * the backend failing to answer, and is reported as such.
119
- *
120
- * **Unknown resolves toward "try again."** The two errors are not symmetrical:
121
- * telling a user with a good key that it is invalid ends the enrolment, while
122
- * telling a user with a bad key to retry costs one more attempt and then tells
123
- * them the truth. So anything unrecognised (including a response with no status
124
- * at all) takes the transient branch.
125
- *
126
- * ## Why retry rather than merely report
127
- *
128
- * The dominant transient case is self-inflicted and self-clearing: we deploy,
129
- * the runner drains, it returns 503 for a moment. Reporting that accurately
130
- * still costs the user an enrolment attempt they did nothing to deserve. One
131
- * retry absorbs it entirely. It is bounded at two attempts and one short delay
132
- * because this runs inside the `/authorize` POST the human is watching.
133
- *
134
- * Note the direction: this can only turn a refusal into a success. It cannot
135
- * strand anyone, which is what separates it from any check on the Google layer.
136
- */
137
- async function verifyConnectorKey(endpoint: string, key: string): Promise<void> {
138
- let cause = '';
139
- for (let attempt = 0; attempt < 2; attempt += 1) {
140
- if (attempt > 0) {
141
- await new Promise((resolve) => setTimeout(resolve, LOGIN_RETRY_DELAY_MS));
142
- }
143
- let res: Response;
144
- try {
145
- res = await fetch(`${endpoint}/health`, {
146
- headers: { Authorization: `Bearer ${key}` },
147
- signal: AbortSignal.timeout(LOGIN_ATTEMPT_TIMEOUT_MS),
148
- });
149
- } catch (err) {
150
- cause = `could not reach the gog backend (${describeCause(err)})`;
151
- continue;
152
- }
153
- if (res.ok) return;
154
- if (res.status === 401 || res.status === 403) {
155
- logAuthTransition('connect.key-rejected', {
156
- endpoint,
157
- reason: `the runner refused the connector key (HTTP ${res.status})`,
158
- });
159
- throw new Error('Invalid connector key (backend rejected it)');
160
- }
161
- cause = `the gog backend did not answer the key check (${describeStatus(res.status)})`;
162
- }
163
- logAuthTransition('connect.runner-unreachable', { endpoint, reason: cause });
164
- // `cause` can quote text this layer did not author — a proxy's error body, a
165
- // socket error that echoed the outgoing Authorization header — so the sentence
166
- // shown on the login page goes through the same redactor as every other error
167
- // this repo hands back.
168
- throw new Error(redactSecrets(`${cause}. ${UNREACHABLE_ADVICE}`));
169
- }
170
-
171
-
172
- /**
173
- * The MCP `instructions` every hosted agent advertises (see `worker.ts`).
174
- *
175
- * ## Why a connector needs to say this at all
176
- *
177
- * There are two independent credentials behind these tools and the client UI
178
- * shows only the first:
179
- *
180
- * Layer 1 claude.ai → this Worker → the Fly runner, authenticated by the
181
- * user's connector key (the runner's RUNNER_KEY), stored in OAUTH_KV.
182
- * Layer 2 `gog` on the Fly machine → Google, authenticated by a refresh token
183
- * in gog's file keyring on the /data volume. The connector never
184
- * sees it, cannot refresh it, and is not told when it dies.
185
- *
186
- * "Connected" is a layer-1 fact. "Refreshed" is smaller still: `ConnectorAuth`
187
- * exposes only a `login` hook — no `validate`, no `refresh` — so a refresh is an
188
- * OAuth exchange inside OAUTH_KV that contacts neither Fly nor Google. Both
189
- * words are outside this repo's control, and both get read as "your Google
190
- * access works". They were, right up until the next Gmail call returned a Google
191
- * 401.
192
- *
193
- * So the boundary we DO own says it plainly, to the one reader who can act on it
194
- * before the user hits the error: the model holding these tools.
195
- */
196
- export const CONNECTOR_INSTRUCTIONS = [
197
- 'These tools reach Google through a `gog` install on the user\'s own Fly.io machine.',
198
- '',
199
- 'There are TWO credentials, and this connector holds only the first:',
200
- ' 1. the connector key, which authorizes this Worker to call that machine;',
201
- ' 2. a Google refresh token in gog\'s keyring ON that machine, which the connector',
202
- ' never sees and cannot refresh.',
203
- '',
204
- 'A "connected" or "refreshed" connector therefore proves only (1). It does NOT mean',
205
- 'Google still accepts (2): a refresh token that expired or was revoked leaves the',
206
- 'connector looking perfectly healthy until the first real call returns a Google 401.',
207
- 'Nothing in the connection status measures Google — gog_auth_health is the only tool',
208
- 'that does, because it performs a real token refresh against Google.',
209
- '',
210
- 'When a call fails with a Google 401 or invalid_grant, do not retry it and do not',
211
- 'assume the connector is broken. Run gog_auth_health to confirm, then re-authorize',
212
- 'with gog_auth_add_url followed by gog_auth_add_complete (the browser-based',
213
- 'gog_auth_add cannot work here — there is no browser on the Fly machine).',
214
- '',
215
- 'If the OAuth client\'s consent screen is still in "Testing" mode, Google expires its',
216
- 'refresh tokens exactly 7 days after issue, so this can recur weekly until the app is',
217
- 'published. gog_auth_health reports how long ago each account was authorized.',
218
- ].join('\n');
219
-
220
- /**
221
- * Ask the runner whether Google still accepts the credential on its volume, and
222
- * record the answer. Resolves in every case; it can neither throw nor return a
223
- * value, because nothing may make a decision out of what it finds.
224
- *
225
- * ## Why measuring here is worth doing, and why refusing here is not
226
- *
227
- * `login()` verifies the connector key against the runner's `/health`, an
228
- * endpoint whose own comment says it "does not depend on gog". So a successful
229
- * login has always been a layer-1 statement, presented to the user as if it
230
- * settled both layers. This makes the connect path measure the layer it was
231
- * silently vouching for.
232
- *
233
- * It must never gate the login. The tools that repair a dead Google credential
234
- * (`gog_auth_add_url`, `gog_auth_add_complete`) are MCP tools, reachable only
235
- * once the connector is connected — so refusing to connect on a dead credential
236
- * would lock the user out of the only path that fixes it. The goal is that
237
- * status never claims health it did not measure, NOT that a bad measurement
238
- * refuses the connection.
239
- *
240
- * ## What it is honestly able to say
241
- *
242
- * Only what was true AT CONNECT TIME, and only on the connect path: claude.ai's
243
- * later "refreshed" never reaches this code (there is no `refresh` hook to run
244
- * it from), so the record is a fixed point in the past, not a live status. Its
245
- * value is that the incident log finally contains what the Google layer was
246
- * doing at the moment the UI said "connected" — which is exactly the correlation
247
- * that could not be made when this was first reported.
248
- */
249
- async function recordGoogleLayerAtConnect(endpoint: string, key: string): Promise<void> {
250
- let event: AuthTransition;
251
- let reason: string | undefined;
252
- try {
253
- const res = await fetch(`${endpoint}/health/google`, {
254
- headers: { Authorization: `Bearer ${key}` },
255
- signal: AbortSignal.timeout(GOOGLE_PROBE_TIMEOUT_MS),
256
- });
257
- if (!res.ok) {
258
- // Includes the 404 from a runner deployed before the probe endpoint
259
- // existed. "I could not ask" is never reported as "Google said no".
260
- event = 'connect.google-unmeasured';
261
- reason = `the runner did not answer the Google probe (HTTP ${res.status})`;
262
- } else {
263
- // `readGoogleProbe` is the ONE place that judges a probe body, shared with
264
- // the post-refusal probe in connector-runtime.ts. It reads the runner's
265
- // `measured` field before its `ok` field, which is what keeps a probe that
266
- // TIMED OUT or could not be RUN out of `-unhealthy` — an event whose
267
- // documented meaning is "Google was asked and refused". The reason string
268
- // it returns comes from the runner's closed vocabulary (PROBE_CAUSES), so
269
- // it carries a classification and never gog's own output.
270
- const verdict = readGoogleProbe(await res.json());
271
- event =
272
- verdict.kind === 'ok'
273
- ? 'connect.google-ok'
274
- : verdict.kind === 'unhealthy'
275
- ? 'connect.google-unhealthy'
276
- : 'connect.google-unmeasured';
277
- reason = verdict.reason;
278
- }
279
- } catch (err) {
280
- // A rejected fetch, an abort at GOOGLE_PROBE_TIMEOUT_MS, or a body that is
281
- // not JSON (a proxy's HTML error page). None of them are facts about Google.
282
- event = 'connect.google-unmeasured';
283
- reason = err instanceof Error ? err.message : String(err);
284
- }
285
- // `reason` can quote text this layer did not author, so the record goes
286
- // through the same redactor as every other auth log line.
287
- logAuthTransition(event, { endpoint, reason });
288
- }
289
-
290
- /**
291
- * `ConnectorAuth` for the gogcli remote connector: the login page collects the
292
- * user's connector key, verifies it by hitting the Fly backend's `/health`
293
- * endpoint with the key as a bearer token, and stores `{ key }` as the OAuth
294
- * props that `worker.ts` turns into a request-scoped Fly executor.
295
- *
296
- * Only the runner judging the bearer (401/403) refuses the login; a backend that
297
- * does not answer is retried once and then reported as unreachable, never as a
298
- * bad key — see `verifyConnectorKey`.
299
- *
300
- * After the key is accepted it also measures the SECOND credential — the Google
301
- * grant on the Fly volume — and records what it found. That measurement changes
302
- * nothing about whether the login succeeds; see `recordGoogleLayerAtConnect`.
303
- */
304
- export const gogAuth: ConnectorAuth<GogProps> = {
305
- service: 'gogcli (Google Workspace)',
306
- accent: '#4285F4',
307
- privacyNote:
308
- 'Your connector key is stored encrypted and used only to reach your own gog backend.',
309
- fields: [{ name: 'key', label: 'gogcli connector key', type: 'password' }],
310
- async login(fields, env) {
311
- const endpoint = (env as any).FLY_ENDPOINT;
312
- // Layer 1. Throws — and only this may refuse the login.
313
- await verifyConnectorKey(endpoint, fields.key);
314
- // Layer 2. Records; never refuses. Deliberately after the key check, so a
315
- // login that never happened says nothing at all about Google.
316
- await recordGoogleLayerAtConnect(endpoint, fields.key);
317
- return { key: fields.key };
318
- },
319
- };
@@ -1,87 +0,0 @@
1
- import type { ConnectorAuth } from './connector-auth.js';
2
-
3
- function escapeHtml(value: unknown): string {
4
- return String(value)
5
- .replaceAll('&', '&amp;')
6
- .replaceAll('<', '&lt;')
7
- .replaceAll('>', '&gt;')
8
- .replaceAll('"', '&quot;')
9
- .replaceAll("'", '&#39;');
10
- }
11
-
12
- function encodeOauthRequest(value: unknown): string {
13
- return btoa(JSON.stringify(value));
14
- }
15
-
16
- function renderLoginPage<Props>(
17
- auth: ConnectorAuth<Props>,
18
- options: { oauthReq: unknown; error?: string },
19
- ): string {
20
- const fields = auth.fields.map((field) => `
21
- <label>${escapeHtml(field.label)}
22
- <input name="${escapeHtml(field.name)}" type="${field.type ?? 'text'}" required>
23
- </label>`).join('');
24
- return `<!doctype html>
25
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
26
- <title>Connect ${escapeHtml(auth.service)}</title>
27
- <style>body{font:16px system-ui;max-width:34rem;margin:4rem auto;padding:0 1rem;color:#202124}form{display:grid;gap:1rem}label{display:grid;gap:.4rem}input,button{font:inherit;padding:.7rem}button{color:white;background:${escapeHtml(auth.accent ?? '#444')};border:0;border-radius:.3rem}.error{color:#b3261e}</style>
28
- </head><body><h1>Connect ${escapeHtml(auth.service)}</h1>
29
- ${options.error ? `<p class="error">${escapeHtml(options.error)}</p>` : ''}
30
- <form method="post">${fields}
31
- <input type="hidden" name="oauthReq" value="${escapeHtml(encodeOauthRequest(options.oauthReq))}">
32
- <button type="submit">Authorize</button></form>
33
- ${auth.privacyNote ? `<p>${escapeHtml(auth.privacyNote)}</p>` : ''}
34
- </body></html>`;
35
- }
36
-
37
- function messageOf(error: unknown): string {
38
- return error instanceof Error ? error.message : String(error);
39
- }
40
-
41
- /** Serve the connector's GET login form and POST authorization completion. */
42
- export async function handleAuthorize<Props>(
43
- request: Request,
44
- env: {
45
- OAUTH_PROVIDER: {
46
- parseAuthRequest(request: Request): Promise<unknown>;
47
- completeAuthorization(input: unknown): Promise<{ redirectTo: string }>;
48
- };
49
- },
50
- auth: ConnectorAuth<Props>,
51
- ): Promise<Response> {
52
- if (request.method === 'GET') {
53
- const oauthReq = await env.OAUTH_PROVIDER.parseAuthRequest(request);
54
- return new Response(renderLoginPage(auth, { oauthReq }), {
55
- headers: { 'content-type': 'text/html' },
56
- });
57
- }
58
-
59
- const formData = await request.formData();
60
- const encodedOauthReq = formData.get('oauthReq');
61
- const oauthReq = typeof encodedOauthReq === 'string'
62
- ? JSON.parse(atob(encodedOauthReq))
63
- : undefined;
64
- const fields = Object.fromEntries(auth.fields.map((field) => {
65
- const value = formData.get(field.name);
66
- return [field.name, typeof value === 'string' ? value : ''];
67
- }));
68
-
69
- try {
70
- const props = await auth.login(fields, env);
71
- const firstField = auth.fields[0];
72
- const userId = auth.userId ?? (firstField ? fields[firstField.name] : 'public');
73
- const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
74
- request: oauthReq,
75
- userId,
76
- scope: [],
77
- metadata: {},
78
- props,
79
- });
80
- return Response.redirect(redirectTo, 302);
81
- } catch (error) {
82
- return new Response(renderLoginPage(auth, { oauthReq, error: messageOf(error) }), {
83
- status: 200,
84
- headers: { 'content-type': 'text/html' },
85
- });
86
- }
87
- }