Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/plugin-and-integration-opt-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@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/<slot>.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.

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.
66 changes: 34 additions & 32 deletions docs/environment-variables.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/core/src/app-config/configuration-error.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
1 change: 1 addition & 0 deletions packages/core/src/app-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
getAppConfig,
resetAppConfigForTests,
} from "./store.js";
export { AppConfigurationError } from "./configuration-error.js";
export {
appConfigSchema,
type AppConfig,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/app-config/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
}),
});
56 changes: 56 additions & 0 deletions packages/core/src/app-config/plugins.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
43 changes: 43 additions & 0 deletions packages/core/src/app-config/plugins.ts
Original file line number Diff line number Diff line change
@@ -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/<slot>.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/<slot>.ts` is unaffected.",
}),
});
2 changes: 2 additions & 0 deletions packages/core/src/app-config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { appConfig } from "./app.js";
import { authConfig } from "./auth.js";
import { integrationsConfig } from "./integrations.js";
import { migrationConfig } from "./migration.js";
import { pluginsConfig } from "./plugins.js";
import { privateBlobConfig } from "./private-blob.js";
import { workspaceConfig } from "./workspace.js";

Expand All @@ -32,6 +33,7 @@ export const appConfigSchema = z.object({
auth: authConfig.prefault({}),
integrations: integrationsConfig.prefault({}),
migration: migrationConfig.prefault({}),
plugins: pluginsConfig.prefault({}),
privateBlob: privateBlobConfig.prefault({}),
workspace: workspaceConfig.prefault({}),
});
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/deploy/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,7 +1013,7 @@
if (workspaceCore && workspaceExportName) {
// Workspace-core layer wins over the framework default.
pluginImports.push(
`import { ${workspaceExportName} as ${varName} } from ${JSON.stringify(

Check warning on line 1016 in packages/core/src/deploy/build.ts

View workflow job for this annotation

GitHub Actions / Lint & format

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
`${workspaceCore.packageName}/server`,
)};`,
);
Expand All @@ -1025,10 +1025,19 @@
`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)})) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Keep generated edge plugin gating edge-safe

The generated edge entry calls isDefaultPluginDisabled() for every default plugin, but that helper delegates to getAppConfig(), which unconditionally reads bare process.env. Edge runtimes without Node’s process can therefore throw ReferenceError: process is not defined during plugin mounting, even with no disabled plugins configured. Use the worker’s runtime environment mechanism or inject the resolved disabled-slot list at build time instead of calling the Node-oriented config store.

Additional Info
Confirmed by 3 parallel reviewers; packages/core/src/app-config/store.ts:121 reads process.env directly.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reproducible — the same generated worker entry already requires process.env before this line. It statically mounts defaultAgentChatPlugin, whose mount body reads bare process.env.NODE_ENV (agent-chat-plugin.ts:654) and calls getAppConfig() (:659), and defaultCoreRoutesPlugin, which does the same. isDefaultPluginDisabled() adds no requirement the edge bundle did not already have, so gating it here would not make edge mounting process-free. Leaving as is.

await ${varName}(nitroApp);
}`);
}
if (edgeDefaultStems.length > 0) {
pluginImports.unshift(
`import { isDefaultPluginDisabled } from "${EDGE_SERVER_ENTRYPOINT}";`,
);
}
const generatedPluginMarks =
providedPluginStems.size > 0
? [
Expand Down
44 changes: 43 additions & 1 deletion packages/core/src/integrations/adapter-overrides.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";

import {
AppConfigurationError,
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,
Expand Down Expand Up @@ -53,3 +58,40 @@ 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();

// 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/);
});
});
25 changes: 25 additions & 0 deletions packages/core/src/integrations/plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading