gogcli-mcp 2.29.0 → 2.30.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 +7582 -2381
- package/dist/lib.js +7575 -2277
- package/manifest.json +1 -1
- package/mint.yaml +2 -2
- package/package.json +4 -4
- package/server.json +2 -2
- package/src/blob-upload.ts +179 -0
- package/src/blob-urls.ts +280 -0
- package/src/lib.ts +19 -0
- package/src/runner.ts +19 -2
- package/src/worker.ts +1 -1
- package/tests/blob-upload.test.ts +204 -0
- package/tests/blob-urls.test.ts +319 -0
- package/tests/runner.test.ts +62 -1
- package/tests/zod-single-copy.test.ts +56 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { uploadToBlobStore, RUNNER_UPLOAD_TIMEOUT_MS } from '../src/blob-upload.js';
|
|
3
|
+
|
|
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
|
+
const SIG = 'AbCdEf-_1234';
|
|
7
|
+
const PUT_URL = `https://host.example/b/reg_1/gmail/m1/Guest%20Copy.pdf?exp=1757000000000&sig=${SIG}`;
|
|
8
|
+
|
|
9
|
+
const ENV = { GOG_RUNNER_URL: 'https://runner.example', GOG_RUNNER_KEY: 'runner-key' };
|
|
10
|
+
|
|
11
|
+
const REQUEST = { path: '/tmp/gog-attachments/m1/Guest_Copy.pdf', url: PUT_URL, contentType: 'application/pdf' };
|
|
12
|
+
|
|
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
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
vi.unstubAllEnvs();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
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);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('refuses, without dialling, when no gog runner is configured', async () => {
|
|
54
|
+
const fetchMock = answers(200, { ok: true });
|
|
55
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
56
|
+
|
|
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();
|
|
62
|
+
});
|
|
63
|
+
|
|
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/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('names the runner\'s status when it answers with no error text', async () => {
|
|
76
|
+
vi.stubGlobal('fetch', answers(502, { retryable: true }));
|
|
77
|
+
|
|
78
|
+
await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/502/);
|
|
79
|
+
});
|
|
80
|
+
|
|
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>'));
|
|
83
|
+
|
|
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/);
|
|
87
|
+
});
|
|
88
|
+
|
|
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>');
|
|
101
|
+
});
|
|
102
|
+
|
|
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
|
+
}));
|
|
107
|
+
|
|
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');
|
|
112
|
+
});
|
|
113
|
+
|
|
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'; }));
|
|
116
|
+
|
|
117
|
+
await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/socket hang up/);
|
|
118
|
+
});
|
|
119
|
+
|
|
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');
|
|
138
|
+
});
|
|
139
|
+
|
|
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
|
+
})));
|
|
146
|
+
|
|
147
|
+
await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/premature close/);
|
|
148
|
+
});
|
|
149
|
+
|
|
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 }));
|
|
156
|
+
|
|
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
|
+
});
|
|
161
|
+
|
|
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 }));
|
|
168
|
+
|
|
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
|
+
});
|
|
173
|
+
|
|
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);
|
|
179
|
+
|
|
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
|
+
});
|
|
183
|
+
|
|
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
|
+
});
|
|
187
|
+
|
|
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);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
blobStoreFromEnv,
|
|
5
|
+
createBlobUrlMinter,
|
|
6
|
+
readPayload,
|
|
7
|
+
writePayload,
|
|
8
|
+
BLOB_URL_MAX_TTL_MS,
|
|
9
|
+
BLOB_URL_CEILING_MARGIN_MS,
|
|
10
|
+
BLOB_URL_DEFAULT_TTL_MS,
|
|
11
|
+
} from '../src/blob-urls.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Fixtures. These are VECTORS: the signatures below were computed against the
|
|
15
|
+
// payload shapes as mcp-host's `blob-key.ts` spells them, independently of this
|
|
16
|
+
// module's code. If a future edit changes how a payload is assembled — a
|
|
17
|
+
// separator, the order of the fields, the encoding of the MAC — these stop
|
|
18
|
+
// matching. That is the point.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const BASE_URL = 'https://mcp.example.com/b/reg_0123456789abcdef01234567';
|
|
22
|
+
const SIGNING_KEY = 'test-blob-signing-key';
|
|
23
|
+
const REGISTRATION_ID = 'reg_0123456789abcdef01234567';
|
|
24
|
+
const REST = 'gmail/attachments/report.pdf';
|
|
25
|
+
const OBJECT_KEY = `${REGISTRATION_ID}/${REST}`;
|
|
26
|
+
const EXP = 1767225600000;
|
|
27
|
+
const NOW = EXP - BLOB_URL_DEFAULT_TTL_MS;
|
|
28
|
+
|
|
29
|
+
/** base64url(HMAC-SHA256(SIGNING_KEY, `<key>\n<exp>`)). */
|
|
30
|
+
const READ_SIG = 'KrMn62kcDi-i9wW3JLvbZJdas4gP-EQuYoO-XMhcpNQ';
|
|
31
|
+
/** base64url(HMAC-SHA256(SIGNING_KEY, `put\0<key>\0application/pdf\0<exp>`)). */
|
|
32
|
+
const WRITE_PDF_SIG = 'qHPIRvchnjsbE8lam5dft3pIDBZu6Ae0HWZIG7kC9DQ';
|
|
33
|
+
/** The same key and exp, signed for `text/plain` instead. */
|
|
34
|
+
const WRITE_TEXT_SIG = 'FHtUlUVeEuREdbdKENy8rDU2ZETBQKF6V9IfaIWwWWo';
|
|
35
|
+
|
|
36
|
+
function sign(payload: string): string {
|
|
37
|
+
return createHmac('sha256', SIGNING_KEY)
|
|
38
|
+
.update(payload, 'utf8')
|
|
39
|
+
.digest('base64url');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const minter = () => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: SIGNING_KEY });
|
|
43
|
+
|
|
44
|
+
function params(url: string): URLSearchParams {
|
|
45
|
+
return new URL(url).searchParams;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.unstubAllEnvs();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe('payload shapes', () => {
|
|
55
|
+
it('signs a read as `<key>\\n<exp>` — a newline, never a NUL', () => {
|
|
56
|
+
const payload = readPayload(OBJECT_KEY, EXP);
|
|
57
|
+
expect(payload).toBe('reg_0123456789abcdef01234567/gmail/attachments/report.pdf\n1767225600000');
|
|
58
|
+
expect(payload).toContain('\n');
|
|
59
|
+
expect(payload).not.toContain('\0');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('signs a write as `put\\0<key>\\0<ct>\\0<exp>` — NUL-separated, content type inside', () => {
|
|
63
|
+
const payload = writePayload(OBJECT_KEY, 'application/pdf', EXP);
|
|
64
|
+
// The NUL is written as a `\u0000` escape, never `\0`: `\0` followed by a
|
|
65
|
+
// DIGIT is a legacy octal escape, so `'\01767…'` is U+0001 + "767…" — the
|
|
66
|
+
// vector then silently pins the wrong bytes (it did, on this test's first run).
|
|
67
|
+
expect(payload).toBe(
|
|
68
|
+
'put\u0000reg_0123456789abcdef01234567/gmail/attachments/report.pdf\u0000application/pdf\u00001767225600000',
|
|
69
|
+
);
|
|
70
|
+
expect(payload.split('\0')).toEqual([
|
|
71
|
+
'put',
|
|
72
|
+
OBJECT_KEY,
|
|
73
|
+
'application/pdf',
|
|
74
|
+
String(EXP),
|
|
75
|
+
]);
|
|
76
|
+
expect(payload).not.toContain('\n');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('never lets the two shapes converge', () => {
|
|
80
|
+
expect(readPayload(OBJECT_KEY, EXP)).not.toBe(writePayload(OBJECT_KEY, 'application/pdf', EXP));
|
|
81
|
+
// A read signature must be unreplayable as a write, whatever the content type.
|
|
82
|
+
expect(sign(readPayload(OBJECT_KEY, EXP))).not.toBe(
|
|
83
|
+
sign(writePayload(OBJECT_KEY, '', EXP)),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('createBlobUrlMinter', () => {
|
|
89
|
+
it('takes the registration id from the base URL\'s last path segment', () => {
|
|
90
|
+
expect(minter().registrationId).toBe(REGISTRATION_ID);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('mints a GET URL matching the pinned read vector', () => {
|
|
94
|
+
const url = minter().getUrl(REST, { now: NOW });
|
|
95
|
+
expect(url).toBe(
|
|
96
|
+
`${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
|
|
97
|
+
);
|
|
98
|
+
expect(params(url).get('sig')).toBe(sign(readPayload(OBJECT_KEY, EXP)));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('mints a PUT URL matching the pinned write vector', () => {
|
|
102
|
+
const target = minter().putUrl(REST, 'application/pdf', { now: NOW });
|
|
103
|
+
expect(target.url).toBe(
|
|
104
|
+
`${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${WRITE_PDF_SIG}`,
|
|
105
|
+
);
|
|
106
|
+
expect(params(target.url).get('sig')).toBe(
|
|
107
|
+
sign(writePayload(OBJECT_KEY, 'application/pdf', EXP)),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('carries the content type it signed BACK with the URL', () => {
|
|
112
|
+
// The one field the gateway checks that no test in this repo can: it
|
|
113
|
+
// rebuilds the payload from the PUT's own `content-type` header
|
|
114
|
+
// (`gateway/src/blob.ts`, defaulting to application/octet-stream when the
|
|
115
|
+
// header is absent), so a caller that drops the header, lets an HTTP
|
|
116
|
+
// library default it, or re-cases it gets a 404 signature mismatch. Handing
|
|
117
|
+
// back a URL alone makes that a thing the uploader has to REMEMBER; handing
|
|
118
|
+
// back the pair makes it a thing it cannot drop.
|
|
119
|
+
const target = minter().putUrl(REST, 'application/pdf', { now: NOW });
|
|
120
|
+
expect(target.contentType).toBe('application/pdf');
|
|
121
|
+
expect(Object.keys(target).sort()).toEqual(['contentType', 'url']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('changes the signature when the content type changes', () => {
|
|
125
|
+
const pdf = params(minter().putUrl(REST, 'application/pdf', { now: NOW }).url).get('sig');
|
|
126
|
+
const text = params(minter().putUrl(REST, 'text/plain', { now: NOW }).url).get('sig');
|
|
127
|
+
expect(pdf).toBe(WRITE_PDF_SIG);
|
|
128
|
+
expect(text).toBe(WRITE_TEXT_SIG);
|
|
129
|
+
expect(pdf).not.toBe(text);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('builds the object key as `<registrationId>/<rest>`, slashes and all', () => {
|
|
133
|
+
const url = minter().getUrl('a/b/c.pdf', { now: NOW });
|
|
134
|
+
// Raw, not `new URL(url).pathname` — same reason as the encoding cases below.
|
|
135
|
+
expect(url.split('?')[0]).toBe(`${BASE_URL}/a/b/c.pdf`);
|
|
136
|
+
expect(params(url).get('sig')).toBe(
|
|
137
|
+
sign(readPayload(`${REGISTRATION_ID}/a/b/c.pdf`, EXP)),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('percent-encodes each URL segment while signing the logical key', () => {
|
|
142
|
+
const url = minter().getUrl('in box/a b.pdf', { now: NOW });
|
|
143
|
+
// Asserted on the RAW string the module returned, never through
|
|
144
|
+
// `new URL(url).pathname`: the parser percent-encodes a space itself on the
|
|
145
|
+
// way in, so a parsed pathname reads identically whether this module
|
|
146
|
+
// encoded anything or not — deleting `encodeURIComponent` from the wire
|
|
147
|
+
// left that assertion green (the one mutation of 25 that survived review).
|
|
148
|
+
expect(url).toBe(
|
|
149
|
+
`${BASE_URL}/in%20box/a%20b.pdf?exp=${EXP}&sig=${sign(readPayload(`${REGISTRATION_ID}/in box/a b.pdf`, EXP))}`,
|
|
150
|
+
);
|
|
151
|
+
// The gateway decodes per segment before it verifies, so the SIGNED key is
|
|
152
|
+
// the logical one — an encoded key here would 404 on every space.
|
|
153
|
+
expect(params(url).get('sig')).toBe(
|
|
154
|
+
sign(readPayload(`${REGISTRATION_ID}/in box/a b.pdf`, EXP)),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('percent-encodes a `#` on the wire, so the query is still a query', () => {
|
|
159
|
+
// `Invoice #1234.pdf` is an ordinary Gmail attachment name, and it is the
|
|
160
|
+
// case a URL parser will NOT repair for us: unencoded, everything from the
|
|
161
|
+
// `#` is a FRAGMENT, so `?exp=&sig=` never reaches the gateway and the
|
|
162
|
+
// answer is 403 "missing signature" with nothing to debug. `?` truncates
|
|
163
|
+
// the path the same way. Raw string again, for the reason above.
|
|
164
|
+
const url = minter().getUrl('Invoice #1234.pdf', { now: NOW });
|
|
165
|
+
expect(url).toBe(
|
|
166
|
+
`${BASE_URL}/Invoice%20%231234.pdf?exp=${EXP}&sig=${sign(readPayload(`${REGISTRATION_ID}/Invoice #1234.pdf`, EXP))}`,
|
|
167
|
+
);
|
|
168
|
+
expect(url).not.toContain('#');
|
|
169
|
+
expect(params(url).get('sig')).toBe(
|
|
170
|
+
sign(readPayload(`${REGISTRATION_ID}/Invoice #1234.pdf`, EXP)),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('tolerates a trailing slash on the base URL', () => {
|
|
175
|
+
const m = createBlobUrlMinter({ baseUrl: `${BASE_URL}/`, signingKey: SIGNING_KEY });
|
|
176
|
+
expect(m.registrationId).toBe(REGISTRATION_ID);
|
|
177
|
+
expect(m.getUrl(REST, { now: NOW })).toBe(
|
|
178
|
+
`${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('defaults the expiry to one hour', () => {
|
|
183
|
+
const exp = Number(params(minter().getUrl(REST, { now: NOW })).get('exp'));
|
|
184
|
+
expect(exp - NOW).toBe(BLOB_URL_DEFAULT_TTL_MS);
|
|
185
|
+
expect(BLOB_URL_DEFAULT_TTL_MS).toBe(60 * 60 * 1000);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('honours a shorter ttl', () => {
|
|
189
|
+
const exp = Number(params(minter().getUrl(REST, { now: NOW, ttlMs: 5000 })).get('exp'));
|
|
190
|
+
expect(exp).toBe(NOW + 5000);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('clamps the expiry UNDER the gateway\'s 24-hour ceiling rather than trusting the caller', () => {
|
|
194
|
+
const exp = Number(
|
|
195
|
+
params(minter().getUrl(REST, { now: NOW, ttlMs: 7 * 24 * 60 * 60 * 1000 })).get('exp'),
|
|
196
|
+
);
|
|
197
|
+
expect(exp).toBe(NOW + BLOB_URL_MAX_TTL_MS - BLOB_URL_CEILING_MARGIN_MS);
|
|
198
|
+
expect(BLOB_URL_MAX_TTL_MS).toBe(24 * 60 * 60 * 1000);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('never mints an exp AT the ceiling, so a clock a hair ahead is not a 403', () => {
|
|
202
|
+
// `blob.ts` refuses with a strict `exp > Date.now() + MAX_TTL_MS`, judged on
|
|
203
|
+
// the GATEWAY's clock against an exp computed on the CHILD's. Landing
|
|
204
|
+
// exactly on the ceiling leaves zero skew budget: one millisecond of the
|
|
205
|
+
// child running ahead is a hard 403 "expiry out of range", whose message
|
|
206
|
+
// names no cause the caller can act on. And BLOB_URL_MAX_TTL_MS is exported,
|
|
207
|
+
// so `ttlMs: BLOB_URL_MAX_TTL_MS` is the natural way to ask for the longest
|
|
208
|
+
// legal link — the API invites exactly the request that would break.
|
|
209
|
+
for (const ttlMs of [BLOB_URL_MAX_TTL_MS, BLOB_URL_MAX_TTL_MS + 1, Number.MAX_SAFE_INTEGER]) {
|
|
210
|
+
const exp = Number(params(minter().getUrl(REST, { now: NOW, ttlMs })).get('exp'));
|
|
211
|
+
expect(exp).toBeLessThan(NOW + BLOB_URL_MAX_TTL_MS);
|
|
212
|
+
expect(exp).toBe(NOW + BLOB_URL_MAX_TTL_MS - BLOB_URL_CEILING_MARGIN_MS);
|
|
213
|
+
}
|
|
214
|
+
expect(BLOB_URL_CEILING_MARGIN_MS).toBeGreaterThan(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('clamps a zero or negative ttl up to one second, never to an already-dead link', () => {
|
|
218
|
+
expect(Number(params(minter().getUrl(REST, { now: NOW, ttlMs: 0 })).get('exp'))).toBe(NOW + 1000);
|
|
219
|
+
expect(Number(params(minter().getUrl(REST, { now: NOW, ttlMs: -1 })).get('exp'))).toBe(NOW + 1000);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('reads the clock when no `now` is given', () => {
|
|
223
|
+
vi.useFakeTimers();
|
|
224
|
+
try {
|
|
225
|
+
vi.setSystemTime(NOW);
|
|
226
|
+
expect(minter().getUrl(REST)).toBe(
|
|
227
|
+
`${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
|
|
228
|
+
);
|
|
229
|
+
} finally {
|
|
230
|
+
vi.useRealTimers();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it.each([
|
|
235
|
+
'https://mcp.example.com/b/',
|
|
236
|
+
'https://mcp.example.com/b',
|
|
237
|
+
'https://mcp.example.com/',
|
|
238
|
+
'https://mcp.example.com',
|
|
239
|
+
])('refuses the base URL %j — no registration id in the path', (baseUrl) => {
|
|
240
|
+
expect(() => createBlobUrlMinter({ baseUrl, signingKey: 'k' }))
|
|
241
|
+
.toThrow(/registration id/i);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('refuses a base URL that is not a URL', () => {
|
|
245
|
+
expect(() => createBlobUrlMinter({ baseUrl: 'not a url', signingKey: 'k' }))
|
|
246
|
+
.toThrow(/MCP_BLOB_BASE_URL/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('refuses an empty signing key', () => {
|
|
250
|
+
expect(() => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: '' }))
|
|
251
|
+
.toThrow(/MCP_BLOB_SIGNING_KEY/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it.each([
|
|
255
|
+
['', 'empty'],
|
|
256
|
+
['/', 'empty segment'],
|
|
257
|
+
['a//b', 'empty segment'],
|
|
258
|
+
['../etc/passwd', 'traversal'],
|
|
259
|
+
['a/./b', 'traversal'],
|
|
260
|
+
['a/../b', 'traversal'],
|
|
261
|
+
])('refuses the object path %j (%s)', (rest) => {
|
|
262
|
+
// The gateway refuses these outright rather than normalising them, so a URL
|
|
263
|
+
// minted for one would 404 at the door. Fail here, where the message can say why.
|
|
264
|
+
expect(() => minter().getUrl(rest, { now: NOW })).toThrow(/object path/i);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// EVERY refusal this module makes, not only the one easiest to reach. The key
|
|
268
|
+
// is a credential and so is a signed URL, while an error message is the one
|
|
269
|
+
// value here that is EXPECTED to be logged — so the guard has to bind the
|
|
270
|
+
// constructor's three refusals as well as the mint-time one. With only the
|
|
271
|
+
// `../escape` case it did not: rewriting the unparseable-base-URL throw to
|
|
272
|
+
// interpolate `config.signingKey` left the whole suite green.
|
|
273
|
+
it.each([
|
|
274
|
+
['empty signing key', () => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: '' })],
|
|
275
|
+
['unparseable base URL', () => createBlobUrlMinter({ baseUrl: 'not a url', signingKey: SIGNING_KEY })],
|
|
276
|
+
['base URL with no registration id', () => createBlobUrlMinter({ baseUrl: 'https://mcp.example.com/b/', signingKey: SIGNING_KEY })],
|
|
277
|
+
['refused object path', () => minter().getUrl('../escape', { now: NOW })],
|
|
278
|
+
])('never puts the signing key or a signed URL in the error for a %s', (_label, refuse) => {
|
|
279
|
+
try {
|
|
280
|
+
refuse();
|
|
281
|
+
expect.unreachable('should have thrown');
|
|
282
|
+
} catch (err) {
|
|
283
|
+
expect(String(err)).not.toContain(SIGNING_KEY);
|
|
284
|
+
expect(String(err)).not.toContain('sig=');
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('blobStoreFromEnv', () => {
|
|
290
|
+
it('returns the pair when both variables are set', () => {
|
|
291
|
+
vi.stubEnv('MCP_BLOB_BASE_URL', BASE_URL);
|
|
292
|
+
vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
|
|
293
|
+
expect(blobStoreFromEnv()).toEqual({ baseUrl: BASE_URL, signingKey: SIGNING_KEY });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('returns undefined on a local stdio install, where neither is set', () => {
|
|
297
|
+
vi.stubEnv('MCP_BLOB_BASE_URL', '');
|
|
298
|
+
vi.stubEnv('MCP_BLOB_SIGNING_KEY', '');
|
|
299
|
+
expect(blobStoreFromEnv()).toBeUndefined();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('returns undefined when only the base URL is set', () => {
|
|
303
|
+
vi.stubEnv('MCP_BLOB_BASE_URL', BASE_URL);
|
|
304
|
+
vi.stubEnv('MCP_BLOB_SIGNING_KEY', '');
|
|
305
|
+
expect(blobStoreFromEnv()).toBeUndefined();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('returns undefined when only the signing key is set', () => {
|
|
309
|
+
vi.stubEnv('MCP_BLOB_BASE_URL', '');
|
|
310
|
+
vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
|
|
311
|
+
expect(blobStoreFromEnv()).toBeUndefined();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('treats an unresolved .mcpb placeholder as unset', () => {
|
|
315
|
+
vi.stubEnv('MCP_BLOB_BASE_URL', '${user_config.blob_base_url}');
|
|
316
|
+
vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
|
|
317
|
+
expect(blobStoreFromEnv()).toBeUndefined();
|
|
318
|
+
});
|
|
319
|
+
});
|
package/tests/runner.test.ts
CHANGED
|
@@ -499,7 +499,7 @@ describe('run', () => {
|
|
|
499
499
|
}
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
-
it('strips GOOGLE_APPLICATION_CREDENTIALS and *_TOKEN/*_SECRET/*
|
|
502
|
+
it('strips GOOGLE_APPLICATION_CREDENTIALS and *_TOKEN/*_SECRET/*_KEY/*_CREDENTIALS vars', async () => {
|
|
503
503
|
const spawner = makeSpawner(0, '{}');
|
|
504
504
|
const snapshot = {
|
|
505
505
|
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS,
|
|
@@ -532,6 +532,67 @@ describe('run', () => {
|
|
|
532
532
|
}
|
|
533
533
|
});
|
|
534
534
|
|
|
535
|
+
// The suffix list was `_TOKEN|_SECRET|_API_KEY|_PRIVATE_KEY` — four spellings
|
|
536
|
+
// of "a key", none of which is a bare `_KEY`. Two credentials this repo hands
|
|
537
|
+
// its own process sit in exactly that gap: `MCP_BLOB_SIGNING_KEY` (mints the
|
|
538
|
+
// signed blob URLs a `deliver="url"` download is uploaded to) and
|
|
539
|
+
// `GOG_RUNNER_KEY` (the bearer for the Fly backend, and for `POST /upload`,
|
|
540
|
+
// which is arbitrary-gog-argv on that box). Neither is anything `gog` reads.
|
|
541
|
+
it('strips a bare *_KEY var — the host signing key and the runner bearer', async () => {
|
|
542
|
+
const spawner = makeSpawner(0, '{}');
|
|
543
|
+
const snapshot = {
|
|
544
|
+
MCP_BLOB_SIGNING_KEY: process.env.MCP_BLOB_SIGNING_KEY,
|
|
545
|
+
GOG_RUNNER_KEY: process.env.GOG_RUNNER_KEY,
|
|
546
|
+
STRIPE_KEY: process.env.STRIPE_KEY,
|
|
547
|
+
AWS_CREDENTIALS: process.env.AWS_CREDENTIALS,
|
|
548
|
+
};
|
|
549
|
+
process.env.MCP_BLOB_SIGNING_KEY = 'blob-signing-secret';
|
|
550
|
+
process.env.GOG_RUNNER_KEY = 'runner-bearer-secret';
|
|
551
|
+
process.env.STRIPE_KEY = 'sk-live-secret';
|
|
552
|
+
process.env.AWS_CREDENTIALS = '/path/to/creds';
|
|
553
|
+
try {
|
|
554
|
+
await run(['docs', 'cat', 'id'], { spawner });
|
|
555
|
+
const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
|
|
556
|
+
expect(envPassed.MCP_BLOB_SIGNING_KEY).toBeUndefined();
|
|
557
|
+
expect(envPassed.GOG_RUNNER_KEY).toBeUndefined();
|
|
558
|
+
expect(envPassed.STRIPE_KEY).toBeUndefined();
|
|
559
|
+
// `_CREDENTIALS`, generalising the one named GOOGLE_APPLICATION_CREDENTIALS.
|
|
560
|
+
expect(envPassed.AWS_CREDENTIALS).toBeUndefined();
|
|
561
|
+
} finally {
|
|
562
|
+
for (const [k, v] of Object.entries(snapshot)) {
|
|
563
|
+
if (v === undefined) delete process.env[k];
|
|
564
|
+
else process.env[k] = v;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// The CONTROL on widening, and the reason the list is not simply "anything
|
|
570
|
+
// that smells like a credential": `GOG_KEYRING_PASSWORD` is how gog decrypts
|
|
571
|
+
// its own file keyring (`GOG_KEYRING_BACKEND=file`), so a `_PASSWORD` rule
|
|
572
|
+
// would strip the one credential the child legitimately needs and turn every
|
|
573
|
+
// call into an auth failure. `GOG_KEYRING_BACKEND` is the same story without
|
|
574
|
+
// the secret.
|
|
575
|
+
it('keeps the credentials gog itself reads from the environment', async () => {
|
|
576
|
+
const spawner = makeSpawner(0, '{}');
|
|
577
|
+
const snapshot = {
|
|
578
|
+
GOG_KEYRING_PASSWORD: process.env.GOG_KEYRING_PASSWORD,
|
|
579
|
+
GOG_KEYRING_BACKEND: process.env.GOG_KEYRING_BACKEND,
|
|
580
|
+
};
|
|
581
|
+
process.env.GOG_KEYRING_PASSWORD = 'keyring-pass';
|
|
582
|
+
process.env.GOG_KEYRING_BACKEND = 'file';
|
|
583
|
+
try {
|
|
584
|
+
await run(['docs', 'cat', 'id'], { spawner });
|
|
585
|
+
const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
|
|
586
|
+
expect(envPassed.GOG_KEYRING_PASSWORD).toBe('keyring-pass');
|
|
587
|
+
expect(envPassed.GOG_KEYRING_BACKEND).toBe('file');
|
|
588
|
+
} finally {
|
|
589
|
+
for (const [k, v] of Object.entries(snapshot)) {
|
|
590
|
+
if (v === undefined) delete process.env[k];
|
|
591
|
+
else process.env[k] = v;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
535
596
|
it('redacts Bearer with quoted/encoded characters', async () => {
|
|
536
597
|
const stderrLeak = 'http 401: header was Bearer eyJ.test+slash/equal=padding more text';
|
|
537
598
|
const spawner = makeSpawner(1, '', stderrLeak);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// The zod counterpart of sdk-single-copy.test.ts, guarding the invariant that
|
|
7
|
+
// broke dependabot #333: the whole monorepo must resolve ONE copy of zod.
|
|
8
|
+
//
|
|
9
|
+
// Same shape of failure, a different package. `@cloudflare/vitest-pool-workers`
|
|
10
|
+
// (a root devDependency) declares zod as an **exact** pin, so it takes the
|
|
11
|
+
// hoisted root slot that `@chrischall/mcp-utils` and the MCP SDK resolve their
|
|
12
|
+
// zod peer from. The moment our workspaces ask for a newer zod than that pin,
|
|
13
|
+
// each nests its own copy — and because a `ZodType` carries brand-bearing
|
|
14
|
+
// internals, TypeScript compares the two NOMINALLY: every schema our tools hand
|
|
15
|
+
// `registerTool` fails with `TS2322: Type 'ZodString' is not assignable to type
|
|
16
|
+
// 'AnySchema'`, with no API change and nothing to fix in the source. #333 split
|
|
17
|
+
// the tree exactly that way and produced 11,024 type errors from a bump of one
|
|
18
|
+
// patch-level dependency.
|
|
19
|
+
//
|
|
20
|
+
// Read the resolved paths in such an error, not the signature.
|
|
21
|
+
//
|
|
22
|
+
// This asserts resolution identity rather than a version string: the failure is
|
|
23
|
+
// "two copies", not "the wrong version", and pinning a version here would just
|
|
24
|
+
// have to be edited on every future bump.
|
|
25
|
+
describe('zod is installed exactly once', () => {
|
|
26
|
+
const here = createRequire(import.meta.url);
|
|
27
|
+
|
|
28
|
+
// `import.meta.resolve`, not `require.resolve`, to reach the dependency's own
|
|
29
|
+
// entry: these packages are ESM-only, so their `exports` maps carry no
|
|
30
|
+
// `require` condition and CJS resolution of the bare specifier throws.
|
|
31
|
+
const resolveFrom = (specifier: string): string =>
|
|
32
|
+
realpathSync(createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve('zod'));
|
|
33
|
+
|
|
34
|
+
it('resolves to the same file for this package and for @chrischall/mcp-utils', () => {
|
|
35
|
+
// mcp-utils declares zod as a peer, and its `accountParam` / `viewParam` /
|
|
36
|
+
// `paginationParams` helpers build the very schemas our registrars pass to
|
|
37
|
+
// `registerTool`, so its copy is the one they must be typed against.
|
|
38
|
+
expect(resolveFrom('@chrischall/mcp-utils')).toBe(realpathSync(here.resolve('zod')));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('resolves to the same file for the MCP SDK, which types every tool schema', () => {
|
|
42
|
+
// `registerTool` accepts the raw shape and infers the handler's argument
|
|
43
|
+
// types from it; a second copy makes every one of those schemas foreign.
|
|
44
|
+
expect(resolveFrom('@modelcontextprotocol/sdk/server/mcp.js')).toBe(
|
|
45
|
+
realpathSync(here.resolve('zod')),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('resolves to the same file for @cloudflare/vitest-pool-workers, which exact-pins zod', () => {
|
|
50
|
+
// The exact pin here is what captured the root hoist slot in #333, and it
|
|
51
|
+
// is why the root `overrides` block carries a zod entry.
|
|
52
|
+
expect(resolveFrom('@cloudflare/vitest-pool-workers')).toBe(
|
|
53
|
+
realpathSync(here.resolve('zod')),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|