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
@@ -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, isRunnerTransportError } from '../runner.js';
5
- import type { GogArg, RunnerFailureKind } from '../runner.js';
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 matches the per-arg byte limit the Fly runner enforced BEFORE large
19
- // payloads could leave argv (the old MAX_ARG_LEN, 4096). That is deliberate:
20
- // every body that used to round-trip inline byte-for-byte still does, so this
21
- // change adds no trailing-newline regression for any body that already worked —
22
- // only bodies that previously exceeded the cap and hard-failed ("each arg must
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 Fly runner's cap and the Linux kernel's
33
- // MAX_ARG_STRLEN are both byte-based, so a multibyte-heavy body (CJK, emoji)
34
- // would slip past a `.length` check at up to 4x its real argv cost.
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
- // `??`, not `||`: 'transport-request' maps to the empty string on purpose —
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 runner even though each input is inside its own documented limit. That
129
- // is the same invisible-transport-rejection failure the ceiling exists to
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
- // connector-runtime sends every payload base64-encoded inside ONE JSON body,
180
- // and the Fly runner caps that body at MAX_BODY_BYTES. Base64 inflates by 4/3,
181
- // so a ceiling expressed in decoded bytes has to be derived from the wire cap
182
- // or it documents a size that gets rejected as "request body too large" — a
183
- // transport rejection from a layer the caller cannot see, which is the exact
184
- // failure the tool-layer check exists to prevent. A 25 MiB total encoded to
185
- // 34,952,536 chars against a 33,554,432 cap, so the limit was unreachable.
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(RUNNER_MAX_BODY_BYTES);
192
- expect(RUNNER_MAX_BODY_BYTES - MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeGreaterThanOrEqual(128 * 1024);
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, vi, afterEach } from 'vitest';
2
- import { uploadToBlobStore, RUNNER_UPLOAD_TIMEOUT_MS } from '../src/blob-upload.js';
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 PUT_URL = `https://host.example/b/reg_1/gmail/m1/Guest%20Copy.pdf?exp=1757000000000&sig=${SIG}`;
15
+ const CONTENT = Buffer.from('%PDF-1.7 twelve bytes and a few more');
8
16
 
9
- const ENV = { GOG_RUNNER_URL: 'https://runner.example', GOG_RUNNER_KEY: 'runner-key' };
17
+ interface Received {
18
+ method?: string;
19
+ headers: IncomingMessage['headers'];
20
+ body: Buffer;
21
+ }
10
22
 
11
- const REQUEST = { path: '/tmp/gog-attachments/m1/Guest_Copy.pdf', url: PUT_URL, contentType: 'application/pdf' };
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 answers(status: number, body: unknown, ok = status >= 200 && status < 300) {
14
- return vi.fn(async () => ({
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
- afterEach(() => {
22
- vi.unstubAllGlobals();
23
- vi.unstubAllEnvs();
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('POSTs the path, the URL and the content type to the runner, bearing its key', async () => {
28
- const fetchMock = answers(200, { ok: true, status: 200, bytes: 99723 });
29
- vi.stubGlobal('fetch', fetchMock);
30
-
31
- const outcome = await uploadToBlobStore(REQUEST, { env: { ...ENV, GOG_RUNNER_URL: 'https://runner.example//' } });
32
-
33
- expect(outcome).toEqual({ bytes: 99723, status: 200 });
34
- const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
35
- // Trailing slashes trimmed — `<endpoint>//upload` is a different route.
36
- expect(url).toBe('https://runner.example/upload');
37
- expect(init.method).toBe('POST');
38
- expect(init.headers).toMatchObject({
39
- Authorization: 'Bearer runner-key',
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
- it('refuses, without dialling, when no gog runner is configured', async () => {
54
- const fetchMock = answers(200, { ok: true });
55
- vi.stubGlobal('fetch', fetchMock);
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
- await expect(uploadToBlobStore(REQUEST, { env: { GOG_RUNNER_URL: ENV.GOG_RUNNER_URL } }))
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('reports the blob store\'s own verdict when the runner relays a refusal', async () => {
65
- vi.stubGlobal('fetch', answers(422, {
66
- error: 'the blob store refused the upload with 403: forbidden',
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
- it('names the runner\'s status when it answers with no error text', async () => {
76
- vi.stubGlobal('fetch', answers(502, { retryable: true }));
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
- await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/502/);
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
- it('survives an answer that is not JSON at all (a proxy\'s HTML error page)', async () => {
82
- vi.stubGlobal('fetch', answers(504, '<html>gateway timeout</html>'));
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
- // The body is quoted, not parsed — and the throw is about the upload, never
85
- // a SyntaxError from somewhere inside this module.
86
- await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/504/);
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
- // A signed URL is a credential with up to 24 h of anybody-who-holds-it access
90
- // to the object. The runner scrubs its own words, but the far side's text is
91
- // a third party's and may quote the request URL straight back — and an error
92
- // thrown here is the one value on this path that is expected to be logged.
93
- it('never lets the signed URL, or its bare signature, into the error it throws', async () => {
94
- vi.stubGlobal('fetch', answers(422, { error: `refused: PUT ${PUT_URL} (sig ${SIG})`, status: 403 }));
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('scrubs a transport failure too — a rejected fetch may quote the URL', async () => {
104
- vi.stubGlobal('fetch', vi.fn(async () => {
105
- throw new Error(`connect ECONNREFUSED while sending ${PUT_URL}`);
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
- const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
109
- expect((err as Error).message).not.toContain(PUT_URL);
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('reports a transport failure that threw something other than an Error', async () => {
115
- vi.stubGlobal('fetch', vi.fn(async () => { throw 'socket hang up'; }));
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
- await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/socket hang up/);
216
+ expect(err.message).toMatch(/did not finish within 50ms/);
118
217
  });
119
218
 
120
- // The response ARRIVES and then its body fails to read — a severed or
121
- // truncated answer. That is a third shape of transport failure, and the
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
- it('reports a body read that rejected with something other than an Error', async () => {
141
- vi.stubGlobal('fetch', vi.fn(async () => ({
142
- ok: true,
143
- status: 200,
144
- text: async () => { throw 'premature close'; },
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
- await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/premature close/);
148
- });
229
+ const err = await failure(uploadToBlobStore(request(), { root }));
149
230
 
150
- // The whole-string replacement is what does the work for a URL with no `sig`
151
- // at all — and a URL that is not parseable must still be redacted rather than
152
- // throwing on the way to a redaction.
153
- it('scrubs a URL that carries no signature and does not parse', async () => {
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
- const err = await uploadToBlobStore({ ...REQUEST, url: odd }, { env: ENV }).catch((e: Error) => e);
158
- expect((err as Error).message).not.toContain(odd);
159
- expect((err as Error).message).toContain('<signed url>');
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
- // `split('')` cuts a message into single characters and interleaves the
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
- const err = await uploadToBlobStore({ ...REQUEST, url: '' }, { env: ENV }).catch((e: Error) => e);
170
- expect((err as Error).message).toContain('the blob store refused the upload');
171
- expect((err as Error).message).not.toContain('<signed url>');
172
- });
244
+ expect(err.message).toMatch(/ECONNREFUSED/);
245
+ expect(err.message).not.toContain(SIG);
246
+ });
173
247
 
174
- it('reads the ambient environment, and its own deadline, when given neither', async () => {
175
- const fetchMock = answers(200, { ok: true, status: 200, bytes: 12 });
176
- vi.stubGlobal('fetch', fetchMock);
177
- vi.stubEnv('GOG_RUNNER_URL', ENV.GOG_RUNNER_URL);
178
- vi.stubEnv('GOG_RUNNER_KEY', ENV.GOG_RUNNER_KEY);
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
- await expect(uploadToBlobStore(REQUEST)).resolves.toEqual({ bytes: 12, status: 200 });
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
- it('exposes its deadline as a constant rather than a literal at the call site', () => {
185
- expect(RUNNER_UPLOAD_TIMEOUT_MS).toBeGreaterThan(30_000);
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
- // The rule this repo already applies at the other hop (`DEADLINE_GRACE_MS` in
189
- // connector-runtime.ts: 30 s backend budget + 5 s): the CALLER's deadline sits
190
- // ABOVE the backend's own, so the backend loses the race only when it
191
- // genuinely cannot answer. The doc block here cited that rule while the
192
- // numbers inverted it — 90 s against the runner's 120 s — so a socket that
193
- // went quiet was aborted on this side 30 s before the runner's own timer could
194
- // name it, turning "the upload timed out after 120000ms" into exactly the
195
- // opaque client abort the rule exists to prevent.
196
- //
197
- // Restated rather than imported: `fly-gog-runner/server.mjs` must not be
198
- // pulled into the Worker bundle (the same reason the attachment ceilings are
199
- // restated in attachments.ts), so the two move by hand and this is the guard.
200
- it('sits above the runner\'s own upload timeout, so the runner answers first', () => {
201
- const UPLOAD_TIMEOUT_MS_ON_THE_BOX = 120_000; // server.mjs UPLOAD_TIMEOUT_MS
202
- expect(RUNNER_UPLOAD_TIMEOUT_MS).toBeGreaterThan(UPLOAD_TIMEOUT_MS_ON_THE_BOX);
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
  });