diff --git a/.changeset/codex-plugins-no-credential.md b/.changeset/codex-plugins-no-credential.md new file mode 100644 index 000000000..ef0684a06 --- /dev/null +++ b/.changeset/codex-plugins-no-credential.md @@ -0,0 +1,14 @@ +--- +"executor": patch +"@executor-js/plugin-mcp": patch +--- + +Adding a Codex plugin no longer asks for anything. `CODEX_HOME` is a path the +scanner already resolved, but it was passed on the channel that makes an +environment variable a credential — so the integration declared it as one, and +a person who reached the connect step was shown a masked field for a value +they should never have to know. + +Stdio integrations can now carry non-secret environment as static +configuration, separate from declared secrets. The Codex plugins use it: they +declare no auth, and their connection is created for them. diff --git a/e2e/local/codex-plugins.test.ts b/e2e/local/codex-plugins.test.ts index db573e8d9..d1362c106 100644 --- a/e2e/local/codex-plugins.test.ts +++ b/e2e/local/codex-plugins.test.ts @@ -178,7 +178,9 @@ scenario( command: plugin.command, args: [...plugin.args], ...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }), - ...(plugin.env === undefined ? {} : { env: { ...plugin.env } }), + // Static, not `env`: CODEX_HOME is a machine path the scanner + // resolved, so it must not become a credential to type. + ...(plugin.env === undefined ? {} : { staticEnv: { ...plugin.env } }), ...(plugin.appServer === undefined ? {} : { appServer: { server: plugin.appServer.server } }), @@ -200,6 +202,12 @@ scenario( connections.map((connection) => String(connection.name)), `${slug} auto-connected`, ).toContain("default"); + // Nothing to configure: the connection carries no credential, so + // the person is never shown a field for a path we already know. + expect( + connections.map((connection) => String(connection.template)), + `${slug} needs no credential`, + ).toEqual(["none"]); const tools = yield* client.tools.list({ query: { integration: slug } }); const names = tools.map((tool) => tool.name); diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 69a76ee2a..fd1ac3a87 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -58,6 +58,9 @@ const AddStdioServerPayload = Schema.Struct({ envVars: Schema.optional(Schema.Array(Schema.String)), /** One-shot secret env values (programmatic). The UI sends `envVars`. */ env: Schema.optional(StringMap), + /** Non-secret environment stored on the integration and injected at spawn. + * Unlike `env`, nothing here becomes a credential the user must type. */ + staticEnv: Schema.optional(StringMap), cwd: Schema.optional(Schema.String), /** Protocol negotiation at connect: `auto` probes `server/discover` (spec * 2026-07-28) for modern-only servers; default is the legacy `initialize` diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 4838af216..bbe914636 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -39,6 +39,7 @@ const toServerInput = ( args?: readonly string[]; envVars?: readonly string[]; env?: Record; + staticEnv?: Record; cwd?: string; versionNegotiation?: "legacy" | "auto"; spawnPerCall?: boolean; @@ -54,6 +55,7 @@ const toServerInput = ( args: p.args ? [...p.args] : undefined, envVars: p.envVars ? [...p.envVars] : undefined, env: p.env, + staticEnv: p.staticEnv, cwd: p.cwd, versionNegotiation: p.versionNegotiation, spawnPerCall: p.spawnPerCall, diff --git a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx index 05b0c6cd2..5075aff6a 100644 --- a/packages/plugins/mcp/src/react/CodexPluginAdd.tsx +++ b/packages/plugins/mcp/src/react/CodexPluginAdd.tsx @@ -53,7 +53,11 @@ export default function CodexPluginAdd(props: { command: plugin.command, args: [...plugin.args], ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}), - ...(plugin.env !== undefined ? { env: { ...plugin.env } } : {}), + // As STATIC env, not `env`: these are machine-derived paths the + // scanner already resolved, so they must not become credentials the + // person is asked to type. Sent this way the integration declares no + // auth, and its connection is created for them. + ...(plugin.env !== undefined ? { staticEnv: { ...plugin.env } } : {}), ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}), }, reactivityKeys: integrationWriteKeys, diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index fc8ce7f12..57c4feef2 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -24,7 +24,7 @@ import { } from "@executor-js/sdk/testing"; import { createMcpConnector } from "./connection"; -import { mcpPlugin, userFacingProbeMessage } from "./plugin"; +import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin"; import { McpInvocationError } from "./errors"; import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; @@ -1536,3 +1536,40 @@ describe("mcpPlugin endpoint telemetry", () => { }), ); }); + +describe("stdio static env", () => { + it("keeps non-secret env off the credential surface", () => { + // `env` declares a credential the user must type; `staticEnv` is machine + // knowledge stored on the integration. A path the scanner already resolved + // belongs in the second, or adding the integration asks for it. + const config = toIntegrationConfig({ + transport: "stdio", + name: "Computer Use", + command: "/usr/local/bin/codex", + args: ["app-server"], + staticEnv: { CODEX_HOME: "/home/a/.codex" }, + }); + + expect(config).toMatchObject({ + env: { CODEX_HOME: "/home/a/.codex" }, + authenticationTemplate: [{ slug: "none", kind: "none" }], + }); + }); + + it("still treats declared env values as credentials", () => { + const config = toIntegrationConfig({ + transport: "stdio", + name: "Secret server", + command: "run", + env: { API_KEY: "sk-live" }, + }); + + expect(config).toMatchObject({ + authenticationTemplate: [{ slug: "env", kind: "stdio_env", vars: ["API_KEY"] }], + }); + expect( + (config as { env?: unknown }).env, + "the secret never lands in the config", + ).toBeUndefined(); + }); +}); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index fa7c30589..0e29fdc73 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -236,6 +236,17 @@ const McpStdioServerInputSchema = Schema.Struct({ * add then auto-creates the connection holding them. The UI uses `envVars` * instead and leaves the values to the connect step. */ env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + /** Non-secret environment the server needs, stored on the integration and + * injected verbatim at spawn. + * + * Separate from `env` because that channel makes every variable a + * CREDENTIAL: it is declared as a `stdio_env` method and the user is asked + * to type its value on a masked form. That is right for an API key and + * wrong for a machine-derived path — a Codex plugin's `CODEX_HOME` is + * already known to the scanner, is not a secret, and must never become a + * field a person has to fill in. Nothing here is a credential, so it does + * not appear in `authenticationTemplate`. */ + staticEnv: Schema.optional(Schema.Record(Schema.String, Schema.String)), cwd: Schema.optional(Schema.String), /** Protocol negotiation at connect: `auto` probes `server/discover` (spec * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize` @@ -406,18 +417,23 @@ const stdioEnvVarNames = (input: McpStdioServerInput): readonly string[] => { return [...names]; }; -const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => { +/** Exported for tests: the credential/non-credential split is a security + * boundary (a value in `env` becomes something the user is asked to type), + * and asserting it through the whole add flow would not show it. */ +export const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => { if (input.transport === "stdio") { // The config only DECLARES the secret env vars by NAME (a `stdio_env` // method); their values are credentials and live on the connection, never // in this blob. Names come from the explicit `envVars` declaration and/or // the keys of any one-shot `env` values. const vars = stdioEnvVarNames(input); + const staticEnv = input.staticEnv; return { transport: "stdio", family: input.family?.trim() || undefined, command: input.command, args: input.args ? [...input.args] : undefined, + env: staticEnv !== undefined && Object.keys(staticEnv).length > 0 ? staticEnv : undefined, cwd: input.cwd, versionNegotiation: input.versionNegotiation, spawnPerCall: input.spawnPerCall,