History

Stop asking for CODEX_HOME when adding a Codex plugin (#1863)

Rhys Sullivan committed 2 months ago

Commit 00c2ab71 parent 4d4ad7c7 files · +88 −4Browse files

Unified · Split

7 files
  1. .changeset/codex-plugins-no-credential.md +14 −0
  2. e2e/local/codex-plugins.test.ts +9 −1
  3. packages/plugins/mcp/src/api/group.ts +3 −0
  4. packages/plugins/mcp/src/api/handlers.ts +2 −0
  5. packages/plugins/mcp/src/react/CodexPluginAdd.tsx +5 −1
  6. packages/plugins/mcp/src/sdk/plugin.test.ts +38 −1
  7. packages/plugins/mcp/src/sdk/plugin.ts +17 −1
.changeset/codex-plugins-no-credential.md+14−0
@@ -0,0 +1,14 @@
1---
2"executor": patch
3"@executor-js/plugin-mcp": patch
4---
5
6Adding a Codex plugin no longer asks for anything. `CODEX_HOME` is a path the
7scanner already resolved, but it was passed on the channel that makes an
8environment variable a credential — so the integration declared it as one, and
9a person who reached the connect step was shown a masked field for a value
10they should never have to know.
11
12Stdio integrations can now carry non-secret environment as static
13configuration, separate from declared secrets. The Codex plugins use it: they
14declare no auth, and their connection is created for them.
e2e/local/codex-plugins.test.ts+9−1
@@ -178,7 +178,9 @@ scenario( · 177 unmodified lines
178 command: plugin.command,178 command: plugin.command,
179 args: [...plugin.args],179 args: [...plugin.args],
180 ...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }),180 ...(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 } }),
182 ...(plugin.appServer === undefined184 ...(plugin.appServer === undefined
183 ? {}185 ? {}
184 : { appServer: { server: plugin.appServer.server } }),186 : { appServer: { server: plugin.appServer.server } }),
@@ -200,6 +202,12 @@ scenario( · 15 unmodified lines
200 connections.map((connection) => String(connection.name)),202 connections.map((connection) => String(connection.name)),
201 `${slug} auto-connected`,203 `${slug} auto-connected`,
202 ).toContain("default");204 ).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"]);
203 211
204 const tools = yield* client.tools.list({ query: { integration: slug } });212 const tools = yield* client.tools.list({ query: { integration: slug } });
205 const names = tools.map((tool) => tool.name);213 const names = tools.map((tool) => tool.name);
packages/plugins/mcp/src/api/group.ts+3−0
@@ -58,6 +58,9 @@ const AddStdioServerPayload = Schema.Struct({ · 57 unmodified lines
58 envVars: Schema.optional(Schema.Array(Schema.String)),58 envVars: Schema.optional(Schema.Array(Schema.String)),
59 /** One-shot secret env values (programmatic). The UI sends `envVars`. */59 /** One-shot secret env values (programmatic). The UI sends `envVars`. */
60 env: Schema.optional(StringMap),60 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),
61 cwd: Schema.optional(Schema.String),64 cwd: Schema.optional(Schema.String),
62 /** Protocol negotiation at connect: `auto` probes `server/discover` (spec65 /** Protocol negotiation at connect: `auto` probes `server/discover` (spec
63 * 2026-07-28) for modern-only servers; default is the legacy `initialize`66 * 2026-07-28) for modern-only servers; default is the legacy `initialize`
packages/plugins/mcp/src/api/handlers.ts+2−0
@@ -39,6 +39,7 @@ const toServerInput = ( · 38 unmodified lines
39 args?: readonly string[];39 args?: readonly string[];
40 envVars?: readonly string[];40 envVars?: readonly string[];
41 env?: Record<string, string>;41 env?: Record<string, string>;
42 staticEnv?: Record<string, string>;
42 cwd?: string;43 cwd?: string;
43 versionNegotiation?: "legacy" | "auto";44 versionNegotiation?: "legacy" | "auto";
44 spawnPerCall?: boolean;45 spawnPerCall?: boolean;
@@ -54,6 +55,7 @@ const toServerInput = ( · 9 unmodified lines
54 args: p.args ? [...p.args] : undefined,55 args: p.args ? [...p.args] : undefined,
55 envVars: p.envVars ? [...p.envVars] : undefined,56 envVars: p.envVars ? [...p.envVars] : undefined,
56 env: p.env,57 env: p.env,
58 staticEnv: p.staticEnv,
57 cwd: p.cwd,59 cwd: p.cwd,
58 versionNegotiation: p.versionNegotiation,60 versionNegotiation: p.versionNegotiation,
59 spawnPerCall: p.spawnPerCall,61 spawnPerCall: p.spawnPerCall,
packages/plugins/mcp/src/react/CodexPluginAdd.tsx+5−1
@@ -53,7 +53,11 @@ export default function CodexPluginAdd(props: { · 52 unmodified lines
53 command: plugin.command,53 command: plugin.command,
54 args: [...plugin.args],54 args: [...plugin.args],
55 ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}),55 ...(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 } } : {}),
57 ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}),61 ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}),
58 },62 },
59 reactivityKeys: integrationWriteKeys,63 reactivityKeys: integrationWriteKeys,
packages/plugins/mcp/src/sdk/plugin.test.ts+38−1
@@ -24,7 +24,7 @@ import { · 23 unmodified lines
24} from "@executor-js/sdk/testing";24} from "@executor-js/sdk/testing";
25 25
26import { createMcpConnector } from "./connection";26import { createMcpConnector } from "./connection";
27import { mcpPlugin, userFacingProbeMessage } from "./plugin";27import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin";
28import { McpInvocationError } from "./errors";28import { McpInvocationError } from "./errors";
29import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";29import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";
30import { makeAnnotationsMcpServer, serveMcpServer } from "../testing";30import { makeAnnotationsMcpServer, serveMcpServer } from "../testing";
@@ -1536,3 +1536,40 @@ describe("mcpPlugin endpoint telemetry", () => { · 1505 unmodified lines
1536 }),1536 }),
1537 );1537 );
1538});1538});
1539
1540describe("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+17−1
@@ -236,6 +236,17 @@ const McpStdioServerInputSchema = Schema.Struct({ · 235 unmodified lines
236 * add then auto-creates the connection holding them. The UI uses `envVars`236 * add then auto-creates the connection holding them. The UI uses `envVars`
237 * instead and leaves the values to the connect step. */237 * instead and leaves the values to the connect step. */
238 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),238 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)),
239 cwd: Schema.optional(Schema.String),250 cwd: Schema.optional(Schema.String),
240 /** Protocol negotiation at connect: `auto` probes `server/discover` (spec251 /** Protocol negotiation at connect: `auto` probes `server/discover` (spec
241 * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize`252 * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize`
@@ -406,18 +417,23 @@ const stdioEnvVarNames = (input: McpStdioServerInput): readonly string[] => { · 164 unmodified lines
406 return [...names];417 return [...names];
407};418};
408 419
409const 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. */
423export const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => {
410 if (input.transport === "stdio") {424 if (input.transport === "stdio") {
411 // The config only DECLARES the secret env vars by NAME (a `stdio_env`425 // The config only DECLARES the secret env vars by NAME (a `stdio_env`
412 // method); their values are credentials and live on the connection, never426 // method); their values are credentials and live on the connection, never
413 // in this blob. Names come from the explicit `envVars` declaration and/or427 // in this blob. Names come from the explicit `envVars` declaration and/or
414 // the keys of any one-shot `env` values.428 // the keys of any one-shot `env` values.
415 const vars = stdioEnvVarNames(input);429 const vars = stdioEnvVarNames(input);
430 const staticEnv = input.staticEnv;
416 return {431 return {
417 transport: "stdio",432 transport: "stdio",
418 family: input.family?.trim() || undefined,433 family: input.family?.trim() || undefined,
419 command: input.command,434 command: input.command,
420 args: input.args ? [...input.args] : undefined,435 args: input.args ? [...input.args] : undefined,
436 env: staticEnv !== undefined && Object.keys(staticEnv).length > 0 ? staticEnv : undefined,
421 cwd: input.cwd,437 cwd: input.cwd,
422 versionNegotiation: input.versionNegotiation,438 versionNegotiation: input.versionNegotiation,
423 spawnPerCall: input.spawnPerCall,439 spawnPerCall: input.spawnPerCall,