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 | 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 === undefined | 184 | ...(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); |
| @@ -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` (spec | 65 | /** 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` |
| @@ -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, |
| @@ -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, |
| @@ -24,7 +24,7 @@ import { · 23 unmodified lines | |||
| 24 | } from "@executor-js/sdk/testing"; | 24 | } from "@executor-js/sdk/testing"; |
| 25 | 25 | ||
| 26 | import { createMcpConnector } from "./connection"; | 26 | import { createMcpConnector } from "./connection"; |
| 27 | import { mcpPlugin, userFacingProbeMessage } from "./plugin"; | 27 | import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin"; |
| 28 | import { McpInvocationError } from "./errors"; | 28 | import { McpInvocationError } from "./errors"; |
| 29 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; | 29 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; |
| 30 | import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; | 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 | * 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` (spec | 251 | /** 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 | ||
| 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 | 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, never | 426 | // method); their values are credentials and live on the connection, never |
| 413 | // in this blob. Names come from the explicit `envVars` declaration and/or | 427 | // 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, |