gogcli-mcp 3.0.0 → 4.0.1

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 +474 -947
  4. package/dist/lib.js +575 -983
  5. package/manifest.json +2 -2
  6. package/mint.yaml +41 -37
  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 +27 -176
  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 +53 -96
  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
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "3.0.0",
6
+ "version": "4.0.1",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -266,7 +266,7 @@
266
266
  },
267
267
  {
268
268
  "name": "gog_drive_read_bytes",
269
- "description": "Fetch a Drive file's raw bytes as a base64 resource (local stdio only; connector returns text-only notice)"
269
+ "description": "Fetch a Drive file's raw bytes as a base64 resource"
270
270
  },
271
271
  {
272
272
  "name": "gog_drive_get",
package/mint.yaml CHANGED
@@ -11,25 +11,29 @@ env:
11
11
  - name: GOG_CLIENT_ID
12
12
  required: false
13
13
  help: >-
14
- Google OAuth client id. NOTE: on the local-spawn path the child `gog`
15
- receives a sanitized env — runner.ts drops GOG_ACCESS_TOKEN and every
16
- *_TOKEN / *_SECRET / *_KEY / *_CREDENTIALS variable — so the CLI
17
- authenticates from its own stored credentials under $HOME (see
18
- state.dataDir), not from these variables being passed through.
14
+ Google OAuth client id. Read by the Node wrapper at startup
15
+ (bootstrap-auth.ts), which imports it into gog's keyring via a temp file
16
+ together with GOG_CLIENT_SECRET, GOG_REFRESH_TOKEN and GOG_ACCOUNT — set
17
+ all four or none. Not a secret, so unlike GOG_CLIENT_SECRET and
18
+ GOG_REFRESH_TOKEN it is not stripped from the spawned `gog`'s
19
+ environment; gog authenticates from its keyring under $HOME (see
20
+ state.dataDir).
19
21
  - name: GOG_CLIENT_SECRET
20
22
  secret: true
21
23
  required: false
22
24
  help: >-
23
- Google OAuth client secret. Stripped from the spawned CLI's environment
24
- by runner.ts's *_SECRET rule — see GOG_CLIENT_ID.
25
+ Google OAuth client secret. Imported into gog's keyring at startup with
26
+ GOG_CLIENT_ID; stripped from the spawned CLI's environment by runner.ts's
27
+ *_SECRET rule.
25
28
  - name: GOG_REFRESH_TOKEN
26
29
  secret: true
27
30
  required: false
28
31
  help: >-
29
- Google OAuth refresh token. Stripped from the spawned CLI's environment
30
- by runner.ts's *_TOKEN rule — see GOG_CLIENT_ID. A hosted deployment must
31
- therefore carry gog's authorised-account state in its persisted data dir,
32
- or drive a remote runner via GOG_RUNNER_URL.
32
+ Google OAuth refresh token (`gog auth tokens export` prints one). Imported
33
+ into gog's keyring at startup with GOG_CLIENT_ID; stripped from the
34
+ spawned CLI's environment by runner.ts's *_TOKEN rule. Changing it
35
+ re-imports on the next start; an in-connector re-auth
36
+ (gog_auth_add_url/complete) stays in effect until it changes.
33
37
  - name: GOG_ACCESS_TOKEN
34
38
  secret: true
35
39
  required: false
@@ -40,8 +44,21 @@ env:
40
44
  - name: GOG_ACCOUNT
41
45
  required: false
42
46
  help: >-
43
- Which configured Google account to act as, when more than one is
44
- authorised. Defaults to the single/most recent account.
47
+ The Google account to act as. Required for the startup auth bootstrap
48
+ (the refresh token is imported under this email); otherwise defaults to
49
+ gog's single/most recent account.
50
+ - name: GOG_KEYRING_BACKEND
51
+ required: false
52
+ help: >-
53
+ gog's credential store. Set to "file" on a headless host — there is no OS
54
+ keychain — so the keyring lives on the persistent data dir.
55
+ - name: GOG_KEYRING_PASSWORD
56
+ secret: true
57
+ required: false
58
+ help: >-
59
+ Encrypts gog's file keyring on the data dir. Required with
60
+ GOG_KEYRING_BACKEND=file. Deliberately not stripped from the spawned
61
+ CLI's environment — it is the one credential gog itself reads.
45
62
  - name: GOG_READONLY
46
63
  required: false
47
64
  help: >-
@@ -52,24 +69,13 @@ env:
52
69
  help: >-
53
70
  Path to the `gog` binary. Leave unset when the dependency below supplies
54
71
  it; set it only to point at a binary you manage yourself.
55
- - name: GOG_RUNNER_URL
56
- required: false
57
- help: >-
58
- URL of a remote gog runner to execute against instead of spawning the
59
- local binary. If you set this, add its host to egress.allow.
60
- - name: GOG_RUNNER_KEY
61
- secret: true
62
- required: false
63
- help: >-
64
- Shared key authenticating calls to GOG_RUNNER_URL. Required whenever that
65
- is set.
66
72
  - name: GOG_TIMEZONE
67
73
  required: false
68
74
  help: >-
69
- The IANA zone gog itself formats its naive timestamps in. The wrapper
70
- reads it (naiveSourceTimeZone) to re-attach the correct offset, so it
71
- should match gog's own configuration. Falls back to DISPLAY_TZ, then to
72
- America/New_York.
75
+ The IANA zone gog formats its naive timestamps in. The wrapper passes
76
+ it to every spawned gog and reads it back (naiveSourceTimeZone) to
77
+ re-attach the offset, so the two always agree. Unset or invalid, both
78
+ use DISPLAY_TZ, then America/New_York — never the host's local zone.
73
79
  - name: DISPLAY_TZ
74
80
  required: false
75
81
  help: >-
@@ -81,9 +87,8 @@ dependencies:
81
87
  # Every tool shells out to the `gog` CLI; without it the server starts and
82
88
  # then fails on the first call. This tag must track
83
89
  # packages/gogcli-mcp/src/runner.ts's MIN_GOG_VERSION (the floor the tools
84
- # assume) and the fly-gog-runner/Dockerfile GOG_VERSION build arg. See
85
- # CLAUDE.md "Required gog version" — bumping the floor means bumping these
86
- # nine pins too.
90
+ # assume). See CLAUDE.md "Required gog version" — bumping the floor means
91
+ # bumping these nine pins, and the pin stored on each mcp-host registration.
87
92
  - kind: github-release
88
93
  repo: openclaw/gogcli
89
94
  tag: v0.40.0
@@ -92,15 +97,14 @@ dependencies:
92
97
  state:
93
98
  dataDir: true
94
99
  reason: >-
95
- `gog` keeps its authorised-account state and token cache under $HOME.
96
- Without a persistent data dir every cold start has no account configured and
97
- every tool call fails until the credentials are re-supplied.
100
+ `gog` keeps its authorised-account state and file keyring under $HOME, and
101
+ the wrapper keeps its auth-bootstrap marker (~/.gogcli-mcp) there, so a
102
+ restart neither re-imports an unchanged secret nor discards an
103
+ in-connector re-auth. Without a persistent data dir every cold start has
104
+ only what the env secrets can re-seed.
98
105
  egress:
99
106
  allow:
100
107
  # Google OAuth token exchange and the Google REST APIs the CLI calls.
101
108
  - oauth2.googleapis.com
102
109
  - www.googleapis.com
103
110
  - googleapis.com
104
- #
105
- # NOTE: if you set GOG_RUNNER_URL to run against a remote gog runner, add
106
- # that host here too — it is supplied at runtime and cannot be declared.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "3.0.0",
3
+ "version": "4.0.1",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -47,9 +47,9 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/node": "^26.5.1",
50
- "@vitest/coverage-v8": "^4.1.8",
50
+ "@vitest/coverage-v8": "^5.0.1",
51
51
  "esbuild": "^0.28.2",
52
52
  "typescript": "^7.0.2",
53
- "vitest": "^4.1.11"
53
+ "vitest": "^5.0.1"
54
54
  }
55
55
  }
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "3.0.0",
10
+ "version": "4.0.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "3.0.0",
15
+ "version": "4.0.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -9,14 +9,13 @@ import type { GogArg, GogFileArg } from './runner.js';
9
9
  * Every attachment input gog offers is a PATH — `gmail send --attach`,
10
10
  * `gmail drafts create --attach`, `drive upload <localPath>` — and those paths
11
11
  * resolve wherever gog runs. On the local stdio transport that is the caller's
12
- * own machine and everything works. On the hosted connector, and on any host
13
- * that reaches a backend through GOG_RUNNER_URL, gog runs somewhere else
14
- * entirely: no path the caller can name exists there, so outbound attachments
15
- * were simply impossible — including via Drive, whose upload takes a path too.
12
+ * own machine and everything works. On a hosted deployment (mcp-host), gog
13
+ * runs somewhere else entirely: no path the caller can name exists there, so
14
+ * outbound attachments would be impossible — including via Drive, whose upload
15
+ * takes a path too.
16
16
  *
17
17
  * The seam that fixes it already existed. `GogFileArg` writes a payload to a
18
- * private temp file NEXT TO GOG — on the runner for the remote path, in a
19
- * mkdtemp dir for the local one — and passes the resulting path. It was built
18
+ * private temp file NEXT TO GOG (a mkdtemp dir) and passes the resulting path. It was built
20
19
  * for oversized text (a long HTML mail body) that could not fit in argv; all
21
20
  * binary attachments need on top of that is a base64 spelling and control of
22
21
  * the basename, both of which are now `GogFileArg` fields.
@@ -27,39 +26,34 @@ import type { GogArg, GogFileArg } from './runner.js';
27
26
 
28
27
  // Ceiling for ONE attachment's decoded bytes.
29
28
  //
30
- // Pinned to the Fly runner's own MAX_FILE_ARG_BYTES (fly-gog-runner/server.mjs)
31
- // so the two agree. That matters: without a check here the local stdio path
32
- // would accept any size while the remote path refused at 8 MiB, and the caller
33
- // would meet the limit as a transport rejection from a layer they cannot see.
34
- // Checked in the TOOL so the error names the file and the limit instead.
29
+ // A published, stable limit: callers plan around it, so it stays fixed. Checked
30
+ // in the TOOL so the error names the file and the limit, rather than the caller
31
+ // meeting an oversized request as an opaque failure from a layer they cannot
32
+ // see.
35
33
  export const MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
36
34
 
37
- // The Fly runner caps an ENTIRE /run request body at 32 MiB
38
- // (fly-gog-runner/server.mjs MAX_BODY_BYTES). Restated rather than imported:
39
- // that package is not a dependency of this one, and the Worker bundle must not
40
- // pull it in. Keep the two in sync.
41
- const RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024;
35
+ // The budget for an ENTIRE request's payload, as spelled on the wire (32 MiB).
36
+ // An MCP tool call carries every attachment as base64 inside one JSON-RPC
37
+ // message, so the whole request — not each file — is what has to stay bounded.
38
+ const REQUEST_MAX_BODY_BYTES = 32 * 1024 * 1024;
42
39
 
43
40
  // Room inside that body for the JSON structure alone — key names, quoting,
44
- // commas, the accessToken, and the short flag strings (`--to=…`, `--subject=…`).
41
+ // commas, and the short flag strings (`--to=…`, `--subject=…`).
45
42
  // It does NOT have to cover the mail body: a large body is a GogFileArg, and
46
43
  // GogFileArgs are measured explicitly below rather than absorbed here.
47
- const RUNNER_BODY_JSON_RESERVE_BYTES = 256 * 1024;
44
+ const REQUEST_BODY_JSON_RESERVE_BYTES = 256 * 1024;
48
45
 
49
46
  /**
50
- * How many bytes of PAYLOAD one `/run` request can carry, counted as they are
51
- * spelled on the wire.
47
+ * How many bytes of PAYLOAD one request can carry, counted as they are spelled
48
+ * on the wire.
52
49
  *
53
50
  * The binding constraint on a message is its wire size, not the decoded size of
54
- * its files, and it is tighter than Gmail's own 25 MB limit: connector-runtime
55
- * sends every payload inside one `JSON.stringify({ args, accessToken })` body,
56
- * where binary rides as base64 (4/3 inflation) and text rides verbatim. A limit
57
- * expressed in decoded bytes must absorb that inflation or it documents a size
58
- * the runner rejects with "request body too large" — a rejection from a layer
59
- * the caller cannot see, which is exactly what pinning the per-file ceiling to
60
- * MAX_FILE_ARG_BYTES exists to prevent.
51
+ * its files, and it is tighter than Gmail's own 25 MB limit: every payload rides
52
+ * inside one JSON body, where binary rides as base64 (4/3 inflation) and text
53
+ * rides verbatim. A limit expressed in decoded bytes must absorb that inflation
54
+ * or it documents a size that cannot actually be sent.
61
55
  */
62
- export const MAX_REQUEST_PAYLOAD_WIRE_BYTES = RUNNER_MAX_BODY_BYTES - RUNNER_BODY_JSON_RESERVE_BYTES;
56
+ export const MAX_REQUEST_PAYLOAD_WIRE_BYTES = REQUEST_MAX_BODY_BYTES - REQUEST_BODY_JSON_RESERVE_BYTES;
63
57
 
64
58
  // Ceiling for all inline attachments on one message, in DECODED bytes — the
65
59
  // units a caller thinks in, derived from the wire budget above.
@@ -70,15 +64,15 @@ export const MAX_REQUEST_PAYLOAD_WIRE_BYTES = RUNNER_MAX_BODY_BYTES - RUNNER_BOD
70
64
  // at roughly 1:1. `inlineAttachmentArgs` therefore measures the actual sibling
71
65
  // args rather than trusting this number, so a 23 MiB attachment set plus a
72
66
  // multi-MiB HTML body is refused here, with an error naming the body, instead of
73
- // arriving as a bare transport rejection.
67
+ // arriving as an opaque failure.
74
68
  //
75
69
  // Neither bound is hypothetical at the edges: three attachments at the
76
70
  // documented 8 MiB per-file maximum is 24 MiB, which alone encodes to exactly
77
- // MAX_BODY_BYTES, leaving nothing for anything else.
71
+ // the 32 MiB request budget, leaving nothing for anything else.
78
72
  export const MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor((MAX_REQUEST_PAYLOAD_WIRE_BYTES * 3) / 4);
79
73
 
80
74
  /**
81
- * Bytes one already-assembled arg occupies in the `/run` JSON body.
75
+ * Bytes one already-assembled arg occupies in the request's JSON body.
82
76
  *
83
77
  * A plain string costs its UTF-8 length; a file arg costs the length of its
84
78
  * `contents` as spelled on the wire — the base64 text for binary, the UTF-8
@@ -127,8 +121,8 @@ export type InlineAttachmentInput = z.infer<typeof inlineAttachmentSchema>;
127
121
  /** Reusable tool parameter — the same field on send, drafts create and update. */
128
122
  export const attachInlineParam = z.array(inlineAttachmentSchema).optional().describe(
129
123
  'Attachments supplied as BYTES rather than as server-side paths — use this whenever you hold a file '
130
- + 'and the gog server does not, which is always the case on the hosted connector and on any remote '
131
- + `deployment. Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). `
124
+ + 'and the gog server does not, which is always the case on a hosted deployment (e.g. mcp-host). '
125
+ + `Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). `
132
126
  + 'Can be combined with `attach`: the two name disjoint files (paths read on the server vs. bytes sent '
133
127
  + 'with the call), and both end up as ordinary attachments on the message.',
134
128
  );
@@ -171,7 +165,7 @@ function decodedLength(contentBase64: string): number | null {
171
165
 
172
166
  /**
173
167
  * Validate ONE caller-supplied file and turn it into a `GogFileArg`, which the
174
- * executor materializes to a temp file beside gog.
168
+ * runner materializes to a temp file beside gog.
175
169
  *
176
170
  * Throws with an actionable message on anything invalid. The MCP layer turns a
177
171
  * thrown handler error into an `isError` result, so every rejection here reaches
@@ -1,66 +1,46 @@
1
- import { readEnvVar } from '@chrischall/mcp-utils';
2
-
3
1
  /**
4
- * Ask the gog runner to stream a file off ITS disk to a signed blob-store URL.
5
- *
6
- * ## Why the runner and not this process
7
- *
8
- * Under the hosted connector this child is a FORWARDER: `run()` POSTs an
9
- * arg-array to fly-gog-runner, the Google refresh token lives in that app's
10
- * keyring, and `gog gmail attachment --out` writes the file to THAT box's disk.
11
- * The child never sees a byte, so the runner is the only party that can send
12
- * one anywhere. That is the same seam `deliverViaDrive` already uses — `gog
13
- * drive upload <path>` pushes bytes from the runner's disk to a remote
14
- * destination with the child out of the way — with a different destination.
2
+ * Stream a downloaded attachment off this machine's disk to a signed
3
+ * blob-store URL.
15
4
  *
16
- * Routing them through the child instead would cap out near 24 MB against
17
- * `/run`'s 32 MB body envelope and cost a ~31 MB base64 string plus a decoded
18
- * copy, for bytes nobody here reads.
5
+ * gog runs in this process tree (mcp-host installs it onto the child's PATH),
6
+ * so `gog gmail attachment --out` has just written the file HERE, and this
7
+ * process is the party that sends it. The rules are the ones the retired Fly
8
+ * runner's `POST /upload` enforced, now applied locally:
19
9
  *
20
- * ## What the runner does with it
10
+ * - The path is confined to the attachment download root, checked lexically
11
+ * and again on the real path (a symlink inside the root is a second way out).
12
+ * Unconfined, this is a file-read primitive aimed at a signed URL of the
13
+ * caller's choosing — and `$HOME` holds gog's keyring.
14
+ * - A file over the blob store's 100 MiB ceiling is refused BEFORE dialling:
15
+ * the gateway's 413 would arrive only after the whole transfer had run.
16
+ * - The PUT carries `Content-Length` from the file's own size (a chunked PUT is
17
+ * a 411) and `Content-Type` byte for byte as given — the signature commits to
18
+ * it, and a normalised header reads as a missing object.
19
+ * - One total deadline bounds the exchange, so a wedged transfer cannot leave
20
+ * the MCP request unanswered.
21
21
  *
22
- * `POST /upload` (fly-gog-runner/server.mjs) confines `path` to the attachment
23
- * directory, refuses anything past the blob store's own 100 MiB ceiling before
24
- * dialling, and PUTs the file with `Content-Length` from its own size and the
25
- * `Content-Type` header byte for byte as given — the PUT signature commits to
26
- * it. Its statuses carry the classification: `422` is deterministic (re-mint,
27
- * do not retry), `502` is the far side or the transfer, `400`/`404` are this
28
- * request. All of them are failures here; none of them is retried from this
29
- * side, because a fresh URL is the only repair for the common one.
22
+ * Every message thrown has the signed URL and its bare `sig=` value scrubbed.
30
23
  */
31
24
 
32
25
  /**
33
- * The deadline this side puts on the exchange.
34
- *
35
- * The runner has one of its own (`UPLOAD_TIMEOUT_MS`, 120 s) but it is an
36
- * INACTIVITY timer spent through `req.setTimeout` — it bounds a socket that has
37
- * gone quiet, never a transfer that keeps dribbling — so nothing else stands
38
- * between a wedged upload and an MCP request that never answers.
39
- *
40
- * Longer than `/run`'s 30 s because this is not a `gog` invocation: it is up to
41
- * 100 MiB leaving a Fly machine, and firing before the runner can report a real
42
- * failure would turn "the blob store refused the signature" into an opaque
43
- * timeout — the same reason `makeFlyExecutor` sits its deadline above the
44
- * backend's.
45
- *
46
- * It is `120 s + 5 s`, and it was 90 s: the doc block above cited that rule
47
- * while the number inverted it, so a socket that went quiet was aborted HERE
48
- * 30 s before the runner's own timer could say "the upload timed out after
49
- * 120000ms". Exactly the opaque client abort the rule exists to prevent, and
50
- * the one shape where the runner has something useful to say. The grace is
51
- * `DEADLINE_GRACE_MS`'s 5 s for `DEADLINE_GRACE_MS`'s reason: enough for the
52
- * answer to travel, and not a second more, because every extra second is a
53
- * second of opaque timeout replacing a real error.
54
- *
55
- * Restated rather than imported — `fly-gog-runner/server.mjs` must not be
56
- * pulled into the Worker bundle, the same trade `attachments.ts` makes for the
57
- * runner's size ceilings — so the two move by hand, and a test asserts the
58
- * ordering rather than leaving it to this comment.
26
+ * Where gmail's attachment download writes (`defaultOutPath` in
27
+ * packages/gogcli-mcp-gmail/src/tools/gmail-extra.ts) and the only tree this
28
+ * module will read back from.
59
29
  */
60
- export const RUNNER_UPLOAD_TIMEOUT_MS = 125_000;
30
+ export const ATTACHMENT_DOWNLOAD_ROOT = '/tmp/gog-attachments';
31
+
32
+ /** mcp-host's blob store caps one object at 100 MiB and answers 413 past it. */
33
+ export const MAX_BLOB_UPLOAD_BYTES = 100 * 1024 * 1024;
34
+
35
+ /** Total deadline on one PUT, from dial to the blob store's last byte. */
36
+ export const BLOB_UPLOAD_TIMEOUT_MS = 120_000;
37
+
38
+ // Enough of the blob store's error body to quote its `{"error":"…"}`, bounded
39
+ // so a stray HTML page cannot become the message.
40
+ const ERROR_BODY_SNIPPET = 512;
61
41
 
62
42
  export interface BlobUploadRequest {
63
- /** The file to send, resolved on the RUNNER's disk. */
43
+ /** The file to send, on this machine, inside the attachment download root. */
64
44
  path: string;
65
45
  /** The signed PUT URL. A credential — never log it, never echo it. */
66
46
  url: string;
@@ -69,30 +49,24 @@ export interface BlobUploadRequest {
69
49
  }
70
50
 
71
51
  export interface BlobUploadOutcome {
72
- /** Bytes the runner actually streamed — the file's own size, not a claim. */
73
- bytes?: number;
74
- /** The blob store's status, as the runner observed it. */
75
- status?: number;
52
+ /** Bytes streamed — the file's own size, not a claim. */
53
+ bytes: number;
54
+ /** The blob store's (2xx) status. */
55
+ status: number;
76
56
  }
77
57
 
78
58
  export interface BlobUploadOptions {
79
- /** Where the runner's endpoint and key are read from. Injected by tests. */
80
- env?: NodeJS.ProcessEnv;
59
+ /** The confinement root. Injected by tests. */
60
+ root?: string;
61
+ /** The total deadline. Injected by tests. */
62
+ timeoutMs?: number;
81
63
  }
82
64
 
83
65
  /**
84
66
  * Remove a signed URL, and the bare signature inside it, from a message.
85
67
  *
86
- * Everything this module throws is expected to be logged, and a failure's text
87
- * is routinely a third party's: the runner scrubs its own words, but the blob
88
- * store's error is quoted through it and an HTTP client's rejection may name
89
- * the URL it was dialling. Both shapes are removed, because either left
90
- * standing is the whole credential — the signature is what `sig=` carries, and
91
- * the rest of the URL is public.
92
- *
93
- * The empty needle is guarded rather than assumed away: `split('')` cuts a
94
- * message into single characters and interleaves the replacement between every
95
- * one of them.
68
+ * The empty needle is guarded: `split('')` cuts a message into single
69
+ * characters and interleaves the replacement between every one of them.
96
70
  */
97
71
  function withoutUrlSecrets(message: string, url: string): string {
98
72
  let out = url ? message.split(url).join('<signed url>') : message;
@@ -101,79 +75,136 @@ function withoutUrlSecrets(message: string, url: string): string {
101
75
  return out;
102
76
  }
103
77
 
78
+ class UploadError extends Error {}
79
+
80
+ async function resolveConfined(root: string, requested: string): Promise<{ file: string; bytes: number }> {
81
+ const path = await import('node:path');
82
+ const { realpath, stat } = await import('node:fs/promises');
83
+ // Separator-terminated, so `/tmp/gog-attachments-evil` is not "inside".
84
+ const isInside = (base: string, candidate: string) =>
85
+ candidate === base || candidate.startsWith(base + path.sep);
86
+
87
+ const lexicalRoot = path.resolve(root);
88
+ const candidate = path.resolve(lexicalRoot, requested);
89
+ if (!isInside(lexicalRoot, candidate)) {
90
+ throw new UploadError(`the attachment path must be inside ${lexicalRoot}`);
91
+ }
92
+ let realFile: string;
93
+ let realRoot: string;
94
+ try {
95
+ realFile = await realpath(candidate);
96
+ // /tmp is itself a symlink on macOS, so the root is compared real-to-real.
97
+ realRoot = await realpath(lexicalRoot);
98
+ } catch (err) {
99
+ throw new UploadError(`the downloaded attachment could not be found: ${(err as Error).message}`);
100
+ }
101
+ if (!isInside(realRoot, realFile)) {
102
+ throw new UploadError(`the attachment path must be inside ${realRoot}`);
103
+ }
104
+ const info = await stat(realFile);
105
+ if (!info.isFile()) throw new UploadError('the attachment path must name a regular file');
106
+ if (info.size > MAX_BLOB_UPLOAD_BYTES) {
107
+ throw new UploadError(
108
+ `the attachment is ${info.size} bytes; the blob store's maximum is ${MAX_BLOB_UPLOAD_BYTES} bytes`,
109
+ );
110
+ }
111
+ return { file: realFile, bytes: info.size };
112
+ }
113
+
114
+ function parseTarget(url: string): URL {
115
+ let target: URL | undefined;
116
+ try {
117
+ target = new URL(url);
118
+ } catch {
119
+ // refused below
120
+ }
121
+ if (!target || (target.protocol !== 'https:' && target.protocol !== 'http:')) {
122
+ throw new UploadError('the upload target must be an absolute http(s) URL');
123
+ }
124
+ return target;
125
+ }
126
+
127
+ // `http.request` rather than `fetch`: undici drops a caller-set Content-Length
128
+ // from a streamed body and sends it chunked, which the gateway refuses. The
129
+ // body is a pipe, so a 100 MiB file is never held in memory.
130
+ async function putFile(
131
+ target: URL,
132
+ file: string,
133
+ bytes: number,
134
+ contentType: string,
135
+ timeoutMs: number,
136
+ ): Promise<{ status: number; body: string }> {
137
+ const { createReadStream } = await import('node:fs');
138
+ const { pipeline } = await import('node:stream');
139
+ const transport = target.protocol === 'https:' ? await import('node:https') : await import('node:http');
140
+
141
+ return new Promise((resolve, reject) => {
142
+ let settled = false;
143
+ const source = createReadStream(file);
144
+ const req = transport.request(target, {
145
+ method: 'PUT',
146
+ headers: { 'content-type': contentType, 'content-length': String(bytes) },
147
+ });
148
+ // Release both ends either way: the blob store may answer (and refuse)
149
+ // before the body is sent, and an unfinished request holds its socket.
150
+ const finish = (settle: () => void) => {
151
+ if (settled) return;
152
+ settled = true;
153
+ clearTimeout(timer);
154
+ source.destroy();
155
+ req.destroy();
156
+ settle();
157
+ };
158
+ const fail = (err: unknown) => finish(() => reject(err));
159
+ const timer = setTimeout(
160
+ () => fail(new UploadError(`the upload to the blob store did not finish within ${timeoutMs}ms`)),
161
+ timeoutMs,
162
+ );
163
+
164
+ req.on('error', fail);
165
+ req.on('response', (res) => {
166
+ const chunks: Buffer[] = [];
167
+ let held = 0;
168
+ res.on('data', (chunk: Buffer) => {
169
+ if (held >= ERROR_BODY_SNIPPET) return;
170
+ chunks.push(chunk);
171
+ held += chunk.length;
172
+ });
173
+ res.on('error', fail);
174
+ res.on('end', () => finish(() => resolve({
175
+ status: res.statusCode as number,
176
+ body: Buffer.concat(chunks).toString().slice(0, ERROR_BODY_SNIPPET),
177
+ })));
178
+ });
179
+ pipeline(source, req, (err) => { if (err) fail(err); });
180
+ });
181
+ }
182
+
104
183
  /**
105
- * Stream the file at `request.path` (on the runner) to `request.url`.
184
+ * PUT the file at `request.path` to `request.url`.
106
185
  *
107
- * Resolves with what the runner reported; THROWS on every failure, with a
108
- * message safe to log. Both variables are required — the same both-or-neither
109
- * rule `useRemoteGogRunner` applies, for the same reason: a URL with no key
110
- * sends unauthenticated requests the runner rejects, and a key with no URL is a
111
- * credential configured for nothing.
186
+ * Resolves with the bytes streamed and the blob store's status; THROWS on every
187
+ * failure, with a message safe to log. Nothing is retried: a fresh URL is the
188
+ * only repair for the common failure (a stale or mismatched signature).
112
189
  */
113
190
  export async function uploadToBlobStore(
114
191
  request: BlobUploadRequest,
115
192
  options: BlobUploadOptions = {},
116
193
  ): Promise<BlobUploadOutcome> {
117
- const env = options.env ?? process.env;
118
- // The shared reader, as everywhere else: blanks, unexpanded `${...}`
119
- // placeholders and the literal "undefined"/"null" are all unset.
120
- const endpoint = readEnvVar('GOG_RUNNER_URL', { env });
121
- const key = readEnvVar('GOG_RUNNER_KEY', { env });
122
- if (!endpoint || !key) {
123
- throw new Error(
124
- 'no gog runner is configured (GOG_RUNNER_URL + GOG_RUNNER_KEY, both or neither), and the ' +
125
- 'attachment bytes are on the runner\'s disk rather than here — so nothing on this side can send them',
126
- );
127
- }
128
-
129
- let response: { ok: boolean; status: number; text(): Promise<string> };
130
- try {
131
- response = await fetch(`${endpoint.replace(/\/+$/, '')}/upload`, {
132
- method: 'POST',
133
- headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
134
- body: JSON.stringify({ path: request.path, url: request.url, contentType: request.contentType }),
135
- signal: AbortSignal.timeout(RUNNER_UPLOAD_TIMEOUT_MS),
136
- });
137
- } catch (err) {
138
- throw new Error(withoutUrlSecrets(
139
- `the upload to the blob store did not complete: ${err instanceof Error ? err.message : String(err)}`,
140
- request.url,
141
- ));
142
- }
143
-
144
- // Inside a try of its own: the response ARRIVING is not the body arriving,
145
- // and a severed or truncated one rejects here. That rejection is a transport
146
- // failure like the dial above and is scrubbed like one — this module's
147
- // contract is that nothing it throws carries the URL, and a guarantee with a
148
- // hole in it is not one.
149
- let raw: string;
150
194
  try {
151
- raw = await response.text();
195
+ const target = parseTarget(request.url);
196
+ const { file, bytes } = await resolveConfined(options.root ?? ATTACHMENT_DOWNLOAD_ROOT, request.path);
197
+ const answer = await putFile(
198
+ target, file, bytes, request.contentType, options.timeoutMs ?? BLOB_UPLOAD_TIMEOUT_MS,
199
+ );
200
+ if (answer.status < 200 || answer.status >= 300) {
201
+ throw new UploadError(`the blob store refused the upload with ${answer.status}: ${answer.body}`.trim());
202
+ }
203
+ return { bytes, status: answer.status };
152
204
  } catch (err) {
153
- throw new Error(withoutUrlSecrets(
154
- `the upload to the blob store did not complete: the runner's answer could not be read: ` +
155
- `${err instanceof Error ? err.message : String(err)}`,
156
- request.url,
157
- ));
158
- }
159
- let body: { error?: string; bytes?: number; status?: number } = {};
160
- try {
161
- body = JSON.parse(raw) as typeof body;
162
- } catch {
163
- // A proxy's HTML error page, or a truncated answer. Quoted, never parsed
164
- // into a decision — the status is the part that classifies.
165
- body = { error: raw };
166
- }
167
- if (!response.ok) {
168
- // The runner's own status says which layer failed (422 refused / 502 far
169
- // side / 400 this request); `body.status` is the blob store's verdict when
170
- // there was one. Both are reported, because "403 inside a 422" is what
171
- // tells a stale signature from a broken runner.
172
- throw new Error(withoutUrlSecrets(
173
- `the runner could not store the attachment (HTTP ${response.status}` +
174
- `${body.status ? `, blob store ${body.status}` : ''}): ${body.error ?? 'no reason given'}`,
175
- request.url,
176
- ));
205
+ const message = err instanceof UploadError
206
+ ? err.message
207
+ : `the upload to the blob store did not complete: ${(err as Error).message}`;
208
+ throw new Error(withoutUrlSecrets(message, request.url));
177
209
  }
178
- return { bytes: body.bytes, status: body.status };
179
210
  }