gogcli-mcp 2.30.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 (64) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +15345 -18417
  4. package/dist/lib.js +13720 -9481
  5. package/manifest.json +2 -2
  6. package/mint.yaml +37 -33
  7. package/package.json +5 -5
  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/gmail-dispatch-guard.ts +106 -0
  14. package/src/gmail-results.ts +1 -1
  15. package/src/index.ts +3 -4
  16. package/src/lib.ts +24 -9
  17. package/src/pagination.ts +1 -1
  18. package/src/runner.ts +23 -175
  19. package/src/tools/api.ts +7 -7
  20. package/src/tools/appscript.ts +16 -16
  21. package/src/tools/auth.ts +16 -16
  22. package/src/tools/calendar.ts +13 -13
  23. package/src/tools/chat.ts +25 -25
  24. package/src/tools/classroom.ts +49 -49
  25. package/src/tools/contacts.ts +9 -9
  26. package/src/tools/docs.ts +13 -13
  27. package/src/tools/drive.ts +23 -25
  28. package/src/tools/gmail.ts +145 -32
  29. package/src/tools/sheets.ts +15 -15
  30. package/src/tools/slides.ts +13 -13
  31. package/src/tools/tasks.ts +13 -13
  32. package/src/tools/utils.ts +14 -61
  33. package/tests/attachments.test.ts +11 -14
  34. package/tests/blob-upload.test.ts +235 -160
  35. package/tests/bootstrap-auth.test.ts +245 -0
  36. package/tests/gmail-dispatch-guard.test.ts +132 -0
  37. package/tests/runner-file-args.test.ts +1 -13
  38. package/tests/runner.test.ts +8 -95
  39. package/tests/sdk-single-copy.test.ts +11 -37
  40. package/tests/tools/appscript.test.ts +1 -1
  41. package/tests/tools/auth-401-shapes.test.ts +2 -3
  42. package/tests/tools/auth.test.ts +5 -4
  43. package/tests/tools/chat.test.ts +1 -1
  44. package/tests/tools/drive.test.ts +11 -3
  45. package/tests/tools/gmail.test.ts +244 -14
  46. package/tests/tools/sheets.test.ts +1 -1
  47. package/tests/tools/utils.test.ts +1 -50
  48. package/tests/zod-single-copy.test.ts +8 -16
  49. package/tsconfig.json +1 -4
  50. package/vitest.config.ts +2 -14
  51. package/src/auth-log.ts +0 -205
  52. package/src/connector-auth.ts +0 -303
  53. package/src/connector-runtime.ts +0 -887
  54. package/src/google-probe.ts +0 -113
  55. package/src/google-token.ts +0 -391
  56. package/src/remote-runner.ts +0 -77
  57. package/src/worker.ts +0 -129
  58. package/tests/auth-log.test.ts +0 -530
  59. package/tests/connector-auth.test.ts +0 -559
  60. package/tests/connector-runtime.test.ts +0 -1644
  61. package/tests/google-probe.test.ts +0 -116
  62. package/tests/google-token.test.ts +0 -425
  63. package/tests/remote-runner.test.ts +0 -202
  64. package/tests/worker.test.ts +0 -167
@@ -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
  });