From 896f80a63751d22b0954532ef05979aab688b88e Mon Sep 17 00:00:00 2001 From: jeffapple Date: Fri, 9 Oct 2026 14:45:59 +0000 Subject: [PATCH 1/2] fix(mcp): resolve a ${VAR} only mcp.yaml names as the member's own secret A `${VAR}` an MCP server references that no secrets file declares and no env.yaml sets is a personal token the team repo cannot hold. It is now treated as a declared secret everywhere: `teamai env set VAR` stores the member's value, a pull resolves it from their stores and environment, a pull that cannot see the value keeps the entry an earlier pull wrote instead of deleting the server, and the missing-secret line names it. --- docs/designs/team-secrets.md | 8 +- docs/designs/team-secrets.zh-CN.md | 6 +- docs/usage-guide.md | 5 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/core/references/commands.md | 2 +- .../e2e/mcp-member-token-1011.test.ts | 193 ++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 12 +- src/env-commands.ts | 10 +- src/env-resolution.ts | 10 +- src/index.ts | 2 +- src/resources/secrets.ts | 48 ++++- 11 files changed, 279 insertions(+), 21 deletions(-) create mode 100644 src/__tests__/e2e/mcp-member-token-1011.test.ts diff --git a/docs/designs/team-secrets.md b/docs/designs/team-secrets.md index 7f97fe547..3e59d73d5 100644 --- a/docs/designs/team-secrets.md +++ b/docs/designs/team-secrets.md @@ -92,7 +92,7 @@ teamai env unset GITHUB_TOKEN [--global] ``` - The value is never taken from an argument, so it stays out of shell history. `--stdin` refuses a terminal. -- `env set` accepts a key the scope declares as a secret or, without `--global`, an `env.yaml` variable it receives; `--global` is for secrets only. On Windows the key may be typed in any case: `env set` and `env unset` use the name the scope declares, `env add --secret` and `env remove --secret` the name already declared, and a value stored under another case of the name is the key's. When the declarations or `env.yaml` cannot be read it changes nothing, since it cannot tell. A project config that exists but can't be read makes `env set`, `env unset` and `env list` fail with its path and why, rather than use the user scope, whose team may not be this project's. +- `env set` accepts a key the scope declares as a secret, a `${VAR}` its MCP servers reference that nothing declares or sets (an [implicit secret](#a-variable-only-mcpyaml-names-is-a-secret)), or, without `--global`, an `env.yaml` variable it receives; `--global` is for secrets only. On Windows the key may be typed in any case: `env set` and `env unset` use the name the scope declares, `env add --secret` and `env remove --secret` the name already declared, and a value stored under another case of the name is the key's. When the declarations or `env.yaml` cannot be read it changes nothing, since it cannot tell. A project config that exists but can't be read makes `env set`, `env unset` and `env list` fail with its path and why, rather than use the user scope, whose team may not be this project's. - Outside any scope (no project here and no user scope), `env set --global` accepts any valid key name and notes that no team declares it yet, so a member can set a token they reuse across teams ahead of time. `env unset` accepts any key that has a value. - `--from-env` warns when the variable is not set in the current shell. While it is unset, the secret is `missing`: the next source in the [order](#resolution) is not used instead, since that could be another account's token. - Run `teamai pull` afterwards to update the MCP servers, and `env.sh` for a variable. @@ -146,6 +146,10 @@ the member's value for this team teamai env set KEY [--from-env VAR] **Out of git.** A resolved value lands in a project-scope MCP config only once the clone's `.git/info/exclude` lists the file (#882). An exclude rule does not stop a file git already tracks, so no resolved value, declared secret or not, is written into a project config `git ls-files` tracks: pull leaves that file as it was (an entry an earlier pull wrote stays), and `pull` (a warning), `teamai mcp list` (`withheld:`) and `teamai doctor` (`MCP servers delivered to ` fails) name the file and the fix: `git rm --cached `, and rotate the token if it was ever committed. An exclusion that fails for another reason (`.git/info` or the exclude file not writable, another teamai command holding it, a git error) leaves the file as it was the same way, with that reason and its fix. +## A variable only mcp.yaml names is a secret + +A `${VAR}` in `mcp/mcp.yaml` (or an active `mcp//mcp.yaml`) that no secrets file declares and no `env.yaml` sets is a member's own token: the team repo cannot hold its value, and a team that has no `env/` at all still references it (#1011). Every consumer treats it as a declared secret, under the file that references it: `teamai env set VAR` stores the member's value; a pull resolves it in the secret [order](#resolution), the member's own environment last; a server whose value a pull cannot see keeps the entry an earlier pull wrote (below), so the session-start pull, which runs without the member's shell exports, no longer removes it; and the [missing line](#a-missing-secret-tells-the-member-what-to-run) names it with the servers that use it. `env list` shows it under `Team secrets` as `used by MCP server `. Declaring it in `env/secrets.yaml` adds a description and a `url`; it changes nothing else. While the secrets files, `env.yaml` or `mcp.yaml` cannot be read, nothing is implied: the command reports that failure. + ## A missing secret keeps the MCP entry `${VAR}` in `mcp/mcp.yaml` can name a declared secret. The session-start pull runs in the agent's environment, which often lacks the member's shell exports (a GUI-launched tool, or a zsh export under `bash -lc`), so a secret can be there for one pull and gone for the next. When a pull finds no value for a server's declared secret: @@ -153,7 +157,7 @@ the member's value for this team teamai env set KEY [--from-env VAR] - A server an earlier pull wrote keeps its entry in each tool's config, as it is, and teamai still manages it: a later pull that finds a value updates it. - A server no pull has written yet is skipped, as before. - The entry is removed when its server leaves `mcp.yaml`, and by `teamai mcp remove`, `teamai uninstall`, and `teamai init` when it moves the Claude Code root. -- A server that also misses a variable not declared as a secret is removed, as before. Variables that aren't declared as secrets keep today's behaviour. +- A server that also misses a variable only `mcp.yaml` names is kept the same way: that variable is an [implicit secret](#a-variable-only-mcpyaml-names-is-a-secret). An `env.yaml` variable always has the team's value, so it is never the one missing. A kept entry holds the value the earlier pull wrote. After a secret is rotated or revoked, the server keeps the old value until a pull finds the new one. diff --git a/docs/designs/team-secrets.zh-CN.md b/docs/designs/team-secrets.zh-CN.md index 596be9c5d..df423a259 100644 --- a/docs/designs/team-secrets.zh-CN.md +++ b/docs/designs/team-secrets.zh-CN.md @@ -146,6 +146,10 @@ teamai env unset GITHUB_TOKEN [--global] **不进入 git。** 只有在本地克隆的 `.git/info/exclude` 列出某个项目级 MCP 配置之后,解析后的值才会写入该文件(#882)。exclude 规则挡不住 git 已跟踪的文件,因此无论是否为已声明密钥,解析后的值都不会写入 `git ls-files` 已跟踪的项目配置:pull 保持该文件原样(之前 pull 写入的条目保留),`pull`(警告)、`teamai mcp list`(`withheld:`)和 `teamai doctor`(`MCP servers delivered to ` 失败)会指出该文件和修复方法:`git rm --cached `,如果它曾随 token 一起提交过,还要轮换 token。因其他原因无法排除时(`.git/info` 或 exclude 文件不可写、另一个 teamai 命令占用它、git 出错),同样保持该文件原样,并给出对应的原因与修复方法。 +## 只在 mcp.yaml 中出现的变量就是密钥 + +`mcp/mcp.yaml`(或生效的 `mcp//mcp.yaml`)里的 `${VAR}`,若没有任何 secrets 文件声明、也没有任何 `env.yaml` 设置,就是成员自己的 token:团队仓库放不下它的值,而一个根本没有 `env/` 的团队也会引用它(#1011)。所有使用方都把它当作已声明的密钥,归属于引用它的那个文件:`teamai env set VAR` 保存成员的值;pull 按密钥的[解析顺序](#解析顺序)解析它,成员自己的环境排在最后;某次 pull 看不到值时,保留上一次 pull 写入的条目(见下节),因此在没有成员 shell 导出的 session-start pull 中不再删除它;[缺少密钥的提示](#缺少密钥时告诉成员该运行什么)会连同使用它的 server 一起列出它。`env list` 在 `Team secrets` 下把它显示为 `used by MCP server `。在 `env/secrets.yaml` 中声明它只是补充 description 和 `url`,其他都不变。secrets 文件、`env.yaml` 或 `mcp.yaml` 无法读取时不做任何推断:命令会报告那个失败。 + ## 缺少密钥时保留 MCP 条目 `mcp/mcp.yaml` 中的 `${VAR}` 可以引用已声明的密钥。会话开始时的 pull 运行在 agent 的环境里,而这个环境常常没有成员 shell 中导出的变量(从图形界面启动的工具,或在 `bash -lc` 下读不到的 zsh export),所以一个密钥可能这次 pull 能找到、下次就找不到。pull 找不到某个 server 所需的已声明密钥时: @@ -153,7 +157,7 @@ teamai env unset GITHUB_TOKEN [--global] - 之前某次 pull 写入过的 server 会在每个工具的配置中原样保留,并仍由 teamai 管理:之后某次 pull 找到值时会更新它。 - 还没有任何 pull 写入过的 server 照旧跳过。 - 该 server 从 `mcp.yaml` 中移除时,条目随之删除;`teamai mcp remove`、`teamai uninstall`,以及 `teamai init` 迁移 Claude Code 根目录时,也会删除它。 -- 同时缺少某个未声明为密钥的变量的 server 照旧删除。未声明为密钥的变量保持现有行为。 +- 同时缺少某个只在 `mcp.yaml` 中出现的变量的 server 同样保留:该变量是[隐式密钥](#只在-mcpyaml-中出现的变量就是密钥)。`env.yaml` 变量总有团队的值,不会是缺少的那个。 保留下来的条目里是之前那次 pull 写入的值。密钥轮换或吊销后,server 会一直使用旧值,直到某次 pull 找到新值。 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index cf189e229..cf63f2a59 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1194,7 +1194,8 @@ teamai env set GITHUB_TOKEN --global # for every team on th teamai env unset GITHUB_TOKEN [--global] ``` -`env set` accepts a key the scope declares as a secret or, without `--global`, an +`env set` accepts a key the scope declares as a secret, a `${VAR}` its MCP servers +reference that nothing declares or sets, or, without `--global`, an `env.yaml` variable it receives, and stores the value in `~/.teamai/secrets/teams/.json` (mode `0600`), one file per team repo, named by the team repo URL in your `~/.teamai/config.yaml` (not `teamai.yaml`'s `repo:`) so renaming `team:` keeps it; with `--global`, in `~/.teamai/secrets/machine.json`, for every team on the @@ -1356,7 +1357,7 @@ An HTTP local-agent update keeps the existing JSON MCP ownership record until th Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, remote transports keep `http` or `sse`, and every managed entry gets the required `tools: ["*"]` allowlist. TeamAI honors `COPILOT_HOME`; project configuration uses Copilot CLI's documented `.github/mcp.json` repository location. See [Adding MCP servers for GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers). Codex supports `stdio` and `http`; `sse` is skipped. Qoder supports the Claude-compatible `mcpServers` format in its scope-specific `.qoder/settings.json`. Kiro supports the same `mcpServers` format in its dedicated, mcpServers-only `.kiro/settings/mcp.json` (see [Kiro's MCP configuration docs](https://kiro.dev/docs/mcp/configuration/)). OpenCode supports `stdio` (written as its `type:"local"` shape), `http`, and `sse` (both `type:"remote"`, negotiated by its client); its servers live under the `mcp` key of the shared `opencode.json`. 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, in `mcp.yaml`. A key the team declares in `env/secrets.yaml` resolves from your value for this team (`teamai env set`), then your value for the machine (`teamai env set --global`), then your own environment, which leaves out values a teamai `env.sh` exported (see [Team secrets](designs/team-secrets.md#resolution)). Any other variable resolves from your value for this team (`teamai env set KEY`), then from the team env variables this directory receives (`env/env.yaml` and the active `env//env.yaml`); the environment fills only a key the team sets nothing for, and no longer overrides a team variable (see [Team secrets](designs/team-secrets.md#variables)). An interactive `pull` and `teamai doctor` say when your export differs from the team's value and is ignored. Unresolved variables skip the server with a hint. A declared secret is different: when a pull can't find it, the entry an earlier pull wrote stays as it is, so it may hold a value that was since rotated, until a pull finds the new one (see [Team secrets](designs/team-secrets.md#a-missing-secret-keeps-the-mcp-entry)). An interactive `pull`, `teamai mcp list`, `teamai env list`, `teamai doctor` and `teamai env exec` name a declared secret with no value, the servers that use it and the command that sets it: `` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (). `` +**Secrets.** Write `${VAR}`, never a literal, in `mcp.yaml`. A key the team declares in `env/secrets.yaml` resolves from your value for this team (`teamai env set`), then your value for the machine (`teamai env set --global`), then your own environment, which leaves out values a teamai `env.sh` exported (see [Team secrets](designs/team-secrets.md#resolution)). Any other variable resolves from your value for this team (`teamai env set KEY`), then from the team env variables this directory receives (`env/env.yaml` and the active `env//env.yaml`); the environment fills only a key the team sets nothing for, and no longer overrides a team variable (see [Team secrets](designs/team-secrets.md#variables)). An interactive `pull` and `teamai doctor` say when your export differs from the team's value and is ignored. A `${VAR}` that nothing declares and no `env.yaml` sets is your own token, resolved as a declared secret (see [Team secrets](designs/team-secrets.md#a-variable-only-mcpyaml-names-is-a-secret)). A declared secret is different: when a pull can't find it, the entry an earlier pull wrote stays as it is, so it may hold a value that was since rotated, until a pull finds the new one (see [Team secrets](designs/team-secrets.md#a-missing-secret-keeps-the-mcp-entry)). An interactive `pull`, `teamai mcp list`, `teamai env list`, `teamai doctor` and `teamai env exec` name a declared secret with no value, the servers that use it and the command that sets it: `` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (). `` teamai **resolves every `${VAR}` to its value and writes it verbatim** into each tool's config, which is then written `0600`, an existing `0644` one included (a config without a resolved value keeps its mode; 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. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b46d2aadf..4de18352f 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1071,7 +1071,7 @@ teamai env set GITHUB_TOKEN --global # 对本机所有团 teamai env unset GITHUB_TOKEN [--global] ``` -`env set` 接受已声明的密钥,不加 `--global` 时也接受该目录收到的 `env.yaml` 变量,并把值保存在 `~/.teamai/secrets/teams/.json` +`env set` 接受已声明的密钥、该 scope 的 MCP server 引用但没有任何声明或设置的 `${VAR}`,不加 `--global` 时也接受该目录收到的 `env.yaml` 变量,并把值保存在 `~/.teamai/secrets/teams/.json` (权限 `0600`),每个团队仓库一个文件,按你的 `~/.teamai/config.yaml` 中的团队仓库 URL 命名(不使用 `teamai.yaml` 的 `repo:`),修改 `team:` 不影响它;加 `--global` 时保存在 `~/.teamai/secrets/machine.json`, 对本机所有团队生效,为某个团队设置的值仍然优先。不在任何 scope 中时,`--global` 接受任何合法的 key, 并提示目前还没有团队声明它。值保持设置时该 key 的类型:团队不再声明某个同时在 `env.yaml` 中设置的密钥后,你的值不会用于该变量,`env list` 会提示先运行 `teamai env unset KEY`,再运行 `teamai env set KEY`。`teamai env list` 和 `teamai list env` 会把每个已声明的密钥 @@ -1209,7 +1209,7 @@ HTTP local-agent 更新 JSON MCP 配置时,先保留原有 ownership 记录, Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远程传输保留 `http` 或 `sse`,每个 TeamAI 管理的条目都会带上必需的 `tools: ["*"]` 允许列表。TeamAI 遵循 `COPILOT_HOME`,项目配置使用 Copilot CLI 官方文档指定的 `.github/mcp.json` 仓库路径。详见 [GitHub Copilot CLI 添加 MCP Server](https://docs.github.com/zh/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers)。Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。Qoder 使用对应作用域 `.qoder/settings.json` 中与 Claude 兼容的 `mcpServers` 格式。Kiro 在专用的、只含 `mcpServers` 的 `.kiro/settings/mcp.json` 中使用同一格式(见 [Kiro MCP 配置文档](https://kiro.dev/docs/mcp/configuration/))。OpenCode 支持 `stdio`(写成其 `type:"local"` 形态)、`http` 和 `sse`(后两者均为 `type:"remote"`,由客户端协商传输协议),其 server 位于共享 `opencode.json` 的 `mcp` 键下。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。 -**密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。团队在 `env/secrets.yaml` 中声明的 key 优先取你为该团队设置的值(`teamai env set`),其次取你为本机设置的值(`teamai env set --global`),再次取你自己的环境,不包括 teamai `env.sh` 导出的值(见[团队密钥](designs/team-secrets.zh-CN.md#解析顺序))。其他变量优先取你为该团队设置的值(`teamai env set KEY`),其次是该目录收到的团队环境变量(`env/env.yaml` 与活动的 `env//env.yaml`);环境只补充团队没有设置的 key,不再覆盖团队变量(见[团队密钥](designs/team-secrets.zh-CN.md#变量))。你导出的值与团队的值不同而被忽略时,交互式 `pull` 和 `teamai doctor` 会指出。变量无法解析则跳过并提示。已声明的密钥不同:pull 找不到它时,之前某次 pull 写入的条目原样保留,因此里面可能是已经轮换掉的旧值,直到某次 pull 找到新值(见[团队密钥](designs/team-secrets.zh-CN.md#缺少密钥时保留-mcp-条目))。交互式 `pull`、`teamai mcp list`、`teamai env list`、`teamai doctor` 和 `teamai env exec` 会指出没有值的已声明密钥、用到它的 server 以及设置它的命令:`` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (). `` +**密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。团队在 `env/secrets.yaml` 中声明的 key 优先取你为该团队设置的值(`teamai env set`),其次取你为本机设置的值(`teamai env set --global`),再次取你自己的环境,不包括 teamai `env.sh` 导出的值(见[团队密钥](designs/team-secrets.zh-CN.md#解析顺序))。其他变量优先取你为该团队设置的值(`teamai env set KEY`),其次是该目录收到的团队环境变量(`env/env.yaml` 与活动的 `env//env.yaml`);环境只补充团队没有设置的 key,不再覆盖团队变量(见[团队密钥](designs/team-secrets.zh-CN.md#变量))。你导出的值与团队的值不同而被忽略时,交互式 `pull` 和 `teamai doctor` 会指出。变量没有任何声明、也没有任何 `env.yaml` 设置的 `${VAR}` 是你自己的 token,按已声明的密钥解析(见[团队密钥](designs/team-secrets.zh-CN.md#只在-mcpyaml-中出现的变量就是密钥))。已声明的密钥不同:pull 找不到它时,之前某次 pull 写入的条目原样保留,因此里面可能是已经轮换掉的旧值,直到某次 pull 找到新值(见[团队密钥](designs/team-secrets.zh-CN.md#缺少密钥时保留-mcp-条目))。交互式 `pull`、`teamai mcp list`、`teamai env list`、`teamai doctor` 和 `teamai env exec` 会指出没有值的已声明密钥、用到它的 server 以及设置它的命令:`` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (). `` teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件,并以 `0600` 写入该文件,已有的 `0644` 文件也会收紧(不含已解析值的配置保持原权限;新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 1031dd830..983fb9ea1 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -203,7 +203,7 @@ Generated: do not edit by hand. Regenerate with - `--secret` — Remove the declared secret only (env/secrets.yaml), for a key env.yaml also sets - `--role ` — Remove from env// instead of env/ - `--project ` — Remove from the project's env namespace (resources.env in manifest/projects.yaml) - - `teamai env set ` — Set your value for a secret the team declares, or an env variable it sets, for this directory's team, on this machine (prompts without echo) + - `teamai env set ` — Set your value for a secret the team declares, a ${VAR} its MCP servers reference, or an env variable it sets, for this directory's team, on this machine (prompts without echo) - `--stdin` — Read the value from piped stdin - `--from-env ` — Read the value from this environment variable each time it is used; no copy is stored - `--global` — Set a secret for every team on this machine; a value set for a team still wins diff --git a/src/__tests__/e2e/mcp-member-token-1011.test.ts b/src/__tests__/e2e/mcp-member-token-1011.test.ts new file mode 100644 index 000000000..be3be3d77 --- /dev/null +++ b/src/__tests__/e2e/mcp-member-token-1011.test.ts @@ -0,0 +1,193 @@ +/** + * E2E (#1011): a `${VAR}` an MCP server references that no secrets file + * declares and no env.yaml sets is the member's own token. + * + * The team repo has no `env/` at all and `mcp/mcp.yaml` names `tapd` with + * `${TAPD_ACCESS_TOKEN}` in a header. Driven through the real CLI binary in a + * sandbox HOME, as the issue reproduces it: + * + * 1. a pull from a shell that exports the token installs `tapd`; + * 2. a pull that cannot see the export (the session-start hook's shell) used + * to delete the installed server — it now keeps the entry and says so; + * 3. `teamai env set TAPD_ACCESS_TOKEN` accepts the key, stores the value in + * the member's own 0600 file, and the next pull resolves the server from + * it with no export at all; the token is written nowhere else. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', + GIT_TERMINAL_PROMPT: '0', +}; + +const KEY = 'TAPD_ACCESS_TOKEN'; +const EXPORTED = 'exported-token-1011'; +const STORED = 'stored-token-1011'; + +interface RunResult { + code: number | null; + output: string; +} + +let sandbox: string; +let home: string; +let cwd: string; + +function git(args: string[], dir: string): void { + execFileSync('git', args, { cwd: dir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } }); +} + +const fwd = (value: string): string => value.split(path.sep).join('/'); + +function runCLI(args: string[], extraEnv: Record = {}, stdin = ''): Promise { + // The member's own shell export is never inherited from this process. + const env: Record = { + ...process.env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', [KEY]: undefined, ...extraEnv, + }; + delete env.CLAUDE_CONFIG_DIR; + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (d: Buffer) => { output += d.toString(); }); + child.stderr.on('data', (d: Buffer) => { output += d.toString(); }); + if (stdin) child.stdin.write(stdin); + child.stdin.end(); + child.on('close', (code) => resolve({ code, output })); + }); +} + +function cursorMcp(): Record }> { + const file = path.join(home, '.cursor', 'mcp.json'); + if (!fs.existsSync(file)) return {}; + return (JSON.parse(fs.readFileSync(file, 'utf8')) as { mcpServers?: Record }> }).mcpServers ?? {}; +} + +function secretsDir(): string { + return path.join(home, '.teamai', 'secrets', 'teams'); +} + +describe.skipIf(process.platform === 'win32')('MCP ${VAR} as the member\'s own token (#1011)', () => { + beforeAll(() => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-1011-e2e-'))); + home = path.join(sandbox, 'home'); + cwd = path.join(sandbox, 'cwd'); + const remote = path.join(sandbox, 'team.git'); + const seed = path.join(sandbox, 'seed'); + const localPath = path.join(home, '.teamai', 'team-repo'); + fs.mkdirSync(path.join(home, '.teamai'), { recursive: true }); + // Cursor is installed; the team declares no env at all. + fs.mkdirSync(path.join(home, '.cursor'), { recursive: true }); + fs.mkdirSync(cwd, { recursive: true }); + + fs.mkdirSync(path.join(seed, 'mcp'), { recursive: true }); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), [ + 'team: tapd-e2e', + `repo: ${fwd(remote)}`, + 'provider: git', + 'sharing:', + ' mcp:', + ' autoApply: true', + ' env:', + ' injectShellProfile: false', + '', + ].join('\n')); + fs.writeFileSync(path.join(seed, 'mcp', 'mcp.yaml'), [ + 'servers:', + ' - name: tapd', + ' transport: http', + ' url: https://tapd.example.com/mcp', + ' headers:', + ` X-Tapd-Access-Token: \${${KEY}}`, + '', + ].join('\n')); + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'seed'], seed); + git(['init', '-q', '--bare', '-b', 'main', remote], sandbox); + git(['push', '-q', remote, 'main'], seed); + git(['clone', '-q', remote, localPath], sandbox); + + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${fwd(localPath)}`, + ` remote: ${fwd(remote)}`, + ' kind: git', + 'username: alice', + 'scope: user', + 'enabledAgents: [cursor]', + '', + ].join('\n')); + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('a pull that sees the shell export installs the server', async () => { + const res = await runCLI(['pull', '--force'], { [KEY]: EXPORTED }); + expect(res.code, res.output).toBe(0); + expect(cursorMcp().tapd?.headers?.['X-Tapd-Access-Token']).toBe(EXPORTED); + }, 60_000); + + it('a pull that cannot see the export keeps the installed server and says so', async () => { + const res = await runCLI(['pull']); + expect(res.code, res.output).toBe(0); + // The entry an earlier pull wrote stays as it is. + expect(cursorMcp().tapd?.headers?.['X-Tapd-Access-Token'], res.output).toBe(EXPORTED); + expect(res.output).toMatch(new RegExp(`tapd: ${KEY} is not set\\. Run \`teamai env set ${KEY}\``)); + expect(res.output).toMatch(/tapd: the entry an earlier pull wrote stays in cursor/); + }, 60_000); + + it('env list shows the referenced variable as a team secret', async () => { + const res = await runCLI(['env', 'list']); + expect(res.code, res.output).toBe(0); + expect(res.output).toContain('Team secrets (1):'); + expect(res.output).toMatch(new RegExp(`${KEY}\\s+missing`)); + }); + + it('env set stores the member\'s value in their own 0600 file', async () => { + const res = await runCLI(['env', 'set', KEY, '--stdin'], {}, `${STORED}\n`); + expect(res.code, res.output).toBe(0); + expect(res.output).toContain(`Set ${KEY} for this team`); + + const files = fs.readdirSync(secretsDir()).filter((f) => f.endsWith('.json')); + expect(files).toHaveLength(1); + const store = path.join(secretsDir(), files[0]); + expect(fs.statSync(store).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(store, 'utf8')).toContain(STORED); + // The value is in the member's store and nowhere else. + expect(fs.readFileSync(path.join(home, '.cursor', 'mcp.json'), 'utf8')).not.toContain(STORED); + }); + + it('the next pull resolves the server from the stored value with no export, and leaves the store alone', async () => { + const files = fs.readdirSync(secretsDir()).filter((f) => f.endsWith('.json')); + const store = path.join(secretsDir(), files[0]); + const before = fs.readFileSync(store, 'utf8'); + const mtime = fs.statSync(store).mtimeMs; + + const res = await runCLI(['pull', '--force']); + expect(res.code, res.output).toBe(0); + expect(cursorMcp().tapd?.headers?.['X-Tapd-Access-Token'], res.output).toBe(STORED); + expect(res.output).not.toContain('is not set'); + + expect(fs.readFileSync(store, 'utf8')).toBe(before); + expect(fs.statSync(store).mtimeMs).toBe(mtime); + // The token appears in no log teamai writes. + const debugLog = path.join(home, '.teamai', 'debug.log'); + if (fs.existsSync(debugLog)) expect(fs.readFileSync(debugLog, 'utf8')).not.toContain(STORED); + expect(res.output).not.toContain(STORED); + }, 60_000); +}); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 32f6048ba..c02c83b3c 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -3962,7 +3962,9 @@ servers: expect(vi.mocked(log.warn).mock.calls.map(([m]) => String(m)).join('\n')).toContain('env/secrets.yaml'); }); - it('still removes a server whose missing variable is not declared as a secret', async () => { + // A variable only mcp.yaml names is the member's own token (#1011): resolved + // as a secret, so the entry an earlier pull wrote stays while its value is gone. + it('keeps a server whose missing variable is referenced by mcp.yaml alone (#1011)', async () => { await writeMcpYaml(` servers: - name: plain @@ -3978,11 +3980,11 @@ servers: vi.stubEnv('PLAIN_KEY', undefined); await reconcileMcpForConfig(teamConfig, localConfig); - expect((await fse.readJson(claudeJson())).mcpServers.plain).toBeUndefined(); - expect(await fse.readFile(codexToml(), 'utf-8')).not.toContain('[mcp_servers.plain]'); + expect((await fse.readJson(claudeJson())).mcpServers.plain.headers['X-Key']).toBe('k'); + expect(await fse.readFile(codexToml(), 'utf-8')).toContain('[mcp_servers.plain]'); }); - it('still removes a server that also misses a variable not declared as a secret', async () => { + it('keeps a server that also misses a variable referenced by mcp.yaml alone (#1011)', async () => { await writeMcpYaml(` servers: - name: both @@ -4001,7 +4003,7 @@ servers: vi.stubEnv('PLAIN_KEY', undefined); await reconcileMcpForConfig(teamConfig, localConfig); - expect((await fse.readJson(claudeJson())).mcpServers.both).toBeUndefined(); + expect((await fse.readJson(claudeJson())).mcpServers.both.headers['X-Key']).toBe('k'); }); it('removeAll removes a kept server, while the declarations cannot be read too', async () => { diff --git a/src/env-commands.ts b/src/env-commands.ts index b120d1c41..36f6b7698 100644 --- a/src/env-commands.ts +++ b/src/env-commands.ts @@ -2,7 +2,7 @@ import { requireInit, detectProjectConfig, describeUnreadableConfig, NotInitiali import { pullRepo } from './utils/git.js'; import { pathExists } from './utils/fs.js'; import { log, spinner } from './utils/logger.js'; -import { EnvHandler, envEntryReader, unknownEnvVariableKeys, type EnvYaml } from './resources/env.js'; +import { EnvHandler, unknownEnvVariableKeys, type EnvYaml } from './resources/env.js'; import { ENV_KEY_RE, envName, envValue, sameEnvName } from './resources/env-key.js'; import { SECRETS_LAYOUT, declaredSecretKeys, readSecretsForEdit, resolveSecretDeclarations, unknownSecretDeclarationKeys, @@ -14,7 +14,7 @@ import { reportMissingSecrets } from './env-advisories.js'; import { envListing } from './env-listing.js'; import { resolveTeamEnv } from './env-resolution.js'; import { - describeEntryFailure, entryFileAbsolutePath, entryFilePath, entryLayout, entryNamespaceFromFlags, moveTo, reportUndeliveredEntryNotices, resolveEntriesFor, TargetFiles, + describeEntryFailure, entryFileAbsolutePath, entryFilePath, entryLayout, entryNamespaceFromFlags, moveTo, reportUndeliveredEntryNotices, TargetFiles, type EntryLayout, type EntryType, } from './namespaced-entries.js'; import type { GlobalOptions, LocalConfig } from './types.js'; @@ -77,7 +77,9 @@ export async function envSet( const localConfig = scope.kind === 'scope' ? scope.localConfig : null; let isVariable = false; if (localConfig) { - const declarations = await resolveSecretDeclarations(localConfig); + // The declarations with the `${VAR}` names its MCP servers reference (#1011). + const teamEnv = await resolveTeamEnv(localConfig); + const { declarations } = teamEnv; if (declarations.kind === 'failed') { log.error(describeEntryFailure(declarations.failure)); return fail(`Cannot tell whether ${key} is a secret this team declares. Nothing was changed.`); @@ -93,7 +95,7 @@ export async function envSet( ); } // #875: without --global, a member may also override a variable the scope receives, for this team. - const env = await resolveEntriesFor(envEntryReader, localConfig); + const env = teamEnv.variables; if (env.kind === 'failed') { log.error(describeEntryFailure(env.failure)); return fail(`Cannot tell whether ${key} is an env variable this team sets. Nothing was changed.`); diff --git a/src/env-resolution.ts b/src/env-resolution.ts index b4db687a4..080cdd5e9 100644 --- a/src/env-resolution.ts +++ b/src/env-resolution.ts @@ -12,7 +12,9 @@ import { memberEnvironment, type MemberEnvironment } from './member-env.js'; import { resolveEntries, resolveEntriesFor, type EntryResolution, type ResolvedEntry } from './namespaced-entries.js'; import { envEntryReader, type EnvVariable } from './resources/env.js'; import { sameEnvName } from './resources/env-key.js'; -import { declaredSecretKeys, resolveSecretDeclarations, type KnownNamespaces, type SecretDeclarations } from './resources/secrets.js'; +import { + declaredSecretKeys, resolveSecretDeclarations, withMcpReferencedSecrets, type KnownNamespaces, type SecretDeclarations, +} from './resources/secrets.js'; import { getMachineSecretsPath, getTeamSecretsPath, readSecretStore, storedEntryKind, storedSecretValue, type SecretStore, type SecretStoreRead, type StoredEntryKind, type StoredSecret, @@ -98,7 +100,11 @@ export async function resolveTeamEnv( const variables = namespaces ? await resolveEntries(envEntryReader, localConfig, namespaces.active) : await resolveEntriesFor(envEntryReader, localConfig); - const declarations = await resolveSecretDeclarations(localConfig, namespaces); + // A `${VAR}` an MCP server references that nothing declares or sets is a + // member's own token, resolved as a secret (#1011). + const declarations = await withMcpReferencedSecrets( + localConfig, await resolveSecretDeclarations(localConfig, namespaces), variables, + ); const secretKeys = declaredSecretKeys(declarations) ?? new Set(); const received = variables.kind === 'resolved' ? variables.entries : []; const plain = received.filter((variable) => !secretKeys.has(variable.name)); diff --git a/src/index.ts b/src/index.ts index a2bd68ed0..c718224e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -705,7 +705,7 @@ envCmd envCmd .command('set ') - .description("Set your value for a secret the team declares, or an env variable it sets, for this directory's team, on this machine (prompts without echo)") + .description("Set your value for a secret the team declares, a ${VAR} its MCP servers reference, or an env variable it sets, for this directory's team, on this machine (prompts without echo)") .option('--stdin', 'Read the value from piped stdin') .option('--from-env ', 'Read the value from this environment variable each time it is used; no copy is stored') .option('--global', 'Set a secret for every team on this machine; a value set for a team still wins') diff --git a/src/resources/secrets.ts b/src/resources/secrets.ts index 411ca58be..58a5b4af0 100644 --- a/src/resources/secrets.ts +++ b/src/resources/secrets.ts @@ -14,11 +14,14 @@ import YAML from 'yaml'; import { z } from 'zod'; import { entryLayout, missingTopLevelKeyReason, readEntryFileText, resolveEntries, resolveEntriesFor, unknownEntryKeys, - writtenList, type EntryLayout, type EntryReader, type EntryResolution, + writtenList, type EntryLayout, type EntryReader, type EntryResolution, type ResolvedEntry, } from '../namespaced-entries.js'; import type { LocalConfig } from '../types.js'; import { ensureDir, readFileSafe, writeFile } from '../utils/fs.js'; import { ENV_KEY_RE, envName } from './env-key.js'; +import type { EnvVariable } from './env.js'; +import { referencedVars } from './mcp-format.js'; +import { mcpEntryReader, teamMcpToDef } from './mcp.js'; const SecretDeclarationSchema = z.object({ key: z.string().regex(ENV_KEY_RE, 'must be a shell variable name: letters, digits and underscores, not starting with a digit'), @@ -145,6 +148,49 @@ export async function resolveSecretDeclarations( return resolution.kind === 'resolved' && !found ? { kind: 'absent' } : resolution; } +/** + * `declarations` plus the `${VAR}` names this scope's MCP servers reference + * that no secrets file declares and env.yaml does not set (#1011): a member's + * own token, which the team repo cannot hold a value for. Each one is a + * secret for every consumer, so `teamai env set KEY` stores it, a pull + * resolves it from the member's stores and environment, a server whose value + * a pull cannot see keeps the entry an earlier pull wrote, and the missing + * line names it. A declaration nothing can read stays as it is: failed + * declarations or variables say nothing about which keys are secrets, and + * MCP servers that cannot be read reference nothing. + */ +export async function withMcpReferencedSecrets( + localConfig: LocalConfig, + declarations: SecretDeclarations, + variables: EntryResolution, +): Promise { + if (declarations.kind === 'failed' || variables.kind === 'failed' || localConfig.repo.kind === 'http') return declarations; + const mcp = await resolveEntriesFor(mcpEntryReader, localConfig); + if (mcp.kind === 'failed') return declarations; + const declared = declaredSecretKeys(declarations); + const set = new Set(variables.entries.map((variable) => envName(variable.name))); + const implicit = new Map>(); + for (const server of mcp.entries) { + const def = teamMcpToDef(server.entry); + for (const key of referencedVars(def)) { + if (declared.has(key) || set.has(envName(key)) || implicit.has(envName(key))) continue; + implicit.set(envName(key), { + entry: { key, description: `used by MCP server ${def.name}` }, + name: key, + namespace: server.namespace, + source: server.source, + replaces: null, + replacedEntry: null, + }); + } + } + if (implicit.size === 0) return declarations; + const base = declarations.kind === 'resolved' + ? declarations + : { kind: 'resolved' as const, entries: [], active: variables.active, notices: [], repeated: [] }; + return { ...base, entries: [...base.entries, ...implicit.values()] }; +} + /** * The keys `declarations` declares, or null when they failed: a failed file is * never "no secrets". Its `has` matches a key in any case on Windows, where From b59a6892361e18c1850e41a20e064ede72000296 Mon Sep 17 00:00:00 2001 From: jeffapple Date: Fri, 9 Oct 2026 14:55:44 +0000 Subject: [PATCH 2/2] test(doctor): a variable only mcp.yaml names is a note, not a failed MCP delivery --- src/__tests__/doctor-mcp-delivery.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 0c625f4ef..aeb72d1b5 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -136,18 +136,21 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).toContain(path.join(homeDir, '.claude.json')); }); - it('names the variable a server was skipped for, and points at env.yaml', async () => { + // A variable only mcp.yaml names is the member's own secret (#1011): the + // server waits for `teamai env set`, as one for a declared secret does, so + // it is doctor's note with that command, not a failed delivery. + it('notes the variable a server waits for, with the command that sets it, instead of failing (#1011)', async () => { await writeTeamMcp( 'servers:\n - name: jira\n transport: stdio\n command: jira-server\n' + ' env:\n TOKEN: "${JIRA_PASSWORD}"\n', ); await writeClaudeConfig({}); - const check = await mcpCheck(); - expect(await check.check()).toBe(false); - expect(check.fix).toContain('jira'); - expect(check.fix).toContain('JIRA_PASSWORD'); - expect(check.fix).toContain('variables:'); + const names = (await checks()).map((c) => c.name); + expect(names).not.toContain('MCP servers delivered to claude'); + const { envAdvisories, describeEnvAdvisory } = await import('../env-advisories.js'); + const notes = (await envAdvisories(localConfig, teamConfig)).map(describeEnvAdvisory); + expect(notes).toEqual(['jira: JIRA_PASSWORD is not set. Run `teamai env set JIRA_PASSWORD`.']); }); it('stays silent about a server the member excluded on purpose', async () => {