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+
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+9−1
@@ -178,7 +178,9 @@ scenario( · 177 unmodified lines
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( · 15 unmodified lines
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+3−0
@@ -58,6 +58,9 @@ const AddStdioServerPayload = Schema.Struct({ · 57 unmodified lines
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+2−0
@@ -39,6 +39,7 @@ const toServerInput = ( · 38 unmodified lines
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 = ( · 9 unmodified lines
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+5−1
@@ -53,7 +53,11 @@ export default function CodexPluginAdd(props: { · 52 unmodified lines
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+38−1
@@ -24,7 +24,7 @@ import { · 23 unmodified lines
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", () => { · 1505 unmodified lines
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+17−1
@@ -236,6 +236,17 @@ const McpStdioServerInputSchema = Schema.Struct({ · 235 unmodified lines
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[] => { · 164 unmodified lines
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,