gogcli-mcp 2.19.1 → 2.20.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
10
- "version": "2.19.1"
10
+ "version": "2.20.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "gogcli",
16
16
  "source": "./",
17
17
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
18
- "version": "2.19.1",
18
+ "version": "2.20.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
3
  "displayName": "gogcli",
4
- "version": "2.19.1",
4
+ "version": "2.20.0",
5
5
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/index.js CHANGED
@@ -31119,6 +31119,13 @@ function isGogFileArg(arg) {
31119
31119
  return typeof arg !== "string";
31120
31120
  }
31121
31121
  var runExecutor = new AsyncLocalStorage();
31122
+ var defaultExecutor;
31123
+ function setDefaultGogExecutor(executor) {
31124
+ defaultExecutor = executor ? { executor } : void 0;
31125
+ }
31126
+ function activeExecutor() {
31127
+ return runExecutor.getStore() ?? defaultExecutor;
31128
+ }
31122
31129
  var TIMEOUT_MS = 3e4;
31123
31130
  function readonlyEnvEnabled() {
31124
31131
  return readEnvVar("GOG_READONLY") !== void 0 && parseBoolEnv("GOG_READONLY", { default: true });
@@ -31279,7 +31286,7 @@ async function run(args, options = {}) {
31279
31286
  const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full" } = options;
31280
31287
  const redact = redactMode === "tokens" ? redactGoogleTokens : redactSecrets2;
31281
31288
  const fullArgs = assembleArgs(args, { account, interactive, readonly: readonly2 });
31282
- const store = runExecutor.getStore();
31289
+ const store = activeExecutor();
31283
31290
  try {
31284
31291
  let output;
31285
31292
  if (spawner) {
@@ -31296,7 +31303,7 @@ async function run(args, options = {}) {
31296
31303
  }
31297
31304
  async function runBinary(args, options = {}) {
31298
31305
  const { account, spawner, timeout, readonly: readonly2 = false } = options;
31299
- if (!spawner && runExecutor.getStore()) {
31306
+ if (!spawner && activeExecutor()) {
31300
31307
  throw new Error(
31301
31308
  "Raw byte retrieval is not available over the hosted connector (its transport is text-only). Use the text-extraction path instead, or run the local stdio server to fetch bytes."
31302
31309
  );
@@ -33072,7 +33079,7 @@ function registerTasksTools(server) {
33072
33079
  }
33073
33080
 
33074
33081
  // src/server.ts
33075
- var VERSION = true ? "2.19.1" : "0.0.0";
33082
+ var VERSION = true ? "2.20.0" : "0.0.0";
33076
33083
  var BASE_TOOL_REGISTRARS = [
33077
33084
  registerApiTools,
33078
33085
  registerAuthTools,
@@ -33092,9 +33099,10 @@ var DEFAULT_TIMEOUT_MS = 3e4;
33092
33099
  var DEADLINE_GRACE_MS = 5e3;
33093
33100
  var RUNNER_GOG_FAILED = 422;
33094
33101
  var RUNNER_DRAINING = 503;
33095
- function makeFlyExecutor(endpoint, key) {
33102
+ function makeFlyExecutor(endpoint, key, readAccessToken) {
33096
33103
  return async (args, opts) => {
33097
33104
  const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
33105
+ const accessToken = readAccessToken?.();
33098
33106
  let res;
33099
33107
  try {
33100
33108
  res = await fetch(endpoint + "/run", {
@@ -33103,7 +33111,7 @@ function makeFlyExecutor(endpoint, key) {
33103
33111
  Authorization: "Bearer " + key,
33104
33112
  "Content-Type": "application/json"
33105
33113
  },
33106
- body: JSON.stringify({ args }),
33114
+ body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
33107
33115
  signal: AbortSignal.timeout(deadlineMs)
33108
33116
  });
33109
33117
  } catch (err) {
@@ -33144,7 +33152,9 @@ function useRemoteGogRunner(env = process.env) {
33144
33152
  const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
33145
33153
  const key = readEnvVar("GOG_RUNNER_KEY", { env });
33146
33154
  if (!endpoint || !key) return false;
33147
- runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ""), key) });
33155
+ setDefaultGogExecutor(
33156
+ makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, () => readEnvVar("GOG_ACCESS_TOKEN", { env }))
33157
+ );
33148
33158
  return true;
33149
33159
  }
33150
33160
 
package/dist/lib.js CHANGED
@@ -23016,6 +23016,13 @@ function isGogFileArg(arg) {
23016
23016
  return typeof arg !== "string";
23017
23017
  }
23018
23018
  var runExecutor = new AsyncLocalStorage();
23019
+ var defaultExecutor;
23020
+ function setDefaultGogExecutor(executor) {
23021
+ defaultExecutor = executor ? { executor } : void 0;
23022
+ }
23023
+ function activeExecutor() {
23024
+ return runExecutor.getStore() ?? defaultExecutor;
23025
+ }
23019
23026
  var TIMEOUT_MS = 3e4;
23020
23027
  var MIN_GOG_VERSION = "0.34.1";
23021
23028
  function readonlyEnvEnabled() {
@@ -23177,7 +23184,7 @@ async function run(args, options = {}) {
23177
23184
  const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full" } = options;
23178
23185
  const redact = redactMode === "tokens" ? redactGoogleTokens : redactSecrets2;
23179
23186
  const fullArgs = assembleArgs(args, { account, interactive, readonly: readonly2 });
23180
- const store = runExecutor.getStore();
23187
+ const store = activeExecutor();
23181
23188
  try {
23182
23189
  let output;
23183
23190
  if (spawner) {
@@ -23194,7 +23201,7 @@ async function run(args, options = {}) {
23194
23201
  }
23195
23202
  async function runBinary(args, options = {}) {
23196
23203
  const { account, spawner, timeout, readonly: readonly2 = false } = options;
23197
- if (!spawner && runExecutor.getStore()) {
23204
+ if (!spawner && activeExecutor()) {
23198
23205
  throw new Error(
23199
23206
  "Raw byte retrieval is not available over the hosted connector (its transport is text-only). Use the text-extraction path instead, or run the local stdio server to fetch bytes."
23200
23207
  );
@@ -24978,7 +24985,7 @@ function registerTasksTools(server) {
24978
24985
  }
24979
24986
 
24980
24987
  // src/server.ts
24981
- var VERSION = true ? "2.19.1" : "0.0.0";
24988
+ var VERSION = true ? "2.20.0" : "0.0.0";
24982
24989
  var BASE_TOOL_REGISTRARS = [
24983
24990
  registerApiTools,
24984
24991
  registerAuthTools,
@@ -24998,9 +25005,10 @@ var DEFAULT_TIMEOUT_MS = 3e4;
24998
25005
  var DEADLINE_GRACE_MS = 5e3;
24999
25006
  var RUNNER_GOG_FAILED = 422;
25000
25007
  var RUNNER_DRAINING = 503;
25001
- function makeFlyExecutor(endpoint, key) {
25008
+ function makeFlyExecutor(endpoint, key, readAccessToken) {
25002
25009
  return async (args, opts) => {
25003
25010
  const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
25011
+ const accessToken = readAccessToken?.();
25004
25012
  let res;
25005
25013
  try {
25006
25014
  res = await fetch(endpoint + "/run", {
@@ -25009,7 +25017,7 @@ function makeFlyExecutor(endpoint, key) {
25009
25017
  Authorization: "Bearer " + key,
25010
25018
  "Content-Type": "application/json"
25011
25019
  },
25012
- body: JSON.stringify({ args }),
25020
+ body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
25013
25021
  signal: AbortSignal.timeout(deadlineMs)
25014
25022
  });
25015
25023
  } catch (err) {
@@ -25050,7 +25058,9 @@ function useRemoteGogRunner(env = process.env) {
25050
25058
  const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
25051
25059
  const key = readEnvVar("GOG_RUNNER_KEY", { env });
25052
25060
  if (!endpoint || !key) return false;
25053
- runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ""), key) });
25061
+ setDefaultGogExecutor(
25062
+ makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, () => readEnvVar("GOG_ACCESS_TOKEN", { env }))
25063
+ );
25054
25064
  return true;
25055
25065
  }
25056
25066
  export {
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": "2.19.1",
6
+ "version": "2.20.0",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.19.1",
3
+ "version": "2.20.0",
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>",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.19.1",
10
+ "version": "2.20.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.19.1",
15
+ "version": "2.20.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -60,9 +60,29 @@ const RUNNER_DRAINING = 503;
60
60
  // clever here (flattening to `--flag=<inline>`, truncating, re-encoding) would
61
61
  // put the payload straight back into argv and re-create the size cap this whole
62
62
  // change exists to escape.
63
- export function makeFlyExecutor(endpoint: string, key: string): GogExecutor {
63
+ // `readAccessToken` is how a hosted gog acts as its CALLER rather than as
64
+ // whoever seeded the backend's volume (#230). The backend holds one Google
65
+ // identity; a token supplied with the request overrides it for that one `gog`
66
+ // invocation, and `gog` already prefers a directly-passed token over its store.
67
+ //
68
+ // A FUNCTION, read per call, not a string captured at construction. The whole
69
+ // claim being made is "this token belongs to this request", and an executor
70
+ // outlives any one request — on the Worker path a single isolate serves many
71
+ // callers, so a captured token would pin the first caller's identity onto
72
+ // everyone who followed. That is the same shared-identity bug this closes,
73
+ // rebuilt one layer up.
74
+ //
75
+ // Absent when there is no token, rather than null or "": the backend has to
76
+ // tell "act as this caller" from "act as the box", and an empty third state is
77
+ // one neither side has a meaning for.
78
+ export function makeFlyExecutor(
79
+ endpoint: string,
80
+ key: string,
81
+ readAccessToken?: () => string | undefined,
82
+ ): GogExecutor {
64
83
  return async (args: GogArg[], opts) => {
65
84
  const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
85
+ const accessToken = readAccessToken?.();
66
86
  let res: Response;
67
87
  try {
68
88
  res = await fetch(endpoint + '/run', {
@@ -71,7 +91,7 @@ export function makeFlyExecutor(endpoint: string, key: string): GogExecutor {
71
91
  Authorization: 'Bearer ' + key,
72
92
  'Content-Type': 'application/json',
73
93
  },
74
- body: JSON.stringify({ args }),
94
+ body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
75
95
  signal: AbortSignal.timeout(deadlineMs),
76
96
  });
77
97
  } catch (err) {
@@ -1,5 +1,5 @@
1
1
  import { readEnvVar } from '@chrischall/mcp-utils';
2
- import { runExecutor } from './runner.js';
2
+ import { setDefaultGogExecutor } from './runner.js';
3
3
  import { makeFlyExecutor } from './connector-runtime.js';
4
4
 
5
5
  /**
@@ -19,16 +19,26 @@ import { makeFlyExecutor } from './connector-runtime.js';
19
19
  * executes remotely; leave either unset and nothing changes, which is what
20
20
  * keeps every existing local install on the binary it already has.
21
21
  *
22
- * ## Why `enterWith` and not `run`
22
+ * ## Why a process-wide default and not the AsyncLocalStorage
23
23
  *
24
- * `runExecutor` is an AsyncLocalStorage. The Worker wraps each REQUEST in
25
- * `runExecutor.run(...)` because a Worker isolate serves many of them and the
26
- * executor differs per user. A stdio process is one user for its whole life,
27
- * and its tool calls arrive later as I/O callbacks — which would NOT inherit a
28
- * store established by a `run()` that had already returned. `enterWith` sets it
29
- * for the remainder of this execution and everything descending from it, which
30
- * is the whole process. Using `run()` here would look correct and then fall
31
- * back to spawning on the first actual tool call.
24
+ * `runExecutor` is an AsyncLocalStorage, and the Worker wraps each REQUEST in
25
+ * `runExecutor.run(...)` because one isolate serves many callers whose backend
26
+ * credentials differ. A stdio process is the opposite: one backend for its
27
+ * whole life.
28
+ *
29
+ * This used to reach for `enterWith` on the theory that it "sets the store for
30
+ * the whole process". It does not. `enterWith` sets the store on the async
31
+ * resource that is CURRENT when it runs here, module evaluation — and the
32
+ * tool calls arrive later as I/O events on the transport's own resources, which
33
+ * do not descend from that. So `getStore()` was undefined at exactly the moment
34
+ * `run()` asked, and the seam reverted to spawning a binary the host does not
35
+ * have. Every hosted gog MCP answered "gog executable not found" while pointed
36
+ * at a healthy backend, and the unit test missed it because a test that calls
37
+ * this function itself awaits inside the resource it just mutated.
38
+ *
39
+ * A process-lifetime value is not a scoped value, so it does not live in a
40
+ * scope: `setDefaultGogExecutor` holds it, and a per-request store still beats
41
+ * it (runner.ts `activeExecutor`) so the Worker path is unchanged.
32
42
  *
33
43
  * Call before the server starts, so no tool can be serviced ahead of it.
34
44
  */
@@ -44,6 +54,17 @@ export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolea
44
54
  // nothing — either alone is a misconfiguration, and silently spawning
45
55
  // instead would hide it until someone wondered why the binary was needed.
46
56
  if (!endpoint || !key) return false;
47
- runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ''), key) });
57
+ // Whose Google identity this process acts as (#230). The backend holds ONE
58
+ // identity on its volume, so without this every caller of a hosted gog acts
59
+ // as whoever seeded it. Under mcp-host's `perUserChild` this process belongs
60
+ // to a single caller and its environment carries that caller's token, so
61
+ // forwarding it is the whole of "act as the person calling you".
62
+ //
63
+ // Read per call rather than captured here, because the executor outlives any
64
+ // one request and the claim being made is about a request. Absent when unset,
65
+ // which is every registration that predates per-caller auth.
66
+ setDefaultGogExecutor(
67
+ makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, () => readEnvVar('GOG_ACCESS_TOKEN', { env })),
68
+ );
48
69
  return true;
49
70
  }
package/src/runner.ts CHANGED
@@ -50,6 +50,40 @@ export type GogExecutor = (
50
50
  // `runExecutor.run({ executor }, ...)`; unset, `run()` falls back to spawning.
51
51
  export const runExecutor = new AsyncLocalStorage<{ executor: GogExecutor }>();
52
52
 
53
+ /**
54
+ * The PROCESS-WIDE executor, for a host that has exactly one backend for the
55
+ * whole process — a stdio bin pointed at a Fly runner (`useRemoteGogRunner`).
56
+ *
57
+ * It exists because AsyncLocalStorage cannot express that. `enterWith` sets the
58
+ * store on the async resource that is current when it runs, and a bin runs it
59
+ * during module evaluation; the tool calls arrive later as I/O events on the
60
+ * transport's own resources, which are not descendants of that evaluation, so
61
+ * `getStore()` is undefined exactly where it is needed. That is not a bug in
62
+ * `enterWith` — a process-lifetime default is simply not a scoped value, and
63
+ * storing it in a scope meant the seam silently reverted to spawning a binary
64
+ * the host does not have.
65
+ *
66
+ * A per-request store still WINS over this (see `activeExecutor`), because the
67
+ * Worker serves many callers from one isolate and each has its own backend
68
+ * credential; this is the fallback for the one-backend case, never a second
69
+ * answer to "whose backend is this".
70
+ */
71
+ let defaultExecutor: { executor: GogExecutor } | undefined;
72
+
73
+ /** Install the process-wide executor. Passing undefined clears it (tests). */
74
+ export function setDefaultGogExecutor(executor: GogExecutor | undefined): void {
75
+ defaultExecutor = executor ? { executor } : undefined;
76
+ }
77
+
78
+ /**
79
+ * Whose executor applies right now: the request's, else the process's, else
80
+ * none (meaning `run()` spawns the local binary). Both call sites ask through
81
+ * here so they can never disagree about which of the three it is.
82
+ */
83
+ function activeExecutor(): { executor: GogExecutor } | undefined {
84
+ return runExecutor.getStore() ?? defaultExecutor;
85
+ }
86
+
53
87
  export interface RunOptions {
54
88
  account?: string;
55
89
  spawner?: Spawner;
@@ -328,7 +362,7 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
328
362
  // successful `gog auth tokens` (or any command echoing a credential) would
329
363
  // otherwise return raw Google tokens (ya29.…/1//…) into model context, where
330
364
  // a sibling tool (gog_gmail_send) could exfiltrate them.
331
- const store = runExecutor.getStore();
365
+ const store = activeExecutor();
332
366
  try {
333
367
  let output: string;
334
368
  if (spawner) {
@@ -358,7 +392,7 @@ export async function runBinary(args: GogArg[], options: RunOptions = {}): Promi
358
392
  // An injected spawner is the stdio/test path and always wins. Otherwise, if an
359
393
  // ambient forward executor is installed (the Worker/Fly connector), refuse:
360
394
  // its text-only transport can't carry bytes intact.
361
- if (!spawner && runExecutor.getStore()) {
395
+ if (!spawner && activeExecutor()) {
362
396
  throw new Error(
363
397
  'Raw byte retrieval is not available over the hosted connector (its transport is text-only). ' +
364
398
  'Use the text-extraction path instead, or run the local stdio server to fetch bytes.',
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, type GogProps } from './connector-auth.js';
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.19.1'; // x-release-please-version
41
+ const VERSION = '2.20.0'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.
@@ -120,6 +120,57 @@ describe('makeFlyExecutor', () => {
120
120
  expect(init.body).toBe(JSON.stringify({ args: ['sheets', 'get', 'A1'] }));
121
121
  });
122
122
 
123
+ // A hosted gog authenticates as whoever seeded the Fly volume, not as the
124
+ // person calling it (#230). mcp-host can give each caller their own child
125
+ // carrying their own GOG_ACCESS_TOKEN, so the missing link is the child
126
+ // handing that token to the backend for ITS call only — never as something
127
+ // ambient on the box, which would be the same shared identity from the other
128
+ // direction.
129
+ describe('per-request access token', () => {
130
+ function okFetch() {
131
+ const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ stdout: 'ok' }) }));
132
+ vi.stubGlobal('fetch', fetchMock);
133
+ return fetchMock;
134
+ }
135
+ function bodyOf(fetchMock: { mock: { calls: unknown[][] } }, i = 0): Record<string, unknown> {
136
+ const [, init] = fetchMock.mock.calls[i] as [string, RequestInit];
137
+ return JSON.parse(init.body as string) as Record<string, unknown>;
138
+ }
139
+
140
+ it('sends the token with the request when one is available', async () => {
141
+ const fetchMock = okFetch();
142
+ const exec = makeFlyExecutor(ENDPOINT, KEY, () => 'ya29.caller-token');
143
+ await exec(['auth', 'status'], {});
144
+ expect(bodyOf(fetchMock)).toEqual({ args: ['auth', 'status'], accessToken: 'ya29.caller-token' });
145
+ });
146
+
147
+ it('omits the field entirely when there is no token', async () => {
148
+ // Absent, not null or "": the backend distinguishes "act as the caller"
149
+ // from "act as the box", and a present-but-empty field would be a third
150
+ // state neither side has a meaning for.
151
+ const fetchMock = okFetch();
152
+ for (const provider of [undefined, () => undefined, () => '']) {
153
+ vi.clearAllMocks();
154
+ const exec = makeFlyExecutor(ENDPOINT, KEY, provider as (() => string | undefined) | undefined);
155
+ await exec(['auth', 'status'], {});
156
+ expect(bodyOf(fetchMock)).toEqual({ args: ['auth', 'status'] });
157
+ }
158
+ });
159
+
160
+ it('reads the token per CALL, not once when the executor is built', async () => {
161
+ // The whole contract is "this token belongs to this request". Reading it
162
+ // once at construction would pin the first caller's identity onto an
163
+ // executor that outlives them — exactly the bug this closes, rebuilt.
164
+ const fetchMock = okFetch();
165
+ const tokens = ['first', 'second'];
166
+ const exec = makeFlyExecutor(ENDPOINT, KEY, () => tokens.shift());
167
+ await exec(['auth', 'status'], {});
168
+ await exec(['auth', 'status'], {});
169
+ expect(bodyOf(fetchMock, 0).accessToken).toBe('first');
170
+ expect(bodyOf(fetchMock, 1).accessToken).toBe('second');
171
+ });
172
+ });
173
+
123
174
  // Everything below is the file-arg wire contract. The Worker has no filesystem
124
175
  // and no gog binary, so a GogFileArg must cross the wire STRUCTURED and be
125
176
  // materialized on the Fly runner. If this layer ever flattened it back into an
@@ -3,16 +3,30 @@ import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { describe, it, expect, vi, afterEach } from 'vitest';
5
5
  import { useRemoteGogRunner } from '../src/remote-runner.js';
6
- import { runExecutor } from '../src/runner.js';
6
+ import { run, runBinary, runExecutor, setDefaultGogExecutor } from '../src/runner.js';
7
7
 
8
8
  /**
9
9
  * The whole point of this seam is that a host without the `gog` binary can
10
- * still serve. Two ways it silently fails: a half-configured env that falls
11
- * back to spawning, and using `run()` instead of `enterWith()` so the store is
12
- * gone by the time a tool call arrives.
10
+ * still serve. Every way it has silently failed so far: a half-configured env
11
+ * that falls back to spawning; a bin that never calls `useRemoteGogRunner()`;
12
+ * and the one that shipped in 2.19.x — parking the executor in an
13
+ * AsyncLocalStorage that the tool calls never inherit, so the seam reported
14
+ * success and then spawned anyway.
15
+ *
16
+ * They share a shape worth naming: every one of them fails by falling back to
17
+ * the local binary, which on a laptop is invisible and on a host is total. So
18
+ * these tests assert on where a call ACTUALLY went, never on whether the wiring
19
+ * step was performed.
13
20
  */
14
21
 
15
- afterEach(() => vi.unstubAllGlobals());
22
+ afterEach(() => {
23
+ vi.unstubAllGlobals();
24
+ vi.unstubAllEnvs();
25
+ // The executor is process-wide BY DESIGN now, so unlike an ALS store it does
26
+ // not unwind when a test ends — without this, the first test to install one
27
+ // would silently hand it to every test that ran after it.
28
+ setDefaultGogExecutor(undefined);
29
+ });
16
30
 
17
31
  describe('useRemoteGogRunner', () => {
18
32
  it('does nothing unless BOTH variables are set, so local installs are untouched', () => {
@@ -33,26 +47,124 @@ describe('useRemoteGogRunner', () => {
33
47
  expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'null', GOG_RUNNER_KEY: 'k' })).toBe(false);
34
48
  });
35
49
 
36
- it('installs an executor that survives into a LATER async callback', async () => {
37
- // The real failure mode this guards: with `run()` the store would be gone
38
- // by the time a tool call arrives as an I/O callback, and the server would
39
- // quietly go back to spawning a binary that is not installed.
50
+ it('addresses and authenticates the backend correctly', async () => {
51
+ // Asserted THROUGH run(), not by reading whichever slot the executor is
52
+ // parked in. The previous version of this test fetched
53
+ // `runExecutor.getStore()` and called what it found, so it was really a
54
+ // test that `enterWith` had been called — which stayed green while the
55
+ // shipped bins routed every call to a local binary (see the next test).
40
56
  const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
41
57
  vi.stubGlobal('fetch', fetchMock);
58
+ vi.stubEnv('GOG_PATH', '/nonexistent/gog');
42
59
 
43
60
  expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test/', GOG_RUNNER_KEY: 'secret' })).toBe(true);
44
61
 
45
62
  // Cross a macrotask boundary, the way a stdio tool call does.
46
63
  await new Promise((r) => setTimeout(r, 0));
47
- const store = runExecutor.getStore();
48
- expect(store?.executor).toBeTypeOf('function');
64
+ expect(await run(['--version'])).toBe('ok');
49
65
 
50
- await store!.executor(['--version'], {});
51
66
  const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
52
67
  // Trailing slash trimmed, so the endpoint is never `…//run`.
53
68
  expect(url).toBe('https://r.test/run');
54
69
  expect((init.headers as Record<string, string>).Authorization).toBe('Bearer secret');
55
70
  });
71
+
72
+ it('still routes remotely from a context the store never reached', async () => {
73
+ // THE ONE THE TEST ABOVE CANNOT CATCH, and it shipped broken.
74
+ //
75
+ // `enterWith` mutates the store of the async resource that is current when
76
+ // it runs. The test above calls useRemoteGogRunner() itself, so everything
77
+ // it awaits afterwards is a descendant of that resource and inherits the
78
+ // store — it passes whether or not the seam works where it matters.
79
+ //
80
+ // A bin does something different: it calls useRemoteGogRunner() while the
81
+ // ES module is evaluating, and then `await runMcp(...)` starts serving. The
82
+ // tool calls arrive later as I/O events on the transport's own async
83
+ // resources, which are NOT descendants of module evaluation, so
84
+ // `getStore()` is undefined by the time `run()` asks. Verified against the
85
+ // published 2.19.1 bin: `useRemoteGogRunner()` returned true, and at call
86
+ // time the store was undefined — every hosted gog MCP answered
87
+ // "gog executable not found" while pointed at a healthy backend.
88
+ //
89
+ // `runExecutor.exit()` is that condition exactly, and deterministically:
90
+ // run the call where the ALS store does not apply.
91
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'remote' }), { status: 200 }));
92
+ vi.stubGlobal('fetch', fetchMock);
93
+ // So the spawn fallback cannot accidentally succeed on a machine that has
94
+ // gog installed and make this pass for the wrong reason.
95
+ vi.stubEnv('GOG_PATH', '/nonexistent/gog');
96
+
97
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
98
+
99
+ const output = await runExecutor.exit(() => run(['auth', 'status']));
100
+ expect(output).toBe('remote');
101
+ expect(fetchMock).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it('forwards its own GOG_ACCESS_TOKEN so a hosted gog acts as its caller', async () => {
105
+ // Under mcp-host's perUserChild, THIS PROCESS belongs to one caller and
106
+ // holds their token (#230). The backend has one Google identity on its
107
+ // volume, so without forwarding, every caller of a hosted gog acts as
108
+ // whoever seeded it.
109
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
110
+ vi.stubGlobal('fetch', fetchMock);
111
+ vi.stubEnv('GOG_PATH', '/nonexistent/gog');
112
+
113
+ expect(
114
+ useRemoteGogRunner({
115
+ GOG_RUNNER_URL: 'https://r.test',
116
+ GOG_RUNNER_KEY: 'secret',
117
+ GOG_ACCESS_TOKEN: 'ya29.the-caller',
118
+ }),
119
+ ).toBe(true);
120
+
121
+ await runExecutor.exit(() => run(['auth', 'status']));
122
+ const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
123
+ expect(JSON.parse(init.body as string).accessToken).toBe('ya29.the-caller');
124
+ });
125
+
126
+ it('sends no token when the caller has not supplied one', async () => {
127
+ // Absent, not empty: the backend distinguishes "act as this caller" from
128
+ // "act as the box", and every registration that predates per-caller auth is
129
+ // the second case.
130
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
131
+ vi.stubGlobal('fetch', fetchMock);
132
+ vi.stubEnv('GOG_PATH', '/nonexistent/gog');
133
+
134
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
135
+
136
+ await runExecutor.exit(() => run(['auth', 'status']));
137
+ const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
138
+ // The KEY must be absent, not present-and-empty. Asserted on its own rather
139
+ // than by matching the whole body: `assembleArgs` folds in ambient settings
140
+ // like GOG_ACCOUNT, so a whole-body match passes or fails depending on the
141
+ // developer's shell.
142
+ expect(JSON.parse(init.body as string)).not.toHaveProperty('accessToken');
143
+ });
144
+
145
+ it('lets a per-request store beat the process-wide default', async () => {
146
+ // The Worker path scopes every request in `runExecutor.run({ executor })`
147
+ // because one isolate serves many callers (connector-runtime.ts). A
148
+ // process-wide default must never shadow that: it is the fallback for a
149
+ // stdio bin, not a second answer to "whose backend is this".
150
+ const perRequest = vi.fn(async () => 'per-request');
151
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ stdout: 'default' }), { status: 200 })));
152
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
153
+
154
+ const output = await runExecutor.run({ executor: perRequest }, () => run(['auth', 'status']));
155
+ expect(output).toBe('per-request');
156
+ expect(perRequest).toHaveBeenCalledTimes(1);
157
+ });
158
+
159
+ it('still refuses binary retrieval when the default is a remote executor', async () => {
160
+ // runBinary's guard asks the same question ("is a text-only forward
161
+ // installed?") and must read the same answer, or a hosted bin would fall
162
+ // through to spawning for bytes while every text call went remote.
163
+ vi.stubEnv('GOG_PATH', '/nonexistent/gog');
164
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
165
+
166
+ await expect(runExecutor.exit(() => runBinary(['drive', 'read']))).rejects.toThrow(/text-only/);
167
+ });
56
168
  });
57
169
 
58
170
  /**