From 3c63601c5e9e2c24bd1298b62ce6d52043dfd5c3 Mon Sep 17 00:00:00 2001 From: Manu MA Date: Tue, 18 Aug 2026 11:54:35 +0200 Subject: [PATCH 1/2] feat: add support for disabling default plugins and configuring integration platforms --- .changeset/plugin-and-integration-opt-out.md | 28 ++++++++ docs/environment-variables.md | 46 ++++++------- packages/core/src/app-config/integrations.ts | 10 +++ packages/core/src/app-config/plugins.spec.ts | 56 ++++++++++++++++ packages/core/src/app-config/plugins.ts | 43 ++++++++++++ packages/core/src/app-config/schema.ts | 2 + packages/core/src/deploy/build.ts | 11 +++- .../integrations/adapter-overrides.spec.ts | 36 +++++++++- packages/core/src/integrations/plugin.spec.ts | 25 +++++++ packages/core/src/integrations/plugin.ts | 66 ++++++++++++++++--- packages/core/src/server/default-plugins.ts | 25 +++++++ packages/core/src/server/edge.ts | 4 ++ .../server/framework-request-handler.spec.ts | 16 +++++ .../src/server/framework-request-handler.ts | 11 +++- 14 files changed, 344 insertions(+), 35 deletions(-) create mode 100644 .changeset/plugin-and-integration-opt-out.md create mode 100644 packages/core/src/app-config/plugins.spec.ts create mode 100644 packages/core/src/app-config/plugins.ts create mode 100644 packages/core/src/server/default-plugins.ts diff --git a/.changeset/plugin-and-integration-opt-out.md b/.changeset/plugin-and-integration-opt-out.md new file mode 100644 index 0000000000..4be9bbd631 --- /dev/null +++ b/.changeset/plugin-and-integration-opt-out.md @@ -0,0 +1,28 @@ +--- +"@agent-native/core": minor +--- + +Let a deployment refuse framework default plugins and narrow which integration +platforms mount, without writing a stub plugin file. + +`plugins.disabled` (env `AGENT_NATIVE_DISABLED_PLUGINS`) names default plugin +slots the framework should not auto-mount — the same list that shows up as +`[agent-native] Auto-mounting N default plugin(s)` under `DEBUG`. It is honored +by the runtime bootstrap and by the generated edge worker entry, so a slot is +withheld on every host. An app that ships its own `server/plugins/.ts` is +unaffected. + +`integrations.platforms` (env `AGENT_NATIVE_INTEGRATION_PLATFORMS`) is an +allow-list of platforms for the integrations plugin, matched against each +adapter's `platform` id. Unset mounts every adapter, as before; a name no +adapter provides throws at plugin init rather than silently mounting a set +nobody asked for. + +Both switches withhold registration rather than reject at request time: a +refused slot never runs its plugin, so its routes are absent from the +middleware chain and its background jobs and pollers never start. The +allow-list now also gates the routes mounted under a platform's literal name — +`/slack/interactions`, `/slack/manifest`, and the two Slack OAuth endpoints +previously stayed mounted whatever the adapter set was. They are gated only +when `integrations.platforms` is declared, so a deployment that does not set it +keeps today's behavior. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 9a59496cb9..8cff44a420 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -302,27 +302,29 @@ environment variable is a declared alias for the same field, listed in the order it is consulted; app configuration wins over any of them. Fields with no alias are settable only in code. -| Field | Environment aliases | Type | Default | Description | -| -------------------------------------- | --------------------------------------------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `a2a.allowedOrigins` | `AGENT_NATIVE_A2A_ALLOWED_ORIGINS` | array | `[]` | Comma-separated extra origins trusted as private A2A siblings. | -| `a2a.allowUnsignedInternal` | `A2A_ALLOW_UNSIGNED_INTERNAL` | boolean | `false` | Trust unsigned internal self-dispatch on an unrecognized non-production host. Never grants trust in production. | -| `agent.engine` | `AGENT_ENGINE` | string | — | Name of the registered agent engine to use. | -| `agent.model` | `AGENT_MODEL` | string | — | Model the agent runs with, when the caller does not pass one. | -| `agent.mode` | `AGENT_MODE` | string | — | Runtime mode. "production" turns off development-only agent behavior. | -| `agent.preferBringYourOwnKey` | `AGENT_ENGINE_PREFER_BYO_KEY` | boolean | `false` | Skip the Builder-managed engine and select a directly configured provider key first. | -| `agent.runSoftTimeoutMs` | `AGENT_RUN_SOFT_TIMEOUT_MS` | number | — | Soft timeout for an agent run, in milliseconds. 0 disables it. | -| `agent.completedRunRetentionMs` | `AGENT_RUN_RETENTION_MS` | number | — | How long a completed agent run row is kept, in milliseconds. | -| `agent.erroredRunRetentionMs` | `AGENT_ERRORED_RUN_RETENTION_MS` | number | — | How long an errored agent run row is kept, in milliseconds. | -| `app.id` | `AGENT_NATIVE_APP_ID`, `APP_ID` | string | — | Stable identity of this app deployment. | -| `app.workspaceId` | `AGENT_NATIVE_WORKSPACE_APP_ID`, `VITE_AGENT_NATIVE_WORKSPACE_APP_ID` | string | — | Identity assigned by a workspace deploy. Credential grants are scoped to this. | -| `app.name` | `APP_NAME` | string | — | User-facing display name of this app. | -| `app.url` | `APP_URL`, `VITE_APP_URL`, `BETTER_AUTH_URL`, `VITE_BETTER_AUTH_URL` | string | — | Canonical public URL of this app, used for user-facing links. | -| `app.packageName` | `npm_package_name` | string | — | Package name of the running app, as npm sets it for a script. | -| `app.template` | `VITE_AGENT_NATIVE_TEMPLATE` | string | — | First-party template this app was generated from. | -| `integrations.allowUnverifiedWebhooks` | `AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKS` | boolean | `false` | Skip inbound webhook signature verification. Development only — every adapter that reads this treats it as a bypass of sender authentication. | -| `privateBlob.provider` | — | string | — | Id of the registered private blob provider to use. Unset falls back to the first registered provider that reports itself configured. | -| `privateBlob.publicUploadFallback` | `AGENT_NATIVE_PRIVATE_BLOB_PUBLIC_UPLOAD_FALLBACK` | boolean | `true` | Store private blobs as encrypted objects in public file-upload storage when no private blob provider is configured. | -| `workspace.gatewayUrl` | `WORKSPACE_GATEWAY_URL`, `VITE_WORKSPACE_GATEWAY_URL` | string | — | URL of the workspace gateway fronting this app. | -| `workspace.oauthOrigin` | `WORKSPACE_OAUTH_ORIGIN`, `VITE_WORKSPACE_OAUTH_ORIGIN` | string | — | Shared origin workspace apps complete OAuth against. | +| Field | Environment aliases | Type | Default | Description | +| -------------------------------------- | --------------------------------------------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `a2a.allowedOrigins` | `AGENT_NATIVE_A2A_ALLOWED_ORIGINS` | array | `[]` | Comma-separated extra origins trusted as private A2A siblings. | +| `a2a.allowUnsignedInternal` | `A2A_ALLOW_UNSIGNED_INTERNAL` | boolean | `false` | Trust unsigned internal self-dispatch on an unrecognized non-production host. Never grants trust in production. | +| `agent.engine` | `AGENT_ENGINE` | string | — | Name of the registered agent engine to use. | +| `agent.model` | `AGENT_MODEL` | string | — | Model the agent runs with, when the caller does not pass one. | +| `agent.mode` | `AGENT_MODE` | string | — | Runtime mode. "production" turns off development-only agent behavior. | +| `agent.preferBringYourOwnKey` | `AGENT_ENGINE_PREFER_BYO_KEY` | boolean | `false` | Skip the Builder-managed engine and select a directly configured provider key first. | +| `agent.runSoftTimeoutMs` | `AGENT_RUN_SOFT_TIMEOUT_MS` | number | — | Soft timeout for an agent run, in milliseconds. 0 disables it. | +| `agent.completedRunRetentionMs` | `AGENT_RUN_RETENTION_MS` | number | — | How long a completed agent run row is kept, in milliseconds. | +| `agent.erroredRunRetentionMs` | `AGENT_ERRORED_RUN_RETENTION_MS` | number | — | How long an errored agent run row is kept, in milliseconds. | +| `app.id` | `AGENT_NATIVE_APP_ID`, `APP_ID` | string | — | Stable identity of this app deployment. | +| `app.workspaceId` | `AGENT_NATIVE_WORKSPACE_APP_ID`, `VITE_AGENT_NATIVE_WORKSPACE_APP_ID` | string | — | Identity assigned by a workspace deploy. Credential grants are scoped to this. | +| `app.name` | `APP_NAME` | string | — | User-facing display name of this app. | +| `app.url` | `APP_URL`, `VITE_APP_URL`, `BETTER_AUTH_URL`, `VITE_BETTER_AUTH_URL` | string | — | Canonical public URL of this app, used for user-facing links. | +| `app.packageName` | `npm_package_name` | string | — | Package name of the running app, as npm sets it for a script. | +| `app.template` | `VITE_AGENT_NATIVE_TEMPLATE` | string | — | First-party template this app was generated from. | +| `integrations.allowUnverifiedWebhooks` | `AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKS` | boolean | `false` | Skip inbound webhook signature verification. Development only — every adapter that reads this treats it as a bypass of sender authentication. | +| `integrations.platforms` | `AGENT_NATIVE_INTEGRATION_PLATFORMS` | array | — | Integration platforms to mount, comma-separated, each matched against an adapter's `platform` id (slack, telegram, whatsapp, microsoft-teams, discord, google-docs, email). Unset mounts every adapter; a name no adapter provides throws at plugin init. | +| `plugins.disabled` | `AGENT_NATIVE_DISABLED_PLUGINS` | array | `[]` | Framework default plugins this deployment refuses to auto-mount, comma-separated. A refused slot mounts none of its routes; an app supplying its own `server/plugins/.ts` is unaffected. | +| `privateBlob.provider` | — | string | — | Id of the registered private blob provider to use. Unset falls back to the first registered provider that reports itself configured. | +| `privateBlob.publicUploadFallback` | `AGENT_NATIVE_PRIVATE_BLOB_PUBLIC_UPLOAD_FALLBACK` | boolean | `true` | Store private blobs as encrypted objects in public file-upload storage when no private blob provider is configured. | +| `workspace.gatewayUrl` | `WORKSPACE_GATEWAY_URL`, `VITE_WORKSPACE_GATEWAY_URL` | string | — | URL of the workspace gateway fronting this app. | +| `workspace.oauthOrigin` | `WORKSPACE_OAUTH_ORIGIN`, `VITE_WORKSPACE_OAUTH_ORIGIN` | string | — | Shared origin workspace apps complete OAuth against. | diff --git a/packages/core/src/app-config/integrations.ts b/packages/core/src/app-config/integrations.ts index 0267c09a01..f31168b29a 100644 --- a/packages/core/src/app-config/integrations.ts +++ b/packages/core/src/app-config/integrations.ts @@ -9,4 +9,14 @@ export const integrationsConfig = z.object({ env: ["AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKS"], doc: "Skip inbound webhook signature verification. Development only — every adapter that reads this treats it as a bypass of sender authentication.", }), + // Blank counts as unset everywhere in the env layer, so this cannot express + // "mount no platforms" — refuse the whole slot with `plugins.disabled` + // instead. + platforms: z + .array(z.string().min(1)) + .optional() + .meta({ + env: ["AGENT_NATIVE_INTEGRATION_PLATFORMS"], + doc: "Integration platforms to mount, comma-separated, each matched against an adapter's `platform` id (slack, telegram, whatsapp, microsoft-teams, discord, google-docs, email). Unset mounts every adapter; a name no adapter provides throws at plugin init.", + }), }); diff --git a/packages/core/src/app-config/plugins.spec.ts b/packages/core/src/app-config/plugins.spec.ts new file mode 100644 index 0000000000..83c00b2585 --- /dev/null +++ b/packages/core/src/app-config/plugins.spec.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DEFAULT_PLUGIN_REGISTRY } from "../deploy/route-discovery.js"; +import { DEFAULT_PLUGIN_SLOTS } from "./plugins.js"; +import { + defineAppConfig, + getAppConfig, + resetAppConfigForTests, +} from "./store.js"; + +const originalEnv = { ...process.env }; + +describe("plugins config", () => { + beforeEach(() => { + resetAppConfigForTests(); + process.env = { ...originalEnv }; + delete process.env.AGENT_NATIVE_DISABLED_PLUGINS; + }); + + afterEach(() => { + resetAppConfigForTests(); + process.env = { ...originalEnv }; + }); + + // The enum is spelled out in the schema so it stays edge-safe, which only + // works if a new default plugin slot fails here instead of quietly becoming + // the one plugin nobody can turn off. + it("covers every slot in DEFAULT_PLUGIN_REGISTRY", () => { + expect([...DEFAULT_PLUGIN_SLOTS].sort()).toEqual( + Object.keys(DEFAULT_PLUGIN_REGISTRY).sort(), + ); + }); + + it("defaults to refusing nothing", () => { + expect(getAppConfig().plugins.disabled).toEqual([]); + }); + + it("reads a comma-separated environment alias", () => { + process.env.AGENT_NATIVE_DISABLED_PLUGINS = "terminal, integrations"; + expect(getAppConfig().plugins.disabled).toEqual([ + "terminal", + "integrations", + ]); + }); + + it("rejects a slot name that does not exist", () => { + process.env.AGENT_NATIVE_DISABLED_PLUGINS = "termnial"; + expect(() => getAppConfig()).toThrow(); + }); + + it("lets an explicit value win over the environment alias", () => { + process.env.AGENT_NATIVE_DISABLED_PLUGINS = "terminal"; + defineAppConfig({ plugins: { disabled: [] } }); + expect(getAppConfig().plugins.disabled).toEqual([]); + }); +}); diff --git a/packages/core/src/app-config/plugins.ts b/packages/core/src/app-config/plugins.ts new file mode 100644 index 0000000000..d38f98d70e --- /dev/null +++ b/packages/core/src/app-config/plugins.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +/** + * The framework's default plugin slots, mirroring `DEFAULT_PLUGIN_REGISTRY`. + * + * Spelled out here rather than imported: that registry lives in the deploy + * layer, which reaches for `node:fs`, and this schema is parsed on edge + * runtimes too. `plugins.spec.ts` fails when the two lists drift. + */ +export const DEFAULT_PLUGIN_SLOTS = [ + "agent-chat", + "auth", + "context-xray", + "core-routes", + "integrations", + "observational-memory", + "onboarding", + "org", + "resources", + "sentry", + "terminal", +] as const; + +export type DefaultPluginSlot = (typeof DEFAULT_PLUGIN_SLOTS)[number]; + +/** + * Which framework default plugins this deployment refuses. + * + * A refused slot mounts nothing, so every route it owns 404s and the UI and + * agent surfaces that call them stop working — `agent-chat`, `auth`, and + * `core-routes` carry most of an app with them. Only the framework's own + * default is withheld: an app that ships `server/plugins/.ts` mounted + * that plugin deliberately and keeps it. + */ +export const pluginsConfig = z.object({ + disabled: z + .array(z.enum(DEFAULT_PLUGIN_SLOTS)) + .default([]) + .meta({ + env: ["AGENT_NATIVE_DISABLED_PLUGINS"], + doc: "Framework default plugins this deployment refuses to auto-mount, comma-separated. A refused slot mounts none of its routes; an app supplying its own `server/plugins/.ts` is unaffected.", + }), +}); diff --git a/packages/core/src/app-config/schema.ts b/packages/core/src/app-config/schema.ts index ecdf2094ce..f5a7396a82 100644 --- a/packages/core/src/app-config/schema.ts +++ b/packages/core/src/app-config/schema.ts @@ -4,6 +4,7 @@ import { a2aConfig } from "./a2a.js"; import { agentConfig } from "./agent.js"; import { appConfig } from "./app.js"; import { integrationsConfig } from "./integrations.js"; +import { pluginsConfig } from "./plugins.js"; import { privateBlobConfig } from "./private-blob.js"; import { workspaceConfig } from "./workspace.js"; @@ -28,6 +29,7 @@ export const appConfigSchema = z.object({ agent: agentConfig.prefault({}), app: appConfig.prefault({}), integrations: integrationsConfig.prefault({}), + plugins: pluginsConfig.prefault({}), privateBlob: privateBlobConfig.prefault({}), workspace: workspaceConfig.prefault({}), }); diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts index d4cb82812f..4aaa49832e 100644 --- a/packages/core/src/deploy/build.ts +++ b/packages/core/src/deploy/build.ts @@ -1015,10 +1015,19 @@ ${["post", "put", "delete"] `import { ${defaultExportName} as ${varName} } from "${EDGE_SERVER_ENTRYPOINT}";`, ); } - pluginCalls.push(` if (typeof ${varName} === "function") { + // The worker entry mounts defaults statically, so the `plugins.disabled` + // check that `bootstrapDefaultPlugins` runs has to happen here too — + // otherwise the same config withholds a plugin on Node hosts and mounts it + // on the edge. + pluginCalls.push(` if (typeof ${varName} === "function" && !isDefaultPluginDisabled(${JSON.stringify(stem)})) { await ${varName}(nitroApp); }`); } + if (edgeDefaultStems.length > 0) { + pluginImports.unshift( + `import { isDefaultPluginDisabled } from "${EDGE_SERVER_ENTRYPOINT}";`, + ); + } const generatedPluginMarks = providedPluginStems.size > 0 ? [ diff --git a/packages/core/src/integrations/adapter-overrides.spec.ts b/packages/core/src/integrations/adapter-overrides.spec.ts index 305786a0ea..a901136e96 100644 --- a/packages/core/src/integrations/adapter-overrides.spec.ts +++ b/packages/core/src/integrations/adapter-overrides.spec.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { resetAppConfigForTests } from "../app-config/index.js"; import { mergeIntegrationAdapters } from "./adapter-overrides.js"; import { + applyConfiguredPlatformAllowList, BUILT_IN_INTEGRATION_ADAPTER_FACTORIES, BUILT_IN_INTEGRATION_ADAPTER_IDS, createBuiltInIntegrationAdapters, @@ -53,3 +55,35 @@ describe("integration adapter overrides", () => { ).toThrow(/either adapters.*adapterOverrides/i); }); }); + +describe("integrations.platforms allow-list", () => { + afterEach(() => { + delete process.env.AGENT_NATIVE_INTEGRATION_PLATFORMS; + resetAppConfigForTests(); + }); + + it("mounts every adapter when no allow-list is declared", () => { + const adapters = [adapter("slack"), adapter("email")]; + expect(applyConfiguredPlatformAllowList(adapters)).toEqual(adapters); + }); + + it("keeps only the named platforms", () => { + process.env.AGENT_NATIVE_INTEGRATION_PLATFORMS = "slack, email"; + resetAppConfigForTests(); + + expect( + applyConfiguredPlatformAllowList(createBuiltInIntegrationAdapters()).map( + ({ platform }) => platform, + ), + ).toEqual(["slack", "email"]); + }); + + it("throws on a platform no adapter provides", () => { + process.env.AGENT_NATIVE_INTEGRATION_PLATFORMS = "slakc"; + resetAppConfigForTests(); + + expect(() => + applyConfiguredPlatformAllowList(createBuiltInIntegrationAdapters()), + ).toThrow(/slakc/); + }); +}); diff --git a/packages/core/src/integrations/plugin.spec.ts b/packages/core/src/integrations/plugin.spec.ts index e207729ce5..a573545fe1 100644 --- a/packages/core/src/integrations/plugin.spec.ts +++ b/packages/core/src/integrations/plugin.spec.ts @@ -2,6 +2,7 @@ import { createHmac } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetAppConfigForTests } from "../app-config/index.js"; import { IntegrationIdentityDeclinedError } from "./identity.js"; import { createIntegrationsPlugin } from "./plugin.js"; import type { PlatformAdapter } from "./types.js"; @@ -361,6 +362,8 @@ describe("integrations plugin routes", () => { delete process.env.APP_BASE_PATH; delete process.env.VITE_APP_BASE_PATH; delete process.env.AGENT_INTEGRATION_DURABLE_DISPATCH; + delete process.env.AGENT_NATIVE_INTEGRATION_PLATFORMS; + resetAppConfigForTests(); process.env.NODE_ENV = originalNodeEnv; if (originalNetlify === undefined) { delete process.env.NETLIFY; @@ -447,6 +450,28 @@ describe("integrations plugin routes", () => { ]); }); + it("does not mount Slack-named routes when the allow-list drops slack", async () => { + process.env.AGENT_NATIVE_INTEGRATION_PLATFORMS = "fake"; + resetAppConfigForTests(); + const nitroApp = createNitroApp(); + await createIntegrationsPlugin({ adapters: [adapter] })(nitroApp); + + // No Slack handler is registered, so both paths fall past the named + // routes to the catch-all, which resolves an adapter and finds none. + await expect( + dispatch(nitroApp, "/_agent-native/integrations/slack/oauth/callback"), + ).resolves.toMatchObject({ + status: 404, + body: { error: "Unknown platform: slack" }, + }); + await expect( + dispatch(nitroApp, "/_agent-native/integrations/slack/manifest"), + ).resolves.toMatchObject({ + status: 404, + body: { error: "Unknown platform: slack" }, + }); + }); + it("serves a deployment-qualified Slack Agent View manifest", async () => { const nitroApp = createNitroApp(); await createIntegrationsPlugin({ adapters: [adapter] })(nitroApp); diff --git a/packages/core/src/integrations/plugin.ts b/packages/core/src/integrations/plugin.ts index fbdd43bc5e..892092761d 100644 --- a/packages/core/src/integrations/plugin.ts +++ b/packages/core/src/integrations/plugin.ts @@ -7,6 +7,7 @@ import { sendRedirect, } from "h3"; import { getRequestHeader } from "h3"; +import type { EventHandler } from "h3"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { @@ -15,6 +16,7 @@ import { isInBackgroundFunctionRuntime, } from "../agent/durable-background.js"; import { abortRun } from "../agent/run-manager.js"; +import { getAppConfig } from "../app-config/index.js"; import { isServerlessRuntime } from "../db/client.js"; import { getOrgContext, resolveOrgIdForEmail } from "../org/context.js"; import { loadResourcesForPrompt } from "../server/agent-chat-plugin.js"; @@ -424,6 +426,32 @@ export function createBuiltInIntegrationAdapters(): PlatformAdapter[] { return BUILT_IN_INTEGRATION_ADAPTER_FACTORIES.map(({ create }) => create()); } +/** + * Narrow the adapter set to `integrations.platforms`, when a deployment + * declares one. + * + * A configured name that matches no adapter throws instead of being ignored: + * the whole point of the allow-list is that the operator knows which platforms + * are live, and silently mounting a set nobody named — or mounting nothing + * because of a typo — is the failure this switch is supposed to prevent. + */ +export function applyConfiguredPlatformAllowList( + adapters: PlatformAdapter[], +): PlatformAdapter[] { + const allowed = getAppConfig().integrations.platforms; + if (!allowed) return adapters; + const available = new Set(adapters.map((adapter) => adapter.platform)); + const unknown = allowed.filter((platform) => !available.has(platform)); + if (unknown.length > 0) { + throw new Error( + `[agent-native] integrations.platforms names ${unknown.join(", ")}, which no mounted adapter provides. ` + + `Available: ${[...available].join(", ") || "(none)"}.`, + ); + } + const allowedSet = new Set(allowed); + return adapters.filter((adapter) => allowedSet.has(adapter.platform)); +} + const INTEGRATION_SYSTEM_PROMPT = `You are an AI agent responding via a messaging platform integration (Slack, Microsoft Teams, Discord interactions, Telegram, WhatsApp, etc.). You have the same capabilities as the web chat agent. Use your tools to help the user. @@ -844,12 +872,13 @@ export function createIntegrationsPlugin( } return async (nitroApp: any) => { markDefaultPluginProvided(nitroApp, "integrations"); - const adapters = + const adapters = applyConfiguredPlatformAllowList( options?.adapters ?? - mergeIntegrationAdapters( - createBuiltInIntegrationAdapters(), - options?.adapterOverrides, - ); + mergeIntegrationAdapters( + createBuiltInIntegrationAdapters(), + options?.adapterOverrides, + ), + ); const adapterMap = new Map(); for (const adapter of adapters) { adapterMap.set(adapter.platform, adapter); @@ -899,6 +928,21 @@ export function createIntegrationsPlugin( const h3 = getH3App(nitroApp); const P = `${FRAMEWORK_ROUTE_PREFIX}/integrations`; + // Routes mounted under a platform's own name rather than reached through + // the `/:platform/...` catch-all. The catch-all 404s a platform the + // allow-list dropped because it resolves an adapter first; these are + // registered by literal path, so they would outlive the platform they + // belong to unless the same allow-list gates the registration. + const allowedPlatforms = getAppConfig().integrations.platforms; + const mountForPlatform = ( + platform: string, + path: string, + handler: EventHandler, + ) => { + if (allowedPlatforms && !allowedPlatforms.includes(platform)) return; + h3.use(path, handler); + }; + async function enqueueSystemNotice( event: any, incoming: IncomingMessage, @@ -2523,7 +2567,8 @@ export function createIntegrationsPlugin( ); // ─── Slack native action controls ───────────────────────────── - h3.use( + mountForPlatform( + "slack", `${P}/slack/interactions`, defineEventHandler(async (event) => { if (getMethod(event) !== "POST") { @@ -2834,7 +2879,8 @@ export function createIntegrationsPlugin( ); // ─── Managed Slack OAuth ────────────────────────────────────── - h3.use( + mountForPlatform( + "slack", `${P}/slack/manifest`, defineEventHandler(async (event) => { if (getMethod(event) !== "GET") { @@ -2874,7 +2920,8 @@ export function createIntegrationsPlugin( }), ); - h3.use( + mountForPlatform( + "slack", `${P}/slack/oauth/install`, defineEventHandler(async (event) => { if (getMethod(event) !== "GET") { @@ -2943,7 +2990,8 @@ export function createIntegrationsPlugin( }), ); - h3.use( + mountForPlatform( + "slack", `${P}/slack/oauth/callback`, defineEventHandler(async (event) => { if (getMethod(event) !== "GET") { diff --git a/packages/core/src/server/default-plugins.ts b/packages/core/src/server/default-plugins.ts new file mode 100644 index 0000000000..041a0419c6 --- /dev/null +++ b/packages/core/src/server/default-plugins.ts @@ -0,0 +1,25 @@ +import { getAppConfig } from "../app-config/index.js"; +import type { DefaultPluginSlot } from "../app-config/plugins.js"; + +/** + * Which default plugin slots this deployment refuses, from `plugins.disabled`. + * + * Read on every call rather than captured once: `getAppConfig()` already caches + * the parse and drops that cache when the environment layer changes, and the + * auto-mount decision runs before some apps have called `defineAppConfig()`, so + * a module-level snapshot would freeze an empty list for the process. + */ +export function getDisabledDefaultPlugins(): readonly DefaultPluginSlot[] { + return getAppConfig().plugins.disabled; +} + +/** + * Whether the framework should withhold its default implementation of `slot`. + * + * Only the framework's own defaults are gated. An app that ships + * `server/plugins/.ts` mounted its plugin deliberately, and this switch + * does not reach in and unmount it. + */ +export function isDefaultPluginDisabled(slot: string): boolean { + return (getDisabledDefaultPlugins() as readonly string[]).includes(slot); +} diff --git a/packages/core/src/server/edge.ts b/packages/core/src/server/edge.ts index ae47f18c6a..8a46710c58 100644 --- a/packages/core/src/server/edge.ts +++ b/packages/core/src/server/edge.ts @@ -1,4 +1,8 @@ export { createAuthPlugin, defaultAuthPlugin } from "./auth-plugin.js"; +export { + getDisabledDefaultPlugins, + isDefaultPluginDisabled, +} from "./default-plugins.js"; export { BETTER_AUTH_MIGRATIONS, runBetterAuthMigrations, diff --git a/packages/core/src/server/framework-request-handler.spec.ts b/packages/core/src/server/framework-request-handler.spec.ts index f163bce267..0109586eed 100644 --- a/packages/core/src/server/framework-request-handler.spec.ts +++ b/packages/core/src/server/framework-request-handler.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetAppConfigForTests } from "../app-config/index.js"; import { getMissingDefaultPlugins } from "../deploy/route-discovery.js"; import { getH3App, @@ -81,6 +82,8 @@ describe("framework request handler", () => { delete process.env.APP_BASE_PATH; delete process.env.VITE_APP_BASE_PATH; delete process.env.AGENT_NATIVE_ROUTE_READY_TIMEOUT_MS; + delete process.env.AGENT_NATIVE_DISABLED_PLUGINS; + resetAppConfigForTests(); vi.restoreAllMocks(); }); @@ -357,6 +360,19 @@ describe("framework request handler", () => { ).resolves.toEqual({ fellThrough: true }); }); + it("does not auto-mount a default plugin slot refused by plugins.disabled", async () => { + process.env.AGENT_NATIVE_DISABLED_PLUGINS = "agent-chat"; + resetAppConfigForTests(); + const nitroApp = createNitroApp(); + vi.mocked(getMissingDefaultPlugins).mockResolvedValueOnce(["agent-chat"]); + + getH3App(nitroApp); + + await expect( + dispatch(nitroApp, "/.well-known/agent-card.json"), + ).resolves.toEqual({ fellThrough: true }); + }); + it("does not block unrelated framework routes on route-scoped plugin init", async () => { const nitroApp = createNitroApp(); let release!: () => void; diff --git a/packages/core/src/server/framework-request-handler.ts b/packages/core/src/server/framework-request-handler.ts index f143fbc7fc..bec2c345ae 100644 --- a/packages/core/src/server/framework-request-handler.ts +++ b/packages/core/src/server/framework-request-handler.ts @@ -19,6 +19,7 @@ import { MCP_PUBLIC_ROUTE_PREFIX } from "../mcp/route-paths.js"; 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 { installHttpResponseTelemetryHooks, recordFrameworkReadyWait, @@ -762,9 +763,12 @@ async function bootstrapDefaultPlugins(nitroApp: any): Promise { const provided = nitroApp[PROVIDED_PLUGIN_STEMS_KEY] as | Set | undefined; - const missing = provided + const undiscovered = provided ? discoveredMissing.filter((stem) => !provided.has(stem)) : discoveredMissing; + const disabled: readonly string[] = getDisabledDefaultPlugins(); + const missing = undiscovered.filter((stem) => !disabled.includes(stem)); + const refused = undiscovered.filter((stem) => disabled.includes(stem)); if (missing.length === 0) return; // Lazy import to avoid circular dependency at module load time @@ -850,7 +854,10 @@ async function bootstrapDefaultPlugins(nitroApp: any): Promise { if (process.env.DEBUG) console.log( - `[agent-native] Auto-mounting ${missing.length} default plugin(s): ${missing.join(", ")}`, + `[agent-native] Auto-mounting ${missing.length} default plugin(s): ${missing.join(", ")}` + + (refused.length > 0 + ? ` (refused by plugins.disabled: ${refused.join(", ")})` + : ""), ); for (const stem of missing) { From 5f9c43ef8f495c4fb92211f0e1b9cb48c2884eab Mon Sep 17 00:00:00 2001 From: Manu MA Date: Thu, 20 Aug 2026 15:02:54 +0200 Subject: [PATCH 2/2] fix: surface invalid plugin and integration-platform configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unknown slot in `plugins.disabled` threw inside the best-effort default-plugin auto-mount catch, which warned and resolved — leaving the app with every default route missing and the deployment reporting success. Read the list in `getH3App()` before that catch is installed. An `integrations.platforms` name no adapter provides now throws a typed `AppConfigurationError`, which the per-plugin auto-mount catch rethrows instead of skipping the slot. --- .changeset/plugin-and-integration-opt-out.md | 6 +++++ docs/environment-variables.md | 6 ++--- .../src/app-config/configuration-error.ts | 14 +++++++++++ packages/core/src/app-config/index.ts | 1 + .../integrations/adapter-overrides.spec.ts | 10 +++++++- packages/core/src/integrations/plugin.ts | 4 ++-- .../server/framework-request-handler.spec.ts | 7 ++++++ .../src/server/framework-request-handler.ts | 23 ++++++++++++++++--- 8 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/app-config/configuration-error.ts diff --git a/.changeset/plugin-and-integration-opt-out.md b/.changeset/plugin-and-integration-opt-out.md index 4be9bbd631..6f3fe4e1f2 100644 --- a/.changeset/plugin-and-integration-opt-out.md +++ b/.changeset/plugin-and-integration-opt-out.md @@ -26,3 +26,9 @@ allow-list now also gates the routes mounted under a platform's literal name — previously stayed mounted whatever the adapter set was. They are gated only when `integrations.platforms` is declared, so a deployment that does not set it keeps today's behavior. + +A misconfigured value in either switch is reported, not absorbed. An unknown +slot name in `plugins.disabled` fails at `getH3App()` rather than inside the +best-effort auto-mount catch, and the allow-list mismatch throws a typed +`AppConfigurationError` that the auto-mount catch rethrows — otherwise a typo +left the deployment reporting success with whole route trees missing. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 7f9869b5db..2710cb0182 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -315,7 +315,7 @@ no alias are settable only in code. | Field | Environment aliases | Type | Default | Description | | --------------------------------------------- | --------------------------------------------------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `a2a.allowedOrigins` | `AGENT_NATIVE_A2A_ALLOWED_ORIGINS` | array | `[]` | Comma-separated extra origins trusted as private A2A siblings. | -| `a2a.allowUnsignedInternal` | `A2A_ALLOW_UNSIGNED_INTERNAL` | boolean | `false` | Trust unsigned internal self-dispatch on an unrecognized non-production host. Never grants trust in production. | +| `a2a.allowUnsignedInternal` | `A2A_ALLOW_UNSIGNED_INTERNAL` | boolean | `false` | Trust unsigned internal self-dispatch on an unrecognized non-production host. Never grants trust in production. | | `agent.engine` | `AGENT_ENGINE` | string | — | Name of the registered agent engine to use. | | `agent.model` | `AGENT_MODEL` | string | — | Model the agent runs with, when the caller does not pass one. | | `agent.mode` | `AGENT_MODE` | string | — | Runtime mode. "production" turns off development-only agent behavior. | @@ -337,8 +337,8 @@ no alias are settable only in code. | `plugins.disabled` | `AGENT_NATIVE_DISABLED_PLUGINS` | array | `[]` | Framework default plugins this deployment refuses to auto-mount, comma-separated. A refused slot mounts none of its routes; an app supplying its own `server/plugins/.ts` is unaffected. | | `privateBlob.provider` | — | string | — | Id of the registered private blob provider to use. Unset falls back to the first registered provider that reports itself configured. | | `privateBlob.publicUploadFallback` | `AGENT_NATIVE_PRIVATE_BLOB_PUBLIC_UPLOAD_FALLBACK` | boolean | `true` | Store private blobs as encrypted objects in public file-upload storage when no private blob provider is configured. | -| `workspace.isWorkspace` | `AGENT_NATIVE_WORKSPACE`, `VITE_AGENT_NATIVE_WORKSPACE` | boolean | — | Whether this app is mounted inside a shared workspace gateway. | -| `workspace.appsJson` | `AGENT_NATIVE_WORKSPACE_APPS_JSON`, `VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON` | string | — | Serialized workspace app manifest used by mounted app runtimes. | +| `workspace.isWorkspace` | `AGENT_NATIVE_WORKSPACE`, `VITE_AGENT_NATIVE_WORKSPACE` | boolean | — | Whether this app is mounted inside a shared workspace gateway. | +| `workspace.appsJson` | `AGENT_NATIVE_WORKSPACE_APPS_JSON`, `VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON` | string | — | Serialized workspace app manifest used by mounted app runtimes. | | `workspace.gatewayUrl` | `WORKSPACE_GATEWAY_URL`, `VITE_WORKSPACE_GATEWAY_URL` | string | — | URL of the workspace gateway fronting this app. | | `workspace.orgDirectoryUrl` | `AGENT_NATIVE_ORG_DIRECTORY_URL` | string | — | URL of the authoritative organization Dispatch directory. | | `workspace.oauthOrigin` | `WORKSPACE_OAUTH_ORIGIN`, `VITE_WORKSPACE_OAUTH_ORIGIN` | string | — | Shared origin workspace apps complete OAuth against. | diff --git a/packages/core/src/app-config/configuration-error.ts b/packages/core/src/app-config/configuration-error.ts new file mode 100644 index 0000000000..51c7083401 --- /dev/null +++ b/packages/core/src/app-config/configuration-error.ts @@ -0,0 +1,14 @@ +/** + * An invalid deployment configuration value, as opposed to a runtime failure. + * + * Best-effort regions that log and continue — plugin auto-mount, most of all — + * must rethrow this rather than absorb it: a typo in a deployment variable + * silently drops whole route trees, so the deployment looks accepted while the + * app is missing. + */ +export class AppConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "AppConfigurationError"; + } +} diff --git a/packages/core/src/app-config/index.ts b/packages/core/src/app-config/index.ts index 2f7373a78f..ea12e843b3 100644 --- a/packages/core/src/app-config/index.ts +++ b/packages/core/src/app-config/index.ts @@ -3,6 +3,7 @@ export { getAppConfig, resetAppConfigForTests, } from "./store.js"; +export { AppConfigurationError } from "./configuration-error.js"; export { appConfigSchema, type AppConfig, diff --git a/packages/core/src/integrations/adapter-overrides.spec.ts b/packages/core/src/integrations/adapter-overrides.spec.ts index a901136e96..cdc18c8158 100644 --- a/packages/core/src/integrations/adapter-overrides.spec.ts +++ b/packages/core/src/integrations/adapter-overrides.spec.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it } from "vitest"; -import { resetAppConfigForTests } from "../app-config/index.js"; +import { + AppConfigurationError, + resetAppConfigForTests, +} from "../app-config/index.js"; import { mergeIntegrationAdapters } from "./adapter-overrides.js"; import { applyConfiguredPlatformAllowList, @@ -82,6 +85,11 @@ describe("integrations.platforms allow-list", () => { process.env.AGENT_NATIVE_INTEGRATION_PLATFORMS = "slakc"; resetAppConfigForTests(); + // Typed so the best-effort plugin auto-mount catch rethrows it instead of + // leaving the deployment with no integrations routes and a warning. + expect(() => + applyConfiguredPlatformAllowList(createBuiltInIntegrationAdapters()), + ).toThrow(AppConfigurationError); expect(() => applyConfiguredPlatformAllowList(createBuiltInIntegrationAdapters()), ).toThrow(/slakc/); diff --git a/packages/core/src/integrations/plugin.ts b/packages/core/src/integrations/plugin.ts index 5724582336..087b98790f 100644 --- a/packages/core/src/integrations/plugin.ts +++ b/packages/core/src/integrations/plugin.ts @@ -16,7 +16,7 @@ import { isInBackgroundFunctionRuntime, } from "../agent/durable-background.js"; import { abortRun } from "../agent/run-manager.js"; -import { getAppConfig } from "../app-config/index.js"; +import { AppConfigurationError, getAppConfig } from "../app-config/index.js"; import { isServerlessRuntime } from "../db/client.js"; import { getOrgContext, resolveOrgIdForEmail } from "../org/context.js"; import { loadResourcesForPrompt } from "../server/agent-chat-plugin.js"; @@ -443,7 +443,7 @@ export function applyConfiguredPlatformAllowList( const available = new Set(adapters.map((adapter) => adapter.platform)); const unknown = allowed.filter((platform) => !available.has(platform)); if (unknown.length > 0) { - throw new Error( + throw new AppConfigurationError( `[agent-native] integrations.platforms names ${unknown.join(", ")}, which no mounted adapter provides. ` + `Available: ${[...available].join(", ") || "(none)"}.`, ); diff --git a/packages/core/src/server/framework-request-handler.spec.ts b/packages/core/src/server/framework-request-handler.spec.ts index 143f9e5bce..7bf9f9d62b 100644 --- a/packages/core/src/server/framework-request-handler.spec.ts +++ b/packages/core/src/server/framework-request-handler.spec.ts @@ -374,6 +374,13 @@ describe("framework request handler", () => { ).resolves.toEqual({ fellThrough: true }); }); + it("surfaces an unknown plugins.disabled slot instead of dropping every default plugin", () => { + process.env.AGENT_NATIVE_DISABLED_PLUGINS = "agent-chatt"; + resetAppConfigForTests(); + + expect(() => getH3App(createNitroApp())).toThrow(/"disabled"/); + }); + it("does not block unrelated framework routes on route-scoped plugin init", async () => { const nitroApp = createNitroApp(); let release!: () => void; diff --git a/packages/core/src/server/framework-request-handler.ts b/packages/core/src/server/framework-request-handler.ts index 3f77881ef7..090466c2ff 100644 --- a/packages/core/src/server/framework-request-handler.ts +++ b/packages/core/src/server/framework-request-handler.ts @@ -14,6 +14,7 @@ import type { EventHandler, H3Event } from "h3"; import { setResponseHeader, setResponseStatus } from "h3"; +import { AppConfigurationError } from "../app-config/index.js"; import { getMissingDefaultPlugins } from "../deploy/route-discovery.js"; import { MCP_PUBLIC_ROUTE_PREFIX } from "../mcp/route-paths.js"; import { @@ -191,7 +192,11 @@ export function getH3App(nitroApp: any): H3AppShim { if (!BOOTSTRAPPED.has(nitroApp)) { BOOTSTRAPPED.add(nitroApp); - nitroApp[BOOTSTRAP_PROMISE_KEY] = bootstrapDefaultPlugins(nitroApp).catch( + // Read before the catch below: an unknown slot name in `plugins.disabled` + // is an invalid deployment, not a plugin that failed to start, and the + // catch would turn it into an app with every default route missing. + const disabledPlugins = getDisabledDefaultPlugins(); + const bootstrap = bootstrapDefaultPlugins(nitroApp, disabledPlugins).catch( (err) => { console.warn( "[agent-native] Failed to auto-mount default plugins:", @@ -201,8 +206,14 @@ export function getH3App(nitroApp: any): H3AppShim { route: "default-plugin-bootstrap", tags: { phase: "default-plugin-bootstrap" }, }); + if (err instanceof AppConfigurationError) throw err; }, ); + // The readiness gate is what observes this rejection, and it only runs on + // a request. Without a handler attached now, Node exits on the unhandled + // rejection before anything can report the configuration error. + bootstrap.catch(() => {}); + nitroApp[BOOTSTRAP_PROMISE_KEY] = bootstrap; // Readiness gate: Nitro v3 doesn't await async plugins, so routes // registered inside an async plugin may not exist when the first @@ -834,7 +845,10 @@ function registerMiddleware( * there instead of from @agent-native/core — this is the middle layer of the * three-layer inheritance model (app local > workspace core > framework). */ -async function bootstrapDefaultPlugins(nitroApp: any): Promise { +async function bootstrapDefaultPlugins( + nitroApp: any, + disabled: readonly string[], +): Promise { IN_BOOTSTRAP.add(nitroApp); try { const cwd = process.cwd(); @@ -845,7 +859,6 @@ async function bootstrapDefaultPlugins(nitroApp: any): Promise { const undiscovered = provided ? discoveredMissing.filter((stem) => !provided.has(stem)) : discoveredMissing; - const disabled: readonly string[] = getDisabledDefaultPlugins(); const missing = undiscovered.filter((stem) => !disabled.includes(stem)); const refused = undiscovered.filter((stem) => disabled.includes(stem)); if (missing.length === 0) return; @@ -954,6 +967,10 @@ async function bootstrapDefaultPlugins(nitroApp: any): Promise { route: "default-plugin-bootstrap", tags: { phase: "default-plugin-bootstrap", plugin: stem }, }); + // A plugin that cannot start is optional; a plugin the deployment + // configured wrongly is not. Skipping it leaves the operator with + // routes that 404 and a deployment that reported success. + if (e instanceof AppConfigurationError) throw e; } } }