Skip to content

Commit 00c2ab7

Browse files
authored
Stop asking for CODEX_HOME when adding a Codex plugin (#1863)
1 parent 4d4ad7c commit 00c2ab7

7 files changed

Lines changed: 88 additions & 4 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"executor": patch
3+
"@executor-js/plugin-mcp": patch
4+
---
5+
6+
Adding a Codex plugin no longer asks for anything. `CODEX_HOME` is a path the
7+
scanner already resolved, but it was passed on the channel that makes an
8+
environment variable a credential — so the integration declared it as one, and
9+
a person who reached the connect step was shown a masked field for a value
10+
they should never have to know.
11+
12+
Stdio integrations can now carry non-secret environment as static
13+
configuration, separate from declared secrets. The Codex plugins use it: they
14+
declare no auth, and their connection is created for them.

e2e/local/codex-plugins.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,9 @@ scenario(
178178
command: plugin.command,
179179
args: [...plugin.args],
180180
...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }),
181-
...(plugin.env === undefined ? {} : { env: { ...plugin.env } }),
181+
// Static, not `env`: CODEX_HOME is a machine path the scanner
182+
// resolved, so it must not become a credential to type.
183+
...(plugin.env === undefined ? {} : { staticEnv: { ...plugin.env } }),
182184
...(plugin.appServer === undefined
183185
? {}
184186
: { appServer: { server: plugin.appServer.server } }),
@@ -200,6 +202,12 @@ scenario(
200202
connections.map((connection) => String(connection.name)),
201203
`${slug} auto-connected`,
202204
).toContain("default");
205+
// Nothing to configure: the connection carries no credential, so
206+
// the person is never shown a field for a path we already know.
207+
expect(
208+
connections.map((connection) => String(connection.template)),
209+
`${slug} needs no credential`,
210+
).toEqual(["none"]);
203211

204212
const tools = yield* client.tools.list({ query: { integration: slug } });
205213
const names = tools.map((tool) => tool.name);

packages/plugins/mcp/src/api/group.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ const AddStdioServerPayload = Schema.Struct({
5858
envVars: Schema.optional(Schema.Array(Schema.String)),
5959
/** One-shot secret env values (programmatic). The UI sends `envVars`. */
6060
env: Schema.optional(StringMap),
61+
/** Non-secret environment stored on the integration and injected at spawn.
62+
* Unlike `env`, nothing here becomes a credential the user must type. */
63+
staticEnv: Schema.optional(StringMap),
6164
cwd: Schema.optional(Schema.String),
6265
/** Protocol negotiation at connect: `auto` probes `server/discover` (spec
6366
* 2026-07-28) for modern-only servers; default is the legacy `initialize`

packages/plugins/mcp/src/api/handlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const toServerInput = (
3939
args?: readonly string[];
4040
envVars?: readonly string[];
4141
env?: Record<string, string>;
42+
staticEnv?: Record<string, string>;
4243
cwd?: string;
4344
versionNegotiation?: "legacy" | "auto";
4445
spawnPerCall?: boolean;
@@ -54,6 +55,7 @@ const toServerInput = (
5455
args: p.args ? [...p.args] : undefined,
5556
envVars: p.envVars ? [...p.envVars] : undefined,
5657
env: p.env,
58+
staticEnv: p.staticEnv,
5759
cwd: p.cwd,
5860
versionNegotiation: p.versionNegotiation,
5961
spawnPerCall: p.spawnPerCall,

packages/plugins/mcp/src/react/CodexPluginAdd.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ export default function CodexPluginAdd(props: {
5353
command: plugin.command,
5454
args: [...plugin.args],
5555
...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}),
56-
...(plugin.env !== undefined ? { env: { ...plugin.env } } : {}),
56+
// As STATIC env, not `env`: these are machine-derived paths the
57+
// scanner already resolved, so they must not become credentials the
58+
// person is asked to type. Sent this way the integration declares no
59+
// auth, and its connection is created for them.
60+
...(plugin.env !== undefined ? { staticEnv: { ...plugin.env } } : {}),
5761
...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}),
5862
},
5963
reactivityKeys: integrationWriteKeys,

packages/plugins/mcp/src/sdk/plugin.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from "@executor-js/sdk/testing";
2525

2626
import { createMcpConnector } from "./connection";
27-
import { mcpPlugin, userFacingProbeMessage } from "./plugin";
27+
import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin";
2828
import { McpInvocationError } from "./errors";
2929
import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";
3030
import { makeAnnotationsMcpServer, serveMcpServer } from "../testing";
@@ -1536,3 +1536,40 @@ describe("mcpPlugin endpoint telemetry", () => {
15361536
}),
15371537
);
15381538
});
1539+
1540+
describe("stdio static env", () => {
1541+
it("keeps non-secret env off the credential surface", () => {
1542+
// `env` declares a credential the user must type; `staticEnv` is machine
1543+
// knowledge stored on the integration. A path the scanner already resolved
1544+
// belongs in the second, or adding the integration asks for it.
1545+
const config = toIntegrationConfig({
1546+
transport: "stdio",
1547+
name: "Computer Use",
1548+
command: "/usr/local/bin/codex",
1549+
args: ["app-server"],
1550+
staticEnv: { CODEX_HOME: "/home/a/.codex" },
1551+
});
1552+
1553+
expect(config).toMatchObject({
1554+
env: { CODEX_HOME: "/home/a/.codex" },
1555+
authenticationTemplate: [{ slug: "none", kind: "none" }],
1556+
});
1557+
});
1558+
1559+
it("still treats declared env values as credentials", () => {
1560+
const config = toIntegrationConfig({
1561+
transport: "stdio",
1562+
name: "Secret server",
1563+
command: "run",
1564+
env: { API_KEY: "sk-live" },
1565+
});
1566+
1567+
expect(config).toMatchObject({
1568+
authenticationTemplate: [{ slug: "env", kind: "stdio_env", vars: ["API_KEY"] }],
1569+
});
1570+
expect(
1571+
(config as { env?: unknown }).env,
1572+
"the secret never lands in the config",
1573+
).toBeUndefined();
1574+
});
1575+
});

packages/plugins/mcp/src/sdk/plugin.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,17 @@ const McpStdioServerInputSchema = Schema.Struct({
236236
* add then auto-creates the connection holding them. The UI uses `envVars`
237237
* instead and leaves the values to the connect step. */
238238
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
239+
/** Non-secret environment the server needs, stored on the integration and
240+
* injected verbatim at spawn.
241+
*
242+
* Separate from `env` because that channel makes every variable a
243+
* CREDENTIAL: it is declared as a `stdio_env` method and the user is asked
244+
* to type its value on a masked form. That is right for an API key and
245+
* wrong for a machine-derived path — a Codex plugin's `CODEX_HOME` is
246+
* already known to the scanner, is not a secret, and must never become a
247+
* field a person has to fill in. Nothing here is a credential, so it does
248+
* not appear in `authenticationTemplate`. */
249+
staticEnv: Schema.optional(Schema.Record(Schema.String, Schema.String)),
239250
cwd: Schema.optional(Schema.String),
240251
/** Protocol negotiation at connect: `auto` probes `server/discover` (spec
241252
* 2026-07-28) for modern-only servers. Defaults to the legacy `initialize`
@@ -406,18 +417,23 @@ const stdioEnvVarNames = (input: McpStdioServerInput): readonly string[] => {
406417
return [...names];
407418
};
408419

409-
const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => {
420+
/** Exported for tests: the credential/non-credential split is a security
421+
* boundary (a value in `env` becomes something the user is asked to type),
422+
* and asserting it through the whole add flow would not show it. */
423+
export const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => {
410424
if (input.transport === "stdio") {
411425
// The config only DECLARES the secret env vars by NAME (a `stdio_env`
412426
// method); their values are credentials and live on the connection, never
413427
// in this blob. Names come from the explicit `envVars` declaration and/or
414428
// the keys of any one-shot `env` values.
415429
const vars = stdioEnvVarNames(input);
430+
const staticEnv = input.staticEnv;
416431
return {
417432
transport: "stdio",
418433
family: input.family?.trim() || undefined,
419434
command: input.command,
420435
args: input.args ? [...input.args] : undefined,
436+
env: staticEnv !== undefined && Object.keys(staticEnv).length > 0 ? staticEnv : undefined,
421437
cwd: input.cwd,
422438
versionNegotiation: input.versionNegotiation,
423439
spawnPerCall: input.spawnPerCall,

0 commit comments

Comments
 (0)