diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 58e57e4..3976b34 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -431,20 +431,11 @@ Where each tool's servers land: Codex supports `stdio` and `http`; `sse` is skipped. Ownership is tracked in `~/.teamai/managed-mcp.json` — hand-added servers are left alone; name collisions skip unless `--force`. -**Secrets.** Write `${VAR}`, never a literal. Values resolve from the environment, then from `env/env.yaml` → `~/.teamai/env`. Unresolved variables skip the server with a hint. +**Secrets.** Write `${VAR}`, never a literal, in `mcp.yaml`. Values resolve from the environment, then from `env/env.yaml` → `~/.teamai/env`. Unresolved variables skip the server with a hint. -Where the tool can expand env vars itself, teamai keeps the secret off disk and writes only the placeholder, in that tool's own syntax: +teamai **resolves every `${VAR}` to its value and writes it verbatim** into each tool's config (new files are created `0600`). It does not rely on any tool's own env-var expansion: that expansion is fragile — most decisively, IDEs launched from the GUI (Dock/Launchpad) never inherit your shell's exported variables, so a `${VAR}` placeholder expands to empty and the server 401s. Resolving to plaintext makes the token present no matter how the tool is started. -| tool | on disk | -|---|---| -| Claude (project `.mcp.json`) | `${VAR}` | -| Cursor | `${env:VAR}` | -| Codex | `bearer_token_env_var` / `env_http_headers` (variable name only) | -| CodeBuddy | resolved to plaintext (see below) | - -Everywhere else — Claude at **user** scope, CodeBuddy, or any placeholder Codex cannot express as a whole-header variable — the value is resolved and written verbatim into the target file (new files are created `0600`). - -> ⚠️ **CodeBuddy is resolved to plaintext on purpose.** Its IDE runs as a GUI app that never inherits your shell's exported variables, so a `${VAR}` placeholder would expand to empty and the server would 401. teamai therefore writes the resolved token into `.codebuddy/mcp.json`. Do not commit that file — add it to `.gitignore`. Cursor and Claude keep the placeholder in every scope, so a committed `.cursor/mcp.json` / `.mcp.json` carries the variable name, not the value. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.cursor/mcp.json`, `.codebuddy/mcp.json`, `.codex/config.toml`) then contain the literal secret — add them to `.gitignore` and never commit them. Claude Code may show project `.mcp.json` servers as pending approval until you accept them once in an interactive session. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2436af2..8eb02da 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -429,20 +429,11 @@ servers: Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。 -**密钥**:写 `${VAR}`,不要写明文。取值优先来自环境变量,其次是 `env/env.yaml` → `~/.teamai/env`。变量无法解析则跳过并提示。 +**密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。取值优先来自环境变量,其次是 `env/env.yaml` → `~/.teamai/env`。变量无法解析则跳过并提示。 -对于自身能展开环境变量的工具,teamai 让密钥不落盘,只写入占位符,且使用该工具各自的语法: +teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -| 工具 | 落盘内容 | -|---|---| -| Claude(项目级 `.mcp.json`) | `${VAR}` | -| Cursor | `${env:VAR}` | -| Codex | `bearer_token_env_var` / `env_http_headers`(只记变量名) | -| CodeBuddy | 解析为明文(见下) | - -其余情况——用户级的 Claude、CodeBuddy,或 Codex 无法用「整个 header 一个变量」表达的占位符——会把取值解析后原样写入目标文件(新建文件权限为 `0600`)。 - -> ⚠️ **CodeBuddy 是有意解析为明文的。** 它的 IDE 以 GUI 应用方式启动,不会继承你 shell 中 `export` 的变量,因此 `${VAR}` 占位符会展开为空、导致服务端 401。为此 teamai 会把解析后的 token 写入 `.codebuddy/mcp.json`。请勿提交该文件——把它加入 `.gitignore`。Cursor 与 Claude 在所有 scope 下都保留占位符,因此即便 `.cursor/mcp.json` / `.mcp.json` 被提交,里面记的也是变量名而非取值。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.cursor/mcp.json`、`.codebuddy/mcp.json`、`.codex/config.toml`)因此含有明文密钥——请把它们加入 `.gitignore`,切勿提交。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index c6d2b00..969c86e 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -154,8 +154,8 @@ servers: }); it('skips a server whose ${VAR} cannot be resolved instead of injecting it broken', async () => { - // Scoped to claude at user scope — the target that resolves ${VAR} onto disk. - // Cursor passes placeholders through in its own syntax and never reaches this path. + // Every tool resolves ${VAR} onto disk now, so an unresolvable var skips the + // server everywhere; scoped to claude here just to keep the assertion focused. await writeMcpYaml(` servers: - name: needs-token @@ -291,7 +291,7 @@ servers: expect(await fse.pathExists(path.join(projectRoot, '.mcp.json'))).toBe(true); }); - it('writes a project secret in each tool\'s own form: placeholder for claude/cursor, plaintext for codebuddy', async () => { + it('resolves a project secret to plaintext in every tool, keyed off `type`', async () => { const projectRoot = path.join(tmpDir, 'proj2'); for (const d of ['.claude', '.cursor', '.codebuddy']) { await fse.ensureDir(path.join(projectRoot, d, 'skills')); @@ -313,31 +313,22 @@ servers: await reconcileMcpForConfig(teamConfig, projectConfig); - // Claude expands ${VAR} itself, so the placeholder is passed through intact. + // teamai resolves every secret to plaintext rather than relying on any tool's + // own ${VAR} expansion, which is fragile (GUI IDEs never inherit shell exports, + // so a placeholder resolves to empty and the server 401s). Each remote server + // keys its transport off `type`, not the ignored `transportType`. const claudeDoc = await fse.readJson(path.join(projectRoot, '.mcp.json')); - expect(claudeDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer ${SECRET_TOKEN}'); + expect(claudeDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer super-secret-value'); - // CodeBuddy's IDE is a GUI app that never inherits the user's shell exports, - // so a ${VAR} placeholder resolves to empty and 401s. We resolve to plaintext - // instead — the token is present regardless of how the tool is launched. The - // transport is keyed off `type` (not the ignored `transportType`). const buddyDoc = await fse.readJson(path.join(projectRoot, '.codebuddy', 'mcp.json')); expect(buddyDoc.mcpServers['with-secret'].type).toBe('http'); expect(buddyDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer super-secret-value'); - // Cursor interpolates ${env:NAME}, so the placeholder is rewritten into that syntax. const cursorDoc = await fse.readJson(path.join(projectRoot, '.cursor', 'mcp.json')); expect(cursorDoc.mcpServers['with-secret'].type).toBe('http'); - expect(cursorDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer ${env:SECRET_TOKEN}'); + expect(cursorDoc.mcpServers['with-secret'].headers.Authorization).toBe('Bearer super-secret-value'); expect(cursorDoc.mcpServers['no-secret']).toBeDefined(); - // Claude and cursor keep the secret off disk; codebuddy is the deliberate - // exception, so it is excluded from the no-plaintext check. - for (const f of ['.mcp.json', '.cursor/mcp.json']) { - const raw = await fse.readFile(path.join(projectRoot, f), 'utf-8'); - expect(raw).not.toContain('super-secret-value'); - } - delete process.env.SECRET_TOKEN; }); @@ -376,9 +367,11 @@ servers: expect(changes.some((c) => c.server === 'evil' && c.action === 'added')).toBe(false); }); - // Verified against codex-cli 0.142.5: it speaks streamable HTTP, and names the - // env var for a bearer token rather than storing the value. - it('writes an http server into codex config.toml, naming the token env var', async () => { + // Verified against codex-cli 0.142.5: it speaks streamable HTTP. Secrets are + // resolved to plaintext like every other tool — codex's env-var naming + // (`bearer_token_env_var`) is not used, so the token is present regardless of + // how codex is launched. + it('writes an http server into codex config.toml with the token resolved to plaintext', async () => { await fse.ensureDir(path.join(homeDir, '.codex', 'skills')); process.env.REMOTE_TOKEN = 'super-secret-value'; await writeMcpYaml(` @@ -399,18 +392,18 @@ servers: const toml = await fse.readFile(path.join(homeDir, '.codex', 'config.toml'), 'utf-8'); expect(toml).toContain('url = "https://example.com/mcp"'); - expect(toml).toContain('bearer_token_env_var = "REMOTE_TOKEN"'); - expect(toml).toContain('env_http_headers = { "X-Trace" = "TRACE_ID" }'); - expect(toml).toContain('http_headers = { "X-Team" = "literal-value" }'); + // All headers resolve to plaintext and land in http_headers; no env-var naming. + expect(toml).not.toContain('bearer_token_env_var'); + expect(toml).not.toContain('env_http_headers'); + expect(toml).toContain('"Authorization" = "Bearer super-secret-value"'); + expect(toml).toContain('"X-Trace" = "trace-1"'); + expect(toml).toContain('"X-Team" = "literal-value"'); expect(toml).toContain('startup_timeout_sec = 600'); - // The point of naming the variable: the value never reaches the file. - expect(toml).not.toContain('super-secret-value'); - expect(toml).not.toContain('trace-1'); delete process.env.REMOTE_TOKEN; delete process.env.TRACE_ID; }); - it('resolves a codex placeholder it cannot name, such as one inside the url', async () => { + it('resolves a codex placeholder inside the url to plaintext', async () => { await fse.ensureDir(path.join(homeDir, '.codex', 'skills')); process.env.REGION = 'eu'; await writeMcpYaml(` diff --git a/src/resources/mcp-format.ts b/src/resources/mcp-format.ts index ccd3fce..8c70f63 100644 --- a/src/resources/mcp-format.ts +++ b/src/resources/mcp-format.ts @@ -46,28 +46,19 @@ export function supportsTransport(format: McpFormat, transport: McpTransport): b * Whether the target file keeps the secret out of itself, letting the ${VAR} be * written through verbatim rather than resolved onto disk. * - * Each tool manages it by different means: - * claude expands env vars, but only in a project-scope .mcp.json. - * cursor interpolates ${env:NAME} anywhere, in every scope — the renderer - * rewrites our ${NAME} into that syntax. - * codebuddy the CLI would expand a bare ${NAME}, but the IDE runs as a GUI app - * that never inherits the user's shell exports, so the placeholder - * resolves to empty and the server 401s. We resolve to plaintext - * instead so the token is present regardless of how the tool starts. - * codex names the variable instead of holding its value - * (`bearer_token_env_var`, `env_http_headers`) — but those fields - * only name a variable for a whole header value, so a placeholder - * embedded in a larger string, or one in the URL, still has to be - * resolved. + * Always false: teamai resolves every secret to plaintext before writing it. + * Env-var passthrough was tried per tool, but each tool expands variables under + * different, fragile conditions — most decisively, IDEs launched from the GUI + * never inherit the user's shell exports, so a ${VAR} placeholder resolves to + * empty and the server 401s. Resolving to plaintext makes the token present no + * matter how the tool is started, at the cost of the value landing on disk (new + * files are created 0600; users should gitignore project-scope MCP configs). */ export function supportsEnvExpansion( - format: McpFormat, - projectScope: boolean, - def?: McpServerDef, + _format: McpFormat, + _projectScope: boolean, + _def?: McpServerDef, ): boolean { - if (format === 'claude') return projectScope; - if (format === 'cursor') return true; - if (format === 'codex' && def?.transport === 'http') return codexCanNameEveryVar(def); return false; } @@ -76,15 +67,6 @@ const BEARER_PLACEHOLDER_RE = /^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; /** A header whose entire value is one placeholder. */ const WHOLE_PLACEHOLDER_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; -function codexCanNameEveryVar(def: McpServerDef): boolean { - if (def.url?.includes('${')) return false; - for (const value of Object.values(def.headers ?? {})) { - if (!value.includes('${')) continue; - if (!BEARER_PLACEHOLDER_RE.test(value) && !WHOLE_PLACEHOLDER_RE.test(value)) return false; - } - return true; -} - interface CodexHeaderPlan { bearerTokenEnvVar?: string; envHttpHeaders: Record;