Skip to content

feat(env): team-declared secrets with member-local values (#875) - #880

Open
SaulMoro wants to merge 195 commits into
Tencent:mainfrom
SaulMoro:feat/875-team-secrets
Open

SaulMoro wants to merge 195 commits into
Tencent:mainfrom
SaulMoro:feat/875-team-secrets

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The team declares which secrets it needs; each member keeps the value on their own machine. MCP servers and CLIs (teamai env exec -- gh …) get it per team, and no value ever lands in the team repo.

flowchart LR
  D["env/secrets.yaml<br/>key · description · url"] --> R{{resolve per team}}
  T["team value<br/>env set KEY"] --> R
  G["global value<br/>env set KEY --global"] --> R
  E["member's own environment<br/>(not what a teamai env.sh exported)"] --> R
  R --> M["${VAR} in MCP servers"]
  R --> X["env exec -- &lt;cli&gt;"]
  R --> N["missing → kept MCP entry +<br/>'Run teamai env set KEY'"]
Loading
secret     team value > global value > member's own environment > missing
variable   team value > env.yaml                  (the environment no longer overrides it)
store      ~/.teamai/secrets/teams/<full SHA-256 of the member's configured repo URL: scheme, ssh user, host, port, path, query>.json · machine.json      0600, { value } | { env }, kind
 teamai env
+  set KEY [--global] [--stdin] [--from-env VAR]    value from a prompt, a pipe or another variable; never an argument
+  unset KEY [--global]
+  exec -- <command>                                 this directory's team env + secrets for one command
   add KEY [value] [--secret] [-d] [--url] …          --secret declares, no value
   remove KEY [--secret]
   list                                              state and source per key; never a secret value

Plus: pull/doctor/mcp list/env list name a missing secret and the command that fixes it (doctor as a note); a pull that can't find a declared secret keeps the MCP entry an earlier pull wrote; the session-start hook tells the agent which secrets exist and to use env exec; skills forbid agents to ask for, pass or print a value.

Design docs/designs/team-secrets.md (+ zh-CN). Plan and conflicts with the code: #879.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change): in MCP servers the member's environment no longer overrides a plain env.yaml variable. A member who relied on export gets a pull notice with the fix (teamai env set KEY).
  • Documentation only
  • Refactor / internal cleanup

Test Plan

npm run test:e2e: 386 passed, 26 skipped (new: env-exec-signals, including a real pty).

Real CLI (built dist/index.js, sandbox HOME, local bare team repo behind a synthetic https URL, build of a2745397; later rounds re-run by their unit tests):

Who Before (main) After
Alice: personal team at home, work team in ~/work/api one token for everyone, from env.yaml MCP: personal token at home, work token in ~/work/api; env exec -- probe gives the same
Bob: exports his tokens, sets nothing session-start pull without the export removes the server interactive pull writes it; pull --silent without the export keeps it; env exec passes his token
Carol: sets nothing server silently absent ⚠ github: GITHUB_TOKEN is not set. Run \teamai env set GITHUB_TOKEN` (https://github.com/settings/tokens).`
Dave: team GITLAB_HOST, different export export wins in MCP notice → env set GITLAB_HOST → MCP, env exec and a new shell use his; another team's export doesn't leak in
Erin: shell opened before a team edit — no false notice across two pulls and doctor (hash record, no plaintext)
$ teamai env exec probe GITHUB_TOKEN --dry-run        ✖ Put -- before the command … (exit 2, nothing runs)
$ broken env/secrets.yaml: teamai mcp list            exit 1, "not resolved", MCP servers stay as they are
$ Ctrl-C through a pty into env exec                  child gets 1 SIGINT (was 2); SIGPIPE → 141
$ unreadable project config: teamai env set …         refuses, names the file, writes nothing (was: user team's store)
$ broken secrets.yaml + legacy token in env.yaml: env exec   child gets neither (was: the repo value)
$ 4 × teamai env set in parallel                      4 of 4 keys stored (was: 1 of 4); values file locked per update
$ teamai --dry-run env set KEY  (no stdin)            preview, nothing asked or written (was: exit 1)
$ secret set, then the team moves KEY to env.yaml      env.sh, MCP and env exec get the env.yaml value; the stored secret never leaks
$ existing 0644 .mcp.json, then a pull with a secret   ends 0600, even when the entry didn't change (files without resolved values keep their mode)
$ 0644 config of a disabled tool holding a resolved value   tightened to 0600 by the protection pass, not rewritten (unit test)
$ shell sourced a non-git project's env.sh, env exec elsewhere   child doesn't get that value (env.sh carries a hash-only provenance marker,
                                                                 which keeps what the file exported before a rewrite dropped it)
$ kill -INT teamai with no terminal (setsid)          the command gets one SIGINT and exits; Ctrl-C in a terminal still gives one
$ a copied repo whose teamai.yaml claims another team's repo:   gets no value (the file is keyed by the member's own configured URL)
$ two teams on one host, ports 2222 and 2223          separate values; http and https don't share; alice@ and bob@ on one ssh host don't;
                                                     scp host:path (home-relative) = ssh://host/~/path, ≠ ssh://host/path
$ unreadable config, HTTP scope, or no config + shell that sourced an env.sh   env exec drops what teamai exported, keeps a hand export
$ Team B's env.yaml broken, or its values file unreadable, + shell that sourced Team A's env.sh
                                                     env exec drops Team A's GITHUB_TOKEN, keeps a hand export (was: passed on)
$ remotes that differ only by ?tenant=a / ?tenant=b    separate values files (was: one shared)
$ two teams behind the remote alias `fork`, different URLs   separate values files, keyed by each URL (was: one, keyed by `fork`)
$ file:///srv/team and file:///srv/team.git              separate values files: two directories (a trailing .git is dropped only for ssh and http(s) remotes)
$ standalone clone: env add KEY --secret, push --all  the pushed branch carries env/secrets.yaml, no value (with #885 on main)
$ broken secrets.yaml + shell that sourced an env.sh  env exec drops the env.sh-exported GITHUB_TOKEN, keeps a hand export
$ tracked .mcp.json + a resolved value               not written; pull, mcp list and doctor say `git rm --cached` + rotate
$ untracked .mcp.json + a resolved value             excluded in .git/info/exclude first, then written 0600 (from #886)
$ missing declared secret, entry kept, tool disabled   the kept entry's exclude line stays (record keeps resolved: true)
$ .codex/config.toml symlinked to a tracked file, pull   link replaced by an excluded file; the tracked file untouched (was: token written into it)
$ .cursor/ symlinked to a tracked config/, pull          /config/mcp.json excluded, or withheld with `git rm --cached config/mcp.json` when tracked (from #886)
$ .git/info/exclude not writable                     nothing written; warning with the reason; withheld in mcp list and doctor
$ two worktrees; server switched from ${VAR} to a literal; pull in A   the shared exclude line stays while B still holds the old token
$ rename team: in teamai.yaml, then pull              same <hash>.json, secret still resolves

No fixture token appears in any teamai output, in debug.log, or in git log -p --all of the team repo.

Not verified: a headless agent run (claude -p) showing the session-start line reaches the agent (no authenticated host in the sandbox). Other providers and agents are left to CI.

Related Issues

Closes #879
Closes #875

Includes #886 (#882, project MCP configs with resolved tokens kept out of git via .git/info/exclude), merged in bde17ab5, 2cf842dc, 0d9f7fa7, 2f39c372, e290adba, 1392f783, 38025a62, 2a219f6f, 7d2463f2, df1970a1, a2745397, b988bd27, 173c7b3e, f8688bde, d051823f, f466dd07, 83b7d12b, 23ecd4b4 and fa4c3d9e (exclusion before any write; removed again once a file is proven clean, judged by the manifest from before the command; no unlocked writes; one withholding path for tracked files) because this PR now writes members' tokens into project MCP configs. Once #886 lands on main, its commits drop out of this diff.

Related: #876 / #878 (shell-profile env block; merge order only), #881 / #885, #892, #893, #894 (found on the way, fixed or tracked separately).

Notes for Reviewers

Door: two-way for the code. Near one-way for members: once they move tokens into env set, reverting leaves those values unused, and the variable-precedence change is what their MCP servers already follow.

Blast Radius: env + MCP. Every team with env.yaml variables sees the new precedence in MCP servers; teams without env/secrets.yaml see no other change.

  • Conflicts between the code and Proposal: declare team secrets in the repo, keep their values on each machine, for MCP servers and CLIs #875 and how each was resolved: Spec: team secrets (#875) #879 § Conflicts (1-14).
  • Still reachable by design: resolved values are written in plaintext to each tool's MCP config (as today), and a command under env exec can read them. This keeps secrets out of git, not away from the member's machine or agent.
  • A key declared as a secret with a value still in env.yaml resolves as the secret; its repo value leaves env.sh, the env backup and every listing. Teams must rotate any token ever committed.
  • Model values (fix(models): key team values by repo identity, migrating legacy slug names (#894) #895) are keyed by the repo: claim in teamai.yaml; secret values deliberately are not: they are keyed by the URL the member configured, so a copied repo claiming another team's repo: gets none of that team's secrets.
  • Nothing has shipped, so there is no migration for the store identity: a member who set values on an earlier build of this branch sets them again.
  • Store and model key files, and the Codex config, are created 0600 at open time; an MCP config that receives a resolved value is written 0600.
  • Each stored value records whether it was set as a secret or a variable override, and is only used as that kind.
  • On Windows a declared secret matches an env.yaml variable in any case (token = TOKEN), so the repo value of token is ignored like TOKEN's; env set/unset/add (variables and --secret)/remove, stored values (every case-alias replaced or removed), the member-environment check, MCP's variable table, ${token} placeholders, mcp list and the missing-secret notice all compare names in any case there.
  • A symlinked project MCP config left unchanged is not rewritten: no released teamai ever wrote through such a link (since MCP sync landed in 9776a0c3, both the JSON and the Codex writers use a temp file and rename, which replaces the link), so no earlier pull can have put a value in the file it links to. Only this branch's own intermediate builds followed the link for Codex; 0cc3fd5e restored the replace.
  • feat(mcp): keep project MCP configs with resolved tokens out of git (#882) #886's known limits are closed there (a record of the configs a pull wrote, the old toolPaths mappings read from the team repo's history, noted servers on a rebuilt record); what remains is listed in feat(mcp): keep project MCP configs with resolved tokens out of git (#882) #886's body.
  • Kept on purpose: a broken secrets.yaml fails two doctor checks (the shell-profile one is fix(env): keep the user scope's env block when a project pulls #878's area); env remove --secret; env exec honours the global --dry-run; env exec forwards SIGINT/SIGQUIT only when teamai isn't the terminal's foreground group (a terminal Ctrl-C already reaches the command; forwarding it too made tools like terraform force-quit); on Windows Ctrl-C is never forwarded, since the console already delivers it and kill('SIGINT') there hard-kills. Known limit: while teamai is the terminal's foreground group, a SIGINT/SIGQUIT sent to teamai's PID alone (kill -INT <pid> from another shell) isn't passed on, because Node can't tell a terminal Ctrl-C from a direct signal and forwarding would double the Ctrl-C; send SIGTERM (forwarded) or signal the command's PID. Documented in team-secrets.md. A shell that sourced an env.sh written before the provenance marker existed can still pass that value until the file is rewritten.

…ut (Tencent#879)

An entry reader took its directory, file name, activation key and failure
wording from its EntryType. It can now declare them as an EntryLayout,
defaulting to entryLayout(type), which gives today's values. This lets a
later reader read env/secrets.yaml and env/<ns>/secrets.yaml activated by
resources.env. No behaviour change: env, hooks, MCP and models resolve and
report as before.

Part of Tencent#875.
…second store

getTeamValuesPath takes the store directory (defaulting to models/teams)
and keeps its <team>-<hash>.json naming. The piped-stdin reader moves to
utils/prompt.ts as readStdin; the --api-key-stdin checks and messages stay
in the models command. No behaviour change.

Refs Tencent#879 (S2), Tencent#875
…ate (Tencent#879)

A team repo can declare the secrets its members need, with no value, in
env/secrets.yaml and env/<ns>/secrets.yaml (key, optional description and
url). They resolve like env.yaml: active through resources.env, a namespace
entry replaces the root entry with the same key. The declarations are
absent, valid or failed; a broken file fails the secrets only, is reported
in secret wording by pull, env list and doctor, and env variables are still
delivered.

- env list and list env show each declared secret as environment or
  missing, never its value, --reveal included.
- doctor fails "Team secrets can be resolved" on a broken file, and its
  notes name env/secrets.yaml, not env/env.yaml, for an override or a key
  repeated in legacy mode (describeEntryNotes takes the reader's layout).
- push lists a changed secrets.yaml, in single-repo mode too.
- docs/designs/team-secrets.md and .zh-CN.md start here, with the Tencent#818
  boundary; usage guide, product overview, multi-project, management
  backend and the admin reference updated.

Part of Tencent#875.
env add <key> [value] --secret [-d] [--url] [--role|--project] writes
env/secrets.yaml or env/<ns>/secrets.yaml with no value; a value is
rejected and never printed. env remove removes a declared secret when
env.yaml does not set the key, and --secret removes only the declaration
for a key both files carry. entryNamespaceFromFlags takes a layout so
the --role warning names secrets.yaml.
…t is missing (Tencent#879)

The session-start pull inherits the agent's environment, which often lacks
the member's shell export, so it removed the MCP entry the interactive pull
had written. A server whose only missing variables are declared secrets now
keeps its entry and ownership record; it is removed when it leaves mcp.yaml
or by removeAll. A failed secrets declaration keeps managed MCP state.
…MCP servers (Tencent#879)

teamai env set KEY (hidden prompt, --stdin, --from-env VAR) and env unset KEY
store a member's value per team repo in ~/.teamai/secrets/teams/, 0600,
accepting only keys the scope declares as secrets. ${VAR} in MCP servers
resolves a declared secret from that value, then from the member's own
environment, which leaves out values a teamai env.sh exported (Conflict 10).
A key declared as a secret and set in env.yaml resolves as the secret: its
repo value leaves env.sh, the env backup, both list renderers and doctor's
expected set (Conflict 13). A failed declaration leaves env.sh and the backup
as they are (Conflict 14). env list shows team.
…ine (Tencent#879)

env set/unset --global keep the value in ~/.teamai/secrets/machine.json.
Resolution becomes team value > machine value > the member's environment,
for MCP servers and env list (state `global`). In a scope --global still
accepts only a declared secret; outside any scope it accepts any valid key
and notes that no team declares it yet.
# Conflicts:
#	docs/designs/team-secrets.md
#	docs/designs/team-secrets.zh-CN.md
#	docs/usage-guide.md
#	docs/usage-guide.zh-CN.md
#	skill-data/setup/references/manage-admin.md
#	src/env-commands.ts
#	src/mcp-reconcile.ts
#	src/resources/secrets.ts
…encent#882)

A project-scope MCP config that carries a resolved ${VAR} sat untracked
and unignored in the business repo, one `git add -A` from committing the
token. After the reconcile writes such a file and git would track it,
teamai lists its path in the clone's .git/info/exclude inside a marked
block (resolved via `git rev-parse --git-path`, so linked worktrees and
submodules work). The committed .gitignore is never touched; an ignored
path or a config with no resolved value adds nothing; dry runs write
nothing. Project-scope uninstall removes only teamai's block, and doctor
reports such a file git would still commit.

The hook sits after the appliers in reconcileMcpForConfig, outside
desiredMcpForTarget/applyJson/applyCodex, so it merges cleanly with Tencent#880.
…encent#879)

Interactive pull, mcp list, env list and doctor print one line per declared
secret with no value, naming the MCP servers that use it, `teamai env set KEY`
and the declared url. doctor prints it as a note and no longer fails the MCP
delivery check for a server skipped only for a missing declared secret. Pull
and doctor also note a kept entry that may hold an old value and a key
declared as a secret and set in env.yaml. The silent pull prints nothing.

The lines come from one envAdvisories() result that later pull notices extend.
…Tencent#882)

The plan now records whether the project's .git/info/exclude holds
teamai's MCP config block (gitExcludeBlock). It counts toward
isPlanEmpty, is listed in the summary and dry run, and gates the
removal, so a plan whose only teamai leftover is the block removes it
instead of reporting "Nothing to uninstall".
# Conflicts:
#	docs/designs/team-secrets.md
#	docs/designs/team-secrets.zh-CN.md
#	docs/usage-guide.md
#	docs/usage-guide.zh-CN.md
@jeff-r2026 jeff-r2026 self-assigned this Sep 28, 2026
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] The PR changes runtime behavior but the description contains no completed end-to-end / real-CLI verification record; it explicitly says the record will be completed later. This violates the repository’s Code Review Rules, which require one representative real-CLI run before merging runtime changes.
  • [P2 non-blocking] src/env-commands.ts:354 preserves every existing field when updating a secret declaration. If the entry contains value: or another unknown key, env add KEY --secret reports success but retains that key, so secretsEntryReader continues rejecting the declaration and the secret remains unusable. Remove unsupported fields during the update or warn that the entry is still not delivered.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1491 records unclaimed servers only when managed-mcp-files.json already lists the target, while src/mcp-reconcile.ts:1501 handles only complete manifest loss. If one tool’s manifest and sidecar records are missing but another tool keeps the manifest nonempty, rebuilding that tool’s record does not mark its pre-existing secret-bearing servers as unverified. A later pull can therefore declare the file clean and remove its Git exclusion while the stale plaintext token remains. Treat every missing per-target ownership record as unnoted and record its unclaimed servers.
  • [P2 non-blocking] src/mcp-reconcile.ts:1145 decides whether to undo a newly added (file, tool) sidecar record using the file-only written set. When Claude and CodeBuddy share .mcp.json, a successful Claude write prevents cleanup of a CodeBuddy record whose injection was skipped, leaving the file attributed to a tool that never wrote it and potentially keeping it ignored indefinitely. Track successful writes by (file, tool).
  • [P2 non-blocking] src/env-exec.ts:249 still suppresses SIGINT and SIGQUIT sent directly to TeamAI while it is the terminal foreground process. For example, kill -INT <teamai-pid> leaves the child running. Documenting the limitation does not provide expected signal forwarding.

The PR description includes sufficient representative real-CLI and end-to-end verification. The other findings from earlier review passes appear resolved.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1252 only restores Git exclusion for secret-bearing configs discovered during the protection scan; permission tightening occurs solely inside the normal appliers. If an existing mode-0644 config belongs to a disabled, undetected, previously mapped, or sibling-worktree tool, later pulls preserve its exclusion but leave its plaintext resolved value readable by other local users. Tighten every file that resolvedValueEvidence identifies, not only active targets.
  • [P2 non-blocking] src/mcp-reconcile.ts:160 stores resolved variables under their declared spelling, while src/resources/mcp-format.ts:153 performs exact-case placeholder lookup. On Windows, TOKEN from secrets.yaml or env.yaml therefore does not satisfy ${token}, despite both naming the same environment variable and the rest of this change treating them case-insensitively. Normalize placeholder lookups with envName.
  • [P2 non-blocking] src/env-exec.ts:249 still suppresses SIGINT and SIGQUIT whenever TeamAI is in the terminal foreground group. A direct kill -INT <teamai-pid> from another shell is swallowed and leaves the child running; documenting the limitation does not provide expected signal forwarding.

The PR description contains sufficient representative real-CLI and end-to-end verification. Previously reported issues other than the signal limitation appear resolved.

@github-actions

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/mcp-cmd.ts:86 and src/env-advisories.ts:58 still compare MCP placeholder names case-sensitively. On Windows, a declared/stored TOKEN correctly satisfies ${token} during injection, but mcp list reports it as missing and missing-secret guidance fails to associate it with the server. Use the same case-insensitive lookup as resolvePlaceholders.
  • [P2 non-blocking] src/env-exec.ts:249 still suppresses SIGINT and SIGQUIT whenever TeamAI is in the terminal foreground group. A direct kill -INT <teamai-pid> from another shell reaches only TeamAI, is swallowed, and leaves the child running.

The PR description includes sufficient representative real-CLI/e2e verification. The previously reported blocking findings appear resolved.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/secret-store.ts:97 removes a trailing .git before hashing the repository identity. Distinct valid remotes such as file:///srv/team and file:///srv/team.git therefore share one secrets file; setting TOKEN for one team lets the other resolve that value. Preserve the complete credential-stripped path when deriving this security boundary.
  • [P2 non-blocking] src/env-commands.ts:458 still searches ordinary env.yaml variables case-sensitively. On Windows, if TOKEN is a variable and token is also declared as a secret, env remove token misses the variable and removes the secret declaration instead; env add token ... similarly creates a second case-alias. Match ordinary add/remove operations with sameEnvName.
  • [P2 non-blocking] src/env-exec.ts:249 still suppresses SIGINT and SIGQUIT whenever TeamAI is in the terminal foreground group. A direct kill -INT <teamai-pid> from another shell reaches only TeamAI, is swallowed, and leaves the child running.

The PR description includes sufficient representative real-CLI/e2e verification. Previously reported findings other than the signal limitation appear resolved.

@github-actions

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/env-exec.ts:249 still suppresses SIGINT and SIGQUIT whenever TeamAI is in the terminal foreground group. A direct kill -INT <teamai-pid> from another shell reaches only TeamAI, is ignored, and leaves the child running. The documented limitation does not provide expected signal forwarding.

Resolved

  • The PR description includes sufficient representative real-CLI and end-to-end verification.
  • The other previously reported findings appear resolved in the current diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spec: team secrets (#875) Proposal: declare team secrets in the repo, keep their values on each machine, for MCP servers and CLIs

2 participants