gogcli-mcp 2.20.0 → 2.21.1
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 +434 -66
- package/dist/lib.js +396 -47
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/auth-log.ts +147 -0
- package/src/connector-runtime.ts +585 -80
- package/src/google-token.ts +391 -0
- package/src/remote-runner.ts +8 -1
- package/src/runner.ts +67 -2
- package/src/tools/auth.ts +6 -1
- package/src/tools/utils.ts +94 -7
- package/src/worker.ts +12 -1
- package/tests/auth-log.test.ts +508 -0
- package/tests/connector-runtime.test.ts +675 -2
- package/tests/google-token.test.ts +425 -0
- package/tests/runner.test.ts +21 -1
- package/tests/sdk-single-copy.test.ts +48 -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 +21 -0
- package/tests/tools/utils.test.ts +99 -2
package/src/auth-log.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Which credential, which service, which backend, and why — every field
|
|
85
|
+
* optional because each call site honestly knows a different subset. A
|
|
86
|
+
* transport 401 has no credential to name; a mint has no service.
|
|
87
|
+
*/
|
|
88
|
+
export interface AuthContext {
|
|
89
|
+
/** `credentialTag` of the hash that keys the token cache. Never a token. */
|
|
90
|
+
credential?: string;
|
|
91
|
+
/** The `gog` service word (gmail, drive, sheets…), when there is one. */
|
|
92
|
+
service?: string;
|
|
93
|
+
/** The gog-runner base URL. Configuration, not a secret. */
|
|
94
|
+
endpoint?: string;
|
|
95
|
+
/** Prose. Redacted with everything else — see the module note. */
|
|
96
|
+
reason?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Transitions that describe something going WRONG, routed to `console.error`.
|
|
101
|
+
* Everything else is routine and goes to `console.warn` (see the module note on
|
|
102
|
+
* why not `console.info`).
|
|
103
|
+
*
|
|
104
|
+
* `token.evicted` is deliberately NOT here: an eviction is the repair working.
|
|
105
|
+
* `replay.declined` is not either — declining is usually the correct, safe
|
|
106
|
+
* answer (a write, a superseded token), and only reads as a failure alongside
|
|
107
|
+
* the record that follows it.
|
|
108
|
+
*/
|
|
109
|
+
const FAILURES: ReadonlySet<AuthTransition> = new Set<AuthTransition>([
|
|
110
|
+
'token.mint-failed',
|
|
111
|
+
'grant.dead',
|
|
112
|
+
'replay.failed',
|
|
113
|
+
'runner.auth-failed',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/** Marker so one `grep gog-auth` finds every record and nothing else. */
|
|
117
|
+
const PREFIX = 'gog-auth ';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* How many hex characters of the cache key identify a credential in a log.
|
|
121
|
+
*
|
|
122
|
+
* 12 hex characters is 48 bits — far past collision risk for the handful of
|
|
123
|
+
* credentials one deployment holds, and short enough to read across lines. The
|
|
124
|
+
* input is already a SHA-256 of (clientId, refreshToken), so truncating it can
|
|
125
|
+
* only ever remove information.
|
|
126
|
+
*/
|
|
127
|
+
const TAG_CHARS = 12;
|
|
128
|
+
|
|
129
|
+
/** The log-safe name for a credential, from the hash that already keys it. */
|
|
130
|
+
export function credentialTag(cacheKeyHash: string): string {
|
|
131
|
+
return cacheKeyHash.slice(0, TAG_CHARS);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Emit one record. Deliberately synchronous, allocation-light and
|
|
136
|
+
* exception-free by construction: this sits on the request path, and an
|
|
137
|
+
* observability call that can fail or block would be worse than the silence it
|
|
138
|
+
* replaces.
|
|
139
|
+
*/
|
|
140
|
+
export function logAuthTransition(event: AuthTransition, context: AuthContext): void {
|
|
141
|
+
// `at` and `event` first so the line reads left to right; JSON.stringify
|
|
142
|
+
// drops the context keys whose value is undefined, which is why an absent
|
|
143
|
+
// credential produces no `"credential":null` noise.
|
|
144
|
+
const record = JSON.stringify({ at: new Date().toISOString(), event, ...context });
|
|
145
|
+
const write = FAILURES.has(event) ? console.error : console.warn;
|
|
146
|
+
write(PREFIX + redactSecrets(record));
|
|
147
|
+
}
|