Unified · Split
| @@ -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. | |
| @@ -178,7 +178,9 @@ scenario( · 177 unmodified lines | ||
| 178 | 178 | command: plugin.command, |
| 179 | 179 | args: [...plugin.args], |
| 180 | 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 | 184 | ...(plugin.appServer === undefined |
| 183 | 185 | ? {} |
| 184 | 186 | : { appServer: { server: plugin.appServer.server } }), |
| @@ -200,6 +202,12 @@ scenario( · 15 unmodified lines | ||
| 200 | 202 | connections.map((connection) => String(connection.name)), |
| 201 | 203 | `${slug} auto-connected`, |
| 202 | 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 | 212 | const tools = yield* client.tools.list({ query: { integration: slug } }); |
| 205 | 213 | const names = tools.map((tool) => tool.name); |
| @@ -58,6 +58,9 @@ const AddStdioServerPayload = Schema.Struct({ · 57 unmodified lines | ||
| 58 | 58 | envVars: Schema.optional(Schema.Array(Schema.String)), |
| 59 | 59 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ |
| 60 | 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 | 64 | cwd: Schema.optional(Schema.String), |
| 62 | 65 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec |
| 63 | 66 | * 2026-07-28) for modern-only servers; default is the legacy `initialize` |
| @@ -39,6 +39,7 @@ const toServerInput = ( · 38 unmodified lines | ||
| 39 | 39 | args?: readonly string[]; |
| 40 | 40 | envVars?: readonly string[]; |
| 41 | 41 | env?: Record<string, string>; |
| 42 | + staticEnv?: Record<string, string>; | |
| 42 | 43 | cwd?: string; |
| 43 | 44 | versionNegotiation?: "legacy" | "auto"; |
| 44 | 45 | spawnPerCall?: boolean; |
| @@ -54,6 +55,7 @@ const toServerInput = ( · 9 unmodified lines | ||
| 54 | 55 | args: p.args ? [...p.args] : undefined, |
| 55 | 56 | envVars: p.envVars ? [...p.envVars] : undefined, |
| 56 | 57 | env: p.env, |
| 58 | + staticEnv: p.staticEnv, | |
| 57 | 59 | cwd: p.cwd, |
| 58 | 60 | versionNegotiation: p.versionNegotiation, |
| 59 | 61 | spawnPerCall: p.spawnPerCall, |
| @@ -53,7 +53,11 @@ export default function CodexPluginAdd(props: { · 52 unmodified lines | ||
| 53 | 53 | command: plugin.command, |
| 54 | 54 | args: [...plugin.args], |
| 55 | 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 | 61 | ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}), |
| 58 | 62 | }, |
| 59 | 63 | reactivityKeys: integrationWriteKeys, |
| @@ -24,7 +24,7 @@ import { · 23 unmodified lines | ||
| 24 | 24 | } from "@executor-js/sdk/testing"; |
| 25 | 25 | |
| 26 | 26 | import { createMcpConnector } from "./connection"; |
| 27 | −import { mcpPlugin, userFacingProbeMessage } from "./plugin"; | |
| 27 | +import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin"; | |
| 28 | 28 | import { McpInvocationError } from "./errors"; |
| 29 | 29 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; |
| 30 | 30 | import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; |
| @@ -1536,3 +1536,40 @@ describe("mcpPlugin endpoint telemetry", () => { · 1505 unmodified lines | ||
| 1536 | 1536 | }), |
| 1537 | 1537 | ); |
| 1538 | 1538 | }); |
| 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 | +}); | |
| @@ -236,6 +236,17 @@ const McpStdioServerInputSchema = Schema.Struct({ · 235 unmodified lines | ||
| 236 | 236 | * add then auto-creates the connection holding them. The UI uses `envVars` |
| 237 | 237 | * instead and leaves the values to the connect step. */ |
| 238 | 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 | 250 | cwd: Schema.optional(Schema.String), |
| 240 | 251 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec |
| 241 | 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 | 417 | return [...names]; |
| 407 | 418 | }; |
| 408 | 419 | |
| 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 => { | |
| 410 | 424 | if (input.transport === "stdio") { |
| 411 | 425 | // The config only DECLARES the secret env vars by NAME (a `stdio_env` |
| 412 | 426 | // method); their values are credentials and live on the connection, never |
| 413 | 427 | // in this blob. Names come from the explicit `envVars` declaration and/or |
| 414 | 428 | // the keys of any one-shot `env` values. |
| 415 | 429 | const vars = stdioEnvVarNames(input); |
| 430 | + const staticEnv = input.staticEnv; | |
| 416 | 431 | return { |
| 417 | 432 | transport: "stdio", |
| 418 | 433 | family: input.family?.trim() || undefined, |
| 419 | 434 | command: input.command, |
| 420 | 435 | args: input.args ? [...input.args] : undefined, |
| 436 | + env: staticEnv !== undefined && Object.keys(staticEnv).length > 0 ? staticEnv : undefined, | |
| 421 | 437 | cwd: input.cwd, |
| 422 | 438 | versionNegotiation: input.versionNegotiation, |
| 423 | 439 | spawnPerCall: input.spawnPerCall, |