@pyric/cli 0.1.0-alpha.30 → 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 (169) hide show
  1. package/dist/assurance/.generated/conformance-verdicts.d.ts +8 -0
  2. package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
  3. package/dist/assurance/.generated/conformance-verdicts.js +8 -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/cli/serve.d.ts +2 -0
  10. package/dist/cli/serve.d.ts.map +1 -1
  11. package/dist/cli/serve.js +13 -2
  12. package/dist/cli/serve.js.map +1 -1
  13. package/dist/conformance/.generated/can-i-use-browser.js +2 -2
  14. package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
  15. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  16. package/dist/conformance/.generated/can-i-use.js +1 -1
  17. package/dist/conformance/.generated/can-i-use.js.map +1 -1
  18. package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
  19. package/dist/conformance/.generated/conformance-docs.js +1 -1
  20. package/dist/conformance/.generated/conformance-docs.js.map +1 -1
  21. package/dist/register/app-bridge-admin-app-check.d.ts +3 -0
  22. package/dist/register/app-bridge-admin-app-check.d.ts.map +1 -0
  23. package/dist/register/app-bridge-admin-app-check.js +43 -0
  24. package/dist/register/app-bridge-admin-app-check.js.map +1 -0
  25. package/dist/register/app-bridge-app-check.d.ts +4 -0
  26. package/dist/register/app-bridge-app-check.d.ts.map +1 -1
  27. package/dist/register/app-bridge-app-check.js +4 -0
  28. package/dist/register/app-bridge-app-check.js.map +1 -1
  29. package/dist/register/app-bridge-shadow.d.ts +4 -2
  30. package/dist/register/app-bridge-shadow.d.ts.map +1 -1
  31. package/dist/register/app-bridge-shadow.js +29 -5
  32. package/dist/register/app-bridge-shadow.js.map +1 -1
  33. package/dist/register/index.d.ts.map +1 -1
  34. package/dist/register/index.js +21 -0
  35. package/dist/register/index.js.map +1 -1
  36. package/dist/serve/ai-diagnostics-relay.d.ts +4 -1
  37. package/dist/serve/ai-diagnostics-relay.d.ts.map +1 -1
  38. package/dist/serve/ai-diagnostics-relay.js +3 -0
  39. package/dist/serve/ai-diagnostics-relay.js.map +1 -1
  40. package/dist/serve/ai-proxy.d.ts +21 -1
  41. package/dist/serve/ai-proxy.d.ts.map +1 -1
  42. package/dist/serve/ai-proxy.js +39 -14
  43. package/dist/serve/ai-proxy.js.map +1 -1
  44. package/dist/serve/ai-terminal-blocks.d.ts +6 -4
  45. package/dist/serve/ai-terminal-blocks.d.ts.map +1 -1
  46. package/dist/serve/ai-terminal-blocks.js +7 -4
  47. package/dist/serve/ai-terminal-blocks.js.map +1 -1
  48. package/dist/serve/ai-terminal-text.d.ts +8 -0
  49. package/dist/serve/ai-terminal-text.d.ts.map +1 -1
  50. package/dist/serve/ai-terminal-text.js +10 -0
  51. package/dist/serve/ai-terminal-text.js.map +1 -1
  52. package/dist/serve/bundler.d.ts +40 -0
  53. package/dist/serve/bundler.d.ts.map +1 -1
  54. package/dist/serve/bundler.js +133 -2
  55. package/dist/serve/bundler.js.map +1 -1
  56. package/dist/serve/entries/ai.d.ts.map +1 -1
  57. package/dist/serve/entries/ai.js +28 -26
  58. package/dist/serve/entries/ai.js.map +1 -1
  59. package/dist/serve/entries/app-ai-passthrough.d.ts +4 -4
  60. package/dist/serve/entries/app-ai-passthrough.d.ts.map +1 -1
  61. package/dist/serve/entries/app-ai-passthrough.js +8 -52
  62. package/dist/serve/entries/app-ai-passthrough.js.map +1 -1
  63. package/dist/serve/firebase-module-swap.d.ts +22 -0
  64. package/dist/serve/firebase-module-swap.d.ts.map +1 -1
  65. package/dist/serve/firebase-module-swap.js +51 -3
  66. package/dist/serve/firebase-module-swap.js.map +1 -1
  67. package/dist/serve/gemini-error-redaction.d.ts +6 -0
  68. package/dist/serve/gemini-error-redaction.d.ts.map +1 -0
  69. package/dist/serve/gemini-error-redaction.js +61 -0
  70. package/dist/serve/gemini-error-redaction.js.map +1 -0
  71. package/dist/serve/gemini-proxy.d.ts +40 -0
  72. package/dist/serve/gemini-proxy.d.ts.map +1 -0
  73. package/dist/serve/gemini-proxy.js +105 -0
  74. package/dist/serve/gemini-proxy.js.map +1 -0
  75. package/dist/serve/hosted/method-protocol.d.ts +4 -4
  76. package/dist/serve/html-injection.d.ts +2 -0
  77. package/dist/serve/html-injection.d.ts.map +1 -1
  78. package/dist/serve/html-injection.js +4 -2
  79. package/dist/serve/html-injection.js.map +1 -1
  80. package/dist/serve/namespace.d.ts +9 -0
  81. package/dist/serve/namespace.d.ts.map +1 -1
  82. package/dist/serve/namespace.js +71 -15
  83. package/dist/serve/namespace.js.map +1 -1
  84. package/dist/serve/sandbox-session.d.ts +3 -0
  85. package/dist/serve/sandbox-session.d.ts.map +1 -1
  86. package/dist/serve/sandbox-session.js +1 -0
  87. package/dist/serve/sandbox-session.js.map +1 -1
  88. package/dist/serve/site-ui/docs/ai-compat/index.html +29 -10
  89. package/dist/serve/site-ui/docs/ai-compat.md +30 -9
  90. package/dist/serve/site-ui/docs/app-check-compat/index.html +42 -18
  91. package/dist/serve/site-ui/docs/app-check-compat.md +42 -18
  92. package/dist/serve/site-ui/docs/auth-compat/index.html +4 -4
  93. package/dist/serve/site-ui/docs/auth-compat.md +4 -4
  94. package/dist/serve/site-ui/docs/build/ai-logic/index.html +21 -5
  95. package/dist/serve/site-ui/docs/build/ai-logic.md +23 -2
  96. package/dist/serve/site-ui/docs/database-compat/index.html +4 -4
  97. package/dist/serve/site-ui/docs/database-compat.md +4 -4
  98. package/dist/serve/site-ui/docs/firestore-compat/index.html +4 -4
  99. package/dist/serve/site-ui/docs/firestore-compat.md +4 -4
  100. package/dist/serve/site-ui/docs/index.json +10 -0
  101. package/dist/serve/site-ui/docs/messaging-compat/index.html +4 -4
  102. package/dist/serve/site-ui/docs/messaging-compat.md +4 -4
  103. package/dist/serve/site-ui/docs/pyric-admin-app-reference-api/index.html +4 -0
  104. package/dist/serve/site-ui/docs/pyric-admin-app-reference-api.md +5 -0
  105. package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api/index.html +2 -0
  106. package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api.md +3 -0
  107. package/dist/serve/site-ui/docs/storage-compat/index.html +5 -5
  108. package/dist/serve/site-ui/docs/storage-compat.md +5 -5
  109. package/dist/serve/site-ui/docs/tutorial/index.html +11 -1
  110. package/dist/serve/site-ui/docs/tutorial.md +12 -1
  111. package/dist/serve/site-ui/examples/chess/index.html +1 -1
  112. package/dist/serve/vite-sandbox-generation.d.ts.map +1 -1
  113. package/dist/serve/vite-sandbox-generation.js +2 -0
  114. package/dist/serve/vite-sandbox-generation.js.map +1 -1
  115. package/dist/serve/worker/ai-proxy-paths.d.ts +32 -0
  116. package/dist/serve/worker/ai-proxy-paths.d.ts.map +1 -0
  117. package/dist/serve/worker/ai-proxy-paths.js +20 -0
  118. package/dist/serve/worker/ai-proxy-paths.js.map +1 -0
  119. package/dist/serve/worker/client/ai.d.ts +3 -1
  120. package/dist/serve/worker/client/ai.d.ts.map +1 -1
  121. package/dist/serve/worker/client/ai.js +3 -0
  122. package/dist/serve/worker/client/ai.js.map +1 -1
  123. package/dist/serve/worker/entry.js +2 -0
  124. package/dist/serve/worker/entry.js.map +1 -1
  125. package/dist/serve/worker/host-ai.d.ts +4 -5
  126. package/dist/serve/worker/host-ai.d.ts.map +1 -1
  127. package/dist/serve/worker/host-ai.js +19 -23
  128. package/dist/serve/worker/host-ai.js.map +1 -1
  129. package/dist/serve/worker/host-context.d.ts +13 -4
  130. package/dist/serve/worker/host-context.d.ts.map +1 -1
  131. package/dist/serve/worker/host-context.js.map +1 -1
  132. package/dist/serve/worker/inbound-validation/operation-arguments.d.ts.map +1 -1
  133. package/dist/serve/worker/inbound-validation/operation-arguments.js +11 -0
  134. package/dist/serve/worker/inbound-validation/operation-arguments.js.map +1 -1
  135. package/dist/serve/worker/protocol.d.ts +13 -0
  136. package/dist/serve/worker/protocol.d.ts.map +1 -1
  137. package/dist/serve/worker/protocol.js.map +1 -1
  138. package/package.json +4 -4
  139. package/src/assurance/.generated/conformance-verdicts.ts +8 -0
  140. package/src/bridge/server/peer.ts +49 -1
  141. package/src/cli/serve.ts +14 -2
  142. package/src/conformance/.generated/can-i-use-browser.ts +2 -2
  143. package/src/conformance/.generated/can-i-use.ts +1 -1
  144. package/src/conformance/.generated/conformance-docs.ts +1 -1
  145. package/src/register/app-bridge-admin-app-check.ts +46 -0
  146. package/src/register/app-bridge-app-check.ts +4 -0
  147. package/src/register/app-bridge-shadow.ts +35 -5
  148. package/src/register/index.ts +23 -0
  149. package/src/serve/ai-diagnostics-relay.ts +6 -1
  150. package/src/serve/ai-proxy.ts +43 -14
  151. package/src/serve/ai-terminal-blocks.ts +8 -4
  152. package/src/serve/ai-terminal-text.ts +11 -0
  153. package/src/serve/bundler.ts +163 -2
  154. package/src/serve/entries/ai.ts +28 -27
  155. package/src/serve/entries/app-ai-passthrough.ts +8 -58
  156. package/src/serve/firebase-module-swap.ts +52 -3
  157. package/src/serve/gemini-error-redaction.ts +60 -0
  158. package/src/serve/gemini-proxy.ts +140 -0
  159. package/src/serve/html-injection.ts +4 -2
  160. package/src/serve/namespace.ts +74 -15
  161. package/src/serve/sandbox-session.ts +4 -0
  162. package/src/serve/vite-sandbox-generation.ts +2 -0
  163. package/src/serve/worker/ai-proxy-paths.ts +35 -0
  164. package/src/serve/worker/client/ai.ts +6 -1
  165. package/src/serve/worker/entry.ts +2 -0
  166. package/src/serve/worker/host-ai.ts +23 -23
  167. package/src/serve/worker/host-context.ts +13 -3
  168. package/src/serve/worker/inbound-validation/operation-arguments.ts +11 -0
  169. package/src/serve/worker/protocol.ts +14 -2
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Production AI mode's App Check verifier for a registered Node process
3
+ * (#1552): firebase-admin's own `verifyToken`, for the tokens the page gets
4
+ * from Firebase.
5
+ *
6
+ * The register installs it behind `pyric.admin.appCheck.firebaseVerifier` in
7
+ * production AI mode only, and loads this module on the first token whose
8
+ * `kid` is not the sandbox's, so a server that never sees one never loads
9
+ * firebase-admin. `firebase-admin/app` and `firebase-admin/app-check` reach
10
+ * the real package from here (`isBypassedBridgeImport`).
11
+ *
12
+ * Each project gets one firebase-admin app whose credential refuses to mint an
13
+ * access token, so the app cannot fall back to Application Default
14
+ * Credentials, the metadata server, or Google's OAuth endpoints. Verifying a
15
+ * token needs none of them: firebase-admin fetches Firebase's public keys from
16
+ * `https://firebaseappcheck.googleapis.com/v1/jwks` without credentials and
17
+ * caches them for six hours. `consume` is never passed here; the mirror
18
+ * records it in the process.
19
+ *
20
+ * The export is typed loosely on purpose: the published declarations of
21
+ * @pyric/cli carry no Firebase SDK edge. pyric-admin types the result.
22
+ */
23
+ import { initializeApp, type App, type Credential } from 'firebase-admin/app';
24
+ import { getAppCheck } from 'firebase-admin/app-check';
25
+
26
+ const NO_CREDENTIALS = "Pyric's App Check verifier holds no credentials.";
27
+
28
+ /** A credential that never mints an access token. */
29
+ const noCredentials: Credential = {
30
+ getAccessToken: () => Promise.reject(new Error(NO_CREDENTIALS)),
31
+ };
32
+
33
+ const appsByProject = new Map<string, App>();
34
+
35
+ function verifierApp(projectId: string): App {
36
+ const existing = appsByProject.get(projectId);
37
+ if (existing !== undefined) return existing;
38
+ const app = initializeApp({ projectId, credential: noCredentials }, `[pyric] app-check verifier ${projectId}`);
39
+ appsByProject.set(projectId, app);
40
+ return app;
41
+ }
42
+
43
+ /** firebase-admin's `verifyToken(token)` for an app scoped to `projectId`, with no options. */
44
+ export function verifyFirebaseAppCheckToken(token: string, projectId: string): Promise<unknown> {
45
+ return getAppCheck(verifierApp(projectId)).verifyToken(token);
46
+ }
@@ -20,6 +20,10 @@ import { shadowAppOf } from './app-bridge-shadow.js';
20
20
 
21
21
  type RealInitialize = typeof realAppCheck.initializeAppCheck;
22
22
 
23
+ /**
24
+ * `shadowAppOf` throws when the shadow app could not be created, so that
25
+ * cause surfaces here before the SDK would fail on a missing container.
26
+ */
23
27
  export function initializeAppCheck(app: { name: string } | undefined, options: unknown): unknown {
24
28
  const shadow = app === undefined ? undefined : shadowAppOf(app);
25
29
  return realAppCheck.initializeAppCheck(shadow as Parameters<RealInitialize>[0], options as Parameters<RealInitialize>[1]);
@@ -2,7 +2,8 @@
2
2
  * Production AI pass-through for canonical `firebase/app` in a registered Node
3
3
  * process: each Pyric app gets a shadow real Firebase app whose container the
4
4
  * real `firebase/ai` reads. Shared by the in-process adapter
5
- * (`./app-bridge.ts`) and the Node host client (`./client/app-bridge.ts`).
5
+ * (`./app-bridge.ts`), the Node host client (`./client/app-bridge.ts`), and
6
+ * the served `firebase/app` entry (`serve/entries/app-ai-passthrough.ts`).
6
7
  */
7
8
  import * as realApp from 'firebase/app';
8
9
  import type { FirebaseApp, FirebaseAppSettings, FirebaseOptions } from 'pyric/app';
@@ -14,6 +15,27 @@ export interface PyricAppRegistry {
14
15
  deleteApp(app: FirebaseApp): Promise<void>;
15
16
  }
16
17
 
18
+ /**
19
+ * Why the shadow app for a name could not be created, kept until a later
20
+ * attempt succeeds or the app is deleted. It sits behind `Symbol.for` so every
21
+ * bundle or module record of this file on a page or in a process shares it.
22
+ */
23
+ const SHADOW_FAILURES = Symbol.for('pyric.ai.shadowAppFailures');
24
+ type GlobalShadowFailures = { [SHADOW_FAILURES]?: Map<string, unknown> };
25
+ const shadowFailures = ((globalThis as GlobalShadowFailures)[SHADOW_FAILURES] ??= new Map<string, unknown>());
26
+
27
+ function causeText(cause: unknown): string {
28
+ const text = cause instanceof Error ? cause.message : String(cause);
29
+ return text.replace(/\.$/, '');
30
+ }
31
+
32
+ function shadowFailedError(name: string, cause: unknown): Error {
33
+ return new Error(
34
+ `Pyric could not create the Firebase app that firebase/app-check and firebase/ai attach to in production AI mode, for app "${name}": ${causeText(cause)}.`,
35
+ { cause },
36
+ );
37
+ }
38
+
17
39
  function decorateApp(
18
40
  app: FirebaseApp,
19
41
  options?: FirebaseOptions,
@@ -34,24 +56,31 @@ function decorateApp(
34
56
  shadow = realApp.initializeApp(initOptions, objectSettings) as { container?: unknown };
35
57
  }
36
58
  }
59
+ shadowFailures.delete(app.name);
37
60
  const hasShadowContainer = shadow.container !== undefined && shadow.container !== null;
38
61
  if (hasShadowContainer) {
39
62
  Object.defineProperty(app, 'container', { value: shadow.container, configurable: true });
40
63
  }
41
- } catch {
42
- // Observational/best-effort: do not break Pyric initialization if shadow initialization fails.
64
+ } catch (error) {
65
+ // Pyric's initializeApp still succeeds; shadowAppOf reports the cause
66
+ // when firebase/app-check or firebase/ai needs the shadow.
67
+ shadowFailures.set(app.name, error);
43
68
  }
44
69
  return app;
45
70
  }
46
71
 
47
72
  /**
48
- * The shadow Firebase app for a Pyric app, by name. A Firebase app passed in
49
- * directly, or a Pyric app with no shadow yet, is returned unchanged.
73
+ * The shadow Firebase app for a Pyric app, by name. When creating it failed,
74
+ * throws an error that names the app and keeps the failure as `cause`. A
75
+ * Firebase app passed in directly, or a Pyric app with no attempt yet, is
76
+ * returned unchanged.
50
77
  */
51
78
  export function shadowAppOf(app: { name: string }): object {
52
79
  try {
53
80
  return realApp.getApp(app.name);
54
81
  } catch {
82
+ const hasFailure = shadowFailures.has(app.name);
83
+ if (hasFailure) throw shadowFailedError(app.name, shadowFailures.get(app.name));
55
84
  return app;
56
85
  }
57
86
  }
@@ -69,6 +98,7 @@ export function shadowedAppRegistry(pyricApp: PyricAppRegistry): PyricAppRegistr
69
98
  return pyricApp.getApps().map((app) => decorateApp(app));
70
99
  },
71
100
  async deleteApp(app) {
101
+ shadowFailures.delete(app.name);
72
102
  try {
73
103
  const shadow = realApp.getApp(app.name);
74
104
  const hasShadow = shadow !== undefined && shadow !== null;
@@ -38,12 +38,16 @@ import { resolveEsmOnlySubpath } from './esm-exports.js';
38
38
  import { NET_GUARD_GLOBAL, installNetGuard, parseGuardMode, type GuardMode } from './net-guard.js';
39
39
  import { emitBeacon } from './beacon.js';
40
40
  import { remoteSandbox } from '../remote/index.js';
41
+ import { resolveAiMode } from '../serve/firebase-module-swap.js';
41
42
 
42
43
  /** The shared seam with pyric-admin's ambient init: a synchronous factory
43
44
  * returning the lazy branded handle. `Symbol.for` so both packages agree
44
45
  * without importing each other's types. */
45
46
  const SANDBOX_FACTORY = Symbol.for('pyric.remote.sandboxFactory');
46
47
 
48
+ /** The seam with pyric-admin's App Check mirror: firebase-admin's verifier for tokens from Firebase (#1552). */
49
+ const APP_CHECK_FIREBASE_VERIFIER = Symbol.for('pyric.admin.appCheck.firebaseVerifier');
50
+
47
51
  interface SyncResolveContext {
48
52
  parentURL?: string;
49
53
  conditions?: string[];
@@ -170,6 +174,8 @@ function activate(): void {
170
174
  (globalThis as Record<symbol, unknown>)[SANDBOX_FACTORY] = (opts?: { url?: string }) =>
171
175
  remoteSandbox({ configuredUrl: opts?.url, instanceId: process.env.PYRIC_SANDBOX_INSTANCE });
172
176
 
177
+ installAppCheckVerifier();
178
+
173
179
  process.stderr.write(
174
180
  `@pyric/cli/register: active. firebase-admin/firebase imports now resolve to the ` +
175
181
  `pyric sandbox (PYRIC_SANDBOX=${process.env.PYRIC_SANDBOX}).\n`,
@@ -187,6 +193,23 @@ function activate(): void {
187
193
  });
188
194
  }
189
195
 
196
+ /**
197
+ * Production AI mode only: the page's App Check tokens come from Firebase, so
198
+ * pyric-admin's mirror hands them to firebase-admin's own verifier (#1552).
199
+ * The bridge, and firebase-admin with it, loads on the first such token, so a
200
+ * server that never verifies one never loads firebase-admin. In sandbox AI
201
+ * mode nothing is installed, and the mirror refuses such a token with a
202
+ * message that says why.
203
+ */
204
+ function installAppCheckVerifier(): void {
205
+ const isProductionAiMode = resolveAiMode(undefined, process.env) === 'production';
206
+ if (!isProductionAiMode) return;
207
+ (globalThis as Record<symbol, unknown>)[APP_CHECK_FIREBASE_VERIFIER] = async (token: string, projectId: string) => {
208
+ const bridge = await import('./app-bridge-admin-app-check.js');
209
+ return bridge.verifyFirebaseAppCheckToken(token, projectId);
210
+ };
211
+ }
212
+
190
213
  /**
191
214
  * The net-guard mode actually in force, for the beacon to report.
192
215
  *
@@ -11,7 +11,8 @@ import { capTerminalText } from './ai-terminal-text.js';
11
11
  * agent driving `pyric dev` in process therefore saw NOTHING.
12
12
  *
13
13
  * - `request_rejected`: a bad role, empty `contents`, a missing thought
14
- * signature, or an engine that answered an error envelope.
14
+ * signature, a request without a valid App Check token, or an engine
15
+ * that answered an error envelope.
15
16
  * - `response_blocked`: the SILENT refusal. A safety / recitation /
16
17
  * blocklist filter answers HTTP 200 with an empty candidate, so nothing
17
18
  * throws anywhere and the app simply renders nothing.
@@ -73,6 +74,8 @@ interface AiRejectionRelayPayload {
73
74
  code?: number;
74
75
  /** The rejection reason: production's own message for shape errors. */
75
76
  message: string;
77
+ /** Pyric's guidance beside production's message, such as the App Check gate's. */
78
+ hint?: string;
76
79
  }
77
80
 
78
81
  /** Shape POSTed for a `response_blocked` event. Carries the wire's own
@@ -148,6 +151,8 @@ function toRejectionPayload(event: ServiceMutation): AiRejectionRelayPayload | n
148
151
  payload.engine = detailText(detail, 'engine');
149
152
  payload.status = detailText(detail, 'status');
150
153
  if (typeof detail.code === 'number') payload.code = detail.code;
154
+ const hint = detailText(detail, 'hint');
155
+ if (hint !== undefined) payload.hint = hint;
151
156
  return payload;
152
157
  }
153
158
 
@@ -16,6 +16,8 @@ import type { AiEngineConfigWire } from './worker/protocol.js';
16
16
  import { permitGuardHost } from '../register/net-guard.js';
17
17
  import { FIREBASE_AI_LOGIC_HOST } from '../google-endpoints.js';
18
18
  import type { AiMode } from './firebase-module-swap.js';
19
+ import { HOSTED_METHOD_BODY_LIMIT } from './hosted/method-protocol.js';
20
+ import { BODY_TOO_LARGE_CODE, collectRawBody } from '../bridge/server/peer.js';
19
21
 
20
22
  /** The dev server's diagnostics throttle, as this module needs it: the
21
23
  * denials route owns the instance, and the proxy shares it so a failing
@@ -30,6 +32,10 @@ export const AI_PROXY_DEFAULT_UPSTREAM = 'http://localhost:11434/v1';
30
32
  /** The route the dev server mounts for OpenAI-compatible traffic. */
31
33
  export const AI_PROXY_ROUTE = '/__pyric/ai-proxy';
32
34
 
35
+ /** Largest request body the proxy forwards: the hosted method route's limit,
36
+ * which already carries AI requests with inline images. */
37
+ export const AI_PROXY_BODY_LIMIT_BYTES = HOSTED_METHOD_BODY_LIMIT;
38
+
33
39
  /**
34
40
  * Request headers that must NOT be forwarded upstream: origin-sensitive
35
41
  * browser context (`origin`/`referer`/`cookie`, since the upstream is a
@@ -280,6 +286,25 @@ export async function fetchAiUpstream(
280
286
  });
281
287
  }
282
288
 
289
+ /**
290
+ * Read an AI proxy request's body, at most {@link AI_PROXY_BODY_LIMIT_BYTES}.
291
+ * Past the limit it answers 413, and on a broken upload 400, and returns
292
+ * null: the caller sends nothing upstream. Both proxy routes read through it.
293
+ */
294
+ export async function readAiProxyBody(
295
+ req: IncomingMessage,
296
+ res: ServerResponse,
297
+ ): Promise<Uint8Array<ArrayBuffer> | null> {
298
+ try {
299
+ return await collectRawBody(req, AI_PROXY_BODY_LIMIT_BYTES);
300
+ } catch (error) {
301
+ const tooLarge = (error as { code?: unknown }).code === BODY_TOO_LARGE_CODE;
302
+ const status = tooLarge ? 413 : 400;
303
+ res.writeHead(status, { 'content-type': 'text/plain' }).end(tooLarge ? 'Payload Too Large' : 'Bad Request');
304
+ return null;
305
+ }
306
+ }
307
+
283
308
  /**
284
309
  * Handle `POST /__pyric/ai-proxy/<suffix>`: a same-origin passthrough to the
285
310
  * configured OpenAI-compatible upstream, so the browser openai engine
@@ -290,7 +315,11 @@ export async function fetchAiUpstream(
290
315
  * - POST only (the OpenAI chat-completions surface is POST); 405 otherwise.
291
316
  * - the path suffix + query ride through verbatim
292
317
  * (`/__pyric/ai-proxy/chat/completions` becomes `<upstream>/chat/completions`).
293
- * - the request body is forwarded verbatim; headers minus the
318
+ * - the caller must pass the namespace's loopback guard (Host, and an
319
+ * Origin naming this server when one is sent), which the namespace runs
320
+ * before this handler: the upstream may carry the developer's key.
321
+ * - the request body is forwarded verbatim, up to
322
+ * {@link AI_PROXY_BODY_LIMIT_BYTES} (413 past it); headers minus the
294
323
  * origin-sensitive/hop-by-hop set above (`authorization` DOES forward,
295
324
  * since upstreams may require a key).
296
325
  * - the response body is STREAMED through chunk-by-chunk. SSE passthrough
@@ -319,17 +348,9 @@ export async function handleAiProxy(
319
348
  const suffix = url.pathname.slice(AI_PROXY_ROUTE.length);
320
349
  const target = `${upstreamBase}${suffix}${url.search}`;
321
350
 
322
- // Buffer the REQUEST body (small JSON payloads); the RESPONSE streams.
323
- const raw = await new Promise<Buffer>((resolve, reject) => {
324
- const chunks: Buffer[] = [];
325
- req.on('data', (c: Buffer) => chunks.push(c));
326
- req.on('end', () => resolve(Buffer.concat(chunks)));
327
- req.on('error', reject);
328
- });
329
- // Copy into a plain-ArrayBuffer view; fetch's BodyInit typing rejects
330
- // Buffer's ArrayBufferLike backing.
331
- const body = new Uint8Array(raw.byteLength);
332
- body.set(raw);
351
+ // The request body is a bounded JSON payload, read once; the RESPONSE streams.
352
+ const body = await readAiProxyBody(req, res);
353
+ if (body === null) return;
333
354
 
334
355
  const headers: Record<string, string> = {};
335
356
  for (const [key, value] of Object.entries(req.headers)) {
@@ -355,6 +376,16 @@ export async function handleAiProxy(
355
376
  return;
356
377
  }
357
378
 
379
+ await relayAiUpstream(upstream, res);
380
+ }
381
+
382
+ /**
383
+ * Send an upstream's answer to the caller: its status and content type, then
384
+ * the body chunk by chunk. SSE passthrough must never buffer. A dropped
385
+ * client cancels the upstream read so an abandoned stream doesn't keep the
386
+ * upstream generating.
387
+ */
388
+ export async function relayAiUpstream(upstream: Response, res: ServerResponse): Promise<void> {
358
389
  const responseHeaders: Record<string, string> = { 'cache-control': 'no-store' };
359
390
  const contentType = upstream.headers.get('content-type');
360
391
  if (contentType) responseHeaders['content-type'] = contentType;
@@ -365,8 +396,6 @@ export async function handleAiProxy(
365
396
  }
366
397
  (res as ServerResponse & { flushHeaders?: () => void }).flushHeaders?.();
367
398
 
368
- // Chunk-by-chunk passthrough. A dropped client cancels the upstream read
369
- // so an abandoned SSE stream doesn't keep the upstream generating.
370
399
  const reader = upstream.body.getReader();
371
400
  res.on('close', () => {
372
401
  void reader.cancel().catch(() => {});
@@ -30,6 +30,8 @@ export interface AiRejectionPayload {
30
30
  code?: unknown;
31
31
  /** The rejection reason: production's own message for shape errors. */
32
32
  message?: unknown;
33
+ /** Pyric's guidance beside production's message, such as the App Check gate's. */
34
+ hint?: unknown;
33
35
  }
34
36
 
35
37
  /** Loosely-typed mirror of the AI blocked-response payload. Unlike a
@@ -99,10 +101,10 @@ function formatModelLine(model: unknown, engine: unknown): string | null {
99
101
  /**
100
102
  * Format a relayed AI broker rejection into the compact terminal block, the
101
103
  * same ` [pyric] ...` idiom the denial relay and the ai-proxy warning print.
102
- * Three lines at most: the reason production itself gives, then the model
103
- * (with the resolved engine, since a rejection reads very differently for
104
- * `scripted` than for a real upstream), then the wire status. Exported for
105
- * unit tests.
104
+ * The reason production itself gives, then the model (with the resolved
105
+ * engine, since a rejection reads very differently for `scripted` than for a
106
+ * real upstream), then the wire status, then pyric's guidance when the broker
107
+ * gave one (the App Check gate does). Exported for unit tests.
106
108
  */
107
109
  export function formatAiRejectionBlock(payload: AiRejectionPayload): string {
108
110
  const message = readText(payload.message) ?? 'request rejected';
@@ -117,6 +119,8 @@ export function formatAiRejectionBlock(payload: AiRejectionPayload): string {
117
119
  if (code !== null) suffix = ` (${code})`;
118
120
  lines.push(` ${label}${suffix}`);
119
121
  }
122
+ const hint = readText(payload.hint);
123
+ if (hint !== null) lines.push(` ${hint}`);
120
124
  return lines.join('\n');
121
125
  }
122
126
 
@@ -37,6 +37,17 @@ export function capTerminalText(text: string): string {
37
37
  const TERMINAL_UNSAFE_CHARS =
38
38
  /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g;
39
39
 
40
+ /**
41
+ * Replace control, bidi-override and line-separator characters with spaces,
42
+ * and change nothing else: no flattening, no whitespace collapsing, no cut.
43
+ * For one line of text a reader copies out of the terminal, such as a
44
+ * pasteable command or JSON fixture, where the exact spacing and the whole
45
+ * length matter. The caller splits lines first and bounds the total.
46
+ */
47
+ export function stripTerminalControls(text: string): string {
48
+ return text.replace(TERMINAL_UNSAFE_CHARS, ' ');
49
+ }
50
+
40
51
  /**
41
52
  * Flatten an upstream-authored string into ONE terminal-safe line.
42
53
  *
@@ -28,6 +28,8 @@ import { dirname, join, relative, sep } from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import * as esbuild from 'esbuild';
30
30
 
31
+ import { bridgeImportEntryKey, REAL_SDK_ENTRIES } from './firebase-module-swap.js';
32
+
31
33
  export { SDK_MODULES } from './firebase-module-swap.js';
32
34
 
33
35
  /**
@@ -160,6 +162,145 @@ function nodeShimPlugin(): esbuild.Plugin {
160
162
  };
161
163
  }
162
164
 
165
+ /**
166
+ * Send the shadow-app wrappers' own `firebase/app` and `firebase/app-check`
167
+ * imports to the Firebase SDK bundle `pyric sandbox` builds in production AI
168
+ * mode (`/__pyric/sdk/firebase-sdk-*.js`). Left bare, the import map would
169
+ * send them back to the wrappers themselves. The rule is
170
+ * `isBypassedBridgeImport`, which the Vite and register planes read too.
171
+ */
172
+ function bridgeImportPlugin(): esbuild.Plugin {
173
+ return {
174
+ name: 'pyric-serve-bridge-imports',
175
+ setup(build) {
176
+ build.onResolve({ filter: /^firebase\// }, (args) => {
177
+ const key = bridgeImportEntryKey(args.path, args.importer);
178
+ if (key === undefined) return undefined;
179
+ return { path: `/__pyric/sdk/${key}.js`, external: true };
180
+ });
181
+ },
182
+ };
183
+ }
184
+
185
+ // ─── the project's Firebase SDK (production AI mode) ─────────────────
186
+
187
+ /** The project's resolved `firebase` package. */
188
+ export interface ProjectFirebase {
189
+ /** The package directory, as resolved from the project. */
190
+ root: string;
191
+ version: string;
192
+ }
193
+
194
+ /**
195
+ * Find the `firebase` package the project's own code resolves, walking up
196
+ * `node_modules` directories from `projectDir` as Node does. Throws a message
197
+ * that tells the user to install it when there is none.
198
+ */
199
+ export function resolveProjectFirebase(projectDir: string): ProjectFirebase {
200
+ let dir = projectDir;
201
+ while (true) {
202
+ const manifest = join(dir, 'node_modules', 'firebase', 'package.json');
203
+ if (existsSync(manifest)) {
204
+ const pkg = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: string };
205
+ return { root: dirname(manifest), version: pkg.version ?? '0.0.0' };
206
+ }
207
+ const parent = dirname(dir);
208
+ if (parent === dir) break;
209
+ dir = parent;
210
+ }
211
+ throw new Error(
212
+ `pyric sandbox: production AI mode (${productionModeSetting(process.env)}) serves the Firebase SDK from this project, ` +
213
+ `but no firebase package resolves from ${projectDir}. Install it with \`npm install firebase\`, ` +
214
+ `or unset ${productionModeSetting(process.env).split('=')[0]} to answer firebase/ai from the Pyric sandbox.`,
215
+ );
216
+ }
217
+
218
+ /**
219
+ * The environment setting that turned production AI mode on, for messages.
220
+ * `PYRIC_AI_MODE=production` wins; otherwise the passthrough switch did it.
221
+ */
222
+ export function productionModeSetting(env: NodeJS.ProcessEnv): string {
223
+ const isModeSet = env.PYRIC_AI_MODE === 'production';
224
+ return isModeSet ? 'PYRIC_AI_MODE=production' : 'PYRIC_AI_PASSTHROUGH=1';
225
+ }
226
+
227
+ export interface ProjectFirebasePlan {
228
+ /** Pass to `bundleSdk`; absent when the real-SDK build is skipped. */
229
+ projectFirebase?: { projectDir: string };
230
+ /** One line to log when the build is skipped. */
231
+ warning?: string;
232
+ }
233
+
234
+ /**
235
+ * Decide whether production AI mode builds the Firebase SDK. Plain
236
+ * `pyric sandbox` serves the page itself, so a missing `firebase` throws.
237
+ * With a child command the child resolves `firebase` on its own, so a
238
+ * missing one only skips the build and warns.
239
+ */
240
+ export function planProjectFirebase(projectDir: string, hasChildCommand: boolean, env: NodeJS.ProcessEnv = process.env): ProjectFirebasePlan {
241
+ try {
242
+ resolveProjectFirebase(projectDir);
243
+ } catch (error) {
244
+ if (!hasChildCommand) throw error;
245
+ return {
246
+ warning:
247
+ ` ⚠ production AI mode (${productionModeSetting(env)}): no firebase package resolves from ${projectDir}, ` +
248
+ `so the Firebase SDK is not built. Pages the sandbox serves itself (import map) need firebase installed where pyric starts; ` +
249
+ `your command resolves firebase on its own.`,
250
+ };
251
+ }
252
+ return { projectFirebase: { projectDir } };
253
+ }
254
+
255
+
256
+ const REAL_SDK_NAMESPACE = 'pyric-firebase-sdk';
257
+
258
+ /**
259
+ * Build `firebase/app`, `firebase/ai` and `firebase/app-check` from the
260
+ * project's `firebase` install into `outDir`, in one pass with splitting so
261
+ * the three share one `@firebase/app` component registry, as Vite's
262
+ * dependency optimizer does.
263
+ */
264
+ async function bundleProjectFirebase(outDir: string, projectDir: string, firebase: ProjectFirebase, minify: boolean): Promise<void> {
265
+ const entryPoints: Record<string, string> = {};
266
+ for (const [specifier, key] of REAL_SDK_ENTRIES) entryPoints[key] = `${REAL_SDK_NAMESPACE}:${specifier}`;
267
+ const result = await esbuild.build({
268
+ entryPoints,
269
+ outdir: outDir,
270
+ bundle: true,
271
+ format: 'esm',
272
+ platform: 'browser',
273
+ splitting: true,
274
+ sourcemap: 'linked',
275
+ minify,
276
+ logLevel: 'silent',
277
+ chunkNames: 'firebase-sdk-[hash]',
278
+ banner: {
279
+ js: `/* The Firebase JS SDK, firebase@${firebase.version} from this project, built by pyric sandbox for production AI mode. */`,
280
+ },
281
+ plugins: [{
282
+ name: 'pyric-serve-project-firebase',
283
+ setup(build) {
284
+ build.onResolve({ filter: new RegExp(`^${REAL_SDK_NAMESPACE}:`) }, (args) => ({
285
+ path: args.path.slice(REAL_SDK_NAMESPACE.length + 1),
286
+ namespace: REAL_SDK_NAMESPACE,
287
+ }));
288
+ build.onLoad({ filter: /.*/, namespace: REAL_SDK_NAMESPACE }, (args) => ({
289
+ contents: `export * from ${JSON.stringify(args.path)};`,
290
+ resolveDir: projectDir,
291
+ loader: 'js',
292
+ }));
293
+ },
294
+ }],
295
+ });
296
+ const hasBuildErrors = result.errors.length > 0;
297
+ if (hasBuildErrors) {
298
+ throw new Error(
299
+ `pyric sandbox: building firebase@${firebase.version} for production AI mode failed:\n${result.errors.map((e) => e.text).join('\n')}`,
300
+ );
301
+ }
302
+ }
303
+
163
304
  // ─── cache ────────────────────────────────────────────────────────────
164
305
 
165
306
  export interface BundleOptions {
@@ -171,6 +312,12 @@ export interface BundleOptions {
171
312
  /** Override the cache root (tests). Default `~/.pyric/serve-cache`. */
172
313
  cacheRoot?: string;
173
314
  minify?: boolean;
315
+ /**
316
+ * Production AI mode: also build the Firebase SDK from this project's own
317
+ * `firebase` install (`/__pyric/sdk/firebase-sdk-*.js`). Absent in sandbox
318
+ * AI mode, which neither builds nor serves it.
319
+ */
320
+ projectFirebase?: { projectDir: string };
174
321
  }
175
322
 
176
323
  export interface BundleResult {
@@ -184,7 +331,7 @@ export interface BundleResult {
184
331
  }
185
332
 
186
333
  /** Bump when bundler logic changes in a way that must invalidate caches. */
187
- const BUNDLER_REV = 9; // 9: cache keys also cover dependency-resolution metadata
334
+ const BUNDLER_REV = 10; // 10: wrappers import the project's Firebase SDK by URL; production keys cover it
188
335
 
189
336
  const RESOLUTION_FILES = [
190
337
  'bun.lock',
@@ -262,12 +409,23 @@ export function cacheKey(
262
409
  h.update(name);
263
410
  h.update(readFileSync(file, 'utf8'));
264
411
  }
412
+ // Production AI mode also serves the project's Firebase SDK, so upgrading
413
+ // `firebase`, or a project resolving another copy, rebuilds it.
414
+ const projectFirebase = opts.projectFirebase;
415
+ if (projectFirebase) {
416
+ const firebase = resolveProjectFirebase(projectFirebase.projectDir);
417
+ h.update(`firebase-sdk\0${firebase.root}\0${firebase.version}`);
418
+ }
265
419
  return `${version}-${h.digest('hex').slice(0, 12)}`;
266
420
  }
267
421
 
268
422
  // ─── the bundle step ──────────────────────────────────────────────────
269
423
 
270
424
  export async function bundleSdk(opts: BundleOptions): Promise<BundleResult> {
425
+ // Resolved first, so a project without `firebase` fails before any build.
426
+ const projectFirebase = opts.projectFirebase
427
+ ? { ...opts.projectFirebase, firebase: resolveProjectFirebase(opts.projectFirebase.projectDir) }
428
+ : undefined;
271
429
  const root = pyricPackageRoot();
272
430
  const version = pyricVersion(root);
273
431
  const key = cacheKey(opts, version);
@@ -317,7 +475,7 @@ export async function bundleSdk(opts: BundleOptions): Promise<BundleResult> {
317
475
  js: '/* pyric sandbox shim serving firebase/*. This is not the real Firebase SDK. */',
318
476
  },
319
477
  external: ['firebase/*'],
320
- plugins: [pyricResolvePlugin(), nodeShimPlugin()],
478
+ plugins: [pyricResolvePlugin(), nodeShimPlugin(), bridgeImportPlugin()],
321
479
  });
322
480
  const hasBuildErrors = result.errors.length > 0;
323
481
  if (hasBuildErrors) {
@@ -325,6 +483,9 @@ export async function bundleSdk(opts: BundleOptions): Promise<BundleResult> {
325
483
  `pyric sandbox: SDK bundle failed:\n${result.errors.map((e) => e.text).join('\n')}`,
326
484
  );
327
485
  }
486
+ if (projectFirebase) {
487
+ await bundleProjectFirebase(outDir, projectFirebase.projectDir, projectFirebase.firebase, opts.minify ?? true);
488
+ }
328
489
  writeFileSync(join(outDir, '.complete'), new Date().toISOString());
329
490
  const files = readdirSync(outDir)
330
491
  .filter((f) => f.endsWith('.js'))