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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +273 -748
- package/dist/lib.js +375 -785
- package/manifest.json +2 -2
- package/mint.yaml +37 -33
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/attachments.ts +28 -34
- package/src/blob-upload.ts +165 -134
- package/src/blob-urls.ts +3 -5
- package/src/bootstrap-auth.ts +97 -0
- package/src/index.ts +3 -4
- package/src/lib.ts +14 -9
- package/src/runner.ts +23 -175
- package/src/tools/appscript.ts +1 -1
- package/src/tools/auth.ts +5 -5
- package/src/tools/drive.ts +1 -3
- package/src/tools/gmail.ts +9 -9
- package/src/tools/utils.ts +12 -58
- package/tests/attachments.test.ts +11 -14
- package/tests/blob-upload.test.ts +235 -160
- package/tests/bootstrap-auth.test.ts +245 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +8 -95
- package/tests/sdk-single-copy.test.ts +4 -12
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +2 -2
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +6 -14
- package/tsconfig.json +1 -2
- package/vitest.config.ts +2 -12
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -319
- package/src/connector-login.ts +0 -87
- package/src/connector-runtime.ts +0 -910
- package/src/google-probe.ts +0 -113
- package/src/google-token.ts +0 -391
- package/src/remote-runner.ts +0 -77
- package/src/worker.ts +0 -117
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-login.test.ts +0 -151
- package/tests/connector-runtime.test.ts +0 -1664
- package/tests/google-probe.test.ts +0 -116
- package/tests/google-token.test.ts +0 -425
- package/tests/remote-runner.test.ts +0 -202
- package/tests/worker.test.ts +0 -167
package/src/tools/utils.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
|
|
3
3
|
import { errorResult, rawTextResult, minifiedResult, stripMediaUrls } from '@chrischall/mcp-utils';
|
|
4
|
-
import { run
|
|
5
|
-
import type { GogArg
|
|
4
|
+
import { run } from '../runner.js';
|
|
5
|
+
import type { GogArg } from '../runner.js';
|
|
6
6
|
import { normalizeTimestamps } from '../timestamps.js';
|
|
7
7
|
import { stripConsumedPageToken } from '../pagination.js';
|
|
8
8
|
|
|
@@ -15,23 +15,20 @@ import { stripConsumedPageToken } from '../pagination.js';
|
|
|
15
15
|
// "\n" cannot round-trip byte-for-byte through the file path. Second, the file
|
|
16
16
|
// path costs a temp dir, a write, and a delete per call.
|
|
17
17
|
//
|
|
18
|
-
// The value
|
|
19
|
-
// payloads could leave argv
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// be at most 4096 chars") now take the file path and its newline trim. The
|
|
24
|
-
// runner's plain-arg cap is now 64 KiB, so a 4096-byte inline value is nowhere
|
|
25
|
-
// near being rejected.
|
|
18
|
+
// The value is the historical per-arg cap (4096) that applied BEFORE large
|
|
19
|
+
// payloads could leave argv. That is deliberate: every body that used to
|
|
20
|
+
// round-trip inline byte-for-byte still does, so there is no trailing-newline
|
|
21
|
+
// regression for any body that already worked — only bodies that previously
|
|
22
|
+
// exceeded the cap and hard-failed now take the file path and its newline trim.
|
|
26
23
|
export const PAYLOAD_INLINE_MAX = 4096;
|
|
27
24
|
|
|
28
25
|
// The ONE place the inline-vs-file decision is made. Every tool that has a
|
|
29
26
|
// gog `--x` / `--x-file` flag pair routes its value through here so the
|
|
30
27
|
// threshold cannot drift between tools.
|
|
31
28
|
//
|
|
32
|
-
// Measures BYTES, not characters: the
|
|
33
|
-
//
|
|
34
|
-
//
|
|
29
|
+
// Measures BYTES, not characters: the Linux kernel's MAX_ARG_STRLEN is
|
|
30
|
+
// byte-based, so a multibyte-heavy body (CJK, emoji) would slip past a
|
|
31
|
+
// `.length` check at up to 4x its real argv cost.
|
|
35
32
|
export function payloadArg(
|
|
36
33
|
inlineFlag: string,
|
|
37
34
|
fileFlag: string,
|
|
@@ -236,31 +233,6 @@ const GRID_LIMIT_HINT =
|
|
|
236
233
|
'\n\nThe target range is outside the sheet\'s current grid. Add the missing rows or columns ' +
|
|
237
234
|
'first with gog_sheets_insert (dimension: rows or cols), then retry the write.';
|
|
238
235
|
|
|
239
|
-
// The hint for each RUNNER-authored failure kind (see RunnerTransportError in
|
|
240
|
-
// runner.ts). These are chosen by the error's TYPE, never by reading its text.
|
|
241
|
-
//
|
|
242
|
-
// transport-auth is the one that motivated all of this. The runner answers a
|
|
243
|
-
// bad bearer with the single word "unauthorized"; read as prose that is
|
|
244
|
-
// indistinguishable from Google rejecting a credential, and the caller was
|
|
245
|
-
// being told all session to re-authorize an account that had never been asked
|
|
246
|
-
// for anything. So this hint names the real cause and says outright that
|
|
247
|
-
// re-authorizing cannot help. It deliberately does NOT contain the literal
|
|
248
|
-
// `gog_auth_add`, which is the token the rest of the auth guidance keys on.
|
|
249
|
-
const RUNNER_TRANSPORT_AUTH_HINT =
|
|
250
|
-
'\n\nThis is the CONNECTOR\'s own transport auth failing, not your Google sign-in. The gog-runner ' +
|
|
251
|
-
'backend rejected the bearer token this server sent, so the request never reached gog and no Google ' +
|
|
252
|
-
'credential was checked — the Google account is not the problem and re-authorizing it cannot fix this. ' +
|
|
253
|
-
'An operator must make the Worker secret GOG_RUNNER_KEY equal RUNNER_KEY on the Fly app ' +
|
|
254
|
-
'(wrangler secret put GOG_RUNNER_KEY / fly secrets set RUNNER_KEY), then retry.';
|
|
255
|
-
|
|
256
|
-
const RUNNER_TRANSPORT_HINTS: Record<RunnerFailureKind, string> = {
|
|
257
|
-
'transport-auth': RUNNER_TRANSPORT_AUTH_HINT,
|
|
258
|
-
// The request itself was malformed, so the runner will refuse it identically
|
|
259
|
-
// every time. Nothing to advise beyond the message the runner already gave.
|
|
260
|
-
'transport-request': '',
|
|
261
|
-
'transport-retryable': TRANSIENT_HINT,
|
|
262
|
-
};
|
|
263
|
-
|
|
264
236
|
// Reduce `gog auth list --json` output to just the configured email addresses.
|
|
265
237
|
// The raw JSON also carries OAuth scopes, the Google subject id, and creation
|
|
266
238
|
// timestamps — none of which belong in an error surfaced to the model, and
|
|
@@ -291,21 +263,6 @@ export function formatAccountList(raw: string): string {
|
|
|
291
263
|
export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
292
264
|
const errText = errorText(err);
|
|
293
265
|
|
|
294
|
-
// STRUCTURE BEFORE PROSE. A RunnerTransportError is this connector's own
|
|
295
|
-
// transport failing — its bearer, its request validation, its drain. Nothing
|
|
296
|
-
// was shown to Google, so none of the patterns below may be consulted for it:
|
|
297
|
-
// they exist to read gog's/Google's words, and the runner's words are not
|
|
298
|
-
// those. Read as prose, the runner's `unauthorized` matched
|
|
299
|
-
// DEFINITE_AUTH_PATTERN and produced AUTH_HINT — a human being told to
|
|
300
|
-
// re-authorize a healthy account over what was really a key mismatch.
|
|
301
|
-
//
|
|
302
|
-
// This short-circuits the ladder rather than joining it, so it is not a new
|
|
303
|
-
// rung in the precedence order documented below; that order still governs
|
|
304
|
-
// every error that genuinely came from gog.
|
|
305
|
-
const transportHint = isRunnerTransportError(err)
|
|
306
|
-
? RUNNER_TRANSPORT_HINTS[err.kind]
|
|
307
|
-
: undefined;
|
|
308
|
-
|
|
309
266
|
const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
|
|
310
267
|
|
|
311
268
|
// Precedence, and the reason for it. Reporting needs-auth is EXPENSIVE to be
|
|
@@ -321,10 +278,7 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
|
321
278
|
const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
322
279
|
const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
|
|
323
280
|
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
324
|
-
|
|
325
|
-
// "this failure is ours and there is nothing to advise" — and `||` would fall
|
|
326
|
-
// through to the prose ladder for exactly the errors that must never reach it.
|
|
327
|
-
const hint = transportHint ?? (isInvalidGrant
|
|
281
|
+
const hint = isInvalidGrant
|
|
328
282
|
? INVALID_GRANT_HINT
|
|
329
283
|
: isAuthError
|
|
330
284
|
? AUTH_HINT
|
|
@@ -332,7 +286,7 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
|
332
286
|
? TRANSIENT_HINT
|
|
333
287
|
: isGridLimitError
|
|
334
288
|
? GRID_LIMIT_HINT
|
|
335
|
-
: ''
|
|
289
|
+
: '';
|
|
336
290
|
try {
|
|
337
291
|
const accounts = formatAccountList(await run(['auth', 'list']));
|
|
338
292
|
return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
|
|
@@ -125,9 +125,8 @@ describe('inlineAttachmentArgs', () => {
|
|
|
125
125
|
// The budget belongs to the REQUEST, not to the attachments. `payloadArg`
|
|
126
126
|
// turns any body over 4 KiB into a GogFileArg that rides in the same JSON
|
|
127
127
|
// body at ~1:1, so a near-max attachment set plus a multi-MiB body overruns
|
|
128
|
-
// the
|
|
129
|
-
//
|
|
130
|
-
// prevent, so the sibling args are measured rather than assumed small.
|
|
128
|
+
// the budget even though each input is inside its own documented limit, so
|
|
129
|
+
// the sibling args are measured rather than assumed small.
|
|
131
130
|
it('counts the message body against the same budget as the attachments', () => {
|
|
132
131
|
// Three files just under the 8 MiB per-file cap, summing to just under the
|
|
133
132
|
// per-message total — i.e. every input inside its own documented limit.
|
|
@@ -176,20 +175,18 @@ describe('inlineAttachmentArgs', () => {
|
|
|
176
175
|
|
|
177
176
|
// THE INVARIANT behind the per-message ceiling, asserted rather than trusted.
|
|
178
177
|
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
it('keeps a full message under the Fly runner request-body cap once base64-inflated', () => {
|
|
187
|
-
const RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024; // fly-gog-runner/server.mjs
|
|
178
|
+
// Every payload rides base64-encoded inside ONE JSON body, capped at a 32 MiB
|
|
179
|
+
// request budget. Base64 inflates by 4/3, so a ceiling expressed in decoded
|
|
180
|
+
// bytes has to be derived from the wire cap or it documents a size that cannot
|
|
181
|
+
// actually be sent. A 25 MiB total encoded to 34,952,536 chars against a
|
|
182
|
+
// 33,554,432 cap, so the limit was unreachable.
|
|
183
|
+
it('keeps a full message under the 32 MiB request budget once base64-inflated', () => {
|
|
184
|
+
const REQUEST_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
188
185
|
const encodedLength = (decoded: number): number => 4 * Math.ceil(decoded / 3);
|
|
189
186
|
|
|
190
187
|
// The payload budget must leave the JSON structure room inside the cap…
|
|
191
|
-
expect(MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeLessThan(
|
|
192
|
-
expect(
|
|
188
|
+
expect(MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeLessThan(REQUEST_MAX_BODY_BYTES);
|
|
189
|
+
expect(REQUEST_MAX_BODY_BYTES - MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeGreaterThanOrEqual(128 * 1024);
|
|
193
190
|
// …and a full attachment set must fit inside that budget once inflated.
|
|
194
191
|
expect(encodedLength(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)).toBeLessThanOrEqual(MAX_REQUEST_PAYLOAD_WIRE_BYTES);
|
|
195
192
|
// The advertised number must itself be sendable — floor, not round.
|
|
@@ -1,204 +1,279 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
3
|
+
import type { AddressInfo } from 'node:net';
|
|
4
|
+
import { mkdtemp, mkdir, writeFile, symlink, truncate, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
uploadToBlobStore,
|
|
9
|
+
ATTACHMENT_DOWNLOAD_ROOT,
|
|
10
|
+
BLOB_UPLOAD_TIMEOUT_MS,
|
|
11
|
+
MAX_BLOB_UPLOAD_BYTES,
|
|
12
|
+
} from '../src/blob-upload.js';
|
|
3
13
|
|
|
4
|
-
// A minted PUT URL, with the two shapes the scrubber has to recognise: the
|
|
5
|
-
// whole string, and the bare signature it carries.
|
|
6
14
|
const SIG = 'AbCdEf-_1234';
|
|
7
|
-
const
|
|
15
|
+
const CONTENT = Buffer.from('%PDF-1.7 twelve bytes and a few more');
|
|
8
16
|
|
|
9
|
-
|
|
17
|
+
interface Received {
|
|
18
|
+
method?: string;
|
|
19
|
+
headers: IncomingMessage['headers'];
|
|
20
|
+
body: Buffer;
|
|
21
|
+
}
|
|
10
22
|
|
|
11
|
-
|
|
23
|
+
let server: Server;
|
|
24
|
+
let base: string;
|
|
25
|
+
let received: Received[];
|
|
26
|
+
let handler: (req: IncomingMessage, res: ServerResponse, body: Buffer) => void;
|
|
27
|
+
let root: string;
|
|
28
|
+
let outside: string;
|
|
12
29
|
|
|
13
|
-
function
|
|
14
|
-
return
|
|
15
|
-
ok,
|
|
16
|
-
status,
|
|
17
|
-
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
18
|
-
}));
|
|
30
|
+
function signedUrl(path = '/b/reg_1/gmail/m1/a1/Guest%20Copy.pdf'): string {
|
|
31
|
+
return `${base}${path}?exp=1757000000000&sig=${SIG}`;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
received = [];
|
|
36
|
+
handler = (_req, res) => { res.writeHead(200); res.end(); };
|
|
37
|
+
server = createServer((req, res) => {
|
|
38
|
+
const chunks: Buffer[] = [];
|
|
39
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
40
|
+
req.on('end', () => {
|
|
41
|
+
const body = Buffer.concat(chunks);
|
|
42
|
+
received.push({ method: req.method, headers: req.headers, body });
|
|
43
|
+
handler(req, res, body);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
47
|
+
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
48
|
+
|
|
49
|
+
const scratch = await mkdtemp(join(tmpdir(), 'blob-upload-'));
|
|
50
|
+
root = join(scratch, 'gog-attachments');
|
|
51
|
+
outside = join(scratch, 'outside');
|
|
52
|
+
await mkdir(join(root, 'm1', 'a1'), { recursive: true });
|
|
53
|
+
await mkdir(outside);
|
|
54
|
+
await writeFile(join(root, 'm1', 'a1', 'Guest_Copy.pdf'), CONTENT);
|
|
55
|
+
await writeFile(join(outside, 'refresh-token'), 'secret');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
server.closeAllConnections();
|
|
60
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
61
|
+
await rm(join(root, '..'), { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const file = () => join(root, 'm1', 'a1', 'Guest_Copy.pdf');
|
|
65
|
+
const request = (overrides: Partial<{ path: string; url: string; contentType: string }> = {}) => ({
|
|
66
|
+
path: file(),
|
|
67
|
+
url: signedUrl(),
|
|
68
|
+
contentType: 'application/pdf',
|
|
69
|
+
...overrides,
|
|
24
70
|
});
|
|
71
|
+
const failure = (promise: Promise<unknown>) => promise.then(
|
|
72
|
+
() => { throw new Error('expected the upload to fail'); },
|
|
73
|
+
(err: Error) => err,
|
|
74
|
+
);
|
|
25
75
|
|
|
26
76
|
describe('uploadToBlobStore', () => {
|
|
27
|
-
it('
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
expect(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
expect(
|
|
39
|
-
|
|
40
|
-
'Content-Type': 'application/json',
|
|
41
|
-
});
|
|
42
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
43
|
-
path: REQUEST.path,
|
|
44
|
-
url: PUT_URL,
|
|
45
|
-
contentType: 'application/pdf',
|
|
46
|
-
});
|
|
47
|
-
// A deadline of our own: the runner's 120 s is an INACTIVITY timer and
|
|
48
|
-
// cannot bound a transfer that keeps dribbling, so nothing else protects
|
|
49
|
-
// the MCP caller from a request that never ends.
|
|
50
|
-
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
77
|
+
it('PUTs the file to the signed URL and reports the size streamed and the status', async () => {
|
|
78
|
+
handler = (_req, res) => { res.writeHead(201); res.end(); };
|
|
79
|
+
|
|
80
|
+
const outcome = await uploadToBlobStore(request(), { root });
|
|
81
|
+
|
|
82
|
+
expect(outcome).toEqual({ bytes: CONTENT.length, status: 201 });
|
|
83
|
+
expect(received).toHaveLength(1);
|
|
84
|
+
expect(received[0].method).toBe('PUT');
|
|
85
|
+
expect(received[0].body.equals(CONTENT)).toBe(true);
|
|
86
|
+
// Content-Length from the file's own size — a chunked PUT is a 411 at the
|
|
87
|
+
// gateway, so this must never be left to the transport.
|
|
88
|
+
expect(received[0].headers['content-length']).toBe(String(CONTENT.length));
|
|
89
|
+
expect(received[0].headers['transfer-encoding']).toBeUndefined();
|
|
51
90
|
});
|
|
52
91
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
92
|
+
// The PUT signature commits to the content type, so any normalisation —
|
|
93
|
+
// re-casing, a charset appended, a default substituted — is a refusal that
|
|
94
|
+
// reads like a missing object.
|
|
95
|
+
it('sends the content type byte for byte as given', async () => {
|
|
96
|
+
await uploadToBlobStore(request({ contentType: 'Application/PDF; Name="x"' }), { root });
|
|
56
97
|
|
|
57
|
-
|
|
58
|
-
.rejects.toThrow(/GOG_RUNNER_URL.*GOG_RUNNER_KEY/s);
|
|
59
|
-
await expect(uploadToBlobStore(REQUEST, { env: { GOG_RUNNER_KEY: ENV.GOG_RUNNER_KEY } }))
|
|
60
|
-
.rejects.toThrow(/GOG_RUNNER_URL.*GOG_RUNNER_KEY/s);
|
|
61
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
98
|
+
expect(received[0].headers['content-type']).toBe('Application/PDF; Name="x"');
|
|
62
99
|
});
|
|
63
100
|
|
|
64
|
-
it('
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
status: 403,
|
|
68
|
-
retryable: false,
|
|
69
|
-
}));
|
|
70
|
-
|
|
71
|
-
await expect(uploadToBlobStore(REQUEST, { env: ENV }))
|
|
72
|
-
.rejects.toThrow(/the blob store refused the upload with 403: forbidden/);
|
|
101
|
+
it('accepts a path relative to the root', async () => {
|
|
102
|
+
await expect(uploadToBlobStore(request({ path: 'm1/a1/Guest_Copy.pdf' }), { root }))
|
|
103
|
+
.resolves.toEqual({ bytes: CONTENT.length, status: 200 });
|
|
73
104
|
});
|
|
74
105
|
|
|
75
|
-
|
|
76
|
-
|
|
106
|
+
describe('confinement to the download root', () => {
|
|
107
|
+
it('refuses a path that walks out of the root, without dialling', async () => {
|
|
108
|
+
const err = await failure(uploadToBlobStore(
|
|
109
|
+
request({ path: join(root, '..', 'outside', 'refresh-token') }), { root },
|
|
110
|
+
));
|
|
111
|
+
|
|
112
|
+
expect(err.message).toMatch(/must be inside/);
|
|
113
|
+
expect(received).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('refuses a sibling that merely shares the root as a prefix', async () => {
|
|
117
|
+
await mkdir(`${root}-evil`);
|
|
118
|
+
await writeFile(`${root}-evil/x`, 'nope');
|
|
119
|
+
|
|
120
|
+
const err = await failure(uploadToBlobStore(request({ path: `${root}-evil/x` }), { root }));
|
|
121
|
+
|
|
122
|
+
expect(err.message).toMatch(/must be inside/);
|
|
123
|
+
expect(received).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('refuses a symlink inside the root that points outside it', async () => {
|
|
127
|
+
await symlink(join(outside, 'refresh-token'), join(root, 'm1', 'escape'));
|
|
128
|
+
|
|
129
|
+
const err = await failure(uploadToBlobStore(request({ path: join(root, 'm1', 'escape') }), { root }));
|
|
130
|
+
|
|
131
|
+
expect(err.message).toMatch(/must be inside/);
|
|
132
|
+
expect(received).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('refuses a directory', async () => {
|
|
136
|
+
const err = await failure(uploadToBlobStore(request({ path: join(root, 'm1') }), { root }));
|
|
137
|
+
|
|
138
|
+
expect(err.message).toMatch(/regular file/);
|
|
139
|
+
expect(received).toHaveLength(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('says the file is missing, rather than failing somewhere later', async () => {
|
|
143
|
+
const err = await failure(uploadToBlobStore(request({ path: join(root, 'm1', 'gone.pdf') }), { root }));
|
|
144
|
+
|
|
145
|
+
expect(err.message).toMatch(/could not be found/);
|
|
146
|
+
expect(received).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('confines to the real attachment root when no root is given', async () => {
|
|
150
|
+
expect(ATTACHMENT_DOWNLOAD_ROOT).toBe('/tmp/gog-attachments');
|
|
77
151
|
|
|
78
|
-
|
|
152
|
+
const err = await failure(uploadToBlobStore(request({ path: '/etc/passwd' })));
|
|
153
|
+
|
|
154
|
+
expect(err.message).toContain('must be inside /tmp/gog-attachments');
|
|
155
|
+
expect(received).toHaveLength(0);
|
|
156
|
+
});
|
|
79
157
|
});
|
|
80
158
|
|
|
81
|
-
|
|
82
|
-
|
|
159
|
+
// mcp-host's blob store answers 413 past 100 MiB, but only after the whole
|
|
160
|
+
// transfer has run — and a caller reads a late size error as a signing one.
|
|
161
|
+
it('refuses a file over the blob store\'s ceiling before dialling', async () => {
|
|
162
|
+
expect(MAX_BLOB_UPLOAD_BYTES).toBe(100 * 1024 * 1024);
|
|
163
|
+
const big = join(root, 'm1', 'a1', 'huge.bin');
|
|
164
|
+
await writeFile(big, '');
|
|
165
|
+
await truncate(big, MAX_BLOB_UPLOAD_BYTES + 1); // sparse: no 100 MiB write
|
|
83
166
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
167
|
+
const err = await failure(uploadToBlobStore(request({ path: big }), { root }));
|
|
168
|
+
|
|
169
|
+
expect(err.message).toContain(`${MAX_BLOB_UPLOAD_BYTES + 1} bytes`);
|
|
170
|
+
expect(err.message).toContain(String(MAX_BLOB_UPLOAD_BYTES));
|
|
171
|
+
expect(received).toHaveLength(0);
|
|
87
172
|
});
|
|
88
173
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
|
|
97
|
-
expect(err).toBeInstanceOf(Error);
|
|
98
|
-
expect((err as Error).message).not.toContain(PUT_URL);
|
|
99
|
-
expect((err as Error).message).not.toContain(SIG);
|
|
100
|
-
expect((err as Error).message).toContain('<signed url>');
|
|
174
|
+
it('refuses a URL that is not absolute http(s), without dialling', async () => {
|
|
175
|
+
for (const url of ['not a url at all', 'ftp://blob.example/x', '']) {
|
|
176
|
+
const err = await failure(uploadToBlobStore(request({ url }), { root }));
|
|
177
|
+
expect(err.message).toMatch(/absolute http\(s\) URL/);
|
|
178
|
+
}
|
|
179
|
+
expect(received).toHaveLength(0);
|
|
101
180
|
});
|
|
102
181
|
|
|
103
|
-
it('
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
182
|
+
it('throws with the blob store\'s status and a bounded quote of its answer', async () => {
|
|
183
|
+
handler = (_req, res) => {
|
|
184
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
185
|
+
res.end(JSON.stringify({ error: 'signature does not verify' }));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const err = await failure(uploadToBlobStore(request(), { root }));
|
|
107
189
|
|
|
108
|
-
|
|
109
|
-
expect(
|
|
110
|
-
expect((err as Error).message).not.toContain(SIG);
|
|
111
|
-
expect((err as Error).message).toContain('ECONNREFUSED');
|
|
190
|
+
expect(err.message).toMatch(/403/);
|
|
191
|
+
expect(err.message).toContain('signature does not verify');
|
|
112
192
|
});
|
|
113
193
|
|
|
114
|
-
it('
|
|
115
|
-
|
|
194
|
+
it('quotes no more than a snippet of a long error body', async () => {
|
|
195
|
+
handler = (_req, res) => {
|
|
196
|
+
res.writeHead(500);
|
|
197
|
+
res.write('x'.repeat(400));
|
|
198
|
+
setTimeout(() => {
|
|
199
|
+
res.write('y'.repeat(400));
|
|
200
|
+
setTimeout(() => res.end('z'.repeat(400)), 20);
|
|
201
|
+
}, 20);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const err = await failure(uploadToBlobStore(request(), { root }));
|
|
205
|
+
|
|
206
|
+
expect(err.message).toMatch(/500/);
|
|
207
|
+
expect(err.message).not.toContain('z');
|
|
208
|
+
expect(err.message.length).toBeLessThan(1000);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('gives up at its total deadline rather than hanging the MCP request', async () => {
|
|
212
|
+
handler = () => { /* never answers */ };
|
|
213
|
+
|
|
214
|
+
const err = await failure(uploadToBlobStore(request(), { root, timeoutMs: 50 }));
|
|
116
215
|
|
|
117
|
-
|
|
216
|
+
expect(err.message).toMatch(/did not finish within 50ms/);
|
|
118
217
|
});
|
|
119
218
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// module's contract is about every one of them: nothing it throws carries the
|
|
123
|
-
// URL. undici's own body errors ('terminated', 'Premature close') happen to
|
|
124
|
-
// name nothing, so this is the guarantee being a property of the function
|
|
125
|
-
// rather than a fact about today's runtime.
|
|
126
|
-
it('scrubs a body that fails to read, as it scrubs a failed dial', async () => {
|
|
127
|
-
vi.stubGlobal('fetch', vi.fn(async () => ({
|
|
128
|
-
ok: true,
|
|
129
|
-
status: 200,
|
|
130
|
-
text: async () => { throw new Error(`terminated while reading ${PUT_URL}`); },
|
|
131
|
-
})));
|
|
132
|
-
|
|
133
|
-
const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
|
|
134
|
-
expect(err).toBeInstanceOf(Error);
|
|
135
|
-
expect((err as Error).message).not.toContain(PUT_URL);
|
|
136
|
-
expect((err as Error).message).not.toContain(SIG);
|
|
137
|
-
expect((err as Error).message).toContain('terminated');
|
|
219
|
+
it('keeps a deadline of its own by default', () => {
|
|
220
|
+
expect(BLOB_UPLOAD_TIMEOUT_MS).toBeGreaterThan(30_000);
|
|
138
221
|
});
|
|
139
222
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
})));
|
|
223
|
+
// A signed URL is a credential with up to 24 h of anybody-who-holds-it access
|
|
224
|
+
// to the object, and everything thrown here is expected to be logged.
|
|
225
|
+
describe('never lets the signed URL or its bare signature into a message', () => {
|
|
226
|
+
it('scrubs the blob store quoting the request back', async () => {
|
|
227
|
+
handler = (req, res) => { res.writeHead(403); res.end(`refused PUT ${base}${req.url} (sig ${SIG})`); };
|
|
146
228
|
|
|
147
|
-
|
|
148
|
-
});
|
|
229
|
+
const err = await failure(uploadToBlobStore(request(), { root }));
|
|
149
230
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const odd = 'not a url at all';
|
|
155
|
-
vi.stubGlobal('fetch', answers(422, { error: `refused: ${odd}`, status: 400 }));
|
|
231
|
+
expect(err.message).not.toContain(signedUrl());
|
|
232
|
+
expect(err.message).not.toContain(SIG);
|
|
233
|
+
expect(err.message).toContain('<signed url>');
|
|
234
|
+
});
|
|
156
235
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
236
|
+
it('scrubs a transport failure', async () => {
|
|
237
|
+
server.closeAllConnections();
|
|
238
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
239
|
+
server = createServer();
|
|
240
|
+
server.listen(0);
|
|
161
241
|
|
|
162
|
-
|
|
163
|
-
// replacement between every one of them, so an empty needle does not redact a
|
|
164
|
-
// message, it destroys it. Today's one call site always mints a URL first —
|
|
165
|
-
// this is a property of the function, which is why it is asserted on it.
|
|
166
|
-
it('an empty URL leaves the message intact rather than shredding it', async () => {
|
|
167
|
-
vi.stubGlobal('fetch', answers(422, { error: 'the blob store refused the upload', status: 403 }));
|
|
242
|
+
const err = await failure(uploadToBlobStore(request(), { root }));
|
|
168
243
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
244
|
+
expect(err.message).toMatch(/ECONNREFUSED/);
|
|
245
|
+
expect(err.message).not.toContain(SIG);
|
|
246
|
+
});
|
|
173
247
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
248
|
+
it('scrubs a response severed mid-body', async () => {
|
|
249
|
+
handler = (_req, res) => {
|
|
250
|
+
res.writeHead(403, { 'content-length': '1000' });
|
|
251
|
+
res.write('partial', () => res.socket?.destroy());
|
|
252
|
+
};
|
|
179
253
|
|
|
180
|
-
|
|
181
|
-
expect((fetchMock.mock.calls[0] as [string, RequestInit])[0]).toBe('https://runner.example/upload');
|
|
182
|
-
});
|
|
254
|
+
const err = await failure(uploadToBlobStore(request(), { root }));
|
|
183
255
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
256
|
+
expect(err.message).toMatch(/upload to the blob store did not complete/);
|
|
257
|
+
expect(err.message).not.toContain(SIG);
|
|
258
|
+
});
|
|
187
259
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
260
|
+
it('scrubs a TLS failure on an https URL', async () => {
|
|
261
|
+
const url = signedUrl().replace('http:', 'https:');
|
|
262
|
+
|
|
263
|
+
const err = await failure(uploadToBlobStore(request({ url }), { root }));
|
|
264
|
+
|
|
265
|
+
expect(err.message).toMatch(/upload to the blob store did not complete/);
|
|
266
|
+
expect(err.message).not.toContain(url);
|
|
267
|
+
expect(err.message).not.toContain(SIG);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// `split('')` would cut the message into characters and interleave the
|
|
271
|
+
// replacement between every one of them.
|
|
272
|
+
it('leaves a message intact when the URL is empty', async () => {
|
|
273
|
+
const err = await failure(uploadToBlobStore(request({ url: '' }), { root }));
|
|
274
|
+
|
|
275
|
+
expect(err.message).not.toContain('<signed url>');
|
|
276
|
+
expect(err.message).toMatch(/absolute http\(s\) URL/);
|
|
277
|
+
});
|
|
203
278
|
});
|
|
204
279
|
});
|