@chrischall/gemini-mcp 2.3.2 → 2.3.4

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/dist/client.js CHANGED
@@ -12,12 +12,12 @@ import { SessionState } from './session.js';
12
12
  import { fetchRemoteImage, readCapped, MAX_REDIRECTS } from './fetch-image.js';
13
13
  import { bytesToBase64 } from './bytes.js';
14
14
  import { explainOutsideUploadDir, uploadRoots } from './images.js';
15
- // NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
16
- // top-level await, no `import.meta.url`. `src/worker.ts` imports it, so every
17
- // line here runs during Cloudflare isolate startup, where global-scope I/O is
18
- // forbidden and wrangler's bundle leaves `import.meta.url` undefined. The
19
- // stdio `.env` bootstrap that used to live here now lives in `src/dotenv.ts`,
20
- // imported only by `src/index.ts`. Guarded by tests/connector-boot.test.ts.
15
+ // NOTE: this module stays SIDE-EFFECT-FREE at module scope — no I/O, no
16
+ // top-level await, no `import.meta.url` — so importing it (tests, registrars)
17
+ // never boots anything. That rule dates from the retired Cloudflare Worker
18
+ // entry point, where global-scope I/O was forbidden; it is kept because it is
19
+ // what makes the module cheap to import. The stdio `.env` bootstrap lives in
20
+ // `src/dotenv.ts`, imported only by `src/index.ts`.
21
21
  const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks gemini-3-pro-image; confirmed via Task 5
22
22
  const SERVICE = 'Gemini';
23
23
  const INTERACTIONS_SERVICE = 'Gemini Interactions';
@@ -44,6 +44,42 @@ export function resolveTimeoutMs(perCallMs, imageSize) {
44
44
  // upload", verified 2026-06-12). The resumable upload endpoint lives under
45
45
  // `/upload/v1beta`, NOT the normal `/v1beta` base.
46
46
  const UPLOAD_BASE_URL = 'https://generativelanguage.googleapis.com/upload/v1beta';
47
+ /** True when `raw` is an https URL on googleapis.com (or a subdomain) — the
48
+ * only place a resumable-upload session URL may send the user's file. */
49
+ function isGoogleUploadUrl(raw) {
50
+ let u;
51
+ try {
52
+ u = new URL(raw);
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ return u.protocol === 'https:' && (u.hostname === 'googleapis.com' || u.hostname.endsWith('.googleapis.com'));
58
+ }
59
+ /**
60
+ * Slowest sustained throughput a media transfer is allowed before it is
61
+ * treated as stalled. A transfer's deadline is the normal request budget plus
62
+ * the time its size takes at this rate, so a large file on a slow link still
63
+ * finishes while a connection that stops sending cannot hang the tool call
64
+ * forever (chrischall/fleet-audit#467) — the progress heartbeat around these
65
+ * calls actively stops the host from timing them out for us.
66
+ */
67
+ const MIN_TRANSFER_BYTES_PER_SEC = 256 * 1024;
68
+ /** Deadline for moving `bytes` up or down; see {@link MIN_TRANSFER_BYTES_PER_SEC}. */
69
+ function transferTimeoutMs(bytes) {
70
+ return resolveTimeoutMs() + Math.ceil((bytes / MIN_TRANSFER_BYTES_PER_SEC) * 1000);
71
+ }
72
+ /**
73
+ * An abort signal that fires after `ms`. A plain timer rather than
74
+ * `AbortSignal.timeout` so it can be cleared once the transfer is done (no
75
+ * stray timer keeping a process alive) and driven by fake timers in tests.
76
+ */
77
+ function transferDeadline(ms) {
78
+ const ctrl = new AbortController();
79
+ const timer = setTimeout(() => ctrl.abort(new DOMException(`timed out after ${ms}ms`, 'TimeoutError')), ms);
80
+ timer.unref?.();
81
+ return { signal: ctrl.signal, clear: () => clearTimeout(timer) };
82
+ }
47
83
  // Documented Files API per-file cap (2 GB); enforced locally so a too-big video
48
84
  // fails fast instead of uploading gigabytes just to be rejected.
49
85
  const FILE_MAX_BYTES = 2 * 1024 ** 3;
@@ -201,10 +237,9 @@ export class GeminiClient {
201
237
  mediaSink;
202
238
  /**
203
239
  * This client's session memory — job registry, last-interaction id, written
204
- * outputs. One client is one session: the hosted connector builds a client
205
- * per authenticated user, so hanging the state here is what keeps it from
206
- * leaking between tenants sharing an isolate (see src/session.ts). The stdio
207
- * server is single-user, so its one singleton client is one session.
240
+ * outputs. One client is one session. Production builds one client per
241
+ * process and one process is one user, so this is process-wide — see the
242
+ * invariant (and what a multi-user host would need) in src/session.ts.
208
243
  */
209
244
  session = new SessionState();
210
245
  /** Per-account character/style library — hosted connector only (see options). */
@@ -359,8 +394,9 @@ export class GeminiClient {
359
394
  * These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
360
395
  * needs the response *header* and step 2 posts a binary body — neither fits
361
396
  * `fetchJson`. The poll DOES go through the shared client (timeout + retry).
362
- * Uploads deliberately have no abort timeout: a multi-hundred-MB video can
363
- * legitimately take longer than any fixed budget.
397
+ * The deadline scales with the file (see `transferTimeoutMs`): a
398
+ * multi-hundred-MB video can legitimately take longer than any fixed budget,
399
+ * but a stalled upload must still fail rather than hang.
364
400
  *
365
401
  * Returned files live ~48h (`expirationTime`); per-file cap is 2 GB
366
402
  * (enforced locally before any bytes are sent).
@@ -427,9 +463,11 @@ export class GeminiClient {
427
463
  * These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
428
464
  * needs the response *header* and step 2 posts a binary body — neither fits
429
465
  * `fetchJson`. The poll DOES go through the shared client (timeout + retry).
430
- * Uploads carry no abort timeout by default: a multi-hundred-MB file can
431
- * legitimately take longer than any fixed budget. A caller that is uploading
432
- * OPPORTUNISTICALLY passes its own `signal` (see `uploadBytes`).
466
+ * By default the two round trips get a deadline sized to the file (see
467
+ * `transferTimeoutMs`) — a multi-hundred-MB file can legitimately take longer
468
+ * than any fixed budget, but a stalled one must fail rather than hang. A
469
+ * caller that is uploading OPPORTUNISTICALLY passes its own, shorter
470
+ * `signal` instead (see `uploadBytes`).
433
471
  */
434
472
  async uploadToFilesApi(body, mimeType, displayName, contentLength,
435
473
  /** Bounds the two upload round trips. The PROCESSING poll below is left
@@ -437,6 +475,33 @@ export class GeminiClient {
437
475
  * video rather than for the images this is used to bound. */
438
476
  signal) {
439
477
  const key = this.requireKey();
478
+ // A caller that uploads opportunistically brings its own (short) signal.
479
+ // Everyone else gets a deadline sized to the file, so a stalled upload
480
+ // fails instead of hanging the tool call (chrischall/fleet-audit#467).
481
+ // The deadline covers the two upload round trips ONLY and is cleared
482
+ // before the PROCESSING poll: the poll has its own attempt cap, and a
483
+ // processing failure (or a slow transcode) reached after the deadline must
484
+ // keep its own error rather than be relabelled a stalled upload.
485
+ const deadline = signal ? undefined : transferDeadline(transferTimeoutMs(contentLength));
486
+ let uploaded;
487
+ try {
488
+ uploaded = await this.sendToFilesApi(body, mimeType, displayName, contentLength, key, signal ?? deadline.signal);
489
+ }
490
+ catch (err) {
491
+ if (deadline?.signal.aborted) {
492
+ throw new McpToolError(`${SERVICE} Files API upload of ${displayName} timed out after ${transferTimeoutMs(contentLength)}ms`, {
493
+ hint: 'The upload stalled. Retry, or check the network between this server and generativelanguage.googleapis.com.',
494
+ });
495
+ }
496
+ throw err;
497
+ }
498
+ finally {
499
+ deadline?.clear();
500
+ }
501
+ return this.awaitFileActive(uploaded.name, uploaded.file, mimeType);
502
+ }
503
+ /** {@link uploadToFilesApi}'s steps 1 and 2 (start + upload/finalize), under `signal`. */
504
+ async sendToFilesApi(body, mimeType, displayName, contentLength, key, signal) {
440
505
  // Local alias so the calls below carry NO receiver: `this.fetchImpl(...)`
441
506
  // would hand native fetch this GeminiClient as `this`, which workerd
442
507
  // rejects with an Illegal invocation (see the constructor note).
@@ -454,7 +519,7 @@ export class GeminiClient {
454
519
  'Content-Type': 'application/json',
455
520
  },
456
521
  body: JSON.stringify({ file: { display_name: displayName } }),
457
- ...(signal ? { signal } : {}),
522
+ signal,
458
523
  });
459
524
  if (!startRes.ok) {
460
525
  throw new McpToolError(formatApiError(startRes.status, 'POST', `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE }));
@@ -465,6 +530,14 @@ export class GeminiClient {
465
530
  hint: 'The Files API resumable-upload contract may have changed — see docs/GEMINI-API.md.',
466
531
  });
467
532
  }
533
+ // The session URL is the destination of the user's file bytes, and it
534
+ // comes from a response header — so it must be a Google upload host over
535
+ // https before anything is posted to it (chrischall/fleet-audit#1011).
536
+ if (!isGoogleUploadUrl(uploadUrl)) {
537
+ throw new McpToolError(`${SERVICE} upload start returned a session URL outside googleapis.com; refusing to send the file`, {
538
+ hint: 'The Files API resumable-upload contract may have changed — see docs/GEMINI-API.md.',
539
+ });
540
+ }
468
541
  // 2. upload + finalize — single shot; the session URL is self-authorizing
469
542
  // (no api-key header needed; verified live).
470
543
  // The Blob body is left EXACTLY as the live-verified video upload sent it:
@@ -479,7 +552,7 @@ export class GeminiClient {
479
552
  'X-Goog-Upload-Command': 'upload, finalize',
480
553
  },
481
554
  body: body,
482
- ...(signal ? { signal } : {}),
555
+ signal,
483
556
  });
484
557
  if (!upRes.ok) {
485
558
  throw new McpToolError(formatApiError(upRes.status, 'POST', '/upload/v1beta/files (finalize)', await upRes.text(), { service: SERVICE }));
@@ -492,6 +565,10 @@ export class GeminiClient {
492
565
  if (!/^files\/[\w-]+$/.test(name)) {
493
566
  throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || '(none)'}`);
494
567
  }
568
+ return { name, file };
569
+ }
570
+ /** {@link uploadToFilesApi}'s step 3: wait out PROCESSING. Outside the upload deadline. */
571
+ async awaitFileActive(name, file, mimeType) {
495
572
  // 3. poll PROCESSING → ACTIVE. Images are normally ACTIVE on arrival; it is
496
573
  // video that spends time here.
497
574
  for (let attempt = 0; file.state === 'PROCESSING'; attempt++) {
@@ -1015,31 +1092,50 @@ export class GeminiClient {
1015
1092
  // fetch-image.ts enforces, and stricter: there is exactly one host
1016
1093
  // generated media is ever served from.
1017
1094
  const url = assertMediaHost(current, requested);
1018
- const res = await doFetch(url.toString(), {
1019
- redirect: 'manual',
1020
- headers: { 'x-goog-api-key': key },
1021
- });
1022
- if (res.status >= 300 && res.status < 400) {
1023
- const location = res.headers?.get('location');
1024
- if (!location) {
1025
- throw new McpToolError(`${SERVICE} media download returned HTTP ${res.status} with no Location header for ${current}`);
1095
+ // One deadline per hop, covering the body as well as the headers: a
1096
+ // server that answers and then stops sending would otherwise hang the
1097
+ // tool call forever (chrischall/fleet-audit#467).
1098
+ const budgetMs = transferTimeoutMs(MEDIA_DOWNLOAD_MAX_BYTES);
1099
+ const deadline = transferDeadline(budgetMs);
1100
+ try {
1101
+ const res = await doFetch(url.toString(), {
1102
+ redirect: 'manual',
1103
+ headers: { 'x-goog-api-key': key },
1104
+ signal: deadline.signal,
1105
+ });
1106
+ if (res.status >= 300 && res.status < 400) {
1107
+ const location = res.headers?.get('location');
1108
+ if (!location) {
1109
+ throw new McpToolError(`${SERVICE} media download returned HTTP ${res.status} with no Location header for ${current}`);
1110
+ }
1111
+ if (hop >= MAX_REDIRECTS) {
1112
+ throw new McpToolError(`${SERVICE} media download for ${requested} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
1113
+ }
1114
+ current = new URL(location, url).toString();
1115
+ continue;
1026
1116
  }
1027
- if (hop >= MAX_REDIRECTS) {
1028
- throw new McpToolError(`${SERVICE} media download for ${requested} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
1117
+ if (!res.ok) {
1118
+ throw new McpToolError(`${SERVICE} media download failed (HTTP ${res.status}) for ${current}`, { hint: 'Generated files live in the Files API for ~48h and the download requires the api key that created them.' });
1029
1119
  }
1030
- current = new URL(location, url).toString();
1031
- continue;
1120
+ // Capped while streaming rather than buffered whole: a generated clip is
1121
+ // small, but `arrayBuffer()` on a surprise is an unbounded allocation.
1122
+ const bytes = await readCapped(res, MEDIA_DOWNLOAD_MAX_BYTES, current, requested, MEDIA_CAP_SUBJECT);
1123
+ return {
1124
+ base64: bytesToBase64(bytes),
1125
+ mimeType: media.mimeType || res.headers?.get('content-type') || 'application/octet-stream',
1126
+ };
1032
1127
  }
1033
- if (!res.ok) {
1034
- throw new McpToolError(`${SERVICE} media download failed (HTTP ${res.status}) for ${current}`, { hint: 'Generated files live in the Files API for ~48h and the download requires the api key that created them.' });
1128
+ catch (err) {
1129
+ if (deadline.signal.aborted && !(err instanceof McpToolError)) {
1130
+ throw new McpToolError(`${SERVICE} media download timed out after ${budgetMs}ms for ${current}`, {
1131
+ hint: 'The download stalled. Recover the result with gemini_get_result, or retry.',
1132
+ });
1133
+ }
1134
+ throw err;
1135
+ }
1136
+ finally {
1137
+ deadline.clear();
1035
1138
  }
1036
- // Capped while streaming rather than buffered whole: a generated clip is
1037
- // small, but `arrayBuffer()` on a surprise is an unbounded allocation.
1038
- const bytes = await readCapped(res, MEDIA_DOWNLOAD_MAX_BYTES, current, requested, MEDIA_CAP_SUBJECT);
1039
- return {
1040
- base64: bytesToBase64(bytes),
1041
- mimeType: media.mimeType || res.headers?.get('content-type') || 'application/octet-stream',
1042
- };
1043
1139
  }
1044
1140
  }
1045
1141
  /** {@link downloadMedia} over a list, preserving order. */
@@ -1185,7 +1281,18 @@ export function hostedStorage(blob = blobStoreFromEnv()) {
1185
1281
  // pinned in the store on first use — derived from the current key so the
1186
1282
  // data already there stays reachable — and never re-derived after that.
1187
1283
  // Lazy because the key is read at request time (deferred-config-error).
1188
- const tenant = pinnedTenant(blob.bucket, () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local'));
1284
+ // With no key configured yet the tenant is provisional: used for that call
1285
+ // but never pinned, or the permanent pin would lock the namespace to
1286
+ // sha('local') and orphan the key's data (chrischall/fleet-audit#1009).
1287
+ // Accepted boundary: the library and media sink memoise their tenant per
1288
+ // process (`tenantResolver`), so a process that starts keyless and has its
1289
+ // key set later WITHOUT a restart keeps using the provisional 'local'
1290
+ // namespace until it restarts. Nothing is pinned, so the restart recovers;
1291
+ // mcp-host sets env at spawn, so a key never arrives mid-process there.
1292
+ const tenant = pinnedTenant(blob.bucket, async () => {
1293
+ const key = readEnvVar('GEMINI_API_KEY');
1294
+ return key ? tenantIdFor(key) : { tenant: await tenantIdFor('local'), pin: false };
1295
+ });
1189
1296
  return {
1190
1297
  mediaSink: createR2Sink(blob.bucket, {
1191
1298
  // The SAME `links` object the minter gets below. Media GETs and upload
package/dist/dotenv.js CHANGED
@@ -5,14 +5,13 @@ import { loadDotenvSafely } from '@chrischall/mcp-utils';
5
5
  * The stdio server's `.env` bootstrap. **Imported only by `src/index.ts`.**
6
6
  *
7
7
  * This used to run at module scope in `src/client.ts`, which put it in the
8
- * hosted connector's import graph (`src/worker.ts` → `./client.js`) and so ran
9
- * it during Worker isolate startup. Two things go wrong there:
8
+ * import graph of the (since retired) Cloudflare Worker connector and so ran
9
+ * it during Worker isolate startup. Two things went wrong there:
10
10
  *
11
11
  * 1. wrangler's bundle leaves `import.meta.url` undefined, so
12
12
  * `fileURLToPath(import.meta.url)` threw and the isolate never booted —
13
13
  * `The Workers runtime failed to start`. Not a test failure: a deploy
14
- * failure, invisible to the workers-pool suite (see
15
- * `tests/connector-boot.test.ts` for why).
14
+ * failure, invisible to the workers-pool suite.
16
15
  * 2. Even had it resolved, it is async I/O at global scope, which Workers
17
16
  * forbids — and there is no `.env` on a Worker regardless. Config arrives
18
17
  * through bindings.
@@ -23,9 +23,8 @@ import { formatMb } from './bytes.js';
23
23
  * - **A hard byte cap enforced while streaming**, not from `Content-Length` —
24
24
  * a hostile or merely wrong server can under-report or omit it.
25
25
  *
26
- * Module scope stays side-effect-free: `src/worker.ts` reaches this file
27
- * through the tool registrars, so anything executed here would run during
28
- * Cloudflare isolate startup.
26
+ * Module scope stays side-effect-free (the same rule as client.ts): the tool
27
+ * registrars import this file, and importing must never do work.
29
28
  */
30
29
  /** Hard ceiling on a fetched image. Above this the URL is rejected outright. */
31
30
  export const IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
@@ -192,7 +191,10 @@ function assertPublicHttpsUrl(raw, requested) {
192
191
  // that this is a literal and not a name.
193
192
  const rawHost = url.hostname.toLowerCase();
194
193
  const isIpv6Literal = rawHost.startsWith('[') && rawHost.endsWith(']');
195
- const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost;
194
+ // A name may carry a trailing dot (the fully-qualified spelling): WHATWG URL
195
+ // keeps it, and `localhost.` still resolves to loopback, so the suffix
196
+ // checks below must see the name without it (chrischall/fleet-audit#472).
197
+ const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost.replace(/\.+$/, '');
196
198
  const v4 = isIpv6Literal ? undefined : ipv4Octets(host);
197
199
  const v6 = isIpv6Literal ? ipv6Groups(host) : undefined;
198
200
  const blocked = isIpv6Literal
@@ -202,6 +204,10 @@ function assertPublicHttpsUrl(raw, requested) {
202
204
  : host === 'localhost' ||
203
205
  host.endsWith('.localhost') ||
204
206
  host.endsWith('.local') ||
207
+ // Private-network names: Fly's 6PN (`<app>.internal`, where the hosted
208
+ // build runs) and GCP's metadata server (`metadata.google.internal`).
209
+ host === 'internal' ||
210
+ host.endsWith('.internal') ||
205
211
  (v4 ? isPrivateIpv4(v4) : false);
206
212
  if (blocked) {
207
213
  throw new McpToolError(`Refusing to fetch ${describeUrl(raw, requested)}: "${url.hostname}" is a private, loopback or link-local address, not a public host.`, { hint: 'images_url is fetched by the server, so it must point at a publicly reachable https:// image.' });
package/dist/images.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
3
4
  import { delimiter, join, resolve, isAbsolute } from 'node:path';
4
5
  import { assertPathWithinRoots, expandPath, fileBlob, readEnvVar, McpToolError, readFileHead, resolveOutputDir as resolveSharedOutputDir, sniffMimeBytes as sniffSharedMime, writeFileSafe, writeUniqueFile, } from '@chrischall/mcp-utils';
5
6
  /**
@@ -388,6 +389,12 @@ export function baseName(name) {
388
389
  .replace(/^-+|-+$/g, '');
389
390
  return safe || 'image';
390
391
  }
392
+ /** The `~/Downloads/<name>` segment mcp-utils' `resolveOutputDir` defaults to. */
393
+ const OUTPUT_DIR_NAME = 'gemini-mcp';
394
+ /** Where output goes when neither a per-call dir nor GEMINI_OUTPUT_DIR is set. */
395
+ function defaultOutputDir() {
396
+ return join(homedir(), 'Downloads', OUTPUT_DIR_NAME);
397
+ }
391
398
  /**
392
399
  * The output directory to LOOK IN (sidecar lookups: `continue_last`'s disk
393
400
  * fallback, the chain-404 re-anchor) — the same resolution and the same
@@ -400,17 +407,19 @@ export function lookupOutputDir(perCall) {
400
407
  const configured = readEnvVar('GEMINI_OUTPUT_DIR');
401
408
  const raw = trimmed ?? configured;
402
409
  if (!raw)
403
- return process.cwd();
410
+ return defaultOutputDir();
404
411
  const dir = expandPath(raw);
405
412
  if (trimmed && configured)
406
413
  assertPathWithinRoots(dir, [configured]);
407
414
  return dir;
408
415
  }
409
416
  /**
410
- * per-call → $GEMINI_OUTPUT_DIR → cwd (mcp-utils `resolveOutputDir`: `~` and
411
- * relative paths expanded, the directory created; a per-call dir confined to
412
- * GEMINI_OUTPUT_DIR when that is set). A blank per-call value
413
- * counts as unset, so it falls through to the env var rather than to cwd.
417
+ * per-call → $GEMINI_OUTPUT_DIR → ~/Downloads/gemini-mcp (mcp-utils
418
+ * `resolveOutputDir`: `~` and relative paths expanded, the directory created;
419
+ * a per-call dir confined to GEMINI_OUTPUT_DIR when that is set). Never the
420
+ * cwd — that is `/` under Claude Desktop and the user's repo under Claude
421
+ * Code. A blank per-call value counts as unset, so it falls through to the
422
+ * env var rather than to the default.
414
423
  */
415
424
  export function resolveOutputDir(perCall) {
416
425
  // output_dir is model-chosen: once the operator sets GEMINI_OUTPUT_DIR, a
@@ -418,6 +427,7 @@ export function resolveOutputDir(perCall) {
418
427
  // keeps the old, unconfined behaviour (the fleet pattern, as in splitwise-mcp).
419
428
  const configured = readEnvVar('GEMINI_OUTPUT_DIR');
420
429
  return resolveSharedOutputDir(perCall?.trim() || undefined, 'GEMINI_OUTPUT_DIR', {
430
+ name: OUTPUT_DIR_NAME,
421
431
  ...(configured ? { allowedRoots: [configured] } : {}),
422
432
  });
423
433
  }
package/dist/index.js CHANGED
@@ -13,13 +13,15 @@ import { client } from './client.js';
13
13
  // singleton is constructed (by the import above) before this line runs.
14
14
  await loadStdioDotenv();
15
15
  // The registrars take their GeminiClient as an argument (they import only the
16
- // *type*), so a non-stdio entry point can build one client per authenticated
17
- // user. This stdio entry point threads the env-driven module-level singleton
18
- // through runMcp's `deps`, which passes it as each registrar's second argument.
16
+ // *type*), so a future multi-user entry point COULD build one client per user.
17
+ // None does: this is the only entry point (mcp-host runs it as a per-registration
18
+ // child), and it threads the env-driven module-level singleton through runMcp's
19
+ // `deps` to every connection — sound because one registration is one user (see
20
+ // src/session.ts).
19
21
  // That singleton defers its config error to the first request — so the server
20
22
  // boots and answers the host's install-time tools/list probe even without
21
23
  // GEMINI_API_KEY. It also owns this process's session state (job registry,
22
- // last-interaction memory); stdio is single-user, so one client is one session.
24
+ // last-interaction memory); one process is one user, so one client is one session.
23
25
  //
24
26
  // The roster itself lives in registrars.ts: this file cannot be imported
25
27
  // without booting a server, and a list nothing can import is a list nothing
package/dist/inputs.js CHANGED
@@ -14,7 +14,7 @@ export function hasImageInput(args) {
14
14
  * How long the opportunistic base64 upload gets before it is abandoned.
15
15
  *
16
16
  * The promotion adds two round trips in front of a generation nobody asked to
17
- * wait for, and `uploadToFilesApi` is otherwise untimed. Past this the call
17
+ * wait for, and `uploadToFilesApi`'s own deadline is sized for big files. Past this the call
18
18
  * proceeds inline: slower to repeat, but not stalled.
19
19
  */
20
20
  const BASE64_UPLOAD_TIMEOUT_MS = 30_000;
package/dist/library.js CHANGED
@@ -17,8 +17,8 @@
17
17
  *
18
18
  * Two properties are load-bearing:
19
19
  *
20
- * - **No expiry.** The retention cron sweeps `gen/`, `media/` and `up/` but
21
- * deliberately never `lib/` (media-cleanup.ts) — a saved character outlives
20
+ * - **No expiry.** The host's retention prune reclaims `gen/` and `up/` but
21
+ * never `lib/` (see blob-store.ts) — a saved character outlives
22
22
  * every signed URL and every upload. That is why saving COPIES the image
23
23
  * bytes into `lib/` rather than storing an `r2_key` pointer: a pointer into
24
24
  * `gen/` or `up/` would dangle after the next sweep.
@@ -40,6 +40,8 @@ export const LIBRARY_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
40
40
  export function isValidLibraryName(name) {
41
41
  return LIBRARY_NAME_PATTERN.test(name);
42
42
  }
43
+ /** Pages one library listing will walk: 20 x 1000 keys, far past any real library. */
44
+ const LIBRARY_MAX_LIST_PAGES = 20;
43
45
  /** Build the R2-backed library for one session. */
44
46
  export function createR2Library(bucket, opts) {
45
47
  const now = opts.now ?? (() => new Date());
@@ -77,10 +79,13 @@ export function createR2Library(bucket, opts) {
77
79
  const prefix = `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/`;
78
80
  const keys = [];
79
81
  let cursor;
80
- for (;;) {
82
+ // Bounded, like the media walk in storage/media.ts: a store that reports
83
+ // `truncated` with no cursor would otherwise re-list page one forever
84
+ // (chrischall/fleet-audit#468), and a listing is unbounded work on a tool call.
85
+ for (let n = 0; n < LIBRARY_MAX_LIST_PAGES; n++) {
81
86
  const page = await bucket.list({ prefix, cursor, limit: 1000 });
82
87
  keys.push(...page.objects.map((o) => o.key).filter((k) => k.endsWith('.json')));
83
- if (!page.truncated)
88
+ if (!page.truncated || !page.cursor)
84
89
  break;
85
90
  cursor = page.cursor;
86
91
  }
package/dist/session.js CHANGED
@@ -5,20 +5,25 @@ import { estimateCost } from './pricing.js';
5
5
  * Everything the server remembers *between tool calls on behalf of one user*.
6
6
  *
7
7
  * All of this used to be module-level (`jobs`/`byKey`/`runningByFingerprint` in
8
- * jobs.ts, `lastInteractionId`/`writtenOutputs` in tools/interact.ts). That is
9
- * correct for stdio, where a process serves exactly one user — and a
10
- * cross-tenant leak on the hosted connector, where ONE child process serves
11
- * every session of this registration at once (mcp-host runs the stdio server as
12
- * a supervised child). Module state there is shared by
13
- * every authenticated claude.ai session on that child: the per-session
14
- * `GeminiClient` isolated the API key but not the memory around it, so a
15
- * colliding `idempotency_key` replayed another user's result, an identical
16
- * prompt attached to another user's in-flight (billable) job, and
17
- * `continue_last: true` resumed another user's interaction under your key.
8
+ * jobs.ts, `lastInteractionId`/`writtenOutputs` in tools/interact.ts). That was
9
+ * a cross-tenant leak on the retired Cloudflare Worker connector, which served
10
+ * many users from one isolate: a colliding `idempotency_key` replayed another
11
+ * user's result, an identical prompt attached to another user's in-flight
12
+ * (billable) job, and `continue_last: true` resumed another user's interaction.
18
13
  *
19
- * So session memory hangs off the `GeminiClient` — the one object the connector
20
- * already builds per authenticated session and
21
- * threads into every registrar. Handlers reach it as `client.session`.
14
+ * So session memory hangs off the `GeminiClient` and is threaded into every
15
+ * registrar; handlers reach it as `client.session`.
16
+ *
17
+ * **The invariant production relies on today: one registration is one user.**
18
+ * Both entry points — stdio, and mcp-host, which runs this same stdio server as
19
+ * a supervised child per registration — build exactly ONE `GeminiClient` (the
20
+ * module-level `client` in client.ts, handed to every connection by `runMcp`
21
+ * in index.ts), so there is one process-wide `SessionState`, shared by every
22
+ * connection to that process. That is safe only because every one of those
23
+ * connections is the same user. If a host ever multiplexes several users onto
24
+ * one process, this is NOT enough on its own: it must also build a client per
25
+ * user (tests/session-isolation.test.ts shows that separate clients do not
26
+ * share state; nothing in production builds more than one today).
22
27
  *
23
28
  * The rule this encodes: **`src/` must hold no module-level mutable state.**
24
29
  * If you need to remember something across calls, put it here.
@@ -92,8 +97,9 @@ export class SessionState {
92
97
  * Accumulated in the CLIENT rather than in the tools, so a call that was
93
98
  * billed and then failed (a safety filter returning no image, say) still
94
99
  * counts — it cost money regardless of whether a tool could use the result.
95
- * Per session, never module scope: one hosted process serves many tenants,
96
- * and a shared total would report other people's spend as yours.
100
+ * On the session, never module scope: a module-level total would report
101
+ * other users' spend as yours on any host that served several users from one
102
+ * process (see the invariant above).
97
103
  */
98
104
  usageTotal;
99
105
  /** How many upstream calls contributed to {@link usageTotal}. */
package/dist/sidecar.js CHANGED
@@ -13,10 +13,24 @@
13
13
  * sidecar for that id names the image file to re-attach, which is exactly
14
14
  * the manual recovery the old error message asked the caller to perform.
15
15
  */
16
- import { readdir, readFile, stat } from 'node:fs/promises';
17
- import { existsSync } from 'node:fs';
18
- import { join } from 'node:path';
16
+ import { readdir, readFile, stat, lstat } from 'node:fs/promises';
17
+ import { join, resolve } from 'node:path';
19
18
  const SIDECAR_SUFFIX = '.json';
19
+ /**
20
+ * `writeSidecar` always names the sidecar after its output: `<file>.<ext>.json`.
21
+ * Any other `.json` in the directory — the output dir can be any
22
+ * operator- or model-chosen directory, such as a cloned repo — is not ours and is never read (chrischall/fleet-audit#473).
23
+ */
24
+ const SIDECAR_NAME = /^.+\.[A-Za-z0-9]+\.json$/;
25
+ /** True for a regular file (NOT a symlink — the server never writes through one). */
26
+ async function isRegularFile(path) {
27
+ try {
28
+ return (await lstat(path)).isFile();
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
20
34
  /**
21
35
  * All parseable sidecars in `dir`, newest first. Never throws: a missing dir,
22
36
  * an unreadable file, malformed JSON, or a record with no `interaction_id` is
@@ -31,10 +45,15 @@ export async function readSidecars(dir) {
31
45
  catch {
32
46
  return [];
33
47
  }
48
+ // The outputs this directory has sidecars for. A recorded image path is
49
+ // only trusted when it is one of these: an output file this server wrote,
50
+ // sitting in this directory beside its own sidecar. That is what keeps a
51
+ // planted sidecar from naming an arbitrary local file for the chain
52
+ // re-anchor to read and send upstream without the confirm gate.
53
+ const sidecarNames = entries.filter((e) => SIDECAR_NAME.test(e));
54
+ const outputs = new Set(sidecarNames.map((e) => resolve(dir, e.slice(0, -SIDECAR_SUFFIX.length))));
34
55
  const records = [];
35
- for (const entry of entries) {
36
- if (!entry.endsWith(SIDECAR_SUFFIX))
37
- continue;
56
+ for (const entry of sidecarNames) {
38
57
  const sidecarPath = join(dir, entry);
39
58
  try {
40
59
  const parsed = JSON.parse(await readFile(sidecarPath, 'utf8'));
@@ -44,10 +63,15 @@ export async function readSidecars(dir) {
44
63
  if (typeof interactionId !== 'string' || !interactionId)
45
64
  continue;
46
65
  const paths = Array.isArray(images) ? images.filter((p) => typeof p === 'string') : [];
66
+ const kept = [];
67
+ for (const p of paths) {
68
+ if (outputs.has(resolve(p)) && (await isRegularFile(p)))
69
+ kept.push(p);
70
+ }
47
71
  records.push({
48
72
  sidecarPath,
49
73
  interactionId,
50
- images: paths.filter((p) => existsSync(p)),
74
+ images: kept,
51
75
  mtimeMs: (await stat(sidecarPath)).mtimeMs,
52
76
  });
53
77
  }
package/dist/tenant.js CHANGED
@@ -32,21 +32,16 @@ async function readPin(bucket) {
32
32
  return undefined;
33
33
  }
34
34
  }
35
- /**
36
- * A memoised tenant resolver for the hosted stores.
37
- *
38
- * Only a fully successful resolution is cached: a failed read rejects and is
39
- * retried on the next call, and a failed pin WRITE still returns the derived
40
- * tenant (so a transient blob hiccup does not fail the tool call) but leaves
41
- * the pin to be written next time.
42
- */
43
35
  export function pinnedTenant(bucket, derive) {
44
36
  let cached;
45
37
  async function resolve() {
46
38
  const pinned = await readPin(bucket);
47
39
  if (pinned)
48
40
  return { tenant: pinned, pinned: true };
49
- const tenant = await derive();
41
+ const derived = await derive();
42
+ if (typeof derived !== 'string')
43
+ return { tenant: derived.tenant, pinned: false };
44
+ const tenant = derived;
50
45
  try {
51
46
  await bucket.put(TENANT_PIN_KEY, JSON.stringify({ tenant }), {
52
47
  httpMetadata: { contentType: 'application/json' },
@@ -27,10 +27,18 @@ export async function confirmWrite(ctx, o) {
27
27
  message: o.message,
28
28
  details: preview,
29
29
  tool: o.tool,
30
+ // One API key per process (a hosted deployment runs one child per
31
+ // account), so there is no in-process principal to bind.
32
+ account: undefined,
33
+ args: withoutConfirmToken(o.args),
30
34
  confirmToken: o.confirmToken,
31
35
  subject: () => ({ target: o.target, payload: o.payload, preview }),
32
36
  }));
33
37
  }
38
+ function withoutConfirmToken(args) {
39
+ const { confirmToken: _omit, ...rest } = args;
40
+ return rest;
41
+ }
34
42
  /**
35
43
  * Confirm-gate for a Gemini tool that ships LOCAL input files (image/video
36
44
  * paths) to Google. Proceeds straight away when the call has no local file
@@ -58,6 +66,7 @@ export async function confirmLocalInputs(ctx, o) {
58
66
  body: { inputs },
59
67
  target: '',
60
68
  payload: { request: o.request, inputs },
69
+ args: o.request,
61
70
  confirmToken: o.confirmToken,
62
71
  });
63
72
  }