From 7814c76fc5c832ee8a8cfd05ddbe44b861d50835 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Tue, 8 Sep 2026 11:04:54 +0200 Subject: [PATCH 01/11] rfc: configurable public framework route prefix Proposal only. No file changes; the proposal is in the pull request body. From d7951e837ddf35bc851633327bfe559b016d93cb Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Thu, 10 Sep 2026 14:02:38 +0200 Subject: [PATCH 02/11] feat(core): make the public framework route prefix configurable Add `runtime.frameworkRoutePrefix` to `agent-native.config.ts` (deployment alias `AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX`) so a deployment can serve framework routes under a public namespace other than `/_agent-native`. Unset, nothing changes. Route registration keeps the internal name. The public prefix is translated to it once, at the request boundary in the framework request handler, before route selection, the readiness gates, and the CSRF check; the internal name answers 404 once a custom prefix is configured. Every URL the framework hands out is built with the public prefix at its construction site: browser requests through `agentNativePath()`, sign-in and OAuth callback URLs, Better Auth's base path, magic-link and reset links, deep links, webhook paths, background self-dispatch, the generated Cloudflare worker, the workspace gateway routing, and the Netlify no-store headers. Requests to other installations keep their own namespace. The value is resolved once, at dev startup or deploy build, and embedded into the browser bundle (serialized config) and the server bundle (one literal env read) together. --- .changeset/framework-route-prefix.md | 5 + .../core/docs/content/agent-native-config.mdx | 90 ++++--- packages/core/src/a2a/server.ts | 3 +- .../core/src/client/agent-chat-adapter.ts | 8 +- packages/core/src/client/api-path.spec.ts | 66 +++++ packages/core/src/client/api-path.ts | 90 ++++++- packages/core/src/client/app-providers.tsx | 3 +- packages/core/src/client/auth/AuthPage.tsx | 5 +- .../src/client/auth/ResetPasswordPage.tsx | 5 +- packages/core/src/client/chat-first.ts | 7 +- .../src/client/composer/runtime-adapters.tsx | 11 +- packages/core/src/client/embed-auth.ts | 11 +- packages/core/src/client/frame.ts | 3 +- packages/core/src/client/route-warmup.tsx | 4 +- packages/core/src/config.spec.ts | 80 ++++++ packages/core/src/config.ts | 20 +- packages/core/src/deploy/build.ts | 63 ++++- .../src/deploy/netlify-static-headers.spec.ts | 27 ++ .../core/src/deploy/netlify-static-headers.ts | 6 +- packages/core/src/deploy/workspace-deploy.ts | 47 +++- packages/core/src/mcp-client/oauth-routes.ts | 6 +- .../src/secrets/register-framework-secrets.ts | 5 +- packages/core/src/server/agent-chat-plugin.ts | 5 +- packages/core/src/server/auth.ts | 29 +- packages/core/src/server/beta-opt-out-html.ts | 9 +- .../core/src/server/better-auth-instance.ts | 34 ++- packages/core/src/server/builder-browser.ts | 3 +- packages/core/src/server/deep-link.ts | 5 +- packages/core/src/server/edge.ts | 1 + .../server/framework-request-handler.spec.ts | 92 +++++++ .../src/server/framework-request-handler.ts | 70 +++++ ...framework-route-prefix.integration.spec.ts | 239 +++++++++++++++++ .../core/src/server/framework-route-prefix.ts | 85 ++++++ packages/core/src/server/google-oauth.ts | 17 +- packages/core/src/server/identity-sso.ts | 7 +- packages/core/src/server/self-dispatch.ts | 3 +- packages/core/src/server/ssr-handler.ts | 2 + .../src/server/workspace-provider-oauth.ts | 4 +- .../src/shared/framework-route-prefix.spec.ts | 228 ++++++++++++++++ .../core/src/shared/framework-route-prefix.ts | 247 ++++++++++++++++++ packages/core/src/shared/ssr-auth-redirect.ts | 3 +- packages/core/src/shared/ssr-beta-redirect.ts | 8 +- packages/core/src/triggers/webhook.ts | 4 +- packages/core/src/vite/client.ts | 45 +++- 44 files changed, 1596 insertions(+), 109 deletions(-) create mode 100644 .changeset/framework-route-prefix.md create mode 100644 packages/core/src/deploy/netlify-static-headers.spec.ts create mode 100644 packages/core/src/server/framework-route-prefix.integration.spec.ts create mode 100644 packages/core/src/server/framework-route-prefix.ts create mode 100644 packages/core/src/shared/framework-route-prefix.spec.ts create mode 100644 packages/core/src/shared/framework-route-prefix.ts diff --git a/.changeset/framework-route-prefix.md b/.changeset/framework-route-prefix.md new file mode 100644 index 00000000000..47833fbc23a --- /dev/null +++ b/.changeset/framework-route-prefix.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add `runtime.frameworkRoutePrefix` (`AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX`) so a deployment can serve framework routes under a public namespace other than `/_agent-native`. Route registration keeps the internal name; the public prefix is translated once at the request boundary, and every URL the framework hands out (client requests, sign-in and OAuth callbacks, magic links, self-dispatch, deploy adapter routing) is built with the configured prefix. Unset, nothing changes. diff --git a/packages/core/docs/content/agent-native-config.mdx b/packages/core/docs/content/agent-native-config.mdx index 8b33f76d206..48f46011125 100644 --- a/packages/core/docs/content/agent-native-config.mdx +++ b/packages/core/docs/content/agent-native-config.mdx @@ -94,20 +94,21 @@ recommended form. These are the shared options read from the typed config and the shared configuration portion of `agent-native.json`: -| Option | Type | Effect | -| ------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `version` | `1` | Optional schema version. If provided, it must be `1`. | -| `onboarding.firstRun` | `"off"`, `"connect"`, `"connect-and-integrations"`, or a mode map | Chooses the first-run Agent Sidebar setup. `off` hides it. `connect` shows Builder/BYOK setup without the generic integrations catalog. `connect-and-integrations` includes that catalog. | -| `runtime.auth.enabled` | `boolean` | Declares whether the app expects the framework or a custom authentication layer. | -| `runtime.database.required` | `boolean` | Declares whether production needs a persistent remote PostgreSQL database. | -| `runtime.environment.required` | `string[]` | Declares additional required environment variable names. Names must match `[A-Za-z_][A-Za-z0-9_]*`; values do not belong in the config. | -| `deployment.environment` | `"local"`, `"beta"`, `"production"`, or `"preview"` | Records the release lane that produced the current client bundle. | -| `diagnostics.failOnBuild` | `boolean` | When `true`, a production Vite build throws on runtime configuration issues. When absent or `false`, it reports them without failing the build. | -| `instructions.runtime` | `string` | Optional relative Markdown path for the in-app runtime agent. Defaults to `AGENTS.md`. | -| `instructions.development` | `string` | Optional relative Markdown path for development/coding agents. Defaults to `AGENTS.md`. | -| `translations.locales` | `string[]` | Lists the locales the app intentionally ships. `en-US` remains the source locale. | -| `changelog.enabled` | `boolean` | Enables the app changelog workflow and lets agents create user-facing entries. | -| `harness` | `boolean` or `{ runtimes: string[] }` | Enables the hosted tools-only harness, optionally narrowed to `claude-code`, `codex`, `pi`, or `opencode`. See [Harness Agents](/docs/harness-agents). | +| Option | Type | Effect | +| ------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | `1` | Optional schema version. If provided, it must be `1`. | +| `onboarding.firstRun` | `"off"`, `"connect"`, `"connect-and-integrations"`, or a mode map | Chooses the first-run Agent Sidebar setup. `off` hides it. `connect` shows Builder/BYOK setup without the generic integrations catalog. `connect-and-integrations` includes that catalog. | +| `runtime.auth.enabled` | `boolean` | Declares whether the app expects the framework or a custom authentication layer. | +| `runtime.database.required` | `boolean` | Declares whether production needs a persistent remote PostgreSQL database. | +| `runtime.environment.required` | `string[]` | Declares additional required environment variable names. Names must match `[A-Za-z_][A-Za-z0-9_]*`; values do not belong in the config. | +| `runtime.frameworkRoutePrefix` | `string` | The public URL namespace the deployment serves framework routes under. Defaults to `/_agent-native`. One absolute segment of letters, digits, `_`, or `-`; `/api`, `/mcp`, and `/.well-known` are refused. See [Framework route prefix](#framework-route-prefix). | +| `deployment.environment` | `"local"`, `"beta"`, `"production"`, or `"preview"` | Records the release lane that produced the current client bundle. | +| `diagnostics.failOnBuild` | `boolean` | When `true`, a production Vite build throws on runtime configuration issues. When absent or `false`, it reports them without failing the build. | +| `instructions.runtime` | `string` | Optional relative Markdown path for the in-app runtime agent. Defaults to `AGENTS.md`. | +| `instructions.development` | `string` | Optional relative Markdown path for development/coding agents. Defaults to `AGENTS.md`. | +| `translations.locales` | `string[]` | Lists the locales the app intentionally ships. `en-US` remains the source locale. | +| `changelog.enabled` | `boolean` | Enables the app changelog workflow and lets agents create user-facing entries. | +| `harness` | `boolean` or `{ runtimes: string[] }` | Enables the hosted tools-only harness, optionally narrowed to `claude-code`, `codex`, `pi`, or `opencode`. See [Harness Agents](/docs/harness-agents). | ## Deterministic environment aliases @@ -115,23 +116,24 @@ Every supported public config path has an environment alias under the `AGENT_NATIVE_CONFIG` namespace. Convert each lower-camel-case path segment to upper snake case and join segments with underscores: -| Config path | Environment alias | -| ------------------------------ | -------------------------------------------------- | -| the whole config | `AGENT_NATIVE_CONFIG` | -| `onboarding.firstRun` | `AGENT_NATIVE_CONFIG_ONBOARDING_FIRST_RUN` | -| `runtime` | `AGENT_NATIVE_CONFIG_RUNTIME` | -| `runtime.auth` | `AGENT_NATIVE_CONFIG_RUNTIME_AUTH` | -| `runtime.auth.enabled` | `AGENT_NATIVE_CONFIG_RUNTIME_AUTH_ENABLED` | -| `runtime.database.required` | `AGENT_NATIVE_CONFIG_RUNTIME_DATABASE_REQUIRED` | -| `runtime.environment.required` | `AGENT_NATIVE_CONFIG_RUNTIME_ENVIRONMENT_REQUIRED` | -| `deployment.environment` | `AGENT_NATIVE_CONFIG_DEPLOYMENT_ENVIRONMENT` | -| `diagnostics.failOnBuild` | `AGENT_NATIVE_CONFIG_DIAGNOSTICS_FAIL_ON_BUILD` | -| `instructions.runtime` | `AGENT_NATIVE_CONFIG_INSTRUCTIONS_RUNTIME` | -| `instructions.development` | `AGENT_NATIVE_CONFIG_INSTRUCTIONS_DEVELOPMENT` | -| `translations.locales` | `AGENT_NATIVE_CONFIG_TRANSLATIONS_LOCALES` | -| `changelog.enabled` | `AGENT_NATIVE_CONFIG_CHANGELOG_ENABLED` | -| `harness` | `AGENT_NATIVE_CONFIG_HARNESS` | -| `harness.runtimes` | `AGENT_NATIVE_CONFIG_HARNESS_RUNTIMES` | +| Config path | Environment alias | +| ------------------------------ | ---------------------------------------------------- | +| the whole config | `AGENT_NATIVE_CONFIG` | +| `onboarding.firstRun` | `AGENT_NATIVE_CONFIG_ONBOARDING_FIRST_RUN` | +| `runtime` | `AGENT_NATIVE_CONFIG_RUNTIME` | +| `runtime.auth` | `AGENT_NATIVE_CONFIG_RUNTIME_AUTH` | +| `runtime.auth.enabled` | `AGENT_NATIVE_CONFIG_RUNTIME_AUTH_ENABLED` | +| `runtime.database.required` | `AGENT_NATIVE_CONFIG_RUNTIME_DATABASE_REQUIRED` | +| `runtime.environment.required` | `AGENT_NATIVE_CONFIG_RUNTIME_ENVIRONMENT_REQUIRED` | +| `runtime.frameworkRoutePrefix` | `AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX` | +| `deployment.environment` | `AGENT_NATIVE_CONFIG_DEPLOYMENT_ENVIRONMENT` | +| `diagnostics.failOnBuild` | `AGENT_NATIVE_CONFIG_DIAGNOSTICS_FAIL_ON_BUILD` | +| `instructions.runtime` | `AGENT_NATIVE_CONFIG_INSTRUCTIONS_RUNTIME` | +| `instructions.development` | `AGENT_NATIVE_CONFIG_INSTRUCTIONS_DEVELOPMENT` | +| `translations.locales` | `AGENT_NATIVE_CONFIG_TRANSLATIONS_LOCALES` | +| `changelog.enabled` | `AGENT_NATIVE_CONFIG_CHANGELOG_ENABLED` | +| `harness` | `AGENT_NATIVE_CONFIG_HARNESS` | +| `harness.runtimes` | `AGENT_NATIVE_CONFIG_HARNESS_RUNTIMES` | The whole config, an object section, or a deeply nested object can be supplied as one JSON string. Arrays also use JSON strings. More-specific aliases are @@ -409,3 +411,29 @@ see [Environment Variables](/docs/environment-variables). A deployment-level key is not a substitute for user- or organization-scoped credentials in a hosted multi-user app. See [Authentication](/docs/authentication) and [Security](/docs/security) for those boundaries. + +## Framework route prefix + +The framework serves its own routes — actions, the agent chat stream, events, uploads, authentication — under `/_agent-native`. That namespace keeps framework routes apart from an app's `/api/*` routes and is the default for every deployment. + +A gateway or reverse proxy in front of the app may own a different namespace. `runtime.frameworkRoutePrefix` selects the public one: + +```ts +import { defineAgentNativeConfig } from "@agent-native/core/config"; + +export default defineAgentNativeConfig({ + runtime: { + frameworkRoutePrefix: "/_platform", + }, +}); +``` + +With that setting the browser calls `/_platform/actions/...`, sign-in and OAuth callbacks are issued under `/_platform/...`, background self-dispatch targets `/_platform/...`, and the deploy adapters route `/_platform/*` to the app. Route registration does not change: plugins still mount on `/_agent-native/...`, and the framework translates the public prefix to the internal one once, at the request boundary, before route selection, authentication classification, and the CSRF check. The internal name is not served once a custom prefix is configured; a request to `/_agent-native/...` answers `404`. + +The value is resolved once, at `agent-native dev` startup or at deploy build, and written into the browser bundle and the server bundle together. Changing it needs a restart or a new build. The prefix composes with `APP_BASE_PATH`: an app mounted at `/mail` with the prefix `/_platform` serves `/mail/_platform/actions/...`. + +A remote installation keeps its own namespace. Requests the framework makes to another Agent-Native app — the MCP hub, the organization directory, A2A peers — are not affected by this deployment's prefix. + +Changing the prefix changes URLs registered elsewhere. Before deploying, update OAuth redirect registrations, webhook subscriptions, MCP connection URLs, and external monitors that point at `/_agent-native/...`; magic links and password-reset links issued before the change stop working, and a cached browser bundle must be reloaded. + +For a multi-app workspace deploy, set the `AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX` alias in the deploy environment so the workspace gateway and every app build read the same value. diff --git a/packages/core/src/a2a/server.ts b/packages/core/src/a2a/server.ts index 5dd7a71ab39..7ab9c0cf145 100644 --- a/packages/core/src/a2a/server.ts +++ b/packages/core/src/a2a/server.ts @@ -14,6 +14,7 @@ import { } from "../integrations/internal-token.js"; import { readDeployCredentialEnv } from "../server/credential-provider.js"; import { getH3App } from "../server/framework-request-handler.js"; +import { publicFrameworkPath } from "../server/framework-route-prefix.js"; import { readBody } from "../server/h3-helpers.js"; import { isSameOriginRequest } from "../server/request-origin.js"; import { generateAgentCard } from "./agent-card.js"; @@ -347,7 +348,7 @@ export function mountA2A( return generateAgentCard( { ...config, skills }, baseUrl, - `${routePrefix}/a2a`, + publicFrameworkPath(`${routePrefix}/a2a`), ); }), ); diff --git a/packages/core/src/client/agent-chat-adapter.ts b/packages/core/src/client/agent-chat-adapter.ts index b0e20216db3..69652a77067 100644 --- a/packages/core/src/client/agent-chat-adapter.ts +++ b/packages/core/src/client/agent-chat-adapter.ts @@ -26,7 +26,11 @@ import { import { getAnalyticsClientPlatform } from "./analytics-platform.js"; import { getOrCreateAnalyticsSessionId } from "./analytics-session.js"; import { captureError } from "./analytics.js"; -import { agentChatStreamingUrl, agentNativePath } from "./api-path.js"; +import { + agentChatStreamingUrl, + agentNativePath, + frameworkRoutePrefix, +} from "./api-path.js"; import { getBrowserTabId } from "./browser-tab-id.js"; import { formatChatErrorText, normalizeChatError } from "./error-format.js"; import { @@ -2017,7 +2021,7 @@ function runtimeDebugUrlForApiUrl(apiUrl: string): string | null { if (typeof window === "undefined") return null; try { const url = new URL(apiUrl, window.location.href); - const marker = "/_agent-native/"; + const marker = `${frameworkRoutePrefix()}/`; const markerIndex = url.pathname.indexOf(marker); if (markerIndex < 0) return null; url.pathname = `${url.pathname.slice(0, markerIndex)}${marker}debug/runtime`; diff --git a/packages/core/src/client/api-path.spec.ts b/packages/core/src/client/api-path.spec.ts index b6f79b461ba..27688c9b2b0 100644 --- a/packages/core/src/client/api-path.spec.ts +++ b/packages/core/src/client/api-path.spec.ts @@ -6,6 +6,8 @@ import { appApiPath, appBasePath, appPath, + frameworkRoutePrefix, + isFrameworkRoutePath, isWorkspaceAppPath, } from "./api-path.js"; import { oauthRedirectUri } from "./frame.js"; @@ -345,3 +347,67 @@ describe("appApiPath", () => { ); }); }); + +describe("configurable framework route prefix", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("defaults to the internal prefix and leaves URLs unchanged", () => { + vi.stubGlobal("window", { location: { pathname: "/" } }); + expect(frameworkRoutePrefix()).toBe("/_agent-native"); + expect(agentNativePath("/_agent-native/actions/x")).toBe( + "/_agent-native/actions/x", + ); + }); + + it("builds browser URLs under the bundled public prefix", () => { + vi.stubGlobal("__AGENT_NATIVE_APP_CONFIG__", { + runtime: { frameworkRoutePrefix: "/_platform" }, + }); + vi.stubGlobal("window", { location: { pathname: "/" } }); + expect(frameworkRoutePrefix()).toBe("/_platform"); + expect(agentNativePath("/_agent-native/actions/x?y=1")).toBe( + "/_platform/actions/x?y=1", + ); + expect(agentNativePath("/api/x")).toBe("/api/x"); + expect(isFrameworkRoutePath("/_platform/events")).toBe(true); + expect(isFrameworkRoutePath("/_agent-native/events")).toBe(true); + expect(isFrameworkRoutePath("/_platform-extra/events")).toBe(false); + }); + + it("applies the app base path once, after the prefix swap", () => { + vi.stubGlobal("__AGENT_NATIVE_APP_CONFIG__", { + runtime: { frameworkRoutePrefix: "/_platform" }, + }); + vi.stubEnv("VITE_APP_BASE_PATH", "/docs"); + vi.stubGlobal("window", { location: { pathname: "/docs/dashboard" } }); + expect(agentNativePath("/_agent-native/events")).toBe( + "/docs/_platform/events", + ); + }); + + it("derives the app base path from the public prefix in the live URL", () => { + vi.stubGlobal("__AGENT_NATIVE_APP_CONFIG__", { + runtime: { frameworkRoutePrefix: "/_platform" }, + }); + vi.stubGlobal("window", { + location: { pathname: "/docs/_platform/builder/callback" }, + }); + expect(appBasePath()).toBe("/docs"); + }); + + it("falls back to the projected shell config and rejects a malformed one", () => { + vi.stubGlobal("window", { + location: { pathname: "/" }, + __AGENT_NATIVE_CONFIG__: { frameworkRoutePrefix: "/_gateway" }, + }); + expect(frameworkRoutePrefix()).toBe("/_gateway"); + vi.stubGlobal("window", { + location: { pathname: "/" }, + __AGENT_NATIVE_CONFIG__: { frameworkRoutePrefix: "/api" }, + }); + expect(() => frameworkRoutePrefix()).toThrow(/reserved namespace/); + }); +}); diff --git a/packages/core/src/client/api-path.ts b/packages/core/src/client/api-path.ts index eaaa304b27e..5bc39aae864 100644 --- a/packages/core/src/client/api-path.ts +++ b/packages/core/src/client/api-path.ts @@ -1,7 +1,54 @@ +import { + FRAMEWORK_INTERNAL_ROUTE_PREFIX, + matchesPathPrefix, + normalizeFrameworkRoutePrefix, + toPublicFrameworkPath, +} from "../shared/framework-route-prefix.js"; import { isTruthyRuntimeValue } from "../shared/runtime-config.js"; +import { injectedAgentNativeConfig } from "./app-config.js"; import { initializeAgentNativeClient } from "./client-bootstrap.js"; -const FRAMEWORK_ROUTE_PREFIX = "/_agent-native"; +/** + * The PUBLIC framework route prefix this bundle was built for. + * + * Framework code keeps naming routes by their internal `/_agent-native` form; + * `agentNativePath()` swaps in this value at the one place browser URLs are + * built. It comes from the resolved `agent-native.config.ts` Vite serialized + * into the bundle, with the SSR shell's projected config as the fallback for + * a script that runs before the bundle (or a bundle built without the + * define). A malformed injected value throws rather than silently building + * URLs under the wrong namespace. + */ +export function frameworkRoutePrefix(): string { + const configured = injectedAgentNativeConfig().runtime?.frameworkRoutePrefix; + if (configured !== undefined) { + return normalizeFrameworkRoutePrefix(configured); + } + const projected = + typeof window === "undefined" + ? undefined + : ( + window as Window & { + __AGENT_NATIVE_CONFIG__?: { frameworkRoutePrefix?: unknown }; + } + ).__AGENT_NATIVE_CONFIG__?.frameworkRoutePrefix; + return normalizeFrameworkRoutePrefix( + projected, + "window.__AGENT_NATIVE_CONFIG__.frameworkRoutePrefix", + ); +} + +/** + * True when `pathname` is under the framework namespace as the browser sees + * it: the public prefix, or — because internal names are what framework code + * writes before `agentNativePath()` runs — the internal one. + */ +export function isFrameworkRoutePath(pathname: string): boolean { + return ( + matchesPathPrefix(pathname, FRAMEWORK_INTERNAL_ROUTE_PREFIX) || + matchesPathPrefix(pathname, frameworkRoutePrefix()) + ); +} function normalizeBasePath(value: string | undefined): string { if (!value || value === "/") return ""; @@ -32,10 +79,30 @@ function clientEnv(): Record | undefined { return importMetaEnv ?? processEnv; } +function frameworkMarkerIndex(pathname: string): number { + // The live URL carries the public prefix; the internal name is still + // accepted because a default deployment's public prefix IS the internal one. + for (const marker of [ + frameworkRoutePrefix(), + FRAMEWORK_INTERNAL_ROUTE_PREFIX, + ]) { + const index = pathname.indexOf(marker); + if (index > 0) return index; + } + return -1; +} + +function isFrameworkSegment(segment: string): boolean { + return ( + `/${segment}` === FRAMEWORK_INTERNAL_ROUTE_PREFIX || + `/${segment}` === frameworkRoutePrefix() + ); +} + function pathDerivedBasePath(): string { if (typeof window === "undefined") return ""; const pathname = window.location.pathname; - const markerIndex = pathname.indexOf(FRAMEWORK_ROUTE_PREFIX); + const markerIndex = frameworkMarkerIndex(pathname); if (markerIndex <= 0) return ""; return normalizeBasePath(pathname.slice(0, markerIndex)); } @@ -64,7 +131,7 @@ function isWorkspaceRuntime(): boolean { function workspacePathBasePath(): string { if (typeof window === "undefined" || !isWorkspaceRuntime()) return ""; const segment = window.location.pathname.split("/").find(Boolean); - if (!segment || segment === "_agent-native" || segment === "api") return ""; + if (!segment || isFrameworkSegment(segment) || segment === "api") return ""; const basePath = normalizeBasePath(segment); // Guard against treating an app-local route (e.g. a client-rendered // "/settings" page reached via stale client-side navigation) as if it @@ -90,13 +157,13 @@ function externalEmbedTargetBasePath(): string { if (typeof target !== "string" || !target.startsWith("/")) return ""; try { const url = new URL(target, "http://agent-native.invalid"); - const markerIndex = url.pathname.indexOf(FRAMEWORK_ROUTE_PREFIX); + const markerIndex = frameworkMarkerIndex(url.pathname); if (markerIndex > 0) { return normalizeBasePath(url.pathname.slice(0, markerIndex)); } if (isWorkspaceRuntime()) { const segment = url.pathname.split("/").find(Boolean); - if (segment && segment !== "_agent-native" && segment !== "api") { + if (segment && !isFrameworkSegment(segment) && segment !== "api") { return normalizeBasePath(segment); } } @@ -244,9 +311,18 @@ export function appApiPath(path: string): string { return appPath(normalized); } +/** + * The browser URL for a framework route named by its internal path. + * + * `/_agent-native/actions/x` becomes `/{base}/{public prefix}/actions/x`. A + * path that does not start with the internal prefix is returned as it is, + * which is also what keeps an already-mounted URL from being prefixed twice. + */ export function agentNativePath(path: string): string { - if (!path.startsWith(FRAMEWORK_ROUTE_PREFIX)) return path; - return appPath(path); + if (!path.startsWith(FRAMEWORK_INTERNAL_ROUTE_PREFIX)) return path; + return appPath( + toPublicFrameworkPath(path, { publicPrefix: frameworkRoutePrefix() }), + ); } /** diff --git a/packages/core/src/client/app-providers.tsx b/packages/core/src/client/app-providers.tsx index 07511796cf9..afc690e423e 100644 --- a/packages/core/src/client/app-providers.tsx +++ b/packages/core/src/client/app-providers.tsx @@ -63,7 +63,7 @@ import { normalizeDocumentTitle, } from "../shared/document-title.js"; import { getSsrBetaRedirectScriptBody } from "../shared/ssr-beta-redirect.js"; -import { agentNativePath } from "./api-path.js"; +import { agentNativePath, frameworkRoutePrefix } from "./api-path.js"; import { ClientOnly } from "./ClientOnly.js"; import { DefaultSpinner } from "./DefaultSpinner.js"; import { EnvironmentBadge } from "./EnvironmentBadge.js"; @@ -181,6 +181,7 @@ function EarlyBetaRedirectScript() { dangerouslySetInnerHTML={{ __html: getSsrBetaRedirectScriptBody( agentNativePath("/_agent-native/auth/session"), + frameworkRoutePrefix(), ), }} /> diff --git a/packages/core/src/client/auth/AuthPage.tsx b/packages/core/src/client/auth/AuthPage.tsx index 91675eeef1b..ba6c89e0ce6 100644 --- a/packages/core/src/client/auth/AuthPage.tsx +++ b/packages/core/src/client/auth/AuthPage.tsx @@ -4,12 +4,14 @@ import { MarketingHome } from "@agent-native/toolkit/marketing"; import { AuthForm } from "@agent-native/toolkit/onboarding"; import * as React from "react"; +import { toPublicFrameworkPath } from "../../shared/framework-route-prefix.js"; import { isQaTestEmail } from "../../shared/qa-test-email.js"; import { signInJourney, type SignInJourney, } from "../../shared/sign-in-journey.js"; import { isSyntheticTrafficValue } from "../../shared/test-traffic.js"; +import { frameworkRoutePrefix } from "../api-path.js"; import { OceanBackground } from "../ocean/OceanBackground.js"; export type AuthView = @@ -744,7 +746,8 @@ export function AuthPage(props: AuthPageProps) { [defaultLocale, locale, locales], ); const apiPath = React.useCallback( - (path: string) => `${runtimeAppBasePath}${path}`, + (path: string) => + `${runtimeAppBasePath}${toPublicFrameworkPath(path, { publicPrefix: frameworkRoutePrefix() })}`, [runtimeAppBasePath], ); const identityHref = React.useMemo( diff --git a/packages/core/src/client/auth/ResetPasswordPage.tsx b/packages/core/src/client/auth/ResetPasswordPage.tsx index c79091e8252..ed14d5c0c19 100644 --- a/packages/core/src/client/auth/ResetPasswordPage.tsx +++ b/packages/core/src/client/auth/ResetPasswordPage.tsx @@ -2,6 +2,9 @@ import * as React from "react"; +import { toPublicFrameworkPath } from "../../shared/framework-route-prefix.js"; +import { frameworkRoutePrefix } from "../api-path.js"; + export interface ResetPasswordPageProps { pageType: "reset-password"; appBasePath: string; @@ -97,7 +100,7 @@ export function ResetPasswordPage({ setMessage(null); try { const response = await fetch( - `${runtimeAppBasePath}/_agent-native/auth/ba/reset-password`, + `${runtimeAppBasePath}${toPublicFrameworkPath("/_agent-native/auth/ba/reset-password", { publicPrefix: frameworkRoutePrefix() })}`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/packages/core/src/client/chat-first.ts b/packages/core/src/client/chat-first.ts index 98880f80867..a61229a8914 100644 --- a/packages/core/src/client/chat-first.ts +++ b/packages/core/src/client/chat-first.ts @@ -6,6 +6,9 @@ import { useSyncExternalStore, } from "react"; +import { toPublicFrameworkPath } from "../shared/framework-route-prefix.js"; +import { frameworkRoutePrefix } from "./api-path.js"; + export const CHAT_FIRST_MODE_STORAGE_KEY = "agent-native:chat-first-mode:v1"; export const CHAT_FIRST_APP_LAYOUT_STORAGE_KEY = "agent-native:chat-first-app-layout:v1"; @@ -1123,7 +1126,9 @@ function appRelativeUrlPath(targetUrl: URL, registeredUrl: URL): string { function viewPath(appId: string, view: string): string { const params = new URLSearchParams({ app: appId, view }); - return `/_agent-native/open?${params.toString()}`; + return toPublicFrameworkPath(`/_agent-native/open?${params.toString()}`, { + publicPrefix: frameworkRoutePrefix(), + }); } export function resolveChatFirstAppTarget( diff --git a/packages/core/src/client/composer/runtime-adapters.tsx b/packages/core/src/client/composer/runtime-adapters.tsx index b725e4a85ab..06fb62613cb 100644 --- a/packages/core/src/client/composer/runtime-adapters.tsx +++ b/packages/core/src/client/composer/runtime-adapters.tsx @@ -1,3 +1,10 @@ +import { toPublicFrameworkPath } from "../../shared/framework-route-prefix.js"; + +// Toolkit names framework routes by their internal path; the host swaps in +// the public prefix before the base path is applied. +function publicFrameworkPathInBrowser(path: string): string { + return toPublicFrameworkPath(path, { publicPrefix: frameworkRoutePrefix() }); +} import { ComposerRuntimeAdaptersProvider, type ComposerRuntimeAdapters, @@ -19,7 +26,7 @@ import { setAgentChatContextItem, } from "../agent-chat.js"; import { SIDEBAR_STATE_CHANGE_EVENT } from "../agent-sidebar-state.js"; -import { appPath } from "../api-path.js"; +import { appPath, frameworkRoutePrefix } from "../api-path.js"; import { readClientAppState, setClientAppState } from "../application-state.js"; import { AssistantUiStaleIndexErrorBoundary } from "../assistant-ui-recovery.js"; import { getBrowserTabId } from "../browser-tab-id.js"; @@ -58,7 +65,7 @@ function subscribeSidebarState( } const coreComposerAdapters: Omit = { - resolvePath: (path) => appPath(path), + resolvePath: (path) => appPath(publicFrameworkPathInBrowser(path)), models: { useChatModels, useAgentEngineConfigured, diff --git a/packages/core/src/client/embed-auth.ts b/packages/core/src/client/embed-auth.ts index f5230d109a8..b4d870e78e3 100644 --- a/packages/core/src/client/embed-auth.ts +++ b/packages/core/src/client/embed-auth.ts @@ -6,10 +6,12 @@ import { EMBED_TOKEN_QUERY_PARAM, MCP_APP_CHAT_BRIDGE_QUERY_PARAM, } from "../shared/embed-auth.js"; +import { FRAMEWORK_INTERNAL_ROUTE_PREFIX } from "../shared/framework-route-prefix.js"; import { SIGN_IN_ENTRY_PATH, SIGN_IN_LEGACY_ENTRY_PATH, } from "../shared/sign-in-journey.js"; +import { frameworkRoutePrefix } from "./api-path.js"; let installed = false; let memoryToken: string | null = null; @@ -382,10 +384,11 @@ function sameOrigin(input: RequestInfo | URL, win: Window): boolean { } function isAgentNativeRuntimePath(pathname: string): boolean { - return ( - pathname === "/_agent-native" || - pathname.endsWith("/_agent-native") || - pathname.includes("/_agent-native/") + return [FRAMEWORK_INTERNAL_ROUTE_PREFIX, frameworkRoutePrefix()].some( + (prefix) => + pathname === prefix || + pathname.endsWith(prefix) || + pathname.includes(`${prefix}/`), ); } diff --git a/packages/core/src/client/frame.ts b/packages/core/src/client/frame.ts index b010455f71d..ab88267c040 100644 --- a/packages/core/src/client/frame.ts +++ b/packages/core/src/client/frame.ts @@ -1,4 +1,5 @@ import { isTruthyRuntimeValue } from "../shared/runtime-config.js"; +import { isFrameworkRoutePath } from "./api-path.js"; import { agentNativePath } from "./api-path.js"; /** @@ -255,7 +256,7 @@ function shouldUseWorkspaceCallbackRelay(path: string): boolean { window.__AGENT_NATIVE_CONFIG__?.workspaceRuntime === true; return ( (projectedWorkspaceRuntime || envFlag("VITE_AGENT_NATIVE_WORKSPACE")) && - path.startsWith("/_agent-native/") && + isFrameworkRoutePath(path) && (path.endsWith("/callback") || path.includes("/callback/")) ); } diff --git a/packages/core/src/client/route-warmup.tsx b/packages/core/src/client/route-warmup.tsx index c235c208022..633fa67ab9a 100644 --- a/packages/core/src/client/route-warmup.tsx +++ b/packages/core/src/client/route-warmup.tsx @@ -7,6 +7,7 @@ import { type AgentNativeRouteWarmupResolvedConfig, type AgentNativeRouteWarmupStrategy, } from "../shared/route-warmup-config.js"; +import { isFrameworkRoutePath } from "./api-path.js"; declare const __AGENT_NATIVE_ROUTE_WARMUP_CONFIG__: | AgentNativeRouteWarmupConfigInput @@ -115,8 +116,7 @@ function stripBasename(pathname: string): string { function isFrameworkOrApiPath(pathname: string): boolean { const appPath = stripBasename(pathname); return ( - appPath === "/_agent-native" || - appPath.startsWith("/_agent-native/") || + isFrameworkRoutePath(appPath) || appPath === "/api" || appPath.startsWith("/api/") || appPath === "/cdn-cgi" || diff --git a/packages/core/src/config.spec.ts b/packages/core/src/config.spec.ts index 853d44d4824..17cdcc14c6d 100644 --- a/packages/core/src/config.spec.ts +++ b/packages/core/src/config.spec.ts @@ -218,6 +218,67 @@ describe("agent-native app config", () => { }); }); + it("validates the public framework route prefix", () => { + expect( + resolveAgentNativeConfig( + { runtime: { frameworkRoutePrefix: " /_platform " } }, + devContext, + ).runtime?.frameworkRoutePrefix, + ).toBe("/_platform"); + expect( + resolveAgentNativeConfig( + { runtime: { auth: { enabled: true } } }, + devContext, + ).runtime?.frameworkRoutePrefix, + ).toBeUndefined(); + expect(() => + resolveAgentNativeConfig( + { runtime: { frameworkRoutePrefix: "/api" } }, + devContext, + ), + ).toThrow( + /runtime\.frameworkRoutePrefix must not use the reserved namespace/, + ); + expect(() => + resolveAgentNativeConfig( + { runtime: { frameworkRoutePrefix: "/a/b" } }, + devContext, + ), + ).toThrow( + /runtime\.frameworkRoutePrefix must be one absolute path segment/, + ); + expect(() => + resolveAgentNativeConfig( + { runtime: { frameworkRoutePrefix: 7 as unknown as string } }, + devContext, + ), + ).toThrow("runtime.frameworkRoutePrefix must be a string"); + }); + + it("keeps the framework route prefix through runtime merges", () => { + const merged = mergeAgentNativeConfigs( + { + runtime: { + frameworkRoutePrefix: "/_platform", + auth: { enabled: true }, + }, + }, + { runtime: { database: { required: true } } }, + ); + expect(merged.runtime).toEqual({ + frameworkRoutePrefix: "/_platform", + auth: { enabled: true }, + database: { required: true }, + environment: undefined, + }); + expect( + mergeAgentNativeConfigs( + { runtime: { frameworkRoutePrefix: "/_platform" } }, + { runtime: { frameworkRoutePrefix: "/_gateway" } }, + ).runtime?.frameworkRoutePrefix, + ).toBe("/_gateway"); + }); + it("validates non-secret runtime requirements", () => { expect(() => normalizeAgentNativeConfig({ @@ -269,6 +330,25 @@ describe("agent-native app config", () => { }); describe("agent-native config environment aliases", () => { + it("reads the framework route prefix from its deployment alias", () => { + expect(agentNativeConfigEnvName(["runtime", "frameworkRoutePrefix"])).toBe( + "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX", + ); + expect( + readAgentNativeConfigEnv({ + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: "/_platform", + }).runtime?.frameworkRoutePrefix, + ).toBe("/_platform"); + expect(() => + resolveAgentNativeConfig( + readAgentNativeConfigEnv({ + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: "/", + }), + devContext, + ), + ).toThrow(/frameworkRoutePrefix must be a single absolute path segment/); + }); + it("maps config paths to deterministic environment names", () => { expect(agentNativeConfigEnvName([])).toBe("AGENT_NATIVE_CONFIG"); expect(agentNativeConfigEnvName(["runtime"])).toBe( diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 358c7d555e3..0f7c29b083c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,9 +3,12 @@ * * This module is intentionally free of Node and framework imports so it can be * used from a typed `agent-native.config.ts` file and from browser code after Vite - * serializes the resolved config into the client bundle. + * serializes the resolved config into the client bundle. The one shared module + * it pulls in is pure for the same reason. */ +import { normalizeFrameworkRoutePrefix } from "./shared/framework-route-prefix.js"; + export const AGENT_NATIVE_CONFIG_VERSION = 1 as const; export type AgentNativeFirstRunOnboardingMode = @@ -50,6 +53,14 @@ export interface AgentNativeRuntimeConfig { auth?: AgentNativeRuntimeAuthConfig; database?: AgentNativeRuntimeDatabaseConfig; environment?: AgentNativeRuntimeEnvironmentConfig; + /** + * The public URL namespace this deployment serves framework routes under. + * Defaults to `/_agent-native`. One absolute segment of letters, digits, + * `_` or `-`; reserved namespaces such as `/api` and `/mcp` are refused. + * Route registration stays on the internal name; the value is translated + * at the request boundary and applied to every URL the framework builds. + */ + frameworkRoutePrefix?: string; } export type AgentNativeDeploymentEnvironment = @@ -189,6 +200,7 @@ const AGENT_NATIVE_CONFIG_ENV_NODES: readonly AgentNativeConfigEnvNode[] = [ path: ["runtime", "environment", "required"], kind: "array", }, + { path: ["runtime", "frameworkRoutePrefix"], kind: "string" }, { path: ["deployment"], kind: "object" }, { path: ["deployment", "environment"], @@ -732,6 +744,12 @@ function normalizeRuntimeConfig( } const result: AgentNativeRuntimeConfig = {}; + if (value.frameworkRoutePrefix !== undefined) { + result.frameworkRoutePrefix = normalizeFrameworkRoutePrefix( + value.frameworkRoutePrefix, + `${source}.frameworkRoutePrefix`, + ); + } for (const section of ["auth", "database", "environment"] as const) { const sectionValue = value[section]; if (sectionValue === undefined) continue; diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts index 53d48903eb5..082a32cd19a 100644 --- a/packages/core/src/deploy/build.ts +++ b/packages/core/src/deploy/build.ts @@ -64,6 +64,7 @@ import { resolveSsrCacheKeyHeaders, SSR_QUERY_CACHE_KEY_HEADER, } from "../shared/cache-control.js"; +import { normalizeFrameworkRoutePrefix } from "../shared/framework-route-prefix.js"; import { LOADING_LABELS } from "../shared/loading-labels.js"; import { mcpEmbedStaticAssetRouteRules } from "../shared/mcp-embed-headers.js"; import { isTruthyRuntimeValue } from "../shared/runtime-config.js"; @@ -1080,6 +1081,44 @@ interface ReactRouterAssetManifestRoute { hydrateFallbackModule?: string; } +/** + * Resolve `runtime.frameworkRoutePrefix` from the app config once, before any + * bundle is written, and publish it to this process's env. Every later reader + * in the build — the Vite define, the Nitro replacement map, the generated + * worker, the Netlify headers — reads that one env key, so the browser bundle, + * the server bundle and the platform routing cannot disagree. + */ +async function resolveDeployFrameworkRoutePrefix(): Promise { + const mode = + process.env.NODE_ENV === "development" ? "development" : "production"; + const workspaceRoot = findAgentNativeWorkspaceRoot(cwd); + const environment = { + ...(workspaceRoot && workspaceRoot !== cwd + ? loadEnv(mode, workspaceRoot, "") + : {}), + ...loadEnv(mode, cwd, ""), + ...process.env, + }; + const config = await loadResolvedAgentNativeConfig( + cwd, + createAgentNativeConfigContext("build", mode), + { environment }, + ); + // guard:allow-env-mutation — build-time process, set once before any bundle is written; no request ever runs here + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + config.runtime?.frameworkRoutePrefix ?? ""; +} + +/** The public prefix baked into generated worker sources. */ +function resolveBuildFrameworkRoutePrefix( + env: NodeJS.ProcessEnv = process.env, +): string { + return normalizeFrameworkRoutePrefix( + env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX?.trim() || undefined, + "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX", + ); +} + function normalizeConfiguredAppBasePath(): string { return normalizeAppBasePath( process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH, @@ -1181,6 +1220,7 @@ export function generateWorkerEntry( immutableAssetPaths: string[] = [], builtAppBasePath = normalizeConfiguredAppBasePath(), options: GenerateWorkerEntryOptions = {}, + builtFrameworkRoutePrefix = resolveBuildFrameworkRoutePrefix(), ): string { const includeReactRouterSsr = options.includeReactRouterSsr ?? true; // The worker ships as a static bundle with no access to runtime env, so the @@ -1280,6 +1320,7 @@ ${["post", "put", "delete"] pluginImports.push( `import { getAppConfig as getAgentNativeAppConfig, + getFrameworkRoutePrefix as getAgentNativeFrameworkRoutePrefix, getSsrAuthRedirectScript as getAgentNativeSsrAuthRedirectScript, resolveAppHomePath as resolveAgentNativeAppHomePath, } from "${EDGE_SERVER_ENTRYPOINT}";`, @@ -1377,6 +1418,11 @@ function normalizeAppBasePath(value) { return "/" + trimmed.replace(/^\\/+/, "").replace(/\\/+$/, ""); } +// The public framework route prefix this bundle was built for. Only path +// CLASSIFICATION happens here; the h3 boundary inside the handler is what +// translates the public prefix to the internal one, exactly once. +const builtFrameworkRoutePrefix = ${JSON.stringify(builtFrameworkRoutePrefix)}; + function getAppBasePath() { const builtAppBasePath = ${JSON.stringify(builtAppBasePath)}; return normalizeAppBasePath( @@ -1420,8 +1466,8 @@ function isApiPath(pathname) { } function isFrameworkPath(pathname) { - return ( - pathname === "/_agent-native" || pathname.startsWith("/_agent-native/") + return ["/_agent-native", builtFrameworkRoutePrefix].some( + (prefix) => pathname === prefix || pathname.startsWith(prefix + "/"), ); } @@ -1807,6 +1853,7 @@ function getAgentNativeAuthRedirectScript() { return getAgentNativeSsrAuthRedirectScript( SSR_AUTH_REDIRECT_COOKIE_NAME, resolveAgentNativeAppHomePath(getAgentNativeAppConfig().app), + getAgentNativeFrameworkRoutePrefix(), ); } @@ -2002,7 +2049,7 @@ function isStaticAppShellRequest(request) { const p = stripAppBasePath(new URL(request.url).pathname); if ( p.startsWith("/.well-known/") || - p.startsWith("/_agent-native/") || + isFrameworkPath(p) || isApiPath(p) || p === "/favicon.ico" || p === "/favicon.png" || @@ -2107,7 +2154,7 @@ ${ const p = stripAppBasePath(new URL(event.req.url).pathname); if ( p.startsWith("/.well-known/") || - p.startsWith("/_agent-native/") || + isFrameworkPath(p) || isApiPath(p) || p === "/favicon.ico" || p === "/favicon.png" || @@ -5361,6 +5408,13 @@ export function resolveNitroBuildReplacements( ), } : {}), + // The public framework route prefix was resolved from the app config at + // the start of this deploy build and written to the env; the deployed + // function's single reader is this literal key. + "process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX": + JSON.stringify( + env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX?.trim() || "", + ), }; } @@ -6065,6 +6119,7 @@ export default bundle; async function main() { console.log(`[deploy] Building for ${preset}...`); + await resolveDeployFrameworkRoutePrefix(); switch (preset) { case "cloudflare_pages": diff --git a/packages/core/src/deploy/netlify-static-headers.spec.ts b/packages/core/src/deploy/netlify-static-headers.spec.ts new file mode 100644 index 00000000000..5f38c710c8e --- /dev/null +++ b/packages/core/src/deploy/netlify-static-headers.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { renderNetlifyStaticHeaders } from "./netlify-static-headers.js"; + +describe("renderNetlifyStaticHeaders", () => { + it("marks the framework namespace no-store under the default prefix", () => { + const rendered = renderNetlifyStaticHeaders({}); + expect(rendered).toContain("/_agent-native/*"); + expect(rendered).not.toContain("/_platform/*"); + }); + + it("follows the configured public prefix", () => { + const rendered = renderNetlifyStaticHeaders({ + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: "/_platform", + }); + expect(rendered).toContain("/_platform/*"); + expect(rendered).not.toContain("/_agent-native/*"); + }); + + it("refuses a malformed deployment prefix", () => { + expect(() => + renderNetlifyStaticHeaders({ + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: "/api", + }), + ).toThrow(/reserved namespace/); + }); +}); diff --git a/packages/core/src/deploy/netlify-static-headers.ts b/packages/core/src/deploy/netlify-static-headers.ts index 7db2932142e..32aa6dd3766 100644 --- a/packages/core/src/deploy/netlify-static-headers.ts +++ b/packages/core/src/deploy/netlify-static-headers.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveSsrCacheHeaders } from "../shared/cache-control.js"; +import { normalizeFrameworkRoutePrefix } from "../shared/framework-route-prefix.js"; import { IMMUTABLE_ASSET_CACHE_CONTROL } from "./immutable-assets.js"; export const GENERATED_NETLIFY_HEADERS_MARKER = @@ -44,7 +45,10 @@ export function renderNetlifyStaticHeaders( GENERATED_NETLIFY_HEADERS_MARKER, renderHeaderBlock("/*", ssrHeaderEntries), renderHeaderBlock("/assets/*", immutableAssetHeaders), - renderHeaderBlock("/_agent-native/*", internalHeaders), + renderHeaderBlock( + `${normalizeFrameworkRoutePrefix(env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX?.trim() || undefined, "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX")}/*`, + internalHeaders, + ), ].join("\n\n") + "\n" ); } diff --git a/packages/core/src/deploy/workspace-deploy.ts b/packages/core/src/deploy/workspace-deploy.ts index e558776f460..9c5b143f397 100644 --- a/packages/core/src/deploy/workspace-deploy.ts +++ b/packages/core/src/deploy/workspace-deploy.ts @@ -38,6 +38,7 @@ import { RECURRING_JOBS_SWEEP_TOKEN_SUBJECT, } from "../jobs/scheduler-dispatch.js"; import { findWorkspaceRoot } from "../scripts/utils.js"; +import { normalizeFrameworkRoutePrefix } from "../shared/framework-route-prefix.js"; import { DEFAULT_WORKSPACE_APP_AUDIENCE, normalizeWorkspaceAppAudience, @@ -61,6 +62,25 @@ import { IMMUTABLE_ASSET_CACHE_HEADERS, } from "./immutable-assets.js"; +/** + * The public framework route prefix the workspace gateway routes on. A + * workspace deploy has no single `agent-native.config.ts`, so the value comes + * from the deployment alias every app build in this process also reads; the + * gateway and each app therefore agree by construction. + */ +function workspaceFrameworkRoutePrefixEnv(): string { + return ( + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX?.trim() || "" + ); +} + +function workspaceFrameworkRoutePrefix(): string { + return normalizeFrameworkRoutePrefix( + workspaceFrameworkRoutePrefixEnv() || undefined, + "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX", + ); +} + export type WorkspaceDeployPreset = "cloudflare_pages" | "netlify" | "vercel"; const NETLIFY_WORKSPACE_STATIC_DIR = "_workspace_static"; @@ -278,6 +298,8 @@ function buildOneApp( : {}), APP_BASE_PATH: `/${app}`, VITE_APP_BASE_PATH: `/${app}`, + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: + workspaceFrameworkRoutePrefixEnv(), AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: workspaceAppAudience, AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify( workspaceAppRouteAccess.publicPaths, @@ -442,7 +464,7 @@ function writeCloudflareRoutingManifest(distDir: string, apps: string[]): void { // handles its root and subtree. const include = apps.flatMap((a) => [`/${a}`, `/${a}/*`]).concat(["/"]); if (apps.includes("dispatch")) { - include.push("/_agent-native/*"); + include.push(`${workspaceFrameworkRoutePrefix()}/*`); include.push("/.well-known/*"); include.push( ...DISPATCH_WORKSPACE_ROOT_REDIRECTS.map(([from]) => `/${from}`), @@ -472,7 +494,7 @@ function writeCloudflareRoutingManifest(distDir: string, apps: string[]): void { ) .join("\n"); const dispatchRootFrameworkRoutes = apps.includes("dispatch") - ? ` if (pathname === "/_agent-native" || pathname.startsWith("/_agent-native/") || pathname === "/.well-known" || pathname.startsWith("/.well-known/")) return ${moduleIdent("dispatch")}.fetch(request, env, ctx); + ? ` if (pathname === ${JSON.stringify(workspaceFrameworkRoutePrefix())} || pathname.startsWith(${JSON.stringify(`${workspaceFrameworkRoutePrefix()}/`)}) || pathname === "/.well-known" || pathname.startsWith("/.well-known/")) return ${moduleIdent("dispatch")}.fetch(request, env, ctx); ` : ""; const dispatchRootFaviconRoute = dispatchFaviconAsset @@ -530,7 +552,9 @@ function writeNetlifyRedirects(distDir: string, apps: string[]): void { ]; if (apps.includes("dispatch")) { - lines.push("/_agent-native/* /.netlify/functions/dispatch-server 200"); + lines.push( + `${workspaceFrameworkRoutePrefix()}/* /.netlify/functions/dispatch-server 200`, + ); lines.push("/.well-known/* /.netlify/functions/dispatch-server 200"); const faviconAsset = dispatchRootFaviconAsset(distDir); if (faviconAsset) { @@ -587,8 +611,11 @@ function writeVercelBuildConfig(outputDir: string, apps: string[]): void { if (apps.includes("dispatch")) { routes.push( - { src: "/_agent-native", dest: "/dispatch-server" }, - { src: "/_agent-native/(.*)", dest: "/dispatch-server" }, + { src: workspaceFrameworkRoutePrefix(), dest: "/dispatch-server" }, + { + src: `${workspaceFrameworkRoutePrefix()}/(.*)`, + dest: "/dispatch-server", + }, { src: "/\\.well-known", dest: "/dispatch-server" }, { src: "/\\.well-known/(.*)", dest: "/dispatch-server" }, ); @@ -971,6 +998,7 @@ ${WORKSPACE_DIRECTORY_ENV_SNIPPET} VITE_AGENT_NATIVE_WORKSPACE: "1", VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)}, VITE_APP_BASE_PATH: basePath, + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: ${JSON.stringify(workspaceFrameworkRoutePrefixEnv())}, VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)}, VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))}, VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))}, @@ -1080,6 +1108,7 @@ ${WORKSPACE_DIRECTORY_ENV_SNIPPET} VITE_AGENT_NATIVE_WORKSPACE: "1", VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)}, VITE_APP_BASE_PATH: basePath, + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: ${JSON.stringify(workspaceFrameworkRoutePrefixEnv())}, VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)}, VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))}, VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))}, @@ -1167,7 +1196,11 @@ function patchNetlifyFunctionEntry( ); const pathConfig = app === "dispatch" - ? ["/_agent-native/*", "/.well-known/*", `${basePath}/*`] + ? [ + `${workspaceFrameworkRoutePrefix()}/*`, + "/.well-known/*", + `${basePath}/*`, + ] : [basePath, `${basePath}.data`, `${basePath}/*`]; const normalizeBasePathHelper = app === "dispatch" @@ -1204,6 +1237,7 @@ ${WORKSPACE_DIRECTORY_ENV_SNIPPET} VITE_AGENT_NATIVE_WORKSPACE: "1", VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)}, VITE_APP_BASE_PATH: basePath, + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: ${JSON.stringify(workspaceFrameworkRoutePrefixEnv())}, VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)}, VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))}, VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))}, @@ -1277,6 +1311,7 @@ ${WORKSPACE_DIRECTORY_ENV_SNIPPET} VITE_AGENT_NATIVE_WORKSPACE: "1", VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)}, VITE_APP_BASE_PATH: basePath, + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: ${JSON.stringify(workspaceFrameworkRoutePrefixEnv())}, VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)}, VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))}, VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))}, diff --git a/packages/core/src/mcp-client/oauth-routes.ts b/packages/core/src/mcp-client/oauth-routes.ts index 654a8a8e52a..8e43bf3e7a6 100644 --- a/packages/core/src/mcp-client/oauth-routes.ts +++ b/packages/core/src/mcp-client/oauth-routes.ts @@ -19,6 +19,7 @@ import { resolveSecretPairs, } from "../server/credential-provider.js"; import { getH3App } from "../server/framework-request-handler.js"; +import { canonicalFrameworkPathname } from "../server/framework-route-prefix.js"; import { getAppBasePath, getAppUrl, @@ -633,7 +634,8 @@ function isRootGoogleCallback(value: string): boolean { try { const url = new URL(value); return ( - url.pathname === "/_agent-native/google/callback" && + canonicalFrameworkPathname(url.pathname) === + "/_agent-native/google/callback" && !url.search && !url.hash ); @@ -645,7 +647,7 @@ function isRootGoogleCallback(value: string): boolean { function isMcpOAuthRedirectUri(value: string): boolean { try { - const pathname = new URL(value).pathname; + const pathname = canonicalFrameworkPathname(new URL(value).pathname); return ( pathname.endsWith("/_agent-native/mcp/servers/oauth/callback") || pathname.endsWith("/_agent-native/google/callback") diff --git a/packages/core/src/secrets/register-framework-secrets.ts b/packages/core/src/secrets/register-framework-secrets.ts index 202ea0e6d54..9cc53d74f51 100644 --- a/packages/core/src/secrets/register-framework-secrets.ts +++ b/packages/core/src/secrets/register-framework-secrets.ts @@ -14,6 +14,7 @@ * key with stricter requirements; the guard below preserves their definition. */ +import { publicFrameworkPath } from "../server/framework-route-prefix.js"; import { getRequiredSecret, registerRequiredSecret } from "./register.js"; export function registerFrameworkSecrets(): void { @@ -111,7 +112,9 @@ export function registerFrameworkSecrets(): void { kind: "oauth", required: false, oauthProvider: provider.oauthProvider, - oauthConnectUrl: `/_agent-native/connections/oauth/${provider.id}/start`, + oauthConnectUrl: publicFrameworkPath( + `/_agent-native/connections/oauth/${provider.id}/start`, + ), }); } } diff --git a/packages/core/src/server/agent-chat-plugin.ts b/packages/core/src/server/agent-chat-plugin.ts index 0702e3d5ff9..361d61431c3 100644 --- a/packages/core/src/server/agent-chat-plugin.ts +++ b/packages/core/src/server/agent-chat-plugin.ts @@ -235,6 +235,7 @@ import { markDefaultPluginProvided, trackPluginInit, } from "./framework-request-handler.js"; +import { publicFrameworkPath } from "./framework-route-prefix.js"; import { getOrigin } from "./google-oauth.js"; import { readBody } from "./h3-helpers.js"; import { loadHostedHarnessConfig } from "./hosted-harness-policy.js"; @@ -2374,7 +2375,9 @@ export function createAgentChatPlugin( callId: approval.toolCallId ?? crypto.randomUUID(), }); const baseUrl = resolveArtifactBaseUrl(context.event); - const approvalPath = `/_agent-native/a2a/approvals/${encodeURIComponent(pending.id)}`; + const approvalPath = publicFrameworkPath( + `/_agent-native/a2a/approvals/${encodeURIComponent(pending.id)}`, + ); const approvalUrl = baseUrl ? `${baseUrl}${approvalPath}` : approvalPath; diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index a06150ae83e..14597a9942c 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -36,6 +36,11 @@ import { resolveEmbedSessionFromRequest, } from "./embed-session.js"; import type { H3AppShim } from "./framework-request-handler.js"; +import { + canonicalFrameworkPathname, + getFrameworkRoutePrefix, + publicFrameworkPath, +} from "./framework-route-prefix.js"; // In h3 v2, `event.req` IS the web Request — but in Nitro's dev server (srvx // runtime), event.url and event.req share the same underlying URL object. @@ -53,7 +58,12 @@ function toWebRequest(event: H3Event): Request { if (ctx?._mountedPathname && ctx._mountPrefix) { try { const url = new URL(req.url); - const mountedPathname = stripAppBasePath(ctx._mountedPathname); + // Better Auth is configured with the PUBLIC base path (it builds its + // own callback and verification URLs from it), so hand it the public + // form of the internal pathname the boundary dispatched on. + const mountedPathname = publicFrameworkPath( + stripAppBasePath(ctx._mountedPathname), + ); if (url.pathname !== mountedPathname) { url.pathname = mountedPathname; const method = req.method.toUpperCase(); @@ -2246,9 +2256,9 @@ function parseDesktopExchangeStoredEntry( function isDesktopMagicLinkCallbackPath(value: string): boolean { try { - return new URL(value, "http://agent-native.invalid").pathname.endsWith( - "/_agent-native/auth/magic-link/desktop-callback", - ); + return canonicalFrameworkPathname( + new URL(value, "http://agent-native.invalid").pathname, + ).endsWith("/_agent-native/auth/magic-link/desktop-callback"); } catch { // coercion-ok: malformed callback URLs are treated as non-desktop callbacks. return false; @@ -2861,7 +2871,7 @@ function extractMcpOAuthCookieAppId( return undefined; } - const match = redirectUri.pathname.match( + const match = canonicalFrameworkPathname(redirectUri.pathname).match( /^\/([a-z0-9][a-z0-9-]*)\/_agent-native\/mcp\/servers\/oauth\/callback$/, ); const appId = match?.[1]; @@ -3177,7 +3187,9 @@ function desktopMagicLinkVerificationUrl( const callback = new URL(callbackURL, getOrigin(event)); if ( callback.origin !== new URL(getOrigin(event)).origin || - !callback.pathname.endsWith(DESKTOP_MAGIC_LINK_CALLBACK_PATH) || + !canonicalFrameworkPathname(callback.pathname).endsWith( + DESKTOP_MAGIC_LINK_CALLBACK_PATH, + ) || !normalizeDesktopFlowId(callback.searchParams.get("flow_id")) || !normalizeDesktopFlowVerifier(callback.searchParams.get("verifier")) ) { @@ -3342,6 +3354,7 @@ function loginHtmlResponse( getSsrAuthRedirectScript( SESSION_HINT_COOKIE, resolveAppHomePath(getAppConfig().app), + getFrameworkRoutePrefix(), ), ); } @@ -6103,7 +6116,7 @@ async function mountBetterAuthRoutes( const query = getQuery(event); if (typeof query.token === "string") { const verificationUrl = new URL( - `${getAppBasePath()}/_agent-native/auth/ba/magic-link/verify`, + `${getAppBasePath()}${publicFrameworkPath("/_agent-native/auth/ba/magic-link/verify")}`, getOrigin(event), ); for (const key of [ @@ -6167,7 +6180,7 @@ async function mountBetterAuthRoutes( ) : undefined; const newUserCallbackUrl = new URL( - `${getAppBasePath()}/_agent-native/auth/magic-link/new-user?return=${encodeURIComponent(callbackPath)}`, + `${getAppBasePath()}${publicFrameworkPath("/_agent-native/auth/magic-link/new-user")}?return=${encodeURIComponent(callbackPath)}`, getOrigin(event), ); if (attributionToken) { diff --git a/packages/core/src/server/beta-opt-out-html.ts b/packages/core/src/server/beta-opt-out-html.ts index 87b71303588..8e918ad4e5f 100644 --- a/packages/core/src/server/beta-opt-out-html.ts +++ b/packages/core/src/server/beta-opt-out-html.ts @@ -13,6 +13,10 @@ import { } from "../shared/ssr-beta-redirect.js"; import { getAppBasePathFromViteEnv } from "./app-base-path.js"; import { resolvePublicAppOriginConfig } from "./app-origin-config.js"; +import { + getFrameworkRoutePrefix, + publicFrameworkPath, +} from "./framework-route-prefix.js"; import { workspaceBasePathFromRequest } from "./onboarding-html.js"; export const BETA_OPT_OUT_PERSISTENCE_MARKER = @@ -261,7 +265,10 @@ export function injectBetaOptOutPersistence( const appBasePath = betaRedirectBasePath(requestPath); html = insertBeforeClosingTag( html, - getSsrBetaRedirectScript(`${appBasePath}/_agent-native/auth/session`), + getSsrBetaRedirectScript( + `${appBasePath}${publicFrameworkPath("/_agent-native/auth/session")}`, + getFrameworkRoutePrefix(), + ), "", ); } diff --git a/packages/core/src/server/better-auth-instance.ts b/packages/core/src/server/better-auth-instance.ts index 01a0f757e14..465fce799c3 100644 --- a/packages/core/src/server/better-auth-instance.ts +++ b/packages/core/src/server/better-auth-instance.ts @@ -90,6 +90,10 @@ import { sendEmail, type EmailReadiness, } from "./email.js"; +import { + canonicalFrameworkPathname, + publicFrameworkPath, +} from "./framework-route-prefix.js"; import { recordActiveGoogleSignInCredentials, resolveGoogleSignInCredentials, @@ -880,19 +884,29 @@ export function desktopMagicLinkLandingUrl(value: string): string | undefined { if (!callbackValue) return undefined; const callbackUrl = new URL(callbackValue, verificationUrl.origin); if (callbackUrl.origin !== verificationUrl.origin) return undefined; - if (!callbackUrl.pathname.endsWith(DESKTOP_MAGIC_LINK_CALLBACK_MARKER)) { + // Better Auth issued these URLs in the public namespace; compare them in + // the internal form the markers are written in. + if ( + !canonicalFrameworkPathname(callbackUrl.pathname).endsWith( + DESKTOP_MAGIC_LINK_CALLBACK_MARKER, + ) + ) { return undefined; } - const verifyMarkerIndex = verificationUrl.pathname.lastIndexOf( + const verificationPathname = canonicalFrameworkPathname( + verificationUrl.pathname, + ); + const verifyMarkerIndex = verificationPathname.lastIndexOf( BETTER_AUTH_MAGIC_LINK_VERIFY_MARKER, ); if (verifyMarkerIndex < 0) return undefined; const landingUrl = new URL(verificationUrl.origin); - landingUrl.pathname = - verificationUrl.pathname.slice(0, verifyMarkerIndex) + - DESKTOP_MAGIC_LINK_LANDING_MARKER; + landingUrl.pathname = publicFrameworkPath( + verificationPathname.slice(0, verifyMarkerIndex) + + DESKTOP_MAGIC_LINK_LANDING_MARKER, + ); for (const key of [ "token", "callbackURL", @@ -1578,7 +1592,13 @@ function resetAuthOnPoolClose(driver?: string, url?: string): void { async function createBetterAuthInstance( config?: BetterAuthConfig, ): Promise { - const basePath = config?.basePath ?? "/_agent-native/auth/ba"; + // Better Auth derives every URL it hands out — social-provider callbacks, + // magic-link verification, password reset — from this base path, so it + // must be the PUBLIC one. The framework still mounts the handler on the + // internal path and passes Better Auth a request in public form. + const basePath = publicFrameworkPath( + config?.basePath ?? "/_agent-native/auth/ba", + ); // Build social providers from env vars const socialProviders: BetterAuthOptions["socialProviders"] = { @@ -1737,7 +1757,7 @@ async function createBetterAuthInstance( process.env.APP_BASE_PATH || "" ).replace(/\/$/, ""); - const resetUrl = `${appUrl}${appBasePath}/_agent-native/auth/reset?token=${encodeURIComponent(token)}`; + const resetUrl = `${appUrl}${appBasePath}${publicFrameworkPath("/_agent-native/auth/reset")}?token=${encodeURIComponent(token)}`; const { subject, html, text, appSender } = renderResetPasswordEmail({ email: user.email, resetUrl, diff --git a/packages/core/src/server/builder-browser.ts b/packages/core/src/server/builder-browser.ts index 64d5d76444c..44cfa1680a2 100644 --- a/packages/core/src/server/builder-browser.ts +++ b/packages/core/src/server/builder-browser.ts @@ -22,6 +22,7 @@ import { import type { BuilderOAuthPermissionScope } from "./builder-oauth.js"; import { readDeployCredentialEnv } from "./credential-provider.js"; import { getWorkspaceA2ADerivedSecret } from "./derived-secret.js"; +import { publicFrameworkPath } from "./framework-route-prefix.js"; import { getAppBasePath, getOrigin, @@ -1322,7 +1323,7 @@ export function buildBuilderCliAuthUrl( * request-bound owner and the connect route can fall back to Fetch Metadata. */ export function getBuilderBrowserConnectUrl(origin: string): string { - return `${normalizeOrigin(origin)}${getAppBasePath()}/_agent-native/builder/connect`; + return `${normalizeOrigin(origin)}${getAppBasePath()}${publicFrameworkPath("/_agent-native/builder/connect")}`; } export function getBuilderBrowserConnectUrlForOwner( diff --git a/packages/core/src/server/deep-link.ts b/packages/core/src/server/deep-link.ts index 2d7299d8469..c63afcc5f7e 100644 --- a/packages/core/src/server/deep-link.ts +++ b/packages/core/src/server/deep-link.ts @@ -21,6 +21,7 @@ import { getConfiguredAppBasePath, normalizeAppBasePath, } from "./app-base-path.js"; +import { publicFrameworkPath } from "./framework-route-prefix.js"; /** Path of the framework deep-link route, relative to the route prefix. */ export const OPEN_ROUTE_SUBPATH = "/open"; @@ -63,7 +64,9 @@ function buildQuery(input: DeepLinkInput): string { */ export function buildDeepLink(input: DeepLinkInput): string { return withCollapsedAgentSidebarParam( - `/_agent-native${OPEN_ROUTE_SUBPATH}?${buildQuery(input)}`, + publicFrameworkPath( + `/_agent-native${OPEN_ROUTE_SUBPATH}?${buildQuery(input)}`, + ), ); } diff --git a/packages/core/src/server/edge.ts b/packages/core/src/server/edge.ts index 380605751a1..95726e1a4c7 100644 --- a/packages/core/src/server/edge.ts +++ b/packages/core/src/server/edge.ts @@ -1,5 +1,6 @@ export { getAppConfig, resolveAppHomePath } from "../app-config/index.js"; export { getSsrAuthRedirectScript } from "../shared/ssr-auth-redirect.js"; +export { getFrameworkRoutePrefix } from "./framework-route-prefix.js"; export { createAuthPlugin, defaultAuthPlugin } from "./auth-plugin.js"; export { getDisabledDefaultPlugins, diff --git a/packages/core/src/server/framework-request-handler.spec.ts b/packages/core/src/server/framework-request-handler.spec.ts index b37aa209361..32ca5566bbc 100644 --- a/packages/core/src/server/framework-request-handler.spec.ts +++ b/packages/core/src/server/framework-request-handler.spec.ts @@ -85,6 +85,7 @@ describe("framework request handler", () => { afterEach(() => { delete process.env.APP_BASE_PATH; delete process.env.VITE_APP_BASE_PATH; + delete process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX; delete process.env.AGENT_NATIVE_ROUTE_READY_TIMEOUT_MS; delete process.env.AGENT_NATIVE_DISABLED_PLUGINS; resetAppConfigForTests(); @@ -235,6 +236,97 @@ describe("framework request handler", () => { }); }); + it("dispatches a public framework prefix onto the internal mount", async () => { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + "/_platform"; + const nitroApp = createNitroApp(); + getH3App(nitroApp).use("/_agent-native/resources", (event: any) => ({ + mountPrefix: event.context._mountPrefix, + mountedPathname: event.context._mountedPathname, + publicPathname: event.context._frameworkPublicPathname, + pathname: event.url.pathname, + path: event.path, + search: event.url.search, + })); + + await expect( + dispatch(nitroApp, "/_platform/resources/tree?scope=org"), + ).resolves.toEqual({ + mountPrefix: "/_agent-native/resources", + mountedPathname: "/_agent-native/resources/tree", + publicPathname: "/_platform/resources/tree", + pathname: "/tree", + path: "/tree?scope=org", + search: "?scope=org", + }); + }); + + it("composes the public prefix with APP_BASE_PATH", async () => { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + "/_platform"; + process.env.APP_BASE_PATH = "/docs"; + const nitroApp = createNitroApp(); + getH3App(nitroApp).use("/_agent-native/resources", (event: any) => ({ + mountPrefix: event.context._mountPrefix, + pathname: event.url.pathname, + })); + + await expect( + dispatch(nitroApp, "/docs/_platform/resources/tree"), + ).resolves.toEqual({ + mountPrefix: "/docs/_agent-native/resources", + pathname: "/tree", + }); + }); + + it("retires the internal prefix once a public one is configured", async () => { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + "/_platform"; + const nitroApp = createNitroApp(); + const handler = vi.fn(() => ({ served: true })); + getH3App(nitroApp).use("/_agent-native/resources", handler); + + let status: number | undefined; + const body = await dispatch( + nitroApp, + "/_agent-native/resources/tree", + (event) => { + Object.defineProperty(event.res, "status", { + set(value: number) { + status = value; + }, + get() { + return status ?? 200; + }, + }); + }, + ); + expect(body).toEqual({ error: "Not found" }); + expect(status).toBe(404); + expect(handler).not.toHaveBeenCalled(); + }); + + it("leaves a similar public prefix and app routes alone", async () => { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + "/_platform"; + const nitroApp = createNitroApp(); + const handler = vi.fn(() => ({ served: true })); + getH3App(nitroApp).use("/_agent-native/resources", handler); + + await expect( + dispatch(nitroApp, "/_platform-extra/resources/tree"), + ).resolves.toEqual({ fellThrough: true }); + await expect(dispatch(nitroApp, "/api/resources")).resolves.toEqual({ + fellThrough: true, + }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("refuses a malformed deployment prefix at boot", () => { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = "/api"; + expect(() => getH3App(createNitroApp())).toThrow(/reserved namespace/); + }); + it("dispatches framework routes under APP_BASE_PATH", async () => { process.env.APP_BASE_PATH = "/docs"; const nitroApp = createNitroApp(); diff --git a/packages/core/src/server/framework-request-handler.ts b/packages/core/src/server/framework-request-handler.ts index e76443e9538..c3f0caf9c93 100644 --- a/packages/core/src/server/framework-request-handler.ts +++ b/packages/core/src/server/framework-request-handler.ts @@ -29,6 +29,11 @@ import { getConfiguredAppBasePath } from "./app-base-path.js"; import { captureError } from "./capture-error.js"; import { createCsrfMiddleware } from "./csrf.js"; import { getDisabledDefaultPlugins } from "./default-plugins.js"; +import { + getFrameworkRoutePrefix, + internalFrameworkPath, + isRetiredInternalFrameworkPath, +} from "./framework-route-prefix.js"; import { installHttpResponseTelemetryHooks, recordFrameworkReadyWait, @@ -53,6 +58,8 @@ const EARLY_FRAMEWORK_PATHS_KEY = "_agentNativeEarlyFrameworkPaths"; const MIDDLEWARE_DISPATCHER_PATCHED_KEY = "_agentNativeMiddlewareDispatcherPatched"; const REQUEST_CONTEXT_BOUNDARY_KEY = "_agentNativeRequestContextBoundary"; +const PUBLIC_PATHNAME_CONTEXT_KEY = "_frameworkPublicPathname"; +const RETIRED_PATH_CONTEXT_KEY = "_frameworkRetiredPathname"; const CANONICAL_AUTH_EARLY_PATHS = [ "/", @@ -123,6 +130,57 @@ function resolveMountMatch( }; } +/** + * Translate a request under the PUBLIC framework prefix to the INTERNAL + * pathname every mount is registered on, once per request. + * + * This is the only place the public namespace exists on the server. It runs + * before route selection, before the readiness gates, before CSRF and before + * any handler, so everything downstream sees `/_agent-native/...` exactly as + * it does on a default deployment. The original public pathname is kept in + * `event.context._frameworkPublicPathname` for the callers that need the URL + * the browser actually used (origin checks, OAuth state); the query, method, + * headers and body are untouched. + * + * When a custom prefix is configured, a request that names the INTERNAL + * prefix is marked retired instead: the deployment declared one namespace, + * and serving both would leave an undeclared second one reachable. + */ +function translatePublicFrameworkRequest(event: H3Event): void { + const eventAny = event as any; + const context = (eventAny.context ??= {}); + if ( + context[PUBLIC_PATHNAME_CONTEXT_KEY] !== undefined || + context[RETIRED_PATH_CONTEXT_KEY] !== undefined + ) { + return; + } + const pathname = event.url?.pathname ?? ""; + const internal = internalFrameworkPath(pathname); + if (internal !== null) { + context[PUBLIC_PATHNAME_CONTEXT_KEY] = pathname; + try { + event.url.pathname = internal; + eventAny.path = `${internal}${event.url.search || ""}`; + } catch { + // coercion-ok: event.url is read-only on some runtimes, the same case + // registerMiddleware's mount stripping tolerates; the public pathname + // stays recorded in context and no mount can match it, so the request + // falls through to a 404 rather than being served under the wrong name. + } + return; + } + if (isRetiredInternalFrameworkPath(pathname)) { + context[RETIRED_PATH_CONTEXT_KEY] = pathname; + } +} + +/** The public pathname the browser requested, when the boundary rewrote it. */ +export function getPublicFrameworkPathname(event: H3Event): string | undefined { + const value = (event as any).context?.[PUBLIC_PATHNAME_CONTEXT_KEY]; + return typeof value === "string" ? value : undefined; +} + /** * Wrapper around Nitro's h3 instance that exposes a v1-style `.use()` API * for registering path-prefix middleware. @@ -183,6 +241,9 @@ export function markFrameworkRoutesReadyBeforeBootstrap( */ export function getH3App(nitroApp: any): H3AppShim { if (!nitroApp) throw new Error("getH3App: nitroApp is required"); + // A malformed deployment value must fail here, at boot, not on the first + // request that happens to build a URL. + getFrameworkRoutePrefix(); ensureGlobalMiddlewareDispatch(nitroApp); installHttpResponseTelemetryHooks(nitroApp); @@ -288,6 +349,7 @@ export function getH3App(nitroApp: any): H3AppShim { // init is missing from the request and 404s. The middleware gate stays as a // fallback for runtimes where `onRequest` isn't wired. nitroApp.hooks?.hook?.("request", async (event: H3Event) => { + translatePublicFrameworkRequest(event); const reqPath = event.url?.pathname ?? ""; if ( resolveMountMatch(reqPath, FRAMEWORK_PREFIX) || @@ -334,6 +396,14 @@ function registerRequestContextBoundary(nitroApp: any): void { if (h3[REQUEST_CONTEXT_BOUNDARY_KEY]) return; const middleware = (event: H3Event, next: () => unknown) => { + // The `request` hook above normally ran first; this is the fallback for + // runtimes where Nitro does not bridge it. Idempotent either way. + translatePublicFrameworkRequest(event); + if ((event as any).context?.[RETIRED_PATH_CONTEXT_KEY] !== undefined) { + setResponseStatus(event, 404); + setResponseHeader(event, "content-type", "application/json"); + return { error: "Not found" }; + } if (hasRequestContext()) return next(); return runWithRequestContext( { diff --git a/packages/core/src/server/framework-route-prefix.integration.spec.ts b/packages/core/src/server/framework-route-prefix.integration.spec.ts new file mode 100644 index 00000000000..9ef00d1a236 --- /dev/null +++ b/packages/core/src/server/framework-route-prefix.integration.spec.ts @@ -0,0 +1,239 @@ +/** + * The public framework route prefix, exercised through the real request + * boundary: the actual core-routes plugin, the actual action mounting, the + * CSRF middleware `getH3App()` registers, and the middleware chain Nitro + * dispatches. A unit test of the path helpers proves the arithmetic; this + * proves a deployment configured with `/_platform` serves actions and the + * event stream there, classifies CSRF on that namespace, and no longer + * answers on `/_agent-native`. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { ActionEntry } from "../agent/production-agent.js"; +import { closeDbExec } from "../db/client.js"; +import { createCoreRoutesPlugin } from "./core-routes-plugin.js"; +import { + awaitBootstrap, + markDefaultPluginProvided, + trackPluginInit, +} from "./framework-request-handler.js"; + +vi.mock("../deploy/route-discovery.js", () => ({ + getMissingDefaultPlugins: vi.fn(async () => []), +})); + +// The event stream resolves its caller through Better Auth's cookie session. +// Minting a real one needs the auth plugin and an email round-trip that add +// nothing to what is under test here, so only the session lookup is stubbed: +// the boundary, the route, and the CSRF middleware stay real. +vi.mock("./auth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSession: vi.fn(async (event: any) => { + const cookie: string = event?.headers?.get?.("cookie") ?? ""; + return cookie.includes("an_session=member") + ? { email: "member@example.com" } + : null; + }), + }; +}); + +function createNitroApp() { + return { h3: { "~middleware": [] as any[] } }; +} + +interface DispatchOptions { + method?: string; + body?: unknown; + headers?: Record; +} + +async function dispatch( + nitroApp: any, + pathname: string, + { method = "GET", body, headers = {} }: DispatchOptions = {}, +) { + const url = `https://host.test${pathname}`; + const requestHeaders = new Headers(headers); + if (body !== undefined && !requestHeaders.has("Content-Type")) { + requestHeaders.set("Content-Type", "application/json"); + } + const req = new Request(url, { + method, + headers: requestHeaders, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const responseHeaders = new Headers(); + const event = { + method, + url: new URL(url), + path: pathname, + context: {}, + req, + headers: requestHeaders, + res: { status: 200, headers: responseHeaders }, + node: { + req: { + method, + url: pathname, + headers: Object.fromEntries( + Array.from(requestHeaders.entries()).map(([key, value]) => [ + key.toLowerCase(), + value, + ]), + ), + }, + res: { + statusCode: 200, + setHeader(name: string, value: string) { + responseHeaders.set(name, value); + }, + }, + }, + }; + + let index = 0; + const next = async (): Promise => { + const middleware = nitroApp.h3["~middleware"][index++]; + if (!middleware) return { fellThrough: true }; + return middleware(event, next); + }; + + const result = await next(); + return { + body: result, + status: event.res.status ?? event.node.res.statusCode, + headers: responseHeaders, + event, + }; +} + +function createActionsPlugin(): (nitroApp: any) => void { + return (nitroApp: any) => { + markDefaultPluginProvided(nitroApp, "agent-chat"); + const initPromise = (async () => { + await awaitBootstrap(nitroApp); + const { mountActionRoutes } = await import("./action-routes.js"); + const actions: Record = { + "host-echo": { + tool: { + description: "Echo params", + parameters: { type: "object", properties: {} }, + }, + run: async (params: Record) => ({ + ok: true, + params, + }), + }, + }; + mountActionRoutes(nitroApp, actions); + })(); + trackPluginInit(nitroApp, initPromise, { + paths: ["/_agent-native/actions"], + }); + }; +} + +describe("public framework route prefix through the real request boundary", () => { + let tempDir = ""; + const originalDatabaseUrl = process.env.DATABASE_URL; + const originalPrefix = + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), "agent-native-route-prefix-")); + process.env.DATABASE_URL = `pglite:${join(tempDir, "route-prefix")}`; + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + "/_platform"; + }); + + afterAll(async () => { + await closeDbExec(); + if (originalDatabaseUrl === undefined) { + delete process.env.DATABASE_URL; + } else { + process.env.DATABASE_URL = originalDatabaseUrl; + } + if (originalPrefix === undefined) { + delete process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX; + } else { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + originalPrefix; + } + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + }); + + it("serves actions, the event stream, and CSRF on the public prefix only", async () => { + const nitroApp = createNitroApp(); + createActionsPlugin()(nitroApp); + const corePluginDone = createCoreRoutesPlugin()(nitroApp); + + // A first-party JSON POST reaches the real action under the public name. + await expect( + dispatch(nitroApp, "/_platform/actions/host-echo", { + method: "POST", + headers: { "X-Agent-Native-CSRF": "1" }, + body: { value: "ok" }, + }), + ).resolves.toMatchObject({ + status: 200, + body: { ok: true, params: { value: "ok" } }, + }); + + // The CSRF classifier sees the internal name after the boundary, so a + // cookie-carrying simple request is still refused on the public prefix. + await expect( + dispatch(nitroApp, "/_platform/actions/host-echo", { + method: "POST", + headers: { "Content-Type": "text/plain", cookie: "an_session=abc" }, + body: { value: "attack" }, + }), + ).resolves.toMatchObject({ status: 403 }); + + // The internal name is retired: it neither serves nor falls through to + // whatever the app mounts after the framework. + await expect( + dispatch(nitroApp, "/_agent-native/actions/host-echo", { + method: "POST", + headers: { "X-Agent-Native-CSRF": "1" }, + body: { value: "leak" }, + }), + ).resolves.toMatchObject({ status: 404, body: { error: "Not found" } }); + + // A framework document with a file extension reaches the handler rather + // than a static-file layer: the speculation rules the SSR shell requests. + const rules = await dispatch(nitroApp, "/_platform/speculation-rules.json"); + expect(rules.body).not.toEqual({ fellThrough: true }); + expect(rules.status).toBe(200); + + // Protected routes stay protected under the public prefix: the change + // event stream refuses an anonymous request... + await expect( + dispatch(nitroApp, "/_platform/events", { + headers: { accept: "text/event-stream" }, + }), + ).resolves.toMatchObject({ status: 401 }); + + // ...and streams for a member, through the same boundary. + const events = await dispatch(nitroApp, "/_platform/events", { + headers: { accept: "text/event-stream", cookie: "an_session=member" }, + }); + const stream = events.body; + const contentType = + stream instanceof Response + ? stream.headers.get("content-type") + : events.headers.get("content-type"); + expect({ status: events.status, contentType }).toEqual({ + status: 200, + contentType: expect.stringContaining("text/event-stream"), + }); + if (stream instanceof Response) await stream.body?.cancel(); + + await corePluginDone; + }); +}); diff --git a/packages/core/src/server/framework-route-prefix.ts b/packages/core/src/server/framework-route-prefix.ts new file mode 100644 index 00000000000..ecd09caf846 --- /dev/null +++ b/packages/core/src/server/framework-route-prefix.ts @@ -0,0 +1,85 @@ +/** + * The server's single reader of the public framework route prefix. + * + * `runtime.frameworkRoutePrefix` is resolved once, by the Vite plugin at dev + * start or by the deploy build, and embedded into the server bundle as a + * literal environment read of `AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX` + * (Vite `define`, Nitro `replace`, and the deploy build's own replacement + * map — the same channel `AGENT_NATIVE_DEPLOYMENT_ENVIRONMENT` uses). A + * declared app-config field would be read through the env layer's dynamic + * lookup, which the build-time replacement cannot reach, and a deployed + * function would then disagree with the browser bundle it serves. + * + * Keep this the only environment read of that key. + */ +import { + FRAMEWORK_INTERNAL_ROUTE_PREFIX, + FRAMEWORK_ROUTE_PREFIX_ENV, + isInternalFrameworkPathLeak, + normalizeFrameworkRoutePrefix, + toInternalFrameworkPath, + toPublicFrameworkPath, +} from "../shared/framework-route-prefix.js"; +import { getConfiguredAppBasePath } from "./app-base-path.js"; + +export { FRAMEWORK_INTERNAL_ROUTE_PREFIX } from "../shared/framework-route-prefix.js"; + +function readConfiguredPrefix(): string | undefined { + // config-ok: embedded at build time by literal replacement, which the app-config env layer's dynamic lookup cannot see (see module comment) + const raw = process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX; + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + // A build with the option unset embeds an empty string; that is the + // default, not an invalid value. + return trimmed === "" ? undefined : trimmed; +} + +/** The validated public prefix. Throws on a malformed deployment value. */ +export function getFrameworkRoutePrefix(): string { + return normalizeFrameworkRoutePrefix( + readConfiguredPrefix(), + FRAMEWORK_ROUTE_PREFIX_ENV, + ); +} + +export function hasCustomFrameworkRoutePrefix(): boolean { + return getFrameworkRoutePrefix() !== FRAMEWORK_INTERNAL_ROUTE_PREFIX; +} + +function prefixOptions() { + return { + publicPrefix: getFrameworkRoutePrefix(), + basePath: getConfiguredAppBasePath(), + }; +} + +/** + * The public form of an internal framework path or URL of THIS deployment. + * Apply it where the URL is built, never to a response body, and never to a + * URL that targets another installation. + */ +export function publicFrameworkPath(path: string): string { + return toPublicFrameworkPath(path, prefixOptions()); +} + +/** + * The internal pathname for an incoming public request, or `null` when the + * request is not under the public framework prefix. + */ +export function internalFrameworkPath(pathname: string): string | null { + return toInternalFrameworkPath(pathname, prefixOptions()); +} + +/** + * The internal form of a pathname that may carry the public prefix — for + * validating a URL a client or provider handed back (an OAuth `redirect_uri`, + * a magic-link callback) against the internal route names the code knows. + */ +export function canonicalFrameworkPathname(pathname: string): string { + return internalFrameworkPath(pathname) ?? pathname; +} + +/** True when a request names the internal prefix that a custom prefix retired. */ +export function isRetiredInternalFrameworkPath(pathname: string): boolean { + return isInternalFrameworkPathLeak(pathname, prefixOptions()); +} diff --git a/packages/core/src/server/google-oauth.ts b/packages/core/src/server/google-oauth.ts index 83d33242c74..7085e611c86 100644 --- a/packages/core/src/server/google-oauth.ts +++ b/packages/core/src/server/google-oauth.ts @@ -38,6 +38,11 @@ import { } from "./better-auth-instance.js"; import { getWorkspaceA2ADerivedSecret } from "./derived-secret.js"; import { writeDesktopSso } from "./desktop-sso.js"; +import { + canonicalFrameworkPathname, + isRetiredInternalFrameworkPath, + publicFrameworkPath, +} from "./framework-route-prefix.js"; import { setIdentityGoogleAuthCookie } from "./identity-auth-provider.js"; import { appendSessionToOAuthReturnUrl } from "./oauth-return-url.js"; import { @@ -220,7 +225,7 @@ export function getAppBasePath(): string { /** Build an absolute same-origin URL that preserves APP_BASE_PATH. */ export function getAppUrl(event: H3Event, path = "/"): string { const cleanPath = path.startsWith("/") ? path : `/${path}`; - return `${getOrigin(event)}${getAppBasePath()}${cleanPath}`; + return `${getOrigin(event)}${getAppBasePath()}${publicFrameworkPath(cleanPath)}`; } function isFrameworkOAuthCallbackPath(pathname: string): boolean { @@ -279,10 +284,10 @@ function getDefaultOAuthRedirectUrl( (isWorkspaceOAuthCallbackRelayEnabled() || options.allowRootCallback) && isFrameworkOAuthCallbackPath(cleanPath) ) { - return `${getOrigin(event)}${cleanPath}`; + return `${getOrigin(event)}${publicFrameworkPath(cleanPath)}`; } const basePath = isRequestUnderAppBasePath(event) ? getAppBasePath() : ""; - return `${getOrigin(event)}${basePath}${cleanPath}`; + return `${getOrigin(event)}${basePath}${publicFrameworkPath(cleanPath)}`; } // ─── redirect_uri Allowlist ────────────────────────────────────────────────── @@ -334,6 +339,10 @@ export function isAllowedOAuthRedirectUri( } if (url.protocol !== expectedUrl.protocol) return false; if (url.host !== expectedUrl.host) return false; + // The candidate arrives in the deployment's PUBLIC namespace; a custom + // prefix retires the internal name, so a redirect there is not ours. + if (isRetiredInternalFrameworkPath(url.pathname)) return false; + const pathname = canonicalFrameworkPathname(url.pathname); // Must live under the framework's namespace. Workspace deploys can route // root /_agent-native/* to Dispatch even when Dispatch itself is mounted at // /dispatch, but app-prefixed requests should not be able to swap their @@ -350,7 +359,7 @@ export function isAllowedOAuthRedirectUri( : []), ] : ["/_agent-native/"]; - if (!allowedPrefixes.some((prefix) => url.pathname.startsWith(prefix))) { + if (!allowedPrefixes.some((prefix) => pathname.startsWith(prefix))) { return false; } return true; diff --git a/packages/core/src/server/identity-sso.ts b/packages/core/src/server/identity-sso.ts index f81314575f1..6624fa1a2d4 100644 --- a/packages/core/src/server/identity-sso.ts +++ b/packages/core/src/server/identity-sso.ts @@ -39,6 +39,7 @@ import { } from "./better-auth-instance.js"; import { resolveAuthCookieNamespace } from "./cookie-namespace.js"; import { readDeployCredentialEnv } from "./credential-provider.js"; +import { publicFrameworkPath } from "./framework-route-prefix.js"; import { createOAuthSession, getAppUrl, getOrigin } from "./google-oauth.js"; import { hasIdentityGoogleAuthCookie } from "./identity-auth-provider.js"; import { @@ -200,7 +201,7 @@ function setPkceVerifierCookie( setCookie(event, verifierCookieName(state), verifier, { httpOnly: true, maxAge: Math.floor(SSO_STATE_TTL_MS / 1_000), - path: IDENTITY_SSO_CALLBACK_PATH, + path: publicFrameworkPath(IDENTITY_SSO_CALLBACK_PATH), sameSite: "lax", secure, }); @@ -347,7 +348,7 @@ function addBridgeParams(url: string, sourceOrigin: string): string { function clearPkceVerifierCookie(event: H3Event, state: string): void { deleteCookie(event, verifierCookieName(state), { - path: IDENTITY_SSO_CALLBACK_PATH, + path: publicFrameworkPath(IDENTITY_SSO_CALLBACK_PATH), }); } @@ -364,7 +365,7 @@ function resolveClientBinding( ): SsoClientBinding | null { const appId = resolveIdentitySsoAppId(event); const clientId = resolveClientId(appId); - const redirectUri = `${getOrigin(event)}${IDENTITY_SSO_CALLBACK_PATH}`; + const redirectUri = `${getOrigin(event)}${publicFrameworkPath(IDENTITY_SSO_CALLBACK_PATH)}`; const authority = normalizeAuthority(hub); if (!authority || !appId || !clientId || !redirectUri) return null; return { appId, clientId, redirectUri, authority }; diff --git a/packages/core/src/server/self-dispatch.ts b/packages/core/src/server/self-dispatch.ts index 7ce37115f4e..1825f84a486 100644 --- a/packages/core/src/server/self-dispatch.ts +++ b/packages/core/src/server/self-dispatch.ts @@ -31,6 +31,7 @@ import { getConfiguredAppBasePath, withConfiguredAppBasePath, } from "./app-base-path.js"; +import { publicFrameworkPath } from "./framework-route-prefix.js"; import { getRequestContext } from "./request-context.js"; /** @@ -177,7 +178,7 @@ export async function fireInternalDispatch( // routes land on the right app; for a host-root function url we must dispatch // to `https://host/.netlify/functions/` instead. Strip the base path // suffix from the resolved base url for `/.netlify/*` dispatch targets only. - const url = `${rootBaseUrlForPath(baseUrl, options.path)}${options.path}`; + const url = `${rootBaseUrlForPath(baseUrl, options.path)}${publicFrameworkPath(options.path)}`; const headers: Record = { "Content-Type": "application/json", }; diff --git a/packages/core/src/server/ssr-handler.ts b/packages/core/src/server/ssr-handler.ts index 9e47b652ee2..2ff51885632 100644 --- a/packages/core/src/server/ssr-handler.ts +++ b/packages/core/src/server/ssr-handler.ts @@ -45,6 +45,7 @@ import { frameworkSessionHintCookieName, resolveAuthCookieNamespace, } from "./cookie-namespace.js"; +import { getFrameworkRoutePrefix } from "./framework-route-prefix.js"; import { getPostHogClientConfigScript } from "./posthog-config.js"; import { runWithRequestContext } from "./request-context.js"; import { @@ -438,6 +439,7 @@ async function rewriteMountedResponse( resolveAuthCookieNamespace().frameworkCookieName, ), resolveAppHomePath(getAppConfig().app), + getFrameworkRoutePrefix(), ) : null, ] diff --git a/packages/core/src/server/workspace-provider-oauth.ts b/packages/core/src/server/workspace-provider-oauth.ts index 4014f5814a5..135c4e4a932 100644 --- a/packages/core/src/server/workspace-provider-oauth.ts +++ b/packages/core/src/server/workspace-provider-oauth.ts @@ -32,6 +32,7 @@ import { safeReturnPath, } from "./auth.js"; import { resolveSecret } from "./credential-provider.js"; +import { canonicalFrameworkPathname } from "./framework-route-prefix.js"; import { decodeOAuthState, encodeOAuthState, @@ -260,7 +261,8 @@ export async function handleWorkspaceProviderOAuthStart( return oauthFlowFailure(event, 400, "Invalid OAuth redirect URI."); } if ( - parsedRedirectUri.pathname !== "/_agent-native/google/callback" || + canonicalFrameworkPathname(parsedRedirectUri.pathname) !== + "/_agent-native/google/callback" || parsedRedirectUri.search || parsedRedirectUri.hash ) { diff --git a/packages/core/src/shared/framework-route-prefix.spec.ts b/packages/core/src/shared/framework-route-prefix.spec.ts new file mode 100644 index 00000000000..fbd752e755f --- /dev/null +++ b/packages/core/src/shared/framework-route-prefix.spec.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; + +import { + FRAMEWORK_INTERNAL_ROUTE_PREFIX, + FrameworkRoutePrefixError, + isInternalFrameworkPathLeak, + matchesPathPrefix, + normalizeFrameworkRoutePrefix, + stripPathPrefix, + toInternalFrameworkPath, + toPublicFrameworkPath, +} from "./framework-route-prefix.js"; + +const custom = { publicPrefix: "/_platform" }; +const customMounted = { publicPrefix: "/_platform", basePath: "/mail" }; +const defaults = { publicPrefix: FRAMEWORK_INTERNAL_ROUTE_PREFIX }; + +describe("normalizeFrameworkRoutePrefix", () => { + it("resolves an unset value to the internal prefix", () => { + expect(normalizeFrameworkRoutePrefix(undefined)).toBe("/_agent-native"); + }); + + it("accepts one absolute segment of letters, digits, _ and -", () => { + expect(normalizeFrameworkRoutePrefix("/_platform")).toBe("/_platform"); + expect(normalizeFrameworkRoutePrefix(" /fw-1 ")).toBe("/fw-1"); + expect(normalizeFrameworkRoutePrefix("/_agent-native")).toBe( + "/_agent-native", + ); + }); + + it.each([ + ["", "empty"], + ["/", "root"], + ["/_platform/", "trailing slash"], + ["_platform", "relative"], + ["/_platform?x=1", "query"], + ["/_platform#x", "fragment"], + ["/a/b", "nested"], + ["/%5Fplatform", "escape"], + ["/..", "dot segment"], + ["/_", "no letter or digit"], + ["/-", "no letter or digit"], + ])("rejects %j (%s)", (value) => { + expect(() => normalizeFrameworkRoutePrefix(value)).toThrow( + FrameworkRoutePrefixError, + ); + }); + + it("refuses the well-known namespace by shape before it can be reserved", () => { + expect(() => normalizeFrameworkRoutePrefix("/.well-known")).toThrow( + FrameworkRoutePrefixError, + ); + }); + + it("rejects non-string values and names the source", () => { + expect(() => normalizeFrameworkRoutePrefix(5, "runtime.x")).toThrow( + "runtime.x must be a string", + ); + }); + + it.each(["/api", "/mcp", "/Api", "/sign-in", "/assets"])( + "rejects the reserved namespace %s", + (value) => { + expect(() => normalizeFrameworkRoutePrefix(value)).toThrow( + /reserved namespace/, + ); + }, + ); +}); + +describe("segment-aware matching", () => { + it("matches the exact prefix and its children only", () => { + expect(matchesPathPrefix("/_platform", "/_platform")).toBe(true); + expect(matchesPathPrefix("/_platform/actions", "/_platform")).toBe(true); + expect(matchesPathPrefix("/_platform-extra", "/_platform")).toBe(false); + expect(matchesPathPrefix("/_platformx/y", "/_platform")).toBe(false); + expect(matchesPathPrefix("/x", "")).toBe(false); + }); + + it("strips to a leading-slash remainder", () => { + expect(stripPathPrefix("/_platform", "/_platform")).toBe("/"); + expect(stripPathPrefix("/_platform/a/b", "/_platform")).toBe("/a/b"); + expect(stripPathPrefix("/_platform-extra", "/_platform")).toBeNull(); + }); +}); + +describe("toInternalFrameworkPath", () => { + it("never translates when the public prefix is the default", () => { + expect(toInternalFrameworkPath("/_agent-native/actions/x", defaults)).toBe( + null, + ); + }); + + it("maps the public prefix to the internal one", () => { + expect(toInternalFrameworkPath("/_platform/actions/x", custom)).toBe( + "/_agent-native/actions/x", + ); + expect(toInternalFrameworkPath("/_platform", custom)).toBe( + "/_agent-native", + ); + expect(toInternalFrameworkPath("/_platform/", custom)).toBe( + "/_agent-native/", + ); + }); + + it("leaves similar prefixes, app routes and the internal name alone", () => { + expect(toInternalFrameworkPath("/_platform-extra/x", custom)).toBeNull(); + expect(toInternalFrameworkPath("/api/x", custom)).toBeNull(); + expect(toInternalFrameworkPath("/_agent-native/x", custom)).toBeNull(); + }); + + it("composes with the app base path once", () => { + expect( + toInternalFrameworkPath("/mail/_platform/actions/x", customMounted), + ).toBe("/mail/_agent-native/actions/x"); + expect(toInternalFrameworkPath("/mail/_platform", customMounted)).toBe( + "/mail/_agent-native", + ); + expect(toInternalFrameworkPath("/_platform/x", customMounted)).toBe( + "/_agent-native/x", + ); + expect(toInternalFrameworkPath("/mailx/_platform/x", customMounted)).toBe( + null, + ); + }); + + it("is idempotent", () => { + const once = toInternalFrameworkPath("/_platform/actions/x", custom)!; + expect(toInternalFrameworkPath(once, custom)).toBeNull(); + }); +}); + +describe("isInternalFrameworkPathLeak", () => { + it("flags the internal name only when a custom prefix is configured", () => { + expect(isInternalFrameworkPathLeak("/_agent-native/x", defaults)).toBe( + false, + ); + expect(isInternalFrameworkPathLeak("/_agent-native/x", custom)).toBe(true); + expect(isInternalFrameworkPathLeak("/_agent-native", custom)).toBe(true); + expect(isInternalFrameworkPathLeak("/_agent-nativex", custom)).toBe(false); + expect( + isInternalFrameworkPathLeak("/mail/_agent-native/x", customMounted), + ).toBe(true); + expect(isInternalFrameworkPathLeak("/_platform/x", custom)).toBe(false); + }); +}); + +describe("toPublicFrameworkPath", () => { + it("returns every input unchanged under the default prefix", () => { + for (const path of [ + "/_agent-native/actions/x?y=1", + "/mail/_agent-native/x", + "https://app.example/_agent-native/x", + "/api/x", + ]) { + expect(toPublicFrameworkPath(path, defaults)).toBe(path); + } + }); + + it("renames the internal segment of a pathname", () => { + expect(toPublicFrameworkPath("/_agent-native/actions/x", custom)).toBe( + "/_platform/actions/x", + ); + expect(toPublicFrameworkPath("/_agent-native", custom)).toBe("/_platform"); + expect(toPublicFrameworkPath("/_agent-native/", custom)).toBe( + "/_platform/", + ); + }); + + it("keeps the query and fragment", () => { + expect( + toPublicFrameworkPath("/_agent-native/auth/session?x=1#frag", custom), + ).toBe("/_platform/auth/session?x=1#frag"); + expect( + toPublicFrameworkPath("/_agent-native/open?c=/_agent-native/x", custom), + ).toBe("/_platform/open?c=/_agent-native/x"); + }); + + it("keeps the app base path where the caller put it", () => { + expect( + toPublicFrameworkPath("/mail/_agent-native/actions/x", customMounted), + ).toBe("/mail/_platform/actions/x"); + expect( + toPublicFrameworkPath("/_agent-native/actions/x", customMounted), + ).toBe("/_platform/actions/x"); + }); + + it("renames inside an absolute URL and leaves other origins' paths intact", () => { + expect( + toPublicFrameworkPath( + "https://app.example/_agent-native/google/callback?state=1", + custom, + ), + ).toBe("https://app.example/_platform/google/callback?state=1"); + expect( + toPublicFrameworkPath( + "https://app.example/mail/_agent-native/x", + customMounted, + ), + ).toBe("https://app.example/mail/_platform/x"); + expect(toPublicFrameworkPath("https://app.example/api/x", custom)).toBe( + "https://app.example/api/x", + ); + }); + + it("does not touch app routes, relative paths, or similar prefixes", () => { + expect(toPublicFrameworkPath("/api/x", custom)).toBe("/api/x"); + expect(toPublicFrameworkPath("_agent-native/x", custom)).toBe( + "_agent-native/x", + ); + expect(toPublicFrameworkPath("/_agent-nativex/y", custom)).toBe( + "/_agent-nativex/y", + ); + expect(toPublicFrameworkPath("mailto:x@example.com", custom)).toBe( + "mailto:x@example.com", + ); + }); + + it("round-trips with the incoming translation", () => { + const publicPath = toPublicFrameworkPath( + "/mail/_agent-native/events", + customMounted, + ); + expect(toInternalFrameworkPath(publicPath, customMounted)).toBe( + "/mail/_agent-native/events", + ); + }); +}); diff --git a/packages/core/src/shared/framework-route-prefix.ts b/packages/core/src/shared/framework-route-prefix.ts new file mode 100644 index 00000000000..3e5474fc2bd --- /dev/null +++ b/packages/core/src/shared/framework-route-prefix.ts @@ -0,0 +1,247 @@ +/** + * The framework's route namespace, in its two forms. + * + * Every framework route is registered under `/_agent-native`. That name is + * the INTERNAL prefix: it is what `getH3App().use(...)` mounts, what handlers + * see in `event.path`, and what the CSRF classifier and route discovery match + * against. It never changes. + * + * A deployment may choose a different PUBLIC prefix (`runtime.frameworkRoutePrefix` + * in `agent-native.config.ts`), because a gateway or reverse proxy in front of + * the app may own the namespace. The public prefix is translated to the + * internal one exactly once, at the request boundary, and every URL the + * framework hands out (browser fetches, redirects, OAuth callbacks, self + * dispatch) is built through `toPublicFrameworkPath` at the point it is + * constructed. Nothing rewrites a response body. + * + * The two prefixes are one value by default, so the translation is a no-op + * for every deployment that does not set the option. + * + * This module runs in the browser and on the server: no Node imports. + */ + +export const FRAMEWORK_INTERNAL_ROUTE_PREFIX = "/_agent-native"; + +/** + * The deployment alias for `runtime.frameworkRoutePrefix`. It is the same + * name `agentNativeConfigEnvName(["runtime", "frameworkRoutePrefix"])` + * derives, spelled out here because the server bundle reads it back as a + * literal `process.env` key embedded at build time. + */ +export const FRAMEWORK_ROUTE_PREFIX_ENV = + "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX"; + +/** + * Namespaces the framework already routes on, or that apps own by convention. + * A public prefix that shadows one of these would make the request boundary + * ambiguous: `/api/actions/...` could be an app route or a framework route. + */ +export const RESERVED_FRAMEWORK_ROUTE_PREFIXES = [ + "/api", + "/mcp", + "/.well-known", + "/assets", + "/sign-in", + "/login", + "/signup", +] as const; + +const PREFIX_SHAPE = /^\/[A-Za-z0-9_-]+$/; +const HAS_ALNUM = /[A-Za-z0-9]/; + +export class FrameworkRoutePrefixError extends Error { + constructor(message: string) { + super(message); + this.name = "FrameworkRoutePrefixError"; + } +} + +/** + * Validate a configured public prefix and return it in canonical form. + * + * The accepted shape is deliberately one segment of `[A-Za-z0-9_-]`. Nested + * namespaces, dots, escapes, queries and trailing slashes all make + * segment-aware matching and the CSRF classification harder to reason about; + * they can be added later as their own change. + * + * `undefined` means "not configured" and resolves to the internal prefix. + * Anything else that is not a valid prefix throws: an empty string or `/` + * is a misconfiguration, not a request for the default. + */ +export function normalizeFrameworkRoutePrefix( + value: unknown, + source = "runtime.frameworkRoutePrefix", +): string { + if (value === undefined) return FRAMEWORK_INTERNAL_ROUTE_PREFIX; + if (typeof value !== "string") { + throw new FrameworkRoutePrefixError(`${source} must be a string`); + } + const trimmed = value.trim(); + if (!trimmed || trimmed === "/") { + throw new FrameworkRoutePrefixError( + `${source} must be a single absolute path segment such as "/_agent-native"; omit it to keep the default`, + ); + } + if (!PREFIX_SHAPE.test(trimmed) || !HAS_ALNUM.test(trimmed)) { + throw new FrameworkRoutePrefixError( + `${source} must be one absolute path segment of letters, digits, "_" or "-" (received ${JSON.stringify(value)})`, + ); + } + const reserved = RESERVED_FRAMEWORK_ROUTE_PREFIXES.find( + (candidate) => candidate.toLowerCase() === trimmed.toLowerCase(), + ); + if (reserved) { + throw new FrameworkRoutePrefixError( + `${source} must not use the reserved namespace ${JSON.stringify(reserved)}`, + ); + } + return trimmed; +} + +/** Segment-aware prefix test: `/_platform` matches `/_platform/x`, not `/_platform-extra`. */ +export function matchesPathPrefix(pathname: string, prefix: string): boolean { + if (!prefix) return false; + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +/** + * The remainder of `pathname` after a segment-aware `prefix`, always starting + * with `/` (`/` for an exact match), or `null` when the prefix does not match. + */ +export function stripPathPrefix( + pathname: string, + prefix: string, +): string | null { + if (!matchesPathPrefix(pathname, prefix)) return null; + return pathname.slice(prefix.length) || "/"; +} + +export interface FrameworkRoutePrefixOptions { + /** The validated public prefix. Equal to the internal prefix by default. */ + publicPrefix: string; + /** The app base path (`/mail`), already normalized, or empty. */ + basePath?: string; +} + +function normalizedBasePath(basePath: string | undefined): string { + if (!basePath || basePath === "/") return ""; + return basePath; +} + +/** + * Translate an incoming PUBLIC request pathname to the INTERNAL pathname the + * framework's mounts are registered on. + * + * Returns `null` when the request is not under the public framework prefix + * (with or without the app base path) — including every request when the + * public prefix is the default, so the boundary can skip the rewrite entirely. + * The translation is idempotent: an internal pathname is never under the + * public prefix once the two differ, so translating twice is translating once. + */ +export function toInternalFrameworkPath( + pathname: string, + { publicPrefix, basePath }: FrameworkRoutePrefixOptions, +): string | null { + if (publicPrefix === FRAMEWORK_INTERNAL_ROUTE_PREFIX) return null; + const base = normalizedBasePath(basePath); + const swapped = + (base && + swapPrefix( + pathname, + `${base}${publicPrefix}`, + `${base}${FRAMEWORK_INTERNAL_ROUTE_PREFIX}`, + )) || + swapPrefix(pathname, publicPrefix, FRAMEWORK_INTERNAL_ROUTE_PREFIX); + return swapped; +} + +/** + * Replace a segment-aware `from` prefix with `to`, keeping the remainder + * byte-for-byte (an exact match stays exact, a trailing slash stays), or + * `null` when `from` does not match. + */ +function swapPrefix(pathname: string, from: string, to: string): string | null { + if (!matchesPathPrefix(pathname, from)) return null; + return `${to}${pathname.slice(from.length)}`; +} + +/** + * True when `pathname` addresses the framework namespace by its INTERNAL + * name while a different public prefix is configured. Such a request must + * not be served: the internal name is not part of the deployment's URL + * surface, and answering it would keep a second, undeclared namespace alive. + */ +export function isInternalFrameworkPathLeak( + pathname: string, + { publicPrefix, basePath }: FrameworkRoutePrefixOptions, +): boolean { + if (publicPrefix === FRAMEWORK_INTERNAL_ROUTE_PREFIX) return false; + const base = normalizedBasePath(basePath); + return ( + matchesPathPrefix(pathname, FRAMEWORK_INTERNAL_ROUTE_PREFIX) || + (base + ? matchesPathPrefix(pathname, `${base}${FRAMEWORK_INTERNAL_ROUTE_PREFIX}`) + : false) + ); +} + +function swapInternalPrefix( + pathname: string, + publicPrefix: string, + base: string, +): string { + return ( + (base && + swapPrefix( + pathname, + `${base}${FRAMEWORK_INTERNAL_ROUTE_PREFIX}`, + `${base}${publicPrefix}`, + )) || + swapPrefix(pathname, FRAMEWORK_INTERNAL_ROUTE_PREFIX, publicPrefix) || + pathname + ); +} + +/** + * Build the PUBLIC form of a framework URL from its INTERNAL form. + * + * Accepts a bare pathname (`/_agent-native/actions/x`), a pathname carrying + * the app base path (`/mail/_agent-native/actions/x`), either with a query or + * fragment, or an absolute URL whose pathname has one of those shapes. Any + * other input is returned unchanged, which is what makes it safe to apply at + * every construction site: a path that is not under the internal prefix is + * not a framework route of this deployment. + * + * It does NOT add the base path. Every existing construction site already + * composes the base path its own way; this helper only renames the segment + * so the base path is applied exactly once, by whoever applied it before. + * + * Never call this for a URL that targets a different installation — a peer + * keeps its own declared namespace. + */ +export function toPublicFrameworkPath( + path: string, + { publicPrefix, basePath }: FrameworkRoutePrefixOptions, +): string { + if (publicPrefix === FRAMEWORK_INTERNAL_ROUTE_PREFIX) return path; + const base = normalizedBasePath(basePath); + + if (path.startsWith("/")) { + const cut = path.search(/[?#]/); + const pathname = cut === -1 ? path : path.slice(0, cut); + const suffix = cut === -1 ? "" : path.slice(cut); + return `${swapInternalPrefix(pathname, publicPrefix, base)}${suffix}`; + } + + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) return path; + let url: URL; + try { + url = new URL(path); + } catch { + return path; + } + const swapped = swapInternalPrefix(url.pathname, publicPrefix, base); + if (swapped === url.pathname) return path; + url.pathname = swapped; + return url.toString(); +} diff --git a/packages/core/src/shared/ssr-auth-redirect.ts b/packages/core/src/shared/ssr-auth-redirect.ts index 102a7ed49bd..894ec8947c4 100644 --- a/packages/core/src/shared/ssr-auth-redirect.ts +++ b/packages/core/src/shared/ssr-auth-redirect.ts @@ -8,6 +8,7 @@ export function getSsrAuthRedirectScript( sessionHintCookieName = "an_session_hint", appHomePath = "/home", + frameworkRoutePrefix = "/_agent-native", ): string { if (appHomePath === "/") return ""; @@ -32,7 +33,7 @@ export function getSsrAuthRedirectScript( redirectFromHint(); return; } - var sessionPath = (root || "") + "/_agent-native/auth/session"; + var sessionPath = (root || "") + ${JSON.stringify(`${frameworkRoutePrefix}/auth/session`)}; function redirectToAppHome() { return fetch(homePath, { method: "HEAD", diff --git a/packages/core/src/shared/ssr-beta-redirect.ts b/packages/core/src/shared/ssr-beta-redirect.ts index d26dd3a7338..38198755c03 100644 --- a/packages/core/src/shared/ssr-beta-redirect.ts +++ b/packages/core/src/shared/ssr-beta-redirect.ts @@ -18,6 +18,7 @@ export const SSR_BETA_REDIRECT_MARKER = 'data-agent-native-beta-redirect="1"'; */ export function getSsrBetaRedirectScriptBody( sessionPath = "/_agent-native/auth/session", + frameworkRoutePrefix = "/_agent-native", ): string { return `(function __anEarlyBetaRedirect() { if (window.__agentNativeBetaRedirectStarted) return; @@ -132,7 +133,8 @@ export function getSsrBetaRedirectScriptBody( var sessionProbePath = ${safeJsonForHtml(sessionPath)}; var appConfig = window.__AGENT_NATIVE_CONFIG__; if (appConfig && appConfig.workspaceRuntime === true) { - var frameworkSessionPath = '/_agent-native/auth/session'; + var frameworkSessionPath = ${safeJsonForHtml(`${frameworkRoutePrefix}/auth/session`)}; + var frameworkSegment = ${safeJsonForHtml(frameworkRoutePrefix.slice(1))}; var knownWorkspaceMounts = Array.isArray(appConfig.workspaceAppMountPaths) ? appConfig.workspaceAppMountPaths : null; @@ -143,6 +145,7 @@ export function getSsrBetaRedirectScriptBody( }); var candidateWorkspaceMount = mountSegment && mountSegment !== '_agent-native' && + mountSegment !== frameworkSegment && mountSegment !== 'api' && mountSegment !== 'sign-in' && mountSegment !== 'login' && @@ -246,6 +249,7 @@ export function getSsrBetaRedirectScriptBody( export function getSsrBetaRedirectScript( sessionPath = "/_agent-native/auth/session", + frameworkRoutePrefix = "/_agent-native", ): string { - return ``; + return ``; } diff --git a/packages/core/src/triggers/webhook.ts b/packages/core/src/triggers/webhook.ts index 44b0f45a61a..43ab9453997 100644 --- a/packages/core/src/triggers/webhook.ts +++ b/packages/core/src/triggers/webhook.ts @@ -1,5 +1,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { publicFrameworkPath } from "../server/framework-route-prefix.js"; + export const AUTOMATION_WEBHOOK_PLATFORM = "automation-webhook"; export const AUTOMATION_WEBHOOK_MAX_BODY_BYTES = 64 * 1024; @@ -23,7 +25,7 @@ export function isAutomationWebhookToken(value: string): boolean { } export function automationWebhookPath(token: string): string { - return `/_agent-native/automations/webhook/${token}`; + return publicFrameworkPath(`/_agent-native/automations/webhook/${token}`); } export function automationWebhookTokenHash(token: string): string { diff --git a/packages/core/src/vite/client.ts b/packages/core/src/vite/client.ts index 2c51c80b736..8634fc3cc19 100644 --- a/packages/core/src/vite/client.ts +++ b/packages/core/src/vite/client.ts @@ -45,6 +45,11 @@ import { EMBED_TOKEN_QUERY_PARAM, MCP_APP_CHAT_BRIDGE_QUERY_PARAM, } from "../shared/embed-auth.js"; +import { + FRAMEWORK_INTERNAL_ROUTE_PREFIX, + matchesPathPrefix, + normalizeFrameworkRoutePrefix, +} from "../shared/framework-route-prefix.js"; import { isMcpEmbedCorsOrigin, MCP_EMBED_CORS_ALLOW_HEADERS, @@ -2510,19 +2515,31 @@ export function stripMountedDevApiPath( return isApiDevPath(stripped) ? stripped : reqUrl; } +function devFrameworkRoutePrefixes(): string[] { + const configured = normalizeFrameworkRoutePrefix( + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX?.trim() || + undefined, + "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX", + ); + return configured === FRAMEWORK_INTERNAL_ROUTE_PREFIX + ? [FRAMEWORK_INTERNAL_ROUTE_PREFIX] + : [FRAMEWORK_INTERNAL_ROUTE_PREFIX, configured]; +} + export function isFrameworkDevPath( reqUrl: string, base: string | undefined, ): boolean { const pathname = devPathname(reqUrl); - if (pathname === "/_agent-native" || pathname.startsWith("/_agent-native/")) { - return true; - } - if (!base || base === "/") return false; - const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base; - return ( - pathname === `${normalizedBase}/_agent-native` || - pathname.startsWith(`${normalizedBase}/_agent-native/`) + const normalizedBase = + !base || base === "/" ? "" : base.endsWith("/") ? base.slice(0, -1) : base; + // Vite's own middleware runs before the h3 boundary translates the public + // prefix, so both names must be recognised here. + return devFrameworkRoutePrefixes().some( + (prefix) => + matchesPathPrefix(pathname, prefix) || + (normalizedBase !== "" && + matchesPathPrefix(pathname, `${normalizedBase}${prefix}`)), ); } @@ -3790,6 +3807,16 @@ function createAgentNativeConfig( : appConfig; const buildId = resolveAgentNativeBuildId(process.env, "development"); const packageVersions = resolveAgentNativePackageVersions(cwd); + // The public framework route prefix is resolved exactly here, once. The + // browser bundle reads it from the serialized config; the server bundle + // reads one literal env key (`server/framework-route-prefix.ts`), embedded + // below for `vite build` and set on this process for the in-process Nitro + // dev server. An empty string is "not configured", never a prefix. + const frameworkRoutePrefix = + resolvedAppConfig.runtime?.frameworkRoutePrefix ?? ""; + // guard:allow-env-mutation — Vite config phase, set once before the in-process Nitro dev server accepts a request + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + frameworkRoutePrefix; // Preload workspace-root .env into process.env so Nitro server code sees // shared keys during dev (Nitro reads process.env, not vite's envDir). @@ -3889,6 +3916,8 @@ function createAgentNativeConfig( options.clientCompatibilityVersion?.trim() || "", ), __AGENT_NATIVE_APP_CONFIG__: JSON.stringify(resolvedAppConfig), + "process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX": + JSON.stringify(frameworkRoutePrefix), __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__: JSON.stringify( process.env.GA_MEASUREMENT_ID?.trim() || "", ), From 1a9c9491d2a65bf003e6f4b6d3af6a3a16927011 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Thu, 10 Sep 2026 17:42:38 +0200 Subject: [PATCH 03/11] fix(core): public prefix for workspace OAuth relay and mount derivation Address review findings on #4482: - The workspace OAuth callback relay returned the internal callback path. The gateway routes only the public prefix, so the provider redirect would have landed on a retired name. Translate the relay path too. - Deriving the app mount from the live URL matched the prefix as a substring, so `/docs/_platform-settings` read as a framework path. Accept the marker only as a whole segment. - Cover a similarly named app route in `agentNativePath()`: the swap is segment-aware and leaves `/_agent-native-extra/...` alone. --- packages/core/src/client/api-path.spec.ts | 40 +++++++++++++++++++++++ packages/core/src/client/api-path.ts | 11 +++++-- packages/core/src/client/frame.ts | 9 +++-- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/core/src/client/api-path.spec.ts b/packages/core/src/client/api-path.spec.ts index 27688c9b2b0..33513ad46d1 100644 --- a/packages/core/src/client/api-path.spec.ts +++ b/packages/core/src/client/api-path.spec.ts @@ -398,6 +398,46 @@ describe("configurable framework route prefix", () => { expect(appBasePath()).toBe("/docs"); }); + it("ignores a route that merely contains the prefix text", () => { + vi.stubGlobal("__AGENT_NATIVE_APP_CONFIG__", { + runtime: { frameworkRoutePrefix: "/_platform" }, + }); + vi.stubGlobal("window", { + location: { pathname: "/docs/_platform-settings" }, + }); + expect(appBasePath()).toBe(""); + vi.stubGlobal("window", { + location: { pathname: "/docs/_platform" }, + }); + expect(appBasePath()).toBe("/docs"); + }); + + it("leaves a similarly named app route alone", () => { + vi.stubGlobal("__AGENT_NATIVE_APP_CONFIG__", { + runtime: { frameworkRoutePrefix: "/_platform" }, + }); + vi.stubGlobal("window", { location: { pathname: "/" } }); + expect(agentNativePath("/_agent-native-extra/settings")).toBe( + "/_agent-native-extra/settings", + ); + }); + + it("issues workspace relay callbacks under the public prefix", () => { + vi.stubGlobal("__AGENT_NATIVE_APP_CONFIG__", { + runtime: { frameworkRoutePrefix: "/_platform" }, + }); + vi.stubGlobal("window", { + location: { pathname: "/mail/settings", origin: "https://ws.example" }, + __AGENT_NATIVE_CONFIG__: { + workspaceRuntime: true, + workspaceOAuthOrigin: "https://ws.example", + }, + }); + expect(oauthRedirectUri("/_agent-native/google/callback")).toBe( + "https://ws.example/_platform/google/callback", + ); + }); + it("falls back to the projected shell config and rejects a malformed one", () => { vi.stubGlobal("window", { location: { pathname: "/" }, diff --git a/packages/core/src/client/api-path.ts b/packages/core/src/client/api-path.ts index 5bc39aae864..76caeb100f6 100644 --- a/packages/core/src/client/api-path.ts +++ b/packages/core/src/client/api-path.ts @@ -82,12 +82,19 @@ function clientEnv(): Record | undefined { function frameworkMarkerIndex(pathname: string): number { // The live URL carries the public prefix; the internal name is still // accepted because a default deployment's public prefix IS the internal one. + // Only a whole segment counts: `/docs/_platform-settings` is an app route. for (const marker of [ frameworkRoutePrefix(), FRAMEWORK_INTERNAL_ROUTE_PREFIX, ]) { - const index = pathname.indexOf(marker); - if (index > 0) return index; + for ( + let index = pathname.indexOf(marker); + index > 0; + index = pathname.indexOf(marker, index + 1) + ) { + const end = index + marker.length; + if (end === pathname.length || pathname[end] === "/") return index; + } } return -1; } diff --git a/packages/core/src/client/frame.ts b/packages/core/src/client/frame.ts index ab88267c040..eb219a9dc7e 100644 --- a/packages/core/src/client/frame.ts +++ b/packages/core/src/client/frame.ts @@ -1,5 +1,6 @@ +import { toPublicFrameworkPath } from "../shared/framework-route-prefix.js"; import { isTruthyRuntimeValue } from "../shared/runtime-config.js"; -import { isFrameworkRoutePath } from "./api-path.js"; +import { frameworkRoutePrefix, isFrameworkRoutePath } from "./api-path.js"; import { agentNativePath } from "./api-path.js"; /** @@ -272,8 +273,12 @@ export function oauthRedirectUri(callbackPath: string): string { const normalized = callbackPath.startsWith("/") ? callbackPath : `/${callbackPath}`; + // The relay skips the app mount but still speaks the deployment's public + // namespace: the gateway routes only the configured prefix. const path = shouldUseWorkspaceCallbackRelay(normalized) - ? normalized + ? toPublicFrameworkPath(normalized, { + publicPrefix: frameworkRoutePrefix(), + }) : agentNativePath(normalized); const oauthOrigin = shouldUseWorkspaceCallbackRelay(normalized) ? workspaceOAuthOrigin() From 90a84ef2b7431dd0599293a697ef437e77a15d92 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Thu, 10 Sep 2026 18:32:39 +0200 Subject: [PATCH 04/11] fix(core): validate the canonical callback pathname for the OAuth root relay The root-relay exception in isAllowedOAuthRedirectUri still tested the raw public pathname against the internal callback shape, so a workspace app under a custom prefix rejected its own gateway callback. Test the canonical form. --- .../google-oauth-redirect-prefix.spec.ts | 76 +++++++++++++++++++ packages/core/src/server/google-oauth.ts | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/server/google-oauth-redirect-prefix.spec.ts diff --git a/packages/core/src/server/google-oauth-redirect-prefix.spec.ts b/packages/core/src/server/google-oauth-redirect-prefix.spec.ts new file mode 100644 index 00000000000..961c491e82f --- /dev/null +++ b/packages/core/src/server/google-oauth-redirect-prefix.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { isAllowedOAuthRedirectUri } from "./google-oauth.js"; + +function eventFor(pathname: string) { + const url = new URL(`https://gateway.test${pathname}`); + return { + url, + path: pathname, + context: { _mountedPathname: pathname }, + req: new Request(url), + headers: new Headers(), + node: { req: { url: pathname, headers: {} } }, + } as any; +} + +describe("isAllowedOAuthRedirectUri under a custom framework route prefix", () => { + afterEach(() => { + delete process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX; + delete process.env.APP_BASE_PATH; + delete process.env.VITE_APP_BASE_PATH; + }); + + it("accepts the public form of a mounted callback and the root relay callback", () => { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + "/_platform"; + process.env.APP_BASE_PATH = "/calendar"; + const event = eventFor("/calendar/_agent-native/google/auth-url"); + const origin = "https://gateway.test"; + + expect( + isAllowedOAuthRedirectUri( + "https://gateway.test/calendar/_platform/google/callback", + event, + origin, + ), + ).toBe(true); + expect( + isAllowedOAuthRedirectUri( + "https://gateway.test/_platform/google/callback", + event, + origin, + { allowRootCallback: true }, + ), + ).toBe(true); + expect( + isAllowedOAuthRedirectUri( + "https://gateway.test/_platform/google/callback", + event, + origin, + ), + ).toBe(false); + }); + + it("rejects the retired internal form and unrelated paths", () => { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + "/_platform"; + const event = eventFor("/_agent-native/google/auth-url"); + const origin = "https://gateway.test"; + expect( + isAllowedOAuthRedirectUri( + "https://gateway.test/_agent-native/google/callback", + event, + origin, + { allowRootCallback: true }, + ), + ).toBe(false); + expect( + isAllowedOAuthRedirectUri( + "https://gateway.test/_platform-extra/google/callback", + event, + origin, + ), + ).toBe(false); + }); +}); diff --git a/packages/core/src/server/google-oauth.ts b/packages/core/src/server/google-oauth.ts index 7085e611c86..0ad67856b0a 100644 --- a/packages/core/src/server/google-oauth.ts +++ b/packages/core/src/server/google-oauth.ts @@ -354,7 +354,7 @@ export function isAllowedOAuthRedirectUri( `${basePath}/_agent-native/`, ...((isWorkspaceOAuthCallbackRelayEnabled() || options.allowRootCallback) && - isFrameworkOAuthCallbackPath(url.pathname) + isFrameworkOAuthCallbackPath(pathname) ? ["/_agent-native/"] : []), ] From 5e0fddd64d549c17f417fb18d093392fcbd65173 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Thu, 10 Sep 2026 18:50:36 +0200 Subject: [PATCH 05/11] fix(deploy): cover the mounted framework namespace in the Netlify no-store rule With APP_BASE_PATH set, framework routes are served at the mount as well; emit the no-store block for both the root and the mounted public prefix. --- .../src/deploy/netlify-static-headers.spec.ts | 9 +++++++ .../core/src/deploy/netlify-static-headers.ts | 24 ++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/core/src/deploy/netlify-static-headers.spec.ts b/packages/core/src/deploy/netlify-static-headers.spec.ts index 5f38c710c8e..64e41bd99ff 100644 --- a/packages/core/src/deploy/netlify-static-headers.spec.ts +++ b/packages/core/src/deploy/netlify-static-headers.spec.ts @@ -17,6 +17,15 @@ describe("renderNetlifyStaticHeaders", () => { expect(rendered).not.toContain("/_agent-native/*"); }); + it("also covers the mounted namespace under APP_BASE_PATH", () => { + const rendered = renderNetlifyStaticHeaders({ + AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX: "/_platform", + APP_BASE_PATH: "/docs", + }); + expect(rendered).toContain("/_platform/*"); + expect(rendered).toContain("/docs/_platform/*"); + }); + it("refuses a malformed deployment prefix", () => { expect(() => renderNetlifyStaticHeaders({ diff --git a/packages/core/src/deploy/netlify-static-headers.ts b/packages/core/src/deploy/netlify-static-headers.ts index 32aa6dd3766..120178b009b 100644 --- a/packages/core/src/deploy/netlify-static-headers.ts +++ b/packages/core/src/deploy/netlify-static-headers.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; +import { normalizeAppBasePath } from "../server/app-base-path.js"; import { resolveSsrCacheHeaders } from "../shared/cache-control.js"; import { normalizeFrameworkRoutePrefix } from "../shared/framework-route-prefix.js"; import { IMMUTABLE_ASSET_CACHE_CONTROL } from "./immutable-assets.js"; @@ -20,6 +21,24 @@ function renderHeaderBlock(pathname: string, headers: HeaderEntries): string { ].join("\n"); } +/** + * The public framework namespace, at the root and — when the app is mounted + * under `APP_BASE_PATH` — at the mount, so authenticated framework responses + * never inherit the cached-shell headers on either path. + */ +function frameworkRoutePatterns( + env: Record, +): string[] { + const prefix = normalizeFrameworkRoutePrefix( + env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX?.trim() || undefined, + "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX", + ); + const basePath = normalizeAppBasePath( + env.VITE_APP_BASE_PATH || env.APP_BASE_PATH, + ); + return basePath ? [`${prefix}/*`, `${basePath}${prefix}/*`] : [`${prefix}/*`]; +} + export function renderNetlifyStaticHeaders( env: Record = process.env, ): string { @@ -45,9 +64,8 @@ export function renderNetlifyStaticHeaders( GENERATED_NETLIFY_HEADERS_MARKER, renderHeaderBlock("/*", ssrHeaderEntries), renderHeaderBlock("/assets/*", immutableAssetHeaders), - renderHeaderBlock( - `${normalizeFrameworkRoutePrefix(env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX?.trim() || undefined, "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX")}/*`, - internalHeaders, + ...frameworkRoutePatterns(env).map((pattern) => + renderHeaderBlock(pattern, internalHeaders), ), ].join("\n\n") + "\n" ); From 7cca2fd2e9215ce47da8cb43de53038cbb7f6212 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Fri, 18 Sep 2026 08:14:18 +0200 Subject: [PATCH 06/11] test(core): release the SSE body the route-prefix integration test opens On `main` the boundary hands the `/events` handler's `ReadableStream` back directly rather than wrapped in a `Response`, so the test's `stream instanceof Response` cancel never ran. The heartbeat interval the stream installs then kept the vitest process alive after the assertions passed. Cancel whichever of the two shapes comes back. --- .../src/server/framework-route-prefix.integration.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/server/framework-route-prefix.integration.spec.ts b/packages/core/src/server/framework-route-prefix.integration.spec.ts index 9ef00d1a236..a5ab79f8c0f 100644 --- a/packages/core/src/server/framework-route-prefix.integration.spec.ts +++ b/packages/core/src/server/framework-route-prefix.integration.spec.ts @@ -232,7 +232,10 @@ describe("public framework route prefix through the real request boundary", () = status: 200, contentType: expect.stringContaining("text/event-stream"), }); - if (stream instanceof Response) await stream.body?.cancel(); + // The stream has to be released either way: its heartbeat interval keeps + // the process alive until the reader goes away. + const streamBody = stream instanceof Response ? stream.body : stream; + if (streamBody instanceof ReadableStream) await streamBody.cancel(); await corePluginDone; }); From a0170497adcfa93ee05c1a206842aefe3c6699f8 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Fri, 18 Sep 2026 07:39:51 +0200 Subject: [PATCH 07/11] fix(core): thread the public prefix through main's new construction sites Two framework URLs arrived on `main` after this branch was opened, so they never went through the merge as conflicts: - `authRoot()` builds the Better Auth root handed to an enterprise SSO provider, which calls back on it. It has to match Better Auth's base path, which this branch already publishes in public form. - `capabilityCookiePath()` scopes the UI action capability cookie. The browser only replays a cookie on the path it requests, which carries the public prefix. Both are no-ops for a deployment that does not set `runtime.frameworkRoutePrefix`. --- packages/core/src/org/enterprise-auth-handlers.ts | 5 ++++- packages/core/src/server/ui-action-capability.ts | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/org/enterprise-auth-handlers.ts b/packages/core/src/org/enterprise-auth-handlers.ts index a58a094b1f7..873b3324c70 100644 --- a/packages/core/src/org/enterprise-auth-handlers.ts +++ b/packages/core/src/org/enterprise-auth-handlers.ts @@ -12,6 +12,7 @@ import { getConfiguredAppBasePath } from "../server/app-base-path.js"; import { getAppProductionUrl } from "../server/app-url.js"; import { getSession } from "../server/auth.js"; import { getBetterAuth } from "../server/better-auth-instance.js"; +import { publicFrameworkPath } from "../server/framework-route-prefix.js"; import { readBody } from "../server/h3-helpers.js"; import { getOrgContext } from "./context.js"; @@ -218,7 +219,9 @@ async function verifyDomainForFrameworkProvider( function authRoot(event: H3Event): string { const origin = getAppProductionUrl(event).replace(/\/$/, ""); const basePath = getConfiguredAppBasePath(); - return `${origin}${basePath}/_agent-native/auth/ba`; + // The SSO provider is handed this root and calls back on it, so it must + // carry the deployment's public framework prefix. + return publicFrameworkPath(`${origin}${basePath}/_agent-native/auth/ba`); } function providerIdFromEvent(event: H3Event): string { diff --git a/packages/core/src/server/ui-action-capability.ts b/packages/core/src/server/ui-action-capability.ts index 62f96e47ad6..a4320590041 100644 --- a/packages/core/src/server/ui-action-capability.ts +++ b/packages/core/src/server/ui-action-capability.ts @@ -15,6 +15,7 @@ import { } from "./app-base-path.js"; import { getSession } from "./auth.js"; import { getH3App } from "./framework-request-handler.js"; +import { publicFrameworkPath } from "./framework-route-prefix.js"; import { signShortLivedToken, verifyShortLivedToken, @@ -39,7 +40,11 @@ function isHttpsRequest(event: H3Event): boolean { } function capabilityCookiePath(appBasePath?: string): string { - return `${normalizeAppBasePath(appBasePath ?? getConfiguredAppBasePath())}/_agent-native/actions`; + // The browser only replays this cookie on the URL it actually requests, + // which carries the public prefix. + return publicFrameworkPath( + `${normalizeAppBasePath(appBasePath ?? getConfiguredAppBasePath())}/_agent-native/actions`, + ); } /** Verify the server-minted browser capability for an authenticated owner. */ From f9168f5ee23088387c0d8789bc78246ffdc405a4 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sun, 20 Sep 2026 16:34:26 -0700 Subject: [PATCH 08/11] fix(core): finish framework route prefix review feedback --- .../core/docs/content/agent-native-config.mdx | 32 +++++++++---------- packages/core/src/client/api-path.spec.ts | 1 + packages/core/src/client/api-path.ts | 6 +++- packages/core/src/server/auth.spec.ts | 8 +++-- packages/core/src/server/auth.ts | 4 +-- .../core/src/server/better-auth-instance.ts | 9 ++---- packages/core/src/server/identity-sso.spec.ts | 27 +++++++++++++++- packages/core/src/server/identity-sso.ts | 11 +++++-- 8 files changed, 65 insertions(+), 33 deletions(-) diff --git a/packages/core/docs/content/agent-native-config.mdx b/packages/core/docs/content/agent-native-config.mdx index 1a4c168d423..71939ac2594 100644 --- a/packages/core/docs/content/agent-native-config.mdx +++ b/packages/core/docs/content/agent-native-config.mdx @@ -94,23 +94,23 @@ recommended form. These are the shared options read from the typed config and the shared configuration portion of `agent-native.json`: -| Option | Type | Effect | -| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `version` | `1` | Optional schema version. If provided, it must be `1`. | -| `onboarding.firstRun` | `"off"`, `"connect"`, `"connect-and-integrations"`, or a mode map | Chooses the first-run Agent Sidebar setup. `off` hides it. `connect` shows Builder/BYOK setup without the generic integrations catalog. `connect-and-integrations` includes that catalog. | -| `runtime.auth.enabled` | `boolean` | Declares whether the app expects the framework or a custom authentication layer. | -| `runtime.database.required` | `boolean` | Declares whether production needs a persistent remote PostgreSQL database. | -| `runtime.environment.required` | `string[]` | Declares additional required environment variable names. Names must match `[A-Za-z_][A-Za-z0-9_]*`; values do not belong in the config. | +| Option | Type | Effect | +| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | `1` | Optional schema version. If provided, it must be `1`. | +| `onboarding.firstRun` | `"off"`, `"connect"`, `"connect-and-integrations"`, or a mode map | Chooses the first-run Agent Sidebar setup. `off` hides it. `connect` shows Builder/BYOK setup without the generic integrations catalog. `connect-and-integrations` includes that catalog. | +| `runtime.auth.enabled` | `boolean` | Declares whether the app expects the framework or a custom authentication layer. | +| `runtime.database.required` | `boolean` | Declares whether production needs a persistent remote PostgreSQL database. | +| `runtime.environment.required` | `string[]` | Declares additional required environment variable names. Names must match `[A-Za-z_][A-Za-z0-9_]*`; values do not belong in the config. | | `runtime.frameworkRoutePrefix` | `string` | The public URL namespace the deployment serves framework routes under. Defaults to `/_agent-native`. One absolute segment of letters, digits, `_`, or `-`; `/api`, `/mcp`, and `/.well-known` are refused. See [Framework route prefix](#framework-route-prefix). | -| `deployment.environment` | `"local"`, `"beta"`, `"production"`, or `"preview"` | Records the release lane that produced the current client bundle. | -| `deployment.workspace.appsDirectory` | `string` | Relative directory containing app packages for a workspace deploy. Defaults to `apps/`; set `"."` for a workspace whose apps live at its root. | -| `deployment.workspace.authMode` | `"shared"` or `"isolated"` | Controls whether apps mounted by a workspace deploy share auth. Defaults to `"shared"`; `"isolated"` keeps one login per app. | -| `diagnostics.failOnBuild` | `boolean` | When `true`, a production Vite build throws on runtime configuration issues. When absent or `false`, it reports them without failing the build. | -| `instructions.runtime` | `string` | Optional relative Markdown path for the in-app runtime agent. Defaults to `AGENTS.md`. | -| `instructions.development` | `string` | Optional relative Markdown path for development/coding agents. Defaults to `AGENTS.md`. | -| `translations.locales` | `string[]` | Lists the locales the app intentionally ships. `en-US` remains the source locale. | -| `changelog.enabled` | `boolean` | Enables the app changelog workflow and lets agents create user-facing entries. | -| `harness` | `boolean` or `{ runtimes: string[] }` | Enables the hosted tools-only harness, optionally narrowed to `claude-code`, `codex`, `pi`, or `opencode`. See [Harness Agents](/docs/harness-agents). | +| `deployment.environment` | `"local"`, `"beta"`, `"production"`, or `"preview"` | Records the release lane that produced the current client bundle. | +| `deployment.workspace.appsDirectory` | `string` | Relative directory containing app packages for a workspace deploy. Defaults to `apps/`; set `"."` for a workspace whose apps live at its root. | +| `deployment.workspace.authMode` | `"shared"` or `"isolated"` | Controls whether apps mounted by a workspace deploy share auth. Defaults to `"shared"`; `"isolated"` keeps one login per app. | +| `diagnostics.failOnBuild` | `boolean` | When `true`, a production Vite build throws on runtime configuration issues. When absent or `false`, it reports them without failing the build. | +| `instructions.runtime` | `string` | Optional relative Markdown path for the in-app runtime agent. Defaults to `AGENTS.md`. | +| `instructions.development` | `string` | Optional relative Markdown path for development/coding agents. Defaults to `AGENTS.md`. | +| `translations.locales` | `string[]` | Lists the locales the app intentionally ships. `en-US` remains the source locale. | +| `changelog.enabled` | `boolean` | Enables the app changelog workflow and lets agents create user-facing entries. | +| `harness` | `boolean` or `{ runtimes: string[] }` | Enables the hosted tools-only harness, optionally narrowed to `claude-code`, `codex`, `pi`, or `opencode`. See [Harness Agents](/docs/harness-agents). | ## Deterministic environment aliases diff --git a/packages/core/src/client/api-path.spec.ts b/packages/core/src/client/api-path.spec.ts index 33513ad46d1..ceecc45f8bc 100644 --- a/packages/core/src/client/api-path.spec.ts +++ b/packages/core/src/client/api-path.spec.ts @@ -416,6 +416,7 @@ describe("configurable framework route prefix", () => { vi.stubGlobal("__AGENT_NATIVE_APP_CONFIG__", { runtime: { frameworkRoutePrefix: "/_platform" }, }); + vi.stubEnv("VITE_APP_BASE_PATH", "/docs"); vi.stubGlobal("window", { location: { pathname: "/" } }); expect(agentNativePath("/_agent-native-extra/settings")).toBe( "/_agent-native-extra/settings", diff --git a/packages/core/src/client/api-path.ts b/packages/core/src/client/api-path.ts index 76caeb100f6..440216ae45f 100644 --- a/packages/core/src/client/api-path.ts +++ b/packages/core/src/client/api-path.ts @@ -326,7 +326,11 @@ export function appApiPath(path: string): string { * which is also what keeps an already-mounted URL from being prefixed twice. */ export function agentNativePath(path: string): string { - if (!path.startsWith(FRAMEWORK_INTERNAL_ROUTE_PREFIX)) return path; + const queryOrFragment = path.search(/[?#]/); + const pathname = + queryOrFragment === -1 ? path : path.slice(0, queryOrFragment); + if (!matchesPathPrefix(pathname, FRAMEWORK_INTERNAL_ROUTE_PREFIX)) + return path; return appPath( toPublicFrameworkPath(path, { publicPrefix: frameworkRoutePrefix() }), ); diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts index f22ad3902e3..33d47e666ff 100644 --- a/packages/core/src/server/auth.spec.ts +++ b/packages/core/src/server/auth.spec.ts @@ -5602,9 +5602,13 @@ describe("server/auth", () => { }); }); - it("strips APP_BASE_PATH before forwarding requests to Better Auth", async () => { + it("preserves APP_BASE_PATH and the public prefix for Better Auth", async () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("APP_BASE_PATH", "/docs"); + vi.stubEnv( + "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX", + "/_platform", + ); delete process.env.ACCESS_TOKEN; delete process.env.ACCESS_TOKENS; @@ -5665,7 +5669,7 @@ describe("server/auth", () => { await baHandler(event); - expect(forwardedPath).toBe("/_agent-native/auth/ba/sign-in/email"); + expect(forwardedPath).toBe("/docs/_platform/auth/ba/sign-in/email"); expect(event.res.headers.get("set-cookie")).toContain( "agent-native-first-run=1", ); diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index dadd15ff65b..af6e6e477f6 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -62,9 +62,7 @@ function toWebRequest(event: H3Event): Request { // Better Auth is configured with the PUBLIC base path (it builds its // own callback and verification URLs from it), so hand it the public // form of the internal pathname the boundary dispatched on. - const mountedPathname = publicFrameworkPath( - stripAppBasePath(ctx._mountedPathname), - ); + const mountedPathname = publicFrameworkPath(ctx._mountedPathname); if (url.pathname !== mountedPathname) { url.pathname = mountedPathname; const method = req.method.toUpperCase(); diff --git a/packages/core/src/server/better-auth-instance.ts b/packages/core/src/server/better-auth-instance.ts index daf189d6b8a..c5dd32ed877 100644 --- a/packages/core/src/server/better-auth-instance.ts +++ b/packages/core/src/server/better-auth-instance.ts @@ -2149,7 +2149,7 @@ async function createBetterAuthInstance( // must be the PUBLIC one. The framework still mounts the handler on the // internal path and passes Better Auth a request in public form. const basePath = publicFrameworkPath( - config?.basePath ?? "/_agent-native/auth/ba", + `${getConfiguredAppBasePath()}${config?.basePath ?? "/_agent-native/auth/ba"}`, ); const access = getAppConfig().access; @@ -2334,12 +2334,7 @@ async function createBetterAuthInstance( urlQueryKeys, }); } - const appBasePath = getConfiguredAppBasePath(); - const magicLinkUrl = appBasePath - ? url.replace(/(\/\/[^/]+)(\/)/, `$1${appBasePath}$2`) - : url; - const deliveredMagicLinkUrl = - desktopMagicLinkLandingUrl(magicLinkUrl) ?? magicLinkUrl; + const deliveredMagicLinkUrl = desktopMagicLinkLandingUrl(url) ?? url; const { subject, html, text, appSender } = renderMagicLinkEmail({ email, magicLinkUrl: deliveredMagicLinkUrl, diff --git a/packages/core/src/server/identity-sso.spec.ts b/packages/core/src/server/identity-sso.spec.ts index 786c17d9bd0..69f83a0dc0f 100644 --- a/packages/core/src/server/identity-sso.spec.ts +++ b/packages/core/src/server/identity-sso.spec.ts @@ -79,9 +79,11 @@ vi.mock("h3", () => ({ getHeader: (event: any, name: string) => event.headers?.[name.toLowerCase()] ?? event.headers?.[name], getMethod: (event: any) => event.method ?? "GET", - setCookie: (event: any, name: string, value: string) => { + setCookie: (event: any, name: string, value: string, options?: any) => { event.cookies ??= {}; event.cookies[name] = value; + event.cookieOptions ??= {}; + event.cookieOptions[name] = options; }, })); @@ -437,6 +439,29 @@ describe("identity SSO browser contract", () => { expect(location.searchParams.has("id_token")).toBe(false); }); + it("keeps the mounted public prefix on the PKCE cookie and callback", async () => { + vi.stubEnv("APP_BASE_PATH", "/mail"); + vi.stubEnv( + "AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX", + "/_platform", + ); + + const query = new URLSearchParams({ return: "/inbox" }); + const loginEvent = event(`/mail/_agent-native/identity/login?${query}`); + const response = await handleIdentitySso(loginEvent, "/login"); + const location = new URL(response.headers.get("Location")!); + const verifierCookie = Object.keys(loginEvent.cookies).find((name) => + name.startsWith("agent_native_sso_verifier_"), + )!; + + expect(location.searchParams.get("redirect_uri")).toBe( + "https://mail.agent-native.com/mail/_platform/identity/callback", + ); + expect(loginEvent.cookieOptions[verifierCookie].path).toBe( + "/mail/_platform/identity/callback", + ); + }); + it("uses a source-origin bridge when the configured hub is on another site", async () => { vi.stubEnv( "AGENT_NATIVE_IDENTITY_HUB_URL", diff --git a/packages/core/src/server/identity-sso.ts b/packages/core/src/server/identity-sso.ts index d761f37b139..e4dc63dd600 100644 --- a/packages/core/src/server/identity-sso.ts +++ b/packages/core/src/server/identity-sso.ts @@ -34,6 +34,7 @@ import { normalizeAppPath, SIGN_IN_ENTRY_PATH, } from "../shared/sign-in-journey.js"; +import { getConfiguredAppBasePath } from "./app-base-path.js"; import { addSignupAttributionHeader, signupAttributionContextFromCookieHeader, @@ -205,6 +206,10 @@ function createPkceChallenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } +function publicIdentitySsoPath(path: string): string { + return publicFrameworkPath(`${getConfiguredAppBasePath()}${path}`); +} + function requestUrl(event: H3Event): string { return (event as any).node?.req?.url ?? event.path ?? "/"; } @@ -218,7 +223,7 @@ function setPkceVerifierCookie( setCookie(event, verifierCookieName(state), verifier, { httpOnly: true, maxAge: Math.floor(SSO_STATE_TTL_MS / 1_000), - path: publicFrameworkPath(IDENTITY_SSO_CALLBACK_PATH), + path: publicIdentitySsoPath(IDENTITY_SSO_CALLBACK_PATH), sameSite: "lax", secure, }); @@ -365,7 +370,7 @@ function addBridgeParams(url: string, sourceOrigin: string): string { function clearPkceVerifierCookie(event: H3Event, state: string): void { deleteCookie(event, verifierCookieName(state), { - path: publicFrameworkPath(IDENTITY_SSO_CALLBACK_PATH), + path: publicIdentitySsoPath(IDENTITY_SSO_CALLBACK_PATH), }); } @@ -396,7 +401,7 @@ function resolveClientBinding( ): SsoClientBinding | null { const appId = resolveIdentitySsoAppId(event); const clientId = resolveClientId(appId); - const redirectUri = `${resolveIdentitySsoClientOrigin(event)}${publicFrameworkPath(IDENTITY_SSO_CALLBACK_PATH)}`; + const redirectUri = `${resolveIdentitySsoClientOrigin(event)}${publicIdentitySsoPath(IDENTITY_SSO_CALLBACK_PATH)}`; const authority = normalizeAuthority(hub); if (!authority || !appId || !clientId || !redirectUri) return null; return { appId, clientId, redirectUri, authority }; From bb8bb7473e1909fe49812b8d14d8d4b82ce7b3cf Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sun, 20 Sep 2026 16:45:01 -0700 Subject: [PATCH 09/11] fix(core): defer action URL configuration --- packages/core/src/client/use-action.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/client/use-action.ts b/packages/core/src/client/use-action.ts index fcdb13f1508..a0a55c4ef18 100644 --- a/packages/core/src/client/use-action.ts +++ b/packages/core/src/client/use-action.ts @@ -45,7 +45,9 @@ import { import { ensureEmbedAuthFetchInterceptor } from "./embed-auth.js"; import { recheckSessionAfterUnauthorized } from "./use-session.js"; -const ACTION_PREFIX = agentNativePath("/_agent-native/actions"); +function actionPrefix(): string { + return agentNativePath("/_agent-native/actions"); +} /** * Upper bound on how long a single action fetch may stay in flight (headers @@ -351,7 +353,7 @@ async function performActionFetch( options?: InternalActionFetchOptions, ): Promise { ensureEmbedAuthFetchInterceptor(); - let url = `${ACTION_PREFIX}/${name}`; + let url = `${actionPrefix()}/${name}`; const browserTabId = getBrowserTabId(); const headers: Record = { "Content-Type": "application/json", From 94a4ca5675c3ce0d5b220d23015edc50f9458d19 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sun, 20 Sep 2026 16:52:25 -0700 Subject: [PATCH 10/11] fix(core): preserve mounted public auth paths --- packages/core/src/server/auth.spec.ts | 5 +- packages/core/src/server/auth.ts | 9 +++- .../google-oauth-redirect-prefix.spec.ts | 48 ++++++++++++++++--- packages/core/src/server/google-oauth.ts | 13 +++-- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts index 33d47e666ff..1b93539d565 100644 --- a/packages/core/src/server/auth.spec.ts +++ b/packages/core/src/server/auth.spec.ts @@ -5661,8 +5661,9 @@ describe("server/auth", () => { }, headers: request.headers, context: { - _mountedPathname: fullPath, - _mountPrefix: "/docs/_agent-native/auth/ba", + _mountedPathname: "/_agent-native/auth/ba/sign-in/email", + _frameworkPublicPathname: "/docs/_platform/auth/ba/sign-in/email", + _mountPrefix: "/_agent-native/auth/ba", }, path: "/sign-in/email", }; diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index af6e6e477f6..2f9861386d4 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -36,7 +36,10 @@ import { requestHasEmbedAuthMarker, resolveEmbedSessionFromRequest, } from "./embed-session.js"; -import type { H3AppShim } from "./framework-request-handler.js"; +import { + getPublicFrameworkPathname, + type H3AppShim, +} from "./framework-request-handler.js"; import { canonicalFrameworkPathname, getFrameworkRoutePrefix, @@ -62,7 +65,9 @@ function toWebRequest(event: H3Event): Request { // Better Auth is configured with the PUBLIC base path (it builds its // own callback and verification URLs from it), so hand it the public // form of the internal pathname the boundary dispatched on. - const mountedPathname = publicFrameworkPath(ctx._mountedPathname); + const mountedPathname = + getPublicFrameworkPathname(event) ?? + publicFrameworkPath(ctx._mountedPathname); if (url.pathname !== mountedPathname) { url.pathname = mountedPathname; const method = req.method.toUpperCase(); diff --git a/packages/core/src/server/google-oauth-redirect-prefix.spec.ts b/packages/core/src/server/google-oauth-redirect-prefix.spec.ts index 961c491e82f..68cf2d1329c 100644 --- a/packages/core/src/server/google-oauth-redirect-prefix.spec.ts +++ b/packages/core/src/server/google-oauth-redirect-prefix.spec.ts @@ -1,16 +1,31 @@ import { afterEach, describe, expect, it } from "vitest"; -import { isAllowedOAuthRedirectUri } from "./google-oauth.js"; +import { + isAllowedOAuthRedirectUri, + resolveOAuthRedirectUri, +} from "./google-oauth.js"; -function eventFor(pathname: string) { +function eventFor(pathname: string, publicPathname?: string) { const url = new URL(`https://gateway.test${pathname}`); + const headers = new Headers({ + host: "gateway.test", + "x-forwarded-proto": "https", + }); return { url, path: pathname, - context: { _mountedPathname: pathname }, - req: new Request(url), - headers: new Headers(), - node: { req: { url: pathname, headers: {} } }, + context: { + _mountedPathname: pathname, + ...(publicPathname ? { _frameworkPublicPathname: publicPathname } : {}), + }, + req: new Request(url, { headers }), + headers, + node: { + req: { + url: pathname, + headers: { host: "gateway.test", "x-forwarded-proto": "https" }, + }, + }, } as any; } @@ -52,6 +67,27 @@ describe("isAllowedOAuthRedirectUri under a custom framework route prefix", () = ).toBe(false); }); + it("keeps the app mount when the boundary preserves the public pathname", () => { + process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = + "/_platform"; + process.env.APP_BASE_PATH = "/calendar"; + const event = eventFor( + "/_agent-native/google/auth-url", + "/calendar/_platform/google/auth-url", + ); + + expect( + isAllowedOAuthRedirectUri( + "https://gateway.test/calendar/_platform/google/callback", + event, + "https://gateway.test", + ), + ).toBe(true); + expect(resolveOAuthRedirectUri(event)).toBe( + "https://gateway.test/calendar/_platform/google/callback", + ); + }); + it("rejects the retired internal form and unrelated paths", () => { process.env.AGENT_NATIVE_CONFIG_RUNTIME_FRAMEWORK_ROUTE_PREFIX = "/_platform"; diff --git a/packages/core/src/server/google-oauth.ts b/packages/core/src/server/google-oauth.ts index 3582b8420c5..198ddb3134e 100644 --- a/packages/core/src/server/google-oauth.ts +++ b/packages/core/src/server/google-oauth.ts @@ -32,6 +32,7 @@ import { } from "./better-auth-instance.js"; import { getWorkspaceA2ADerivedSecret } from "./derived-secret.js"; import { writeDesktopSso } from "./desktop-sso.js"; +import { getPublicFrameworkPathname } from "./framework-request-handler.js"; import { canonicalFrameworkPathname, isRetiredInternalFrameworkPath, @@ -422,6 +423,9 @@ function isFrameworkOAuthCallbackPath(pathname: string): boolean { } function getOriginalRequestPath(event: H3Event): string { + const publicPathname = getPublicFrameworkPathname(event); + if (publicPathname) return publicPathname; + const mountedPathname = (event as any).context?._mountedPathname; if (typeof mountedPathname === "string" && mountedPathname) { return mountedPathname; @@ -449,9 +453,12 @@ function isRequestUnderAppBasePath(event: H3Event): boolean { const basePath = getAppBasePath(); if (!basePath) return false; const requestPath = getOriginalRequestPath(event); - return ( - requestPath === `${basePath}/_agent-native` || - requestPath.startsWith(`${basePath}/_agent-native/`) + const frameworkPrefixes = [ + `${basePath}/_agent-native`, + `${basePath}${publicFrameworkPath("/_agent-native")}`, + ]; + return frameworkPrefixes.some( + (prefix) => requestPath === prefix || requestPath.startsWith(`${prefix}/`), ); } From d8470d38ecd40092dc07b01cd95a6d652eae7722 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sun, 20 Sep 2026 17:15:17 -0700 Subject: [PATCH 11/11] fix(core): keep deferred client bootstrap test-safe --- packages/core/src/client/api-path.ts | 11 ++++++++-- packages/core/src/client/embed-auth.ts | 21 ++++++++++++++++--- .../core/src/client/route-chunk-recovery.ts | 6 ++++-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/core/src/client/api-path.ts b/packages/core/src/client/api-path.ts index 440216ae45f..592e62033e9 100644 --- a/packages/core/src/client/api-path.ts +++ b/packages/core/src/client/api-path.ts @@ -107,7 +107,12 @@ function isFrameworkSegment(segment: string): boolean { } function pathDerivedBasePath(): string { - if (typeof window === "undefined") return ""; + if ( + typeof window === "undefined" || + typeof window.location?.pathname !== "string" + ) { + return ""; + } const pathname = window.location.pathname; const markerIndex = frameworkMarkerIndex(pathname); if (markerIndex <= 0) return ""; @@ -137,7 +142,9 @@ function isWorkspaceRuntime(): boolean { function workspacePathBasePath(): string { if (typeof window === "undefined" || !isWorkspaceRuntime()) return ""; - const segment = window.location.pathname.split("/").find(Boolean); + const pathname = window.location?.pathname; + if (typeof pathname !== "string") return ""; + const segment = pathname.split("/").find(Boolean); if (!segment || isFrameworkSegment(segment) || segment === "api") return ""; const basePath = normalizeBasePath(segment); // Guard against treating an app-local route (e.g. a client-rendered diff --git a/packages/core/src/client/embed-auth.ts b/packages/core/src/client/embed-auth.ts index b4d870e78e3..f2d2a178297 100644 --- a/packages/core/src/client/embed-auth.ts +++ b/packages/core/src/client/embed-auth.ts @@ -573,10 +573,11 @@ export function ensureEmbedAuthFetchInterceptor(): void { if (installed) return; if (typeof win.fetch !== "function") return; - installed = true; - const originalFetch = win.fetch.bind(win); - win.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const patchedFetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { const request = requestUrlAndKey(input, init, win); const embedMode = isEmbedAuthActive(); if (request?.shouldGuard) { @@ -599,4 +600,18 @@ export function ensureEmbedAuthFetchInterceptor(): void { } return response; }) as typeof fetch; + try { + win.fetch = patchedFetch; + } catch { + try { + Object.defineProperty(win, "fetch", { + configurable: true, + value: patchedFetch, + writable: true, + }); + } catch { + return; + } + } + installed = true; } diff --git a/packages/core/src/client/route-chunk-recovery.ts b/packages/core/src/client/route-chunk-recovery.ts index 433e4f6e02a..6e0ea9e14c1 100644 --- a/packages/core/src/client/route-chunk-recovery.ts +++ b/packages/core/src/client/route-chunk-recovery.ts @@ -294,7 +294,9 @@ function patchHistoryMethod( } function patchReload(win: Window, state: RouteChunkRecoveryState): void { - const originalReload = win.location.reload.bind(win.location); + const originalReload = win.location.reload; + if (typeof originalReload !== "function") return; + const boundReload = originalReload.bind(win.location); const patchedReload = function patchedReload() { if (Date.now() - state.routeModuleFailureAt <= 1_000) { // The console hook may already have started the recovery navigation. @@ -318,7 +320,7 @@ function patchReload(win: Window, state: RouteChunkRecoveryState): void { reloadForStaleChunk(win); return; } - originalReload(); + boundReload(); }; try {