@pyric/cli 0.1.0-alpha.30.1 → 0.1.0-alpha.30.2

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 (109) hide show
  1. package/dist/assurance/.generated/conformance-verdicts.d.ts +4 -0
  2. package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
  3. package/dist/assurance/.generated/conformance-verdicts.js +4 -0
  4. package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
  5. package/dist/bridge/server/peer.d.ts +10 -0
  6. package/dist/bridge/server/peer.d.ts.map +1 -1
  7. package/dist/bridge/server/peer.js +46 -1
  8. package/dist/bridge/server/peer.js.map +1 -1
  9. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  10. package/dist/conformance/.generated/can-i-use.js +1 -1
  11. package/dist/conformance/.generated/can-i-use.js.map +1 -1
  12. package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
  13. package/dist/conformance/.generated/conformance-docs.js +1 -1
  14. package/dist/conformance/.generated/conformance-docs.js.map +1 -1
  15. package/dist/serve/ai-diagnostics-relay.d.ts +4 -1
  16. package/dist/serve/ai-diagnostics-relay.d.ts.map +1 -1
  17. package/dist/serve/ai-diagnostics-relay.js +3 -0
  18. package/dist/serve/ai-diagnostics-relay.js.map +1 -1
  19. package/dist/serve/ai-proxy.d.ts +21 -1
  20. package/dist/serve/ai-proxy.d.ts.map +1 -1
  21. package/dist/serve/ai-proxy.js +39 -14
  22. package/dist/serve/ai-proxy.js.map +1 -1
  23. package/dist/serve/ai-terminal-blocks.d.ts +6 -4
  24. package/dist/serve/ai-terminal-blocks.d.ts.map +1 -1
  25. package/dist/serve/ai-terminal-blocks.js +7 -4
  26. package/dist/serve/ai-terminal-blocks.js.map +1 -1
  27. package/dist/serve/ai-terminal-text.d.ts +8 -0
  28. package/dist/serve/ai-terminal-text.d.ts.map +1 -1
  29. package/dist/serve/ai-terminal-text.js +10 -0
  30. package/dist/serve/ai-terminal-text.js.map +1 -1
  31. package/dist/serve/entries/ai.d.ts.map +1 -1
  32. package/dist/serve/entries/ai.js +28 -26
  33. package/dist/serve/entries/ai.js.map +1 -1
  34. package/dist/serve/gemini-error-redaction.d.ts +6 -0
  35. package/dist/serve/gemini-error-redaction.d.ts.map +1 -0
  36. package/dist/serve/gemini-error-redaction.js +61 -0
  37. package/dist/serve/gemini-error-redaction.js.map +1 -0
  38. package/dist/serve/gemini-proxy.d.ts +40 -0
  39. package/dist/serve/gemini-proxy.d.ts.map +1 -0
  40. package/dist/serve/gemini-proxy.js +105 -0
  41. package/dist/serve/gemini-proxy.js.map +1 -0
  42. package/dist/serve/namespace.d.ts +9 -0
  43. package/dist/serve/namespace.d.ts.map +1 -1
  44. package/dist/serve/namespace.js +66 -14
  45. package/dist/serve/namespace.js.map +1 -1
  46. package/dist/serve/sandbox-session.d.ts +3 -0
  47. package/dist/serve/sandbox-session.d.ts.map +1 -1
  48. package/dist/serve/sandbox-session.js +1 -0
  49. package/dist/serve/sandbox-session.js.map +1 -1
  50. package/dist/serve/site-ui/docs/ai-compat/index.html +20 -1
  51. package/dist/serve/site-ui/docs/ai-compat.md +21 -0
  52. package/dist/serve/site-ui/docs/app-check-compat/index.html +8 -8
  53. package/dist/serve/site-ui/docs/app-check-compat.md +8 -8
  54. package/dist/serve/site-ui/docs/build/ai-logic/index.html +19 -5
  55. package/dist/serve/site-ui/docs/build/ai-logic.md +19 -2
  56. package/dist/serve/site-ui/docs/index.json +10 -0
  57. package/dist/serve/site-ui/docs/storage-compat/index.html +2 -2
  58. package/dist/serve/site-ui/docs/storage-compat.md +2 -2
  59. package/dist/serve/site-ui/docs/tutorial/index.html +11 -1
  60. package/dist/serve/site-ui/docs/tutorial.md +12 -1
  61. package/dist/serve/site-ui/examples/chess/index.html +1 -1
  62. package/dist/serve/vite-sandbox-generation.d.ts.map +1 -1
  63. package/dist/serve/vite-sandbox-generation.js +2 -0
  64. package/dist/serve/vite-sandbox-generation.js.map +1 -1
  65. package/dist/serve/worker/ai-proxy-paths.d.ts +32 -0
  66. package/dist/serve/worker/ai-proxy-paths.d.ts.map +1 -0
  67. package/dist/serve/worker/ai-proxy-paths.js +20 -0
  68. package/dist/serve/worker/ai-proxy-paths.js.map +1 -0
  69. package/dist/serve/worker/client/ai.d.ts +3 -1
  70. package/dist/serve/worker/client/ai.d.ts.map +1 -1
  71. package/dist/serve/worker/client/ai.js +3 -0
  72. package/dist/serve/worker/client/ai.js.map +1 -1
  73. package/dist/serve/worker/entry.js +2 -0
  74. package/dist/serve/worker/entry.js.map +1 -1
  75. package/dist/serve/worker/host-ai.d.ts +4 -5
  76. package/dist/serve/worker/host-ai.d.ts.map +1 -1
  77. package/dist/serve/worker/host-ai.js +19 -23
  78. package/dist/serve/worker/host-ai.js.map +1 -1
  79. package/dist/serve/worker/host-context.d.ts +13 -4
  80. package/dist/serve/worker/host-context.d.ts.map +1 -1
  81. package/dist/serve/worker/host-context.js.map +1 -1
  82. package/dist/serve/worker/inbound-validation/operation-arguments.d.ts.map +1 -1
  83. package/dist/serve/worker/inbound-validation/operation-arguments.js +11 -0
  84. package/dist/serve/worker/inbound-validation/operation-arguments.js.map +1 -1
  85. package/dist/serve/worker/protocol.d.ts +13 -0
  86. package/dist/serve/worker/protocol.d.ts.map +1 -1
  87. package/dist/serve/worker/protocol.js.map +1 -1
  88. package/package.json +4 -4
  89. package/src/assurance/.generated/conformance-verdicts.ts +4 -0
  90. package/src/bridge/server/peer.ts +49 -1
  91. package/src/conformance/.generated/can-i-use.ts +1 -1
  92. package/src/conformance/.generated/conformance-docs.ts +1 -1
  93. package/src/serve/ai-diagnostics-relay.ts +6 -1
  94. package/src/serve/ai-proxy.ts +43 -14
  95. package/src/serve/ai-terminal-blocks.ts +8 -4
  96. package/src/serve/ai-terminal-text.ts +11 -0
  97. package/src/serve/entries/ai.ts +28 -27
  98. package/src/serve/gemini-error-redaction.ts +60 -0
  99. package/src/serve/gemini-proxy.ts +140 -0
  100. package/src/serve/namespace.ts +69 -14
  101. package/src/serve/sandbox-session.ts +4 -0
  102. package/src/serve/vite-sandbox-generation.ts +2 -0
  103. package/src/serve/worker/ai-proxy-paths.ts +35 -0
  104. package/src/serve/worker/client/ai.ts +6 -1
  105. package/src/serve/worker/entry.ts +2 -0
  106. package/src/serve/worker/host-ai.ts +23 -23
  107. package/src/serve/worker/host-context.ts +13 -3
  108. package/src/serve/worker/inbound-validation/operation-arguments.ts +11 -0
  109. package/src/serve/worker/protocol.ts +14 -2
@@ -23,7 +23,7 @@ import { createReadStream, existsSync, statSync } from 'node:fs';
23
23
  import { basename, join } from 'node:path';
24
24
  import type { IncomingMessage, ServerResponse } from 'node:http';
25
25
  import type { ActivityIncident } from 'pyric/firestore/internal';
26
- import { collectBody } from '../bridge/server/peer.js';
26
+ import { BODY_TOO_LARGE_CODE, collectBody } from '../bridge/server/peer.js';
27
27
  import { handleIndexConfig } from './index-config-route.js';
28
28
  import { StateFileError, type StateSection, type StateStore } from './state-store.js';
29
29
  import { createWriterLock, type WriterLock } from './writer-lock.js';
@@ -32,7 +32,9 @@ import { pipeFileToResponse, getHeader, isAllowedHost, isAllowedOrigin, isAllowe
32
32
  import type { InitPayload } from './init-payload.js';
33
33
  import { handleActivity } from './activity-route.js';
34
34
  import { handleAiProxy, AI_PROXY_ROUTE } from './ai-proxy.js';
35
+ import { createGeminiProxy, isGeminiProxyPath, type GeminiProxyConfig } from './gemini-proxy.js';
35
36
  import { aiTerminalBlockFor, type AiDiagnosticPayload } from './ai-terminal-blocks.js';
37
+ import { sanitizeForTerminal, stripTerminalControls } from './ai-terminal-text.js';
36
38
  import { createSiteTreeHandler } from './site-tree.js';
37
39
  import { handleAvatar } from './assets/avatar-route.js';
38
40
  import { ASSETS_ROUTE_PREFIX } from './assets/avatar-url.js';
@@ -147,6 +149,10 @@ export interface NamespaceOptions {
147
149
  * (local Ollama). Always mounted — the route only touches the network
148
150
  * when a request arrives. */
149
151
  aiProxyUpstream?: string;
152
+ /** The gemini engine `/__pyric/ai-proxy/gemini` sends for a browser engine
153
+ * (#1584): the Vite plugin's `ai.engine` when it is gemini. Absent, it
154
+ * sends to Google AI Studio with the key from this process's environment. */
155
+ aiGemini?: GeminiProxyConfig;
150
156
  /** Receives one handshake beacon per pyric-launched child (`POST
151
157
  * /__pyric/beacon`, sent by `@pyric/cli/register` once its hooks and
152
158
  * net-guard are installed). Absent means the route still 204s; the child
@@ -354,6 +360,11 @@ async function handleCapture(
354
360
 
355
361
  const DENIAL_THROTTLE_MS = 5_000;
356
362
 
363
+ /** Largest relayed denial the route reads. A real one is a message, a path, a
364
+ * uid and a short owner stack, a few KiB at most; the index route's 64 KiB
365
+ * leaves room for a deep stack without letting a page grow the server. */
366
+ export const DENIAL_BODY_LIMIT_BYTES = 64 * 1024;
367
+
357
368
  /** Loosely-typed mirror of the client's relay payload — JSON from the served
358
369
  * page, read defensively since nothing here enforces the wire shape. */
359
370
  interface DenialRelayPayload {
@@ -397,27 +408,48 @@ export function createDenialThrottle(windowMs: number = DENIAL_THROTTLE_MS): {
397
408
  }
398
409
  export type DenialThrottle = ReturnType<typeof createDenialThrottle>;
399
410
 
411
+ /** A page-supplied string as one terminal-safe line. Every field of a relayed
412
+ * denial comes from the served page, which need not be the developer's own
413
+ * code, so none of it reaches the terminal with control characters intact. */
414
+ function pageText(value: string): string {
415
+ return sanitizeForTerminal(value);
416
+ }
417
+
418
+ /** A multi-line page-supplied string (a query-proof remediation) as indented
419
+ * terminal lines. Each line keeps its spacing and its whole length, because a
420
+ * reader pastes the `pyric rules test` command and the `Fixture case:` JSON
421
+ * out of it; only control and bidi characters are replaced. The denial body
422
+ * limit bounds the total, so no line or line count is cut here. */
423
+ function pageLines(value: string): string[] {
424
+ const lines = value.split(/\r?\n/).map((line) => stripTerminalControls(line).trimEnd());
425
+ const first = lines.findIndex((line) => line.length > 0);
426
+ if (first === -1) return [];
427
+ let last = lines.length - 1;
428
+ while (lines[last]!.length === 0) last -= 1;
429
+ return lines.slice(first, last + 1).map((line) => (line.length > 0 ? ` ${line}` : ''));
430
+ }
431
+
400
432
  /** Format a relayed denial into the compact terminal block: the message,
401
433
  * then (when present) the request method/path, the auth uid, and any
402
434
  * remediation guidance. A few lines, never a JSON dump. */
403
435
  export function formatDenialBlock(payload: DenialRelayPayload): string {
404
436
  const message = typeof payload.message === 'string' ? payload.message : 'permission denied';
405
- const lines = [` ⚠ [pyric] denied: ${message}`];
437
+ const lines = [` ⚠ [pyric] denied: ${pageText(message)}`];
406
438
  const ctx = payload.denialContext;
407
439
  const request = ctx?.request;
408
440
  if (request?.method && request?.path) {
409
- const instance = typeof payload.instance === 'string' ? ` (${payload.instance})` : '';
410
- lines.push(` ${request.method} ${request.path}${instance}`);
441
+ const instance = typeof payload.instance === 'string' ? ` (${pageText(payload.instance)})` : '';
442
+ lines.push(` ${pageText(String(request.method))} ${pageText(String(request.path))}${instance}`);
411
443
  }
412
444
  const uid = ctx?.auth?.uid;
413
- lines.push(` auth: ${uid ?? 'anonymous'}`);
445
+ lines.push(` auth: ${uid === undefined || uid === null ? 'anonymous' : pageText(String(uid))}`);
414
446
  // An RTDB message names no rule, so the block names the deciding one.
415
447
  if (ctx?.engine === 'rtdb' && typeof ctx.matchedRule === 'string') {
416
- const node = typeof ctx.matchedPath === 'string' ? `${ctx.matchedPath} ` : '';
417
- lines.push(` rule: ${node}${ctx.matchedRule}`);
448
+ const node = typeof ctx.matchedPath === 'string' ? `${pageText(ctx.matchedPath)} ` : '';
449
+ lines.push(` rule: ${node}${pageText(ctx.matchedRule)}`);
418
450
  }
419
451
  const remediation = typeof payload.remediation === 'string' ? payload.remediation : ctx?.remediation;
420
- if (remediation) lines.push(` ${remediation}`);
452
+ if (remediation) lines.push(...pageLines(String(remediation)));
421
453
  lines.push(...ownerLines(payload.owners));
422
454
  return lines.join('\n');
423
455
  }
@@ -437,7 +469,7 @@ function isRelayedFrame(value: unknown): value is RelayedFrame {
437
469
  /** A frame's file as a reader looks for it: a served URL loses its origin and query. */
438
470
  function frameLocation(frame: RelayedFrame): string {
439
471
  const path = frame.file.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, '').replace(/[?#].*$/, '');
440
- return `${path}:${frame.line}`;
472
+ return pageText(`${path}:${frame.line}`);
441
473
  }
442
474
 
443
475
  /**
@@ -450,7 +482,7 @@ function ownerLines(owners: unknown): string[] {
450
482
  if (!Array.isArray(owners)) return [];
451
483
  const frame = owners.find((owner) => (owner as { kind?: unknown })?.kind === 'frame');
452
484
  if (!isRelayedFrame(frame)) return [];
453
- const name = typeof frame.function === 'string' && frame.function.length > 0 ? `${frame.function} ` : '';
485
+ const name = typeof frame.function === 'string' && frame.function.length > 0 ? `${pageText(frame.function)} ` : '';
454
486
  const lines = [` owner: ${name}${frameLocation(frame)}`];
455
487
  const stack = (frame as { stack?: unknown }).stack;
456
488
  const chain = Array.isArray(stack) ? stack.filter(isRelayedFrame) : [];
@@ -492,8 +524,12 @@ export function noteAiDiagnostic(
492
524
  * (target, reason) per window is the right volume for every one of them. A
493
525
  * rules denial's target also names its RTDB instance, so the same path
494
526
  * denied on two instances prints once for each.
495
- * Always 204s (best-effort diagnostics: a malformed body or a caller with no
496
- * logger wired must never fail the page that reported the refusal). */
527
+ * Answers 204 (best-effort diagnostics: a malformed body or a caller with no
528
+ * logger wired must never fail the page that reported the refusal), except
529
+ * 413 for a body over {@link DENIAL_BODY_LIMIT_BYTES}. On the 0.1.0-alpha.30
530
+ * line the namespace runs no loopback guard before this route, so a page on
531
+ * any origin can reach here; the body limit and the terminal-text sanitizing
532
+ * still apply. */
497
533
  async function handleDenials(
498
534
  throttle: DenialThrottle,
499
535
  logger: ServeLogger | undefined,
@@ -505,7 +541,7 @@ async function handleDenials(
505
541
  return;
506
542
  }
507
543
  try {
508
- const payload = (await collectBody(req)) as DenialRelayPayload & AiDiagnosticPayload;
544
+ const payload = (await collectBody(req, DENIAL_BODY_LIMIT_BYTES)) as DenialRelayPayload & AiDiagnosticPayload;
509
545
  const isAiDiagnostic = aiTerminalBlockFor(payload.kind) !== null;
510
546
  if (isAiDiagnostic) {
511
547
  noteAiDiagnostic(throttle, logger, payload);
@@ -519,7 +555,12 @@ async function handleDenials(
519
555
  logger.note(formatDenialBlock(payload));
520
556
  }
521
557
  }
522
- } catch {
558
+ } catch (error) {
559
+ const tooLarge = (error as { code?: unknown } | null)?.code === BODY_TOO_LARGE_CODE;
560
+ if (tooLarge) {
561
+ res.writeHead(413, { 'content-type': 'text/plain' }).end('Payload Too Large');
562
+ return;
563
+ }
523
564
  /* malformed body: drop it; this is a diagnostics side channel */
524
565
  }
525
566
  res.writeHead(204).end();
@@ -573,6 +614,7 @@ export function createPyricNamespace(opts: NamespaceOptions) {
573
614
  ? createSiteTreeHandler(siteUiDir, opts.workerVersion, hostedStudioProject(opts))
574
615
  : null;
575
616
  const denialThrottle = createDenialThrottle();
617
+ const geminiProxy = createGeminiProxy(opts.aiGemini, denialThrottle, opts.logger);
576
618
  // Issued once per server boot. The outer static/Vite host guard protects
577
619
  // init.json before this capability is disclosed to the served runtime.
578
620
  const activity = opts.activity;
@@ -688,8 +730,18 @@ export function createPyricNamespace(opts: NamespaceOptions) {
688
730
  events.handle(req, res);
689
731
  return true;
690
732
  }
733
+ const isGeminiProxyRequest = isGeminiProxyPath(url.pathname);
734
+ if (isGeminiProxyRequest) {
735
+ // It spends the server's key, so only this app's pages may call it.
736
+ const hostRefused = !guardLoopback(req, res, opts.boundHost ?? 'localhost', opts.allowedHosts);
737
+ if (hostRefused) return true;
738
+ return geminiProxy(req, res, url).then(() => true);
739
+ }
691
740
  const isAiProxyRequest = url.pathname === AI_PROXY_ROUTE || url.pathname.startsWith(`${AI_PROXY_ROUTE}/`);
692
741
  if (isAiProxyRequest) {
742
+ // The upstream may carry the developer's key, so only this server's pages may spend it.
743
+ const hostRefused = !guardLoopback(req, res, opts.boundHost ?? 'localhost', opts.allowedHosts);
744
+ if (hostRefused) return true;
693
745
  return handleAiProxy(opts.aiProxyUpstream, denialThrottle, req, res, url, opts.logger).then(
694
746
  () => true,
695
747
  );
@@ -700,6 +752,9 @@ export function createPyricNamespace(opts: NamespaceOptions) {
700
752
  }
701
753
  const isDenialRequest = url.pathname === '/__pyric/denials';
702
754
  if (isDenialRequest) {
755
+ // No origin guard on the 0.1.0-alpha.30 line: without the learned proxy
756
+ // ports from #1497, it would refuse the Next dev server's page under
757
+ // `pyric sandbox -- next dev`. The body limit still applies.
703
758
  return handleDenials(denialThrottle, opts.logger, req, res).then(() => true);
704
759
  }
705
760
  const isInitRequest = url.pathname === '/__pyric/init.json';
@@ -44,6 +44,7 @@ import { restoredStateCounts, stateForSummary } from './state-summary.js';
44
44
  import { parseStateFile } from './state-file.js';
45
45
  import { inlineStorage } from './state-objects.js';
46
46
  import { createHostedPersistence, formatStorageRepairs, type HostedPersistence } from './hosted/persistence.js';
47
+ import type { GeminiProxyConfig } from './gemini-proxy.js';
47
48
  import {
48
49
  runProjectFixturesOnRulesReload,
49
50
  type RulesFixtureCheckSummary,
@@ -69,6 +70,8 @@ export interface SandboxSessionOptions {
69
70
  deployHostedRules?: (service: 'firestore' | 'database' | 'storage', source: string | null, instance?: DatabaseRulesDestination) => Promise<void>;
70
71
  ai?: InitPayload['ai'];
71
72
  aiProxyUpstream?: string;
73
+ /** The gemini engine the dev server's gemini proxy sends for a browser engine. */
74
+ aiGemini?: GeminiProxyConfig;
72
75
  /** Resolved `avatars` option (already reduced by `avatars-config.ts` from
73
76
  * whatever the caller — the Vite plugin or `pyric sandbox` — accepted).
74
77
  * Absent behaves like `{ enabled: false }`: no avatar route is mounted. */
@@ -444,6 +447,7 @@ export async function createSandboxSession(
444
447
  workerVersion: options.sdk.workerVersion,
445
448
  avatars: avatarsResolver,
446
449
  aiProxyUpstream: options.aiProxyUpstream,
450
+ aiGemini: options.aiGemini,
447
451
  activity: options.activity,
448
452
  beacon: options.beacon,
449
453
  beaconToken: options.beaconToken,
@@ -33,6 +33,7 @@ import { resolveAvatarsConfig, type PyricAvatarsOptions } from './avatars-config
33
33
  import { resolveViteRulesConfig } from './vite-rules-source.js';
34
34
  import type { ViteWorkerRuntime, ViteWorkerRuntimeStatus } from './vite-worker-runtime.js';
35
35
  import { viteWorkerEpochSalt } from './vite-ai-config.js';
36
+ import { geminiProxyConfig } from './gemini-proxy.js';
36
37
  import {
37
38
  attachViteGenerationBridge,
38
39
  createViteGenerationBridge,
@@ -278,6 +279,7 @@ export async function createViteSandboxGeneration(
278
279
  ai: aiOptions,
279
280
  avatars: avatarsConfig,
280
281
  aiProxyUpstream: ai.proxyUpstream,
282
+ aiGemini: geminiProxyConfig(ai.engineWire),
281
283
  activity: (incident) => server.config.logger.warn(formatActivityWarning(incident)),
282
284
  logger: {
283
285
  info: (message) => server.config.logger.info(message),
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The dev server's AI proxy routes, as the browser engines name them, and
3
+ * the gemini engine a browser page or SharedWorker runs.
4
+ *
5
+ * Browser-safe: the worker host and the served `firebase/ai` entry import
6
+ * this, so it imports nothing from node.
7
+ */
8
+ import type { AiEngineConfigWire } from './protocol.js';
9
+
10
+ /** The serve proxy route the browser openai engine defaults to (#98.2). */
11
+ export const AI_PROXY_PATH = '/__pyric/ai-proxy';
12
+
13
+ /** The serve route a browser gemini engine sends through. The server sends
14
+ * each request on to Gemini with the key from its own environment (#1584). */
15
+ export const GEMINI_PROXY_PATH = `${AI_PROXY_PATH}/gemini`;
16
+
17
+ type GeminiWire = Extract<AiEngineConfigWire, { kind: 'gemini' }>;
18
+
19
+ /** A browser gemini engine config: the wire shape, plus the flag that tells
20
+ * the engine the dev server attaches the credentials. */
21
+ export type BrowserGeminiEngine = GeminiWire & { serverAttachesAuth?: true };
22
+
23
+ /**
24
+ * The gemini engine config for a browser. The browser has no server
25
+ * environment, so a config without its own `apiKey` sends through the dev
26
+ * server's {@link GEMINI_PROXY_PATH}, which attaches the key and sends to
27
+ * the server's configured base URL: the key never reaches the browser. A
28
+ * config with an `apiKey` already holds its key in the browser, so it keeps
29
+ * its own target.
30
+ */
31
+ export function browserGeminiEngine(wire: GeminiWire): BrowserGeminiEngine {
32
+ const holdsItsOwnKey = wire.apiKey !== undefined;
33
+ if (holdsItsOwnKey) return { ...wire };
34
+ return { kind: 'gemini', baseUrl: GEMINI_PROXY_PATH, serverAttachesAuth: true };
35
+ }
@@ -3,7 +3,7 @@
3
3
  * as the mirror AnswerEngine on the worker path (see entries/ai.ts).
4
4
  */
5
5
 
6
- import type { AiEngineConfigWire, InboundMessage } from '../protocol.js';
6
+ import type { AiAppCheckWire, AiEngineConfigWire, InboundMessage } from '../protocol.js';
7
7
  import { closeSubscription, nextId, nextSubId, openSnapshotSubscription, rpc } from './core.js';
8
8
  import type { ClientDb } from './handles.js';
9
9
 
@@ -26,6 +26,8 @@ export interface AiOpParams {
26
26
  request: Record<string, unknown>;
27
27
  /** First-op engine config — see {@link AiEngineConfigWire}. */
28
28
  engine?: AiEngineConfigWire;
29
+ /** The App Check claim an app-backed handle attaches — see {@link AiAppCheckWire}. */
30
+ appCheck?: AiAppCheckWire;
29
31
  }
30
32
 
31
33
  /** Unary `generateContent` against the worker's broker. Resolves with the
@@ -42,6 +44,7 @@ export async function aiGenerateContent(
42
44
  model: params.model,
43
45
  request: params.request,
44
46
  ...(params.engine !== undefined ? { engine: params.engine } : {}),
47
+ ...(params.appCheck !== undefined ? { appCheck: params.appCheck } : {}),
45
48
  })) as Record<string, unknown>;
46
49
  }
47
50
 
@@ -57,6 +60,7 @@ export async function aiCountTokens(
57
60
  model: params.model,
58
61
  request: params.request,
59
62
  ...(params.engine !== undefined ? { engine: params.engine } : {}),
63
+ ...(params.appCheck !== undefined ? { appCheck: params.appCheck } : {}),
60
64
  })) as Record<string, unknown>;
61
65
  }
62
66
 
@@ -100,6 +104,7 @@ export async function* aiStreamGenerateContent(
100
104
  model: params.model,
101
105
  request: params.request,
102
106
  ...(params.engine !== undefined ? { engine: params.engine } : {}),
107
+ ...(params.appCheck !== undefined ? { appCheck: params.appCheck } : {}),
103
108
  } satisfies InboundMessage);
104
109
  if (!opened) push({
105
110
  kind: 'error',
@@ -123,6 +123,8 @@ async function buildCtx(): Promise<HostCtx> {
123
123
  ? (url) => new EventSource(url) as unknown as EventSourceLike
124
124
  : null,
125
125
  });
126
+ // This worker runs in the browser: the gemini engine sends through the dev server.
127
+ ctx.aiRunsInBrowser = true;
126
128
  return ctx;
127
129
  }
128
130
 
@@ -10,7 +10,9 @@ import { packAiEvidence, setAiEvidence, type AiEvidence } from 'pyric/ai/interna
10
10
  * 2. The openai engine's default base URL in this (browser) context is the
11
11
  * same-origin `/__pyric/ai-proxy` route, so a localhost upstream (Ollama)
12
12
  * needs no CORS setup. Relative URLs resolve against the worker script's
13
- * origin, so the plain path works from inside the SharedWorker.
13
+ * origin, so the plain path works from inside the SharedWorker. The
14
+ * gemini engine sends through `/__pyric/ai-proxy/gemini`, where the
15
+ * server attaches the key, so the key never enters the browser.
14
16
  * 3. `ai.generateContent` / `ai.countTokens` are unary ops exactly like
15
17
  * `rtdb.*` / `auth.*`; `ai.streamGenerateContent` is a FINITE
16
18
  * subscription that delivers `{ chunk }` snaps in order, then a terminal
@@ -31,9 +33,9 @@ import { getAI, TARGET_SYMBOL, type AIOptions } from 'pyric/ai';
31
33
 
32
34
  import { serializeError, type OpMessage, type AiStreamSubMessage, type AiEngineConfigWire } from './protocol.js';
33
35
  import { type HostCtx, type PortLike, type AiBrokerLike, post, ok, fail } from './host-context.js';
36
+ import { AI_PROXY_PATH, browserGeminiEngine } from './ai-proxy-paths.js';
34
37
 
35
- /** The serve proxy route the browser openai engine defaults to (#98.2). */
36
- export const AI_PROXY_PATH = '/__pyric/ai-proxy';
38
+ export { AI_PROXY_PATH } from './ai-proxy-paths.js';
37
39
 
38
40
  /** Is this op an AI op (routed here instead of handleOp)? */
39
41
  export function isAiOp(method: OpMessage['method']): boolean {
@@ -50,7 +52,11 @@ type AiOpMessage = Extract<OpMessage, { method: 'ai.generateContent' | 'ai.count
50
52
  * plain authoring shapes; predicate matchers never made it across the port —
51
53
  * structured clone rejects functions loudly at send time).
52
54
  */
53
- function resolveEngineConfig(wire: AiEngineConfigWire, upstream: HostCtx['aiUpstream']): NonNullable<AIOptions['engine']> {
55
+ function resolveEngineConfig(
56
+ wire: AiEngineConfigWire,
57
+ upstream: HostCtx['aiUpstream'],
58
+ runsInBrowser: boolean,
59
+ ): NonNullable<AIOptions['engine']> {
54
60
  if (wire.kind === 'openai') {
55
61
  const configuredUrl = wire.baseUrl ?? AI_PROXY_PATH;
56
62
  const usesProxy = configuredUrl.replace(/\/$/, '') === AI_PROXY_PATH;
@@ -65,18 +71,10 @@ function resolveEngineConfig(wire: AiEngineConfigWire, upstream: HostCtx['aiUpst
65
71
  }
66
72
  const isGeminiWire = wire.kind === 'gemini';
67
73
  if (isGeminiWire) {
68
- const result: Record<string, unknown> = {
69
- kind: 'gemini',
70
- };
71
- const hasBaseUrl = wire.baseUrl !== undefined;
72
- if (hasBaseUrl) {
73
- result.baseUrl = wire.baseUrl;
74
- }
75
- const hasApiKey = wire.apiKey !== undefined;
76
- if (hasApiKey) {
77
- result.apiKey = wire.apiKey;
78
- }
79
- return result as unknown as NonNullable<AIOptions['engine']>;
74
+ // Node reads the key from its own environment. A browser worker has
75
+ // none, so it sends through the dev server's gemini proxy.
76
+ const config = runsInBrowser ? browserGeminiEngine(wire) : wire;
77
+ return { ...config } as NonNullable<AIOptions['engine']>;
80
78
  }
81
79
  return {
82
80
  kind: 'scripted',
@@ -100,7 +98,7 @@ function resolveEngineConfig(wire: AiEngineConfigWire, upstream: HostCtx['aiUpst
100
98
  export function ensureAiBroker(ctx: HostCtx, opEngine?: AiEngineConfigWire): AiBrokerLike {
101
99
  if (ctx.aiBroker) return ctx.aiBroker;
102
100
  const wire = ctx.aiEngine ?? opEngine;
103
- const ai = getAI(ctx.sandbox, wire ? { engine: resolveEngineConfig(wire, ctx.aiUpstream) } : undefined);
101
+ const ai = getAI(ctx.sandbox, wire ? { engine: resolveEngineConfig(wire, ctx.aiUpstream, ctx.aiRunsInBrowser === true) } : undefined);
104
102
  const target = (ai as unknown as {
105
103
  [TARGET_SYMBOL]?: { broker: AiBrokerLike };
106
104
  })[TARGET_SYMBOL];
@@ -123,14 +121,14 @@ export async function handleAiOp(ctx: HostCtx, port: PortLike, msg: OpMessage):
123
121
  observation.identity(identity);
124
122
  switch (aiMsg.method) {
125
123
  case 'ai.generateContent': {
126
- const response = await broker.generateContent(aiMsg.request, aiMsg.model);
124
+ const response = await broker.generateContent(aiMsg.request, aiMsg.model, aiMsg.appCheck);
127
125
  observation.response(response);
128
126
  observation.finish('completed');
129
127
  ok(port, aiMsg.id, packAiEvidence(response));
130
128
  break;
131
129
  }
132
130
  case 'ai.countTokens': {
133
- const response = await broker.countTokens(aiMsg.request, aiMsg.model);
131
+ const response = await broker.countTokens(aiMsg.request, aiMsg.model, aiMsg.appCheck);
134
132
  observation.response(response);
135
133
  observation.finish('completed');
136
134
  ok(port, aiMsg.id, packAiEvidence(response));
@@ -156,9 +154,9 @@ export async function handleAiOp(ctx: HostCtx, port: PortLike, msg: OpMessage):
156
154
  * (sub handling is synchronous, like the other sub handlers); a client
157
155
  * `unsub` mid-stream flips the registered cancel and delivery stops.
158
156
  *
159
- * Broker validation runs eagerly inside `streamGenerateContent` (before
160
- * iteration) — either way a throw lands as the shared `{ __error }` snap
161
- * convention, terminal for this sub.
157
+ * The App Check gate runs first, then broker validation runs eagerly inside
158
+ * `streamGenerateContent` (before iteration) — either way a throw lands as
159
+ * the shared `{ __error }` snap convention, terminal for this sub.
162
160
  */
163
161
  export function handleAiSub(ctx: HostCtx, port: PortLike, msg: AiStreamSubMessage): void {
164
162
  let portSubs = ctx.subs.get(port);
@@ -182,7 +180,9 @@ export function handleAiSub(ctx: HostCtx, port: PortLike, msg: AiStreamSubMessag
182
180
  const broker = ensureAiBroker(ctx, msg.engine);
183
181
  identity = broker.observationIdentity?.(msg.model);
184
182
  observation.identity(identity);
185
- for await (const chunk of broker.streamGenerateContent(msg.request, msg.model)) {
183
+ // The App Check gate answers before any chunk, as AI Logic answers 403 before a stream.
184
+ await broker.admitAppCheck(msg.model, msg.appCheck);
185
+ for await (const chunk of broker.streamGenerateContent(msg.request, msg.model, msg.appCheck)) {
186
186
  if (cancelled) return;
187
187
  observation.response(chunk, true);
188
188
  post(port, { t: 'snap', subId: msg.subId, value: { chunk: packAiEvidence(chunk) } });
@@ -24,6 +24,7 @@ import {
24
24
  type OutboundMessage,
25
25
  type AuthPersistenceMode,
26
26
  type AiEngineConfigWire,
27
+ type AiAppCheckWire,
27
28
  type AppConfigMessage,
28
29
  } from './protocol.js';
29
30
 
@@ -40,9 +41,11 @@ import {
40
41
  */
41
42
  export interface AiBrokerLike {
42
43
  observationIdentity?(model: string): import('pyric/ai/internal').AiEvidence;
43
- generateContent(req: Record<string, unknown>, model: string): Promise<Record<string, unknown>>;
44
- streamGenerateContent(req: Record<string, unknown>, model: string): AsyncIterable<Record<string, unknown>>;
45
- countTokens(req: Record<string, unknown>, model: string): Promise<Record<string, unknown>>;
44
+ /** AI Logic's App Check gate; a request with no claim passes. */
45
+ admitAppCheck(model: string, appCheck: AiAppCheckWire | undefined): Promise<void>;
46
+ generateContent(req: Record<string, unknown>, model: string, appCheck?: AiAppCheckWire): Promise<Record<string, unknown>>;
47
+ streamGenerateContent(req: Record<string, unknown>, model: string, appCheck?: AiAppCheckWire): AsyncIterable<Record<string, unknown>>;
48
+ countTokens(req: Record<string, unknown>, model: string, appCheck?: AiAppCheckWire): Promise<Record<string, unknown>>;
46
49
  }
47
50
 
48
51
  // ─── Port interface ───────────────────────────────────────────────────────
@@ -278,6 +281,13 @@ export interface HostCtx {
278
281
  aiEngine?: AiEngineConfigWire;
279
282
  /** Server-only OpenAI transport. Browser workers retain the same-origin proxy. */
280
283
  aiUpstream?: { baseUrl: string; fetch: typeof fetch };
284
+ /**
285
+ * The broker's engines run in the browser SharedWorker, which has no server
286
+ * environment: the gemini engine sends through the dev server's gemini
287
+ * proxy, which attaches the key (#1584). Set by the worker entry only; a
288
+ * Node host and a Node repro replay leave it unset.
289
+ */
290
+ aiRunsInBrowser?: boolean;
281
291
  /**
282
292
  * Lazily-built agent tool dispatcher (the canonical sandbox tool set) bound to
283
293
  * THIS worker's sandbox. The bridge peer forwards `tool` messages here so the
@@ -495,10 +495,21 @@ export function assertOperationArguments(message: Record<string, unknown>): void
495
495
  }
496
496
 
497
497
 
498
+ /** An App Check claim is absent (a handle with no app) or a record whose token is a string or null. */
499
+ function assertAiAppCheck(appCheck: unknown): void {
500
+ const hasNoClaim = appCheck === undefined;
501
+ if (hasNoClaim) return;
502
+ requireRecord(appCheck, 'appCheck');
503
+ const token = appCheck.token;
504
+ const hasTokenShape = token === null || typeof token === 'string';
505
+ requireShape(hasTokenShape, 'appCheck.token');
506
+ }
507
+
498
508
  /** Unary and streaming AI requests share the same adapter-owned wire fields. */
499
509
  export function assertAiArguments(message: Record<string, unknown>): void {
500
510
  requireString(message.model, 'model');
501
511
  requireRecord(message.request, 'request');
512
+ assertAiAppCheck(message.appCheck);
502
513
  const engine = message.engine;
503
514
  const hasNoEngine = engine === undefined;
504
515
  if (hasNoEngine) return;
@@ -59,6 +59,17 @@ export type AiEngineConfigWire =
59
59
  }
60
60
  | { kind: 'gemini'; baseUrl?: string; apiKey?: string };
61
61
 
62
+ /**
63
+ * The App Check claim an AI request from an app-backed handle carries, as
64
+ * production reads `X-Firebase-AppCheck`: a token, or `null` when the handle
65
+ * has none. The worker broker refuses a request whose claim has no valid
66
+ * sandbox token. Requests from handles with no app send no claim and are not
67
+ * gated.
68
+ */
69
+ export interface AiAppCheckWire {
70
+ token: string | null;
71
+ }
72
+
62
73
  /**
63
74
  * Wire form of the Gemini error envelope an `AiBrokerError` carries.
64
75
  */
@@ -289,8 +300,8 @@ export type OpMessage = (
289
300
  */
290
301
  | ({ t: 'op'; id: string; method: 'storage.createUploadSession'; path: string; contentType?: string; metadata?: Record<string, unknown>; downloadTokens?: string; origin?: string } & StorageBucketWire)
291
302
  // AI ops
292
- | { t: 'op'; id: string; method: 'ai.generateContent'; model: string; request: Record<string, unknown>; engine?: AiEngineConfigWire }
293
- | { t: 'op'; id: string; method: 'ai.countTokens'; model: string; request: Record<string, unknown>; engine?: AiEngineConfigWire }
303
+ | { t: 'op'; id: string; method: 'ai.generateContent'; model: string; request: Record<string, unknown>; engine?: AiEngineConfigWire; appCheck?: AiAppCheckWire }
304
+ | { t: 'op'; id: string; method: 'ai.countTokens'; model: string; request: Record<string, unknown>; engine?: AiEngineConfigWire; appCheck?: AiAppCheckWire }
294
305
  | { t: 'op'; id: string; method: 'getRuntimeEpoch' }
295
306
  | { t: 'op'; id: string; method: 'retireRuntime'; targetEpoch: string }
296
307
  | { t: 'op'; id: string; method: 'getVersion' }
@@ -377,6 +388,7 @@ export interface AiStreamSubMessage {
377
388
  model: string;
378
389
  request: Record<string, unknown>;
379
390
  engine?: AiEngineConfigWire;
391
+ appCheck?: AiAppCheckWire;
380
392
  }
381
393
 
382
394
  export interface MessagingSubMessage {