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
14 changes: 14 additions & 0 deletions .changeset/codex-plugins-no-credential.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion e2e/local/codex-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }),
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/plugins/mcp/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/mcp/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const toServerInput = (
args?: readonly string[];
envVars?: readonly string[];
env?: Record<string, string>;
staticEnv?: Record<string, string>;
cwd?: string;
versionNegotiation?: "legacy" | "auto";
spawnPerCall?: boolean;
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/plugins/mcp/src/react/CodexPluginAdd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 38 additions & 1 deletion packages/plugins/mcp/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
});
});
18 changes: 17 additions & 1 deletion packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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,
Expand Down
Loading