mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2
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/README.md +21 -16
- package/dist/accounts.d.ts +44 -5
- package/dist/accounts.js +267 -25
- package/dist/auth.d.ts +11 -4
- package/dist/auth.js +85 -78
- package/dist/client-config.d.ts +90 -0
- package/dist/client-config.js +282 -0
- package/dist/client.js +10 -4
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +117 -0
- package/dist/discover.js +57 -3
- package/dist/doctor.d.ts +86 -0
- package/dist/doctor.js +384 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +52 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -1
- package/dist/fanout.js +8 -5
- package/dist/fs-atomic.d.ts +3 -0
- package/dist/fs-atomic.js +123 -0
- package/dist/http-config.d.ts +35 -0
- package/dist/http-config.js +113 -0
- package/dist/http-transport.d.ts +59 -0
- package/dist/http-transport.js +269 -0
- package/dist/identity.d.ts +24 -0
- package/dist/identity.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +199 -18
- package/dist/master-key.d.ts +53 -0
- package/dist/master-key.js +242 -0
- package/dist/mcp-token.d.ts +81 -0
- package/dist/mcp-token.js +176 -0
- package/dist/migrate-config.d.ts +7 -0
- package/dist/migrate-config.js +103 -0
- package/dist/migrate-tokens.js +4 -3
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/oauth-as.d.ts +52 -0
- package/dist/oauth-as.js +457 -0
- package/dist/oauth-consent.d.ts +28 -0
- package/dist/oauth-consent.js +109 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +30 -0
- package/dist/reauth-hint.d.ts +2 -0
- package/dist/reauth-hint.js +15 -0
- package/dist/registry-transfer.d.ts +41 -0
- package/dist/registry-transfer.js +219 -0
- package/dist/registry.d.ts +27 -1
- package/dist/registry.js +117 -6
- package/dist/scope-catalog.d.ts +19 -0
- package/dist/scope-catalog.js +182 -0
- package/dist/scope-observability.d.ts +78 -0
- package/dist/scope-observability.js +192 -0
- package/dist/services.js +1 -1
- package/dist/setup-prompt.d.ts +9 -0
- package/dist/setup-prompt.js +59 -0
- package/dist/ssrf-guard.d.ts +48 -0
- package/dist/ssrf-guard.js +242 -0
- package/dist/token-store.js +23 -125
- package/dist/tools/_errors.d.ts +8 -2
- package/dist/tools/_errors.js +97 -6
- package/dist/tools/account-wizard.d.ts +33 -0
- package/dist/tools/account-wizard.js +284 -0
- package/dist/tools/accounts-tool.d.ts +8 -5
- package/dist/tools/accounts-tool.js +38 -15
- package/dist/tools/admin.js +2 -2
- package/dist/tools/calendar.js +2 -2
- package/dist/tools/chat.js +2 -2
- package/dist/tools/contacts.d.ts +40 -0
- package/dist/tools/contacts.js +135 -2
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.js +60 -9
- package/dist/tools/drive.d.ts +3 -0
- package/dist/tools/drive.js +60 -8
- package/dist/tools/forms.js +4 -2
- package/dist/tools/generated/_shared.d.ts +1 -3
- package/dist/tools/generated/_shared.js +3 -3
- package/dist/tools/generated/admin.js +175 -132
- package/dist/tools/generated/appsmarket.js +6 -2
- package/dist/tools/generated/calendar.js +45 -27
- package/dist/tools/generated/chat.js +81 -47
- package/dist/tools/generated/classroom.js +131 -104
- package/dist/tools/generated/cloudidentity.js +73 -61
- package/dist/tools/generated/cloudsearch.js +59 -49
- package/dist/tools/generated/contacts.js +22 -14
- package/dist/tools/generated/drive.js +51 -35
- package/dist/tools/generated/driveactivity.js +5 -1
- package/dist/tools/generated/drivelabels.js +30 -25
- package/dist/tools/generated/forms.js +7 -3
- package/dist/tools/generated/gmail.js +73 -58
- package/dist/tools/generated/groupsmigration.js +5 -1
- package/dist/tools/generated/groupssettings.js +7 -3
- package/dist/tools/generated/keep.js +12 -7
- package/dist/tools/generated/licensing.js +11 -7
- package/dist/tools/generated/meet.js +20 -13
- package/dist/tools/generated/postmaster.js +8 -4
- package/dist/tools/generated/reseller.js +22 -17
- package/dist/tools/generated/script.js +26 -16
- package/dist/tools/generated/sheets.js +11 -7
- package/dist/tools/generated/tasks.js +6 -2
- package/dist/tools/generated/vault.js +38 -33
- package/dist/tools/generated/workspaceevents.js +12 -7
- package/dist/tools/gmail-mime.d.ts +38 -8
- package/dist/tools/gmail-mime.js +75 -71
- package/dist/tools/gmail.d.ts +31 -0
- package/dist/tools/gmail.js +483 -99
- package/dist/tools/google-api.js +7 -4
- package/dist/tools/meet.js +2 -2
- package/dist/tools/searchconsole.js +2 -2
- package/dist/tools/sheets.js +6 -2
- package/dist/tools/slides.js +4 -2
- package/dist/tools/tasks.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/write-control.d.ts +24 -1
- package/dist/write-control.js +26 -1
- package/package.json +26 -7
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// B13 / C11: SSRF guard for every server-side fetch (the CIMD client_id doc).
|
|
2
|
+
// D4 decision (dependencies-and-bv §2.2): a small AUDITED private-range check,
|
|
3
|
+
// no dependency — HTTPS-only, resolve-then-check ALL addresses (anti-rebind),
|
|
4
|
+
// and cap redirects / size / time. Blocks 10/8, 172.16/12, 192.168/16, 127/8,
|
|
5
|
+
// 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and v4-mapped forms.
|
|
6
|
+
import { lookup } from 'node:dns/promises';
|
|
7
|
+
import { isIPv4, isIPv6, BlockList } from 'node:net';
|
|
8
|
+
export class SsrfBlockedError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'SsrfBlockedError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
|
|
15
|
+
export function isBlockedIPv4(ip) {
|
|
16
|
+
const parts = ip.split('.').map(Number);
|
|
17
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
18
|
+
return true;
|
|
19
|
+
const [a, b] = parts;
|
|
20
|
+
if (a === 0)
|
|
21
|
+
return true; // 0.0.0.0/8 "this network"
|
|
22
|
+
if (a === 10)
|
|
23
|
+
return true; // 10/8 private
|
|
24
|
+
if (a === 127)
|
|
25
|
+
return true; // 127/8 loopback
|
|
26
|
+
if (a === 169 && b === 254)
|
|
27
|
+
return true; // 169.254/16 link-local (incl. cloud metadata)
|
|
28
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
29
|
+
return true; // 172.16/12 private
|
|
30
|
+
if (a === 192 && b === 168)
|
|
31
|
+
return true; // 192.168/16 private
|
|
32
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
33
|
+
return true; // 100.64/10 CGNAT
|
|
34
|
+
if (a >= 224)
|
|
35
|
+
return true; // 224/4 multicast + 240/4 reserved
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// Built-in subnet math (no hand-rolled IPv6 parsing): blocks loopback,
|
|
39
|
+
// unspecified, ULA, link-local, multicast, and ALL v4-mapped / v4-compatible /
|
|
40
|
+
// NAT64 / 6to4 embeddings (a public host never resolves to those, so blocking
|
|
41
|
+
// the whole range closes the embedded-private-target gap without parsing).
|
|
42
|
+
const V6_BLOCKED = new BlockList();
|
|
43
|
+
V6_BLOCKED.addSubnet('::', 96, 'ipv6'); // ::/96 = ::1, ::, and v4-compatible
|
|
44
|
+
V6_BLOCKED.addSubnet('::ffff:0:0', 96, 'ipv6'); // v4-mapped
|
|
45
|
+
V6_BLOCKED.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64
|
|
46
|
+
V6_BLOCKED.addSubnet('2002::', 16, 'ipv6'); // 6to4
|
|
47
|
+
V6_BLOCKED.addSubnet('fc00::', 7, 'ipv6'); // unique-local
|
|
48
|
+
V6_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); // link-local
|
|
49
|
+
V6_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); // multicast
|
|
50
|
+
/** True if an IPv6 literal is blocked. */
|
|
51
|
+
export function isBlockedIPv6(ip) {
|
|
52
|
+
const addr = ip.replace(/^\[|\]$/g, '');
|
|
53
|
+
if (!isIPv6(addr))
|
|
54
|
+
return true;
|
|
55
|
+
return V6_BLOCKED.check(addr, 'ipv6');
|
|
56
|
+
}
|
|
57
|
+
export function isBlockedIp(ip) {
|
|
58
|
+
if (isIPv4(ip))
|
|
59
|
+
return isBlockedIPv4(ip);
|
|
60
|
+
if (isIPv6(ip))
|
|
61
|
+
return isBlockedIPv6(ip);
|
|
62
|
+
return true; // unparseable → block
|
|
63
|
+
}
|
|
64
|
+
async function defaultResolveAll(host) {
|
|
65
|
+
// A bare IP literal needs no DNS; check it directly.
|
|
66
|
+
if (isIPv4(host) || isIPv6(host))
|
|
67
|
+
return [host];
|
|
68
|
+
const results = await lookup(host, { all: true });
|
|
69
|
+
return results.map((r) => r.address);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Assert a URL is safe to fetch server-side: HTTPS scheme, and every address
|
|
73
|
+
* the host resolves to is public. Throws SsrfBlockedError otherwise, and
|
|
74
|
+
* returns the resolved IPs.
|
|
75
|
+
*
|
|
76
|
+
* NOTE: this checks the host's addresses but does NOT pin them — the caller
|
|
77
|
+
* fetches by hostname, so undici re-resolves at connect time (a check-time vs
|
|
78
|
+
* connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
|
|
79
|
+
* (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
|
|
80
|
+
* allowlist (oauth-as validateClient rejects any non-allowlisted host BEFORE
|
|
81
|
+
* fetching), so an attacker can't steer the fetch at an arbitrary hostname;
|
|
82
|
+
* this public-range check is defense-in-depth on top of that.
|
|
83
|
+
*/
|
|
84
|
+
export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
|
|
85
|
+
let url;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(rawUrl);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new SsrfBlockedError(`not a valid URL: ${rawUrl}`);
|
|
91
|
+
}
|
|
92
|
+
if (url.protocol !== 'https:') {
|
|
93
|
+
throw new SsrfBlockedError(`only https is allowed (got ${url.protocol})`);
|
|
94
|
+
}
|
|
95
|
+
const resolveAll = deps.resolveAll ?? defaultResolveAll;
|
|
96
|
+
let addresses;
|
|
97
|
+
try {
|
|
98
|
+
addresses = await resolveAll(url.hostname);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
throw new SsrfBlockedError(`DNS resolution failed for ${url.hostname}: ${e.message}`);
|
|
102
|
+
}
|
|
103
|
+
if (addresses.length === 0)
|
|
104
|
+
throw new SsrfBlockedError(`no addresses resolved for ${url.hostname}`);
|
|
105
|
+
for (const ip of addresses) {
|
|
106
|
+
if (isBlockedIp(ip)) {
|
|
107
|
+
throw new SsrfBlockedError(`${url.hostname} resolves to a blocked address (${ip})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { url, addresses };
|
|
111
|
+
}
|
|
112
|
+
// Connection-level failures that a retry can legitimately recover from. A dead
|
|
113
|
+
// IPv6 route (broken egress with Happy-Eyeballs falling through), a reset, or a
|
|
114
|
+
// DNS blip are transient; an SSRF block, a bad HTTP status, or malformed JSON
|
|
115
|
+
// are deterministic and must NOT be retried.
|
|
116
|
+
const TRANSIENT_CODES = new Set([
|
|
117
|
+
'ETIMEDOUT',
|
|
118
|
+
'ECONNRESET',
|
|
119
|
+
'ECONNREFUSED',
|
|
120
|
+
'EAI_AGAIN',
|
|
121
|
+
'ENETUNREACH',
|
|
122
|
+
'EHOSTUNREACH',
|
|
123
|
+
'EPIPE',
|
|
124
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
125
|
+
'UND_ERR_SOCKET',
|
|
126
|
+
]);
|
|
127
|
+
function isTransientFetchError(e) {
|
|
128
|
+
if (e instanceof SsrfBlockedError)
|
|
129
|
+
return false; // deterministic security/shape reject
|
|
130
|
+
const name = e?.name;
|
|
131
|
+
if (name === 'AbortError' || name === 'TimeoutError')
|
|
132
|
+
return true; // our per-attempt timeout: dead/slow peer
|
|
133
|
+
const cause = e?.cause;
|
|
134
|
+
const code = cause?.code ?? e?.code;
|
|
135
|
+
if (code && TRANSIENT_CODES.has(code))
|
|
136
|
+
return true;
|
|
137
|
+
// undici surfaces connection failures as TypeError('fetch failed') with the
|
|
138
|
+
// real reason in .cause; retry even when the cause carries no useful code.
|
|
139
|
+
if (e instanceof TypeError && /fetch failed/i.test(e.message))
|
|
140
|
+
return true;
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* SSRF-guarded fetch of a CIMD client-metadata document (JSON). Every hop
|
|
145
|
+
* (including redirects) is HTTPS + public-IP checked; response is size- and
|
|
146
|
+
* time-capped. Returns the parsed JSON object.
|
|
147
|
+
*/
|
|
148
|
+
export async function fetchCimdDocument(rawUrl, opts = {}) {
|
|
149
|
+
const maxBytes = opts.maxBytes ?? 64 * 1024;
|
|
150
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
151
|
+
const maxRedirects = opts.maxRedirects ?? 3;
|
|
152
|
+
const retries = opts.retries ?? 2;
|
|
153
|
+
const backoffMs = opts.retryBackoffMs ?? 200;
|
|
154
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
155
|
+
const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
156
|
+
let current = rawUrl;
|
|
157
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
158
|
+
let redirectTo = null;
|
|
159
|
+
let doc = null;
|
|
160
|
+
// Retry transient connection failures IN PLACE. Each attempt re-runs the
|
|
161
|
+
// resolve-then-check so a retry can never skip the anti-rebind guard, and
|
|
162
|
+
// the timer is per-attempt (armed across the body read, #10).
|
|
163
|
+
for (let attempt = 0;; attempt++) {
|
|
164
|
+
await assertPublicHttpsUrl(current, opts.ssrf);
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
167
|
+
try {
|
|
168
|
+
const res = await doFetch(current, { redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' } });
|
|
169
|
+
if (res.status >= 300 && res.status < 400) {
|
|
170
|
+
const loc = res.headers.get('location');
|
|
171
|
+
if (!loc)
|
|
172
|
+
throw new SsrfBlockedError('redirect without a Location header');
|
|
173
|
+
redirectTo = loc;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (!res.ok)
|
|
177
|
+
throw new SsrfBlockedError(`CIMD fetch failed: HTTP ${res.status}`);
|
|
178
|
+
const text = await readCapped(res, maxBytes, controller);
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(text);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
throw new SsrfBlockedError('CIMD document is not valid JSON');
|
|
185
|
+
}
|
|
186
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
187
|
+
throw new SsrfBlockedError('CIMD document is not a JSON object');
|
|
188
|
+
}
|
|
189
|
+
doc = parsed;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
if (attempt < retries && isTransientFetchError(e)) {
|
|
194
|
+
await sleep(backoffMs * (attempt + 1));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (redirectTo !== null) {
|
|
204
|
+
current = new URL(redirectTo, current).toString();
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
return doc;
|
|
208
|
+
}
|
|
209
|
+
throw new SsrfBlockedError(`too many redirects (> ${maxRedirects})`);
|
|
210
|
+
}
|
|
211
|
+
/** Read a response body, aborting the moment it exceeds maxBytes (never buffers
|
|
212
|
+
* an oversized body). Falls back to a capped arrayBuffer when there is no
|
|
213
|
+
* readable stream (e.g. a test Response). */
|
|
214
|
+
async function readCapped(res, maxBytes, controller) {
|
|
215
|
+
const reader = res.body?.getReader?.();
|
|
216
|
+
if (!reader) {
|
|
217
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
218
|
+
if (buf.byteLength > maxBytes)
|
|
219
|
+
throw new SsrfBlockedError(`CIMD document exceeds ${maxBytes} bytes`);
|
|
220
|
+
return new TextDecoder().decode(buf);
|
|
221
|
+
}
|
|
222
|
+
const chunks = [];
|
|
223
|
+
let size = 0;
|
|
224
|
+
for (;;) {
|
|
225
|
+
const { done, value } = await reader.read();
|
|
226
|
+
if (done)
|
|
227
|
+
break;
|
|
228
|
+
size += value.byteLength;
|
|
229
|
+
if (size > maxBytes) {
|
|
230
|
+
controller.abort();
|
|
231
|
+
throw new SsrfBlockedError(`CIMD document exceeds ${maxBytes} bytes`);
|
|
232
|
+
}
|
|
233
|
+
chunks.push(value);
|
|
234
|
+
}
|
|
235
|
+
const all = new Uint8Array(size);
|
|
236
|
+
let off = 0;
|
|
237
|
+
for (const c of chunks) {
|
|
238
|
+
all.set(c, off);
|
|
239
|
+
off += c.byteLength;
|
|
240
|
+
}
|
|
241
|
+
return new TextDecoder().decode(all);
|
|
242
|
+
}
|
package/dist/token-store.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import { getAccountSet } from './accounts.js';
|
|
4
|
+
import { noteSuccessfulDecrypt, resolveMasterKeyForDispatch } from './master-key.js';
|
|
5
|
+
import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
5
6
|
const ENC_VERSION = 1;
|
|
6
7
|
const ALGO = 'aes-256-gcm';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
// KDF is COMPAT-FROZEN: generated keys are randomBytes(32) base64 and take the
|
|
9
|
+
// raw path; only human-passphrase keys hit the bare-sha256 branch (v5 legacy —
|
|
10
|
+
// changing it would brick every existing token). HKDF + ENC_VERSION=2 is the
|
|
11
|
+
// sanctioned post-6.0 hardening route.
|
|
11
12
|
export function deriveKey(masterKey) {
|
|
12
13
|
if (!masterKey) {
|
|
13
14
|
throw new Error('MASTER_KEY is required to encrypt/decrypt tokens. Generate one with: openssl rand -base64 32');
|
|
@@ -48,141 +49,38 @@ export function decryptToken(fileContents, masterKey) {
|
|
|
48
49
|
return JSON.parse(plaintext.toString('utf8'));
|
|
49
50
|
}
|
|
50
51
|
function masterKey() {
|
|
51
|
-
return
|
|
52
|
+
return resolveMasterKeyForDispatch().key;
|
|
53
|
+
}
|
|
54
|
+
function encPath(alias) {
|
|
55
|
+
return getAccountSet().configs[alias].encPath;
|
|
52
56
|
}
|
|
53
57
|
export function readToken(alias) {
|
|
54
58
|
let contents;
|
|
55
59
|
try {
|
|
56
|
-
contents = fs.readFileSync(
|
|
60
|
+
contents = fs.readFileSync(encPath(alias), 'utf8');
|
|
57
61
|
}
|
|
58
62
|
catch {
|
|
59
63
|
return null;
|
|
60
64
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
65
|
+
const data = decryptToken(contents, masterKey());
|
|
66
|
+
noteSuccessfulDecrypt();
|
|
67
|
+
return data;
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const ownerFile = `${lock}.${process.pid}.${randomBytes(6).toString('hex')}.owner`;
|
|
71
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
72
|
-
fs.writeFileSync(ownerFile, String(process.pid), { mode: 0o600, flag: 'wx' });
|
|
73
|
-
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
74
|
-
try {
|
|
75
|
-
while (true) {
|
|
76
|
-
try {
|
|
77
|
-
fs.linkSync(ownerFile, lock);
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
const err = error;
|
|
82
|
-
if (err.code !== 'EEXIST')
|
|
83
|
-
throw error;
|
|
84
|
-
try {
|
|
85
|
-
const observedOwner = fs.readFileSync(lock, 'utf8');
|
|
86
|
-
const owner = Number(observedOwner);
|
|
87
|
-
// Non-PID content can only come from a corrupt or interrupted lock
|
|
88
|
-
// write — recover it like a dead owner instead of spinning to timeout.
|
|
89
|
-
let ownerDead = !(Number.isSafeInteger(owner) && owner > 0);
|
|
90
|
-
if (!ownerDead) {
|
|
91
|
-
try {
|
|
92
|
-
process.kill(owner, 0);
|
|
93
|
-
}
|
|
94
|
-
catch (ownerError) {
|
|
95
|
-
const code = ownerError.code;
|
|
96
|
-
// EPERM: PID exists but is not signalable (recycled by another user);
|
|
97
|
-
// treat as alive, never break a lock we cannot verify.
|
|
98
|
-
if (code === 'ESRCH')
|
|
99
|
-
ownerDead = true;
|
|
100
|
-
else if (code !== 'EPERM')
|
|
101
|
-
throw ownerError;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
if (ownerDead) {
|
|
105
|
-
if (fs.readFileSync(lock, 'utf8') === observedOwner)
|
|
106
|
-
fs.rmSync(lock, { force: true });
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
catch (readError) {
|
|
111
|
-
if (readError.code === 'ENOENT') {
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
throw readError;
|
|
115
|
-
}
|
|
116
|
-
if (Date.now() >= deadline) {
|
|
117
|
-
throw new Error(`Timed out waiting for token lock: ${alias}`, { cause: error });
|
|
118
|
-
}
|
|
119
|
-
sleep(LOCK_RETRY_MS);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
finally {
|
|
124
|
-
fs.rmSync(ownerFile, { force: true });
|
|
125
|
-
}
|
|
126
|
-
try {
|
|
127
|
-
return fn();
|
|
128
|
-
}
|
|
129
|
-
finally {
|
|
130
|
-
fs.rmSync(lock, { force: true });
|
|
131
|
-
}
|
|
69
|
+
// Lock + atomic-write mechanics live in fs-atomic.ts (generalized path-keyed
|
|
70
|
+
// from this file's original alias-keyed implementation; behavior unchanged).
|
|
71
|
+
export function writeToken(alias, data) {
|
|
72
|
+
withFileLock(encPath(alias), () => writeTokenAtomic(alias, data), `token lock: ${alias}`);
|
|
132
73
|
}
|
|
133
74
|
function writeTokenAtomic(alias, data) {
|
|
134
|
-
|
|
135
|
-
const dir = path.dirname(p);
|
|
136
|
-
const tmp = path.join(dir, `.${path.basename(p)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
137
|
-
try {
|
|
138
|
-
fs.writeFileSync(tmp, encryptToken(data, masterKey()), { mode: 0o600, flag: 'wx' });
|
|
139
|
-
// Open read-write, not read-only: on Windows fsync maps to FlushFileBuffers,
|
|
140
|
-
// which returns EPERM on a read-only handle.
|
|
141
|
-
const fd = fs.openSync(tmp, 'r+');
|
|
142
|
-
try {
|
|
143
|
-
fs.fsyncSync(fd);
|
|
144
|
-
}
|
|
145
|
-
finally {
|
|
146
|
-
fs.closeSync(fd);
|
|
147
|
-
}
|
|
148
|
-
renameWithRetry(tmp, p);
|
|
149
|
-
}
|
|
150
|
-
finally {
|
|
151
|
-
try {
|
|
152
|
-
fs.rmSync(tmp, { force: true });
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
// force only suppresses ENOENT; a Windows handle-holder can make this
|
|
156
|
-
// throw and mask the real write error. The orphan tmp is harmless.
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
// Windows only: renaming over a momentarily-open file throws transient EPERM/EACCES/EBUSY (reads take no lock); see docs/internals.md.
|
|
161
|
-
function renameWithRetry(from, to) {
|
|
162
|
-
for (let attempt = 1;; attempt++) {
|
|
163
|
-
try {
|
|
164
|
-
fs.renameSync(from, to);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
catch (error) {
|
|
168
|
-
const code = error.code;
|
|
169
|
-
const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
|
170
|
-
if (!transient || attempt >= RENAME_ATTEMPTS)
|
|
171
|
-
throw error;
|
|
172
|
-
sleep(RENAME_RETRY_MS * attempt);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
export function writeToken(alias, data) {
|
|
177
|
-
withTokenLock(alias, () => writeTokenAtomic(alias, data));
|
|
75
|
+
atomicWriteFileSync(encPath(alias), encryptToken(data, masterKey()), 0o600);
|
|
178
76
|
}
|
|
179
77
|
export function updateToken(alias, updates) {
|
|
180
|
-
|
|
78
|
+
withFileLock(encPath(alias), () => {
|
|
181
79
|
const existing = readToken(alias) ?? {};
|
|
182
80
|
const definedUpdates = Object.fromEntries(Object.entries(updates).filter(([, value]) => value !== null && value !== undefined));
|
|
183
81
|
writeTokenAtomic(alias, { ...existing, ...definedUpdates });
|
|
184
|
-
});
|
|
82
|
+
}, `token lock: ${alias}`);
|
|
185
83
|
}
|
|
186
84
|
export function hasToken(alias) {
|
|
187
|
-
return fs.existsSync(
|
|
85
|
+
return fs.existsSync(encPath(alias));
|
|
188
86
|
}
|
package/dist/tools/_errors.d.ts
CHANGED
|
@@ -6,8 +6,14 @@ export interface ErrorEnvelope {
|
|
|
6
6
|
retriable: boolean;
|
|
7
7
|
account: string;
|
|
8
8
|
}
|
|
9
|
-
export declare function mapGoogleError(error: any, account: Account, forbiddenHint?: string)
|
|
10
|
-
|
|
9
|
+
export declare function mapGoogleError(error: any, account: Account, forbiddenHint?: string, scopeContext?: () => {
|
|
10
|
+
hint: string;
|
|
11
|
+
retriable: boolean;
|
|
12
|
+
} | null): ErrorEnvelope;
|
|
13
|
+
export declare function handleGoogleApiError(error: any, account: Account, forbiddenHint?: string, scopeContext?: () => {
|
|
14
|
+
hint: string;
|
|
15
|
+
retriable: boolean;
|
|
16
|
+
} | null): {
|
|
11
17
|
content: {
|
|
12
18
|
type: "text";
|
|
13
19
|
text: string;
|
package/dist/tools/_errors.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reauthHint } from '../reauth-hint.js';
|
|
1
2
|
function statusOf(error) {
|
|
2
3
|
const c = error?.code ?? error?.status ?? error?.response?.status;
|
|
3
4
|
const n = typeof c === 'string' ? Number(c) : c;
|
|
@@ -11,29 +12,106 @@ function reasonOf(error) {
|
|
|
11
12
|
function messageOf(error) {
|
|
12
13
|
return error?.response?.data?.error?.message ?? error?.message ?? String(error);
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
// Connect/DNS syscall codes. ENOTFOUND (no such name) is the one non-transient
|
|
16
|
+
// member. node-fetch flattens the happy-eyeballs AggregateError to a bare code
|
|
17
|
+
// with an empty message, so the code is the only surviving signal to surface.
|
|
18
|
+
const RETRIABLE_NET_CODES = new Set([
|
|
19
|
+
'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ENETUNREACH',
|
|
20
|
+
'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
|
|
21
|
+
]);
|
|
22
|
+
const NET_CODES = new Set([...RETRIABLE_NET_CODES, 'ENOTFOUND']);
|
|
23
|
+
/** First known network code on the error or its cause chain (GaxiosError.cause
|
|
24
|
+
* -> FetchError; undici TypeError.cause -> AggregateError.errors). */
|
|
25
|
+
function netCodeOf(error) {
|
|
26
|
+
for (let e = error, depth = 0; e && depth < 5; e = e.cause ?? e.error, depth++) {
|
|
27
|
+
if (typeof e.code === 'string' && NET_CODES.has(e.code))
|
|
28
|
+
return e.code;
|
|
29
|
+
if (Array.isArray(e.errors)) {
|
|
30
|
+
const sub = e.errors.find((x) => typeof x?.code === 'string' && NET_CODES.has(x.code));
|
|
31
|
+
if (sub)
|
|
32
|
+
return sub.code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Console deep-link to enable one API (noob-proofing hint, B10). */
|
|
38
|
+
function apiEnableLink(api) {
|
|
39
|
+
return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
|
|
40
|
+
}
|
|
41
|
+
/** Extract the disabled API id from an accessNotConfigured / SERVICE_DISABLED
|
|
42
|
+
* error so the hint can deep-link straight to its enable page. */
|
|
43
|
+
function disabledApiId(message) {
|
|
44
|
+
const url = message.match(/\/apis\/api\/([a-z0-9-]+)\.googleapis\.com/i);
|
|
45
|
+
if (url)
|
|
46
|
+
return url[1].toLowerCase();
|
|
47
|
+
const named = message.match(/\b([A-Za-z][A-Za-z0-9 ]*?) API has not been used/);
|
|
48
|
+
if (named)
|
|
49
|
+
return named[1].trim().toLowerCase().replace(/\s+/g, '');
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
|
|
15
53
|
const status = statusOf(error);
|
|
16
54
|
const reason = reasonOf(error);
|
|
17
55
|
const message = messageOf(error);
|
|
56
|
+
// 7-day Testing-mode trap: a BYO OAuth client in "Testing" status expires
|
|
57
|
+
// refresh tokens weekly; the dead token surfaces as invalid_grant. Name the
|
|
58
|
+
// real fix (publish to production) so the agent stops looping on re-auth.
|
|
59
|
+
// Checked before the generic 401 so it wins on either 400 or 401.
|
|
60
|
+
const rawError = error?.response?.data?.error;
|
|
61
|
+
const grantHaystack = [message, reason, typeof rawError === 'string' ? rawError : '', error?.response?.data?.error_description]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join(' ');
|
|
64
|
+
if ((status === 400 || status === 401) && /invalid_grant/i.test(grantHaystack)) {
|
|
65
|
+
return {
|
|
66
|
+
error: 'reauth_required',
|
|
67
|
+
message,
|
|
68
|
+
hint: `Refresh token for "${account}" is dead (often the 7-day Testing-mode trap). ` +
|
|
69
|
+
'Set Publishing status to In production at https://console.cloud.google.com/auth/audience, ' +
|
|
70
|
+
`then ${reauthHint(account)}`,
|
|
71
|
+
retriable: false,
|
|
72
|
+
account,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
18
75
|
if (status === 401) {
|
|
19
76
|
return {
|
|
20
77
|
error: 'auth_required',
|
|
21
78
|
message: `Authentication failed for account "${account}".`,
|
|
22
|
-
hint:
|
|
79
|
+
hint: reauthHint(account),
|
|
23
80
|
retriable: false,
|
|
24
81
|
account,
|
|
25
82
|
};
|
|
26
83
|
}
|
|
27
84
|
if (status === 403) {
|
|
85
|
+
// API not enabled for the project: a distinct, self-serve fix (enable the
|
|
86
|
+
// API) rather than a scope/permission dead-end.
|
|
87
|
+
const notEnabled = reason === 'accessNotConfigured' ||
|
|
88
|
+
reason === 'SERVICE_DISABLED' ||
|
|
89
|
+
/has not been used in project|accessNotConfigured|SERVICE_DISABLED|it is disabled/i.test(message);
|
|
90
|
+
if (notEnabled) {
|
|
91
|
+
const api = disabledApiId(message);
|
|
92
|
+
return {
|
|
93
|
+
error: 'api_not_enabled',
|
|
94
|
+
message,
|
|
95
|
+
hint: api
|
|
96
|
+
? `Enable this API for your project: ${apiEnableLink(api)}`
|
|
97
|
+
: 'Enable the API for your project at https://console.cloud.google.com/apis/library',
|
|
98
|
+
retriable: false,
|
|
99
|
+
account,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
28
102
|
const scopeIssue = reason === 'insufficientPermissions' ||
|
|
29
103
|
reason === 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' ||
|
|
30
104
|
/insufficient.*scope/i.test(message);
|
|
31
105
|
if (scopeIssue) {
|
|
106
|
+
// #114 three-state enrichment when the method's required scopes are
|
|
107
|
+
// known (escape hatch / generated tools): state-specific remediation and
|
|
108
|
+
// an honest retriable so agents stop retrying dead ends.
|
|
109
|
+
const enriched = scopeContext?.() ?? null;
|
|
32
110
|
return {
|
|
33
111
|
error: 'insufficient_scope',
|
|
34
112
|
message,
|
|
35
|
-
hint: forbiddenHint ?? `Re-auth "${account}" with the scope this operation needs.`,
|
|
36
|
-
retriable: false,
|
|
113
|
+
hint: enriched?.hint ?? forbiddenHint ?? `Re-auth "${account}" with the scope this operation needs.`,
|
|
114
|
+
retriable: enriched?.retriable ?? false,
|
|
37
115
|
account,
|
|
38
116
|
};
|
|
39
117
|
}
|
|
@@ -58,10 +136,23 @@ export function mapGoogleError(error, account, forbiddenHint) {
|
|
|
58
136
|
if (status !== undefined && status >= 500) {
|
|
59
137
|
return { error: 'upstream_error', message, retriable: true, account };
|
|
60
138
|
}
|
|
139
|
+
if (status === undefined) {
|
|
140
|
+
const netCode = netCodeOf(error);
|
|
141
|
+
if (netCode) {
|
|
142
|
+
return {
|
|
143
|
+
error: 'network_error',
|
|
144
|
+
message: message.includes(netCode) ? message : message.endsWith('reason: ') ? `${message}${netCode}` : `${message} (${netCode})`,
|
|
145
|
+
hint: `Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
|
|
146
|
+
'If it persists on a high-latency or broken-IPv6 link, raise the happy-eyeballs budget: NODE_OPTIONS=--network-family-autoselection-attempt-timeout=4000 (server default 2000ms), and check connectivity with curl.',
|
|
147
|
+
retriable: RETRIABLE_NET_CODES.has(netCode),
|
|
148
|
+
account,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
61
152
|
return { error: 'upstream_error', message, retriable: false, account };
|
|
62
153
|
}
|
|
63
|
-
export function handleGoogleApiError(error, account, forbiddenHint) {
|
|
64
|
-
const envelope = mapGoogleError(error, account, forbiddenHint);
|
|
154
|
+
export function handleGoogleApiError(error, account, forbiddenHint, scopeContext) {
|
|
155
|
+
const envelope = mapGoogleError(error, account, forbiddenHint, scopeContext);
|
|
65
156
|
return {
|
|
66
157
|
content: [{ type: 'text', text: JSON.stringify(envelope) }],
|
|
67
158
|
isError: true,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { ToolRegistry } from '../registry.js';
|
|
3
|
+
export interface AddForm {
|
|
4
|
+
alias: string;
|
|
5
|
+
email: string;
|
|
6
|
+
allBundles: boolean;
|
|
7
|
+
forms: boolean;
|
|
8
|
+
chat: boolean;
|
|
9
|
+
otherBundles: string;
|
|
10
|
+
admin: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Every optional bundle (admin is granted via its own checkbox, not here). */
|
|
13
|
+
export declare function allOptionalBundles(): string[];
|
|
14
|
+
/** elicitation/create form schema (flat primitives only, per the MCP spec). */
|
|
15
|
+
export declare function addFormSchema(): Record<string, unknown>;
|
|
16
|
+
export type AddValidation = {
|
|
17
|
+
ok: true;
|
|
18
|
+
alias: string;
|
|
19
|
+
email: string;
|
|
20
|
+
bundles: string[];
|
|
21
|
+
admin: boolean;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
slug: string;
|
|
25
|
+
message: string;
|
|
26
|
+
};
|
|
27
|
+
/** Validate the collected form against the alias rules, dup check, and the
|
|
28
|
+
* bundle catalog. Pure (no I/O) for unit testing. */
|
|
29
|
+
export declare function validateAddForm(input: Partial<AddForm>, existingAliases: string[]): AddValidation;
|
|
30
|
+
/** Scopes requested by the profile but NOT granted at consent (granular
|
|
31
|
+
* consent / unchecked bundles). Pure. */
|
|
32
|
+
export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
|
|
33
|
+
export declare function registerAccountWizardTools(registry: ToolRegistry, server: McpServer): void;
|