Skip to content

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

Merged
jeff-r2026 merged 195 commits into
Tencent:mainfrom
SaulMoro:feat/875-team-secrets
Sep 30, 2026
Merged

jeff-r2026 merged 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.

@jeff-r2026
jeff-r2026 merged commit 7c929a9 into Tencent:main Sep 30, 2026
12 checks passed
@SaulMoro
SaulMoro deleted the feat/875-team-secrets branch September 30, 2026 08:29
jeff-r2026 pushed a commit that referenced this pull request Sep 30, 2026
…882) (#886)

* feat(mcp): keep project MCP configs with resolved values out of git (#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 #880.

* fix(uninstall): count the .git/info/exclude block in the removal plan (#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".

* fix(mcp): keep the exclude block until every MCP config is clean (#882)

- uninstall keeps a repository's .git/info/exclude block while a config in
  it could not be parsed and still holds teamai servers, and warns
- uninstall finds and removes the block in nested repositories holding an
  MCP config, across every worktree
- the block opens at the last start marker, so an orphaned start never
  pairs with a later block's end and takes the member's lines
- doctor counts only servers the ownership manifest records, not a member's
  own server under a team name

* fix(uninstall): remove the exclude block only once its files are proven clean (#882)

A missing or unreadable managed-mcp.json made the MCP cleanup return early
without reporting anything, so uninstall removed the block while .mcp.json
still held the resolved token.

Uninstall now inspects every path the block protects after the cleanup. The
block goes only when each one is missing, or parses and holds none of the
team's servers that need a resolved ${VAR}. Anything it cannot check keeps
the block, with a warning naming the file. This replaces the leftInPlace
report from the reconcile, which the check subsumes.

* fix(mcp): protect every project MCP config holding a resolved value (#882)

Pull, doctor and uninstall each skipped a case they had not inspected and
treated it as safe. Now:

- pull lists a config in .git/info/exclude whether or not it delivered to
  it this run: a disabled or undetected tool's file, a team with automatic
  delivery off, an unreadable mcp.yaml (any teamai entry counts), a failed
  write to another tool's config, and a lost ownership manifest (the
  resolved value found in the file)
- doctor checks the same files, including one that does not parse, and
  counts a git error as a failure
- git check-ignore failing inside a repository is no longer read as "not
  tracked": the path is excluded anyway, or teamai warns with git's error
- uninstall also keeps the block while a file contains the value (8+
  characters, not a path or the login name) of a variable still set in the
  environment, which finds a server since dropped from mcp.yaml

* fix(mcp): judge MCP configs by disk and manifest, not current config (#882)

Pull, doctor and uninstall still decided "clean" from the current team
config in places. Now one function, resolvedValueEvidence, decides for all
three:

- a teamai-owned entry still in the file counts when its server has left
  mcp.yaml, as well as when it needs a resolved ${VAR} or mcp.yaml cannot
  be read (doctor no longer skips that case)
- targets include the built-in location of a tool the team dropped from
  toolPaths or moved
- doctor names a file two tools share once
- exclude updates take the existing acquireLock helper, re-read the file
  and write it atomically, so concurrent commands keep each other's paths
- uninstall inspects every worktree of each repository owning a block,
  including a nested repository's linked worktrees, and applies the
  manifest rule per worktree
- the kept-block warning names each file and why, such as the variable
  whose value matched

* fix(mcp): skip the .git/info/exclude write while another command holds its lock (#882)

After the 2.5 s wait for the exclude file's lock, updateExclude wrote without
it, so two writers could drop each other's pattern and leave a plaintext MCP
config committable. It now writes nothing and reports 'locked': pull warns that
the file is not excluded yet and to run `teamai pull` again (doctor's exclude
check keeps reporting it meanwhile), and uninstall keeps the block and warns.

* fix(uninstall): keep an exclude entry unless its MCP config is proven free of teamai's servers (#882)

Uninstall judged a protected file clean from the current mcp.yaml, manifest
and resolvable values, so with the manifest lost, the server gone from
mcp.yaml and its value unset, a plaintext token looked like the member's own
server and the exclusion went. It now fails closed and works per entry: a
pattern goes only when its file is gone, holds no server, or holds none of
teamai's servers with managed-mcp.json still there to say what teamai wrote.
A kept entry is named with its file, why, and how to clean it by hand, since
a rerun of uninstall finds no config after a full uninstall.

* fix(mcp): exclude a project MCP config from git before writing a resolved value into it (#882)

Pull listed the file in .git/info/exclude only after writing the plaintext,
and a failed exclusion only warned, so the secret-bearing file stayed
eligible for git add -A. The exclusion now comes first; when it cannot be
established (exclude file or .git/info not writable, lock held past the
wait, file already tracked, git error) the file is left as it was and the
warning names the reason and the fix.

* fix(mcp): report a server withheld from a file git would commit in mcp list and doctor (#882)

* fix(mcp): report a tracked file on a dry run and a withheld server already installed (#882)

Backports #880's merge 0d9f7fa: a dry run (doctor, mcp list) names a
tracked file before any pull has listed it, mcp list reports withheld for a
server an earlier pull installed, and doctor's withheld note carries the
exclusion's own fix instead of the pull --force advice.

* fix(mcp): name a tracked MCP config before an unwritable .git/info/exclude (#882)

A tracked file needs `git rm --cached` whatever else is wrong, so
ensureExcludedFromGit checks gitTracks before the writability check, on a
pull and a dry run alike, and lists nothing for it.

* fix(mcp): take a project MCP config's exclude line back out once it holds no resolved value (#882)

A pull that lists a config in .git/info/exclude and then writes no value
into it (it does not parse, a member's server holds the team's name, the
write fails) removes the line it added. After a pull or `teamai mcp
remove`, a line whose configs are proven clean in every worktree, by the
proof uninstall uses (moved to mcp-reconcile.ts), is removed under the
lock; one not proven clean stays. A config listed before its write is
listed again after it, so a concurrent uninstall that dropped the line
between the check and the write does not leave the value unprotected.

* fix(mcp): judge a project MCP config by the manifest as it stood before the pull rewrote it (#882)

A pull whose manifest was lost before it ran recreates managed-mcp.json
while reconciling, so the clean-file proof read the new record and took
the exclude line out of a file still holding a teamai server that left
mcp.yaml with its variable unset. The proof now uses this worktree's
manifest as read before the reconcile.

* fix(mcp): log a rolled-back exclude line at debug level (#882)

A line this pull added and took back out, because it wrote no resolved
value into the file, was reported as removed although the member never
saw it added. Only removing a line an earlier run added stays at info.

* fix(mcp): keep a shared exclude line while another worktree's config holds a server (#882)

A pull or `teamai mcp remove` judged every linked worktree's MCP config
with today's definitions and values. Once a server's ${VAR} became a
literal, and the value was no longer set, a pull in worktree A took
worktree B's stale token-bearing entry for clean and removed the shared
/.mcp.json line, so `git add -A` in B staged the token.

These commands now release a line only when the current worktree's file
passes the full proof and every other worktree's file is missing or holds
no MCP server. `teamai uninstall` keeps its full proof in each worktree.

* test(mcp): build the other worktree from the real temp path so the test checks what it names (#882)

* fix(uninstall): list no worktrees for a project root that no longer exists (#882)

buildRemovalPlan now lists every worktree to find teamai's exclude blocks,
outside the MCP cleanup's try. simple-git throws synchronously for a missing
directory, so a project uninstall whose root is gone crashed; main's #878 test
caught it after the merge.

* fix(mcp): treat an empty, unparsable or tool-less managed-mcp.json as no record (#882)

The clean-file proof took any managed-mcp.json on disk as teamai's record,
so an empty or truncated one let a pull, `mcp remove` or uninstall judge a
file still holding a stale secret-bearing entry clean and drop its exclude
line. A file now counts as recorded only when the manifest parses and holds
an entry for that tool's file. A project record teamai empties stays as []
so a file left with only the member's own servers can still be released.

* fix(mcp): keep the exclude line of a config this pull wrote when a later step fails (#882)

* fix(mcp): read a check-ignore error as unsafe unless ls-files proves the config untracked (#882)

* fix(mcp): report withheld only for targets delivery would write the server to (#882)

* docs(mcp): describe the .git/info/exclude block in the setup skill and the stricter git check (#882)

* fix(mcp): keep the exclude line of an entry a pull wrote with a resolved value after its definition turns literal (#882)

* fix(mcp): judge a nested repository's linked worktree config by its sibling's tool (#882)

* feat(mcp): record the project MCP configs a pull wrote a resolved value to in managed-mcp-files.json (#882)

* fix(mcp): keep protecting a config a pull wrote under a toolPaths mapping the team has since changed (#882)

* fix(mcp): keep a config's exclude line past the pull that rebuilt its lost record (#882)

* test(mcp): pin today's exclude rules for a missing, corrupt or locked managed-mcp-files.json (#882)

* docs(mcp): describe managed-mcp-files.json and the configs it keeps protected (#882)

* refactor(mcp): keep the #882 record edits off the lines #880 changes (#882)

* fix(mcp): protect a config an older teamai wrote under a mapping an earlier teamai.yaml made (#882)

A teamai from before managed-mcp-files.json kept no record of the path it
wrote a resolved value to. Once the team changed that toolPaths mapping, no
pull visited the file. The first pull on this version now reads every
mcpProject path the team repo's history of teamai.yaml mapped, once per
worktree: a file under the project root that no current mapping or record
reaches, and that holds a resolved value, is listed in .git/info/exclude and
recorded. A git error leaves the read for the next pull; a shallow clone
reads the history it has.

* fix(mcp): keep a rebuilt record from persisting without its note of the file's other servers (#882)

When a pull rebuilt a lost managed-mcp.json and could not note the other
servers in the file (managed-mcp-files.json locked, an I/O error), it still
wrote the rebuilt record, so no later pull knew the record was rebuilt and a
stale server's line could go. The same manifest write now marks those records
unnoted: the file counts as having no record, so it keeps its line while it
holds a server, and the next pull notes them and clears the mark.

* fix(mcp): take back a managed-mcp-files.json record for a config the pull then did not write (#882)

A pull records a config before writing a resolved value to it. When the
write failed or did not happen (the file does not parse), the record stayed,
and once the mapping changed a config of the member's own at that path was
kept excluded while it held any server. The pull now takes back a record it
added for a file it did not write, as it does the file's exclude line; the
settle after records it again if the file holds a resolved value anyway.

* docs(mcp): describe the teamai.yaml history read, the unnoted rebuilt record and the record a failed write takes back (#882)

* refactor(mcp): keep the r3 edits off the lines #880 changes (#882)

* fix(mcp): also protect a config an older teamai wrote under a built-in default it has since changed (#882)

* fix(mcp): judge a project MCP config under a symlinked directory where the write lands (#882)

The appliers replace the file itself (tmp + rename) but follow its
directories. Every git check now judges realFilePath(file), the one
resolver the release keying already used: a directory linked out of any
repository no longer withholds the servers on git's "not a git
repository", and a tracked file there is named with both paths, with a
git rm --cached that works (git refuses the path through the link).

* docs(mcp): describe how a config under a symlinked directory is kept out of git (#882)

* refactor(mcp): keep realFilePath next to existingAncestor, without an import cycle (#882)

* fix(mcp): judge a config under an earlier teamai.yaml mapping as a recorded file, not by today's records (#882)

* fix(mcp): have doctor check the configs earlier teamai.yaml mappings reach until a pull reads them (#882)

* docs(mcp): describe how a config under an earlier teamai.yaml mapping is judged, and doctor's check of it (#882)

* fix(mcp): record a config under an earlier teamai.yaml mapping that git tracks, and judge it once git no longer does (#882)

* fix(mcp): keep judging a recorded config for a tool the team moved while another tool still maps it (#882)

* docs(mcp): describe the tracked config an earlier mapping reached, and a moved tool's config another tool still maps (#882)

* fix(mcp): find a config an older teamai wrote under an earlier mapping another tool maps today, and judge it by that tool's records (#882)

* docs(mcp): describe the history read's configs another tool maps today (#882)

* fix(mcp): prove a shared config clean only while every tool that wrote a resolved value there has its record (#882)

* fix(mcp): judge a built-in location no mapping reaches today as an earlier-mapped file, and a shared config no pull recorded by every tool mapping it (#882)

* docs(mcp): describe the built-in location of a moved or dropped tool, and a shared config no pull recorded (#882)

* fix(mcp): name the ignore rule that re-includes a config teamai just listed, instead of saying git tracks it (#882)

* fix(mcp): judge a moved tool's built-in location another tool maps for that tool too, and hold a config's line while no managed-mcp.json claims its servers (#882)

* docs(mcp): describe the no-manifest rule, a moved tool's built-in location another tool maps, and a re-including ignore rule (#882)

* fix(mcp): keep a tool's record as it was when its config does not parse, and take back each tool a write that did not happen recorded (#882)

* fix(mcp): mark the records a pull with no managed-mcp.json writes as unnoted until the servers no record claims are noted (#882)

* fix(mcp): have doctor judge a record marked unnoted like a missing managed-mcp.json (#882)

* fix(mcp): judge a config tools of different formats share in each of their formats before releasing its line (#882)

* fix(mcp): note the servers no record claims in the file of a tool whose record a pull writes first, as with no managed-mcp.json at all (#882)

* fix(mcp): take back a tool managed-mcp-files.json recorded before a write unless its own records hold a resolved value there (#882)

* fix(mcp): treat an installed tool's missing record as lost at every pull and in doctor, not only an empty managed-mcp.json (#882)

* fix(mcp): note the unclaimed servers under every format of a shared config, and pin an uninstalled tool's leftover config (#882)

* fix(mcp): count an uninstalled tool's missing record when no installed tool maps its file, and name a re-including .gitignore rule on a dry run (#882)

* fix(mcp): scope a shared config's claims to the tools reading the same key, and settle its notes on every format's view (#882)

* fix(mcp): keep suspect an uninstalled tool managed-mcp-files.json lists as a writer, though an installed tool maps the file (#882)

* fix(mcp): judge a moved tool's file by the records of the tools reading the same key today (#882)

* fix(mcp): read a Copilot project config's bare servers beside the mcpServers another tool added, and remove teamai's there (#882)

* fix(mcp): keep a project config the local agent writes a header or env value to out of git, and have doctor check it for HTTP teams (#882)

* fix(mcp): document the local agent's project-scope exclusion and Copilot's bare servers beside mcpServers (#882)

* fix(mcp): tell the local agent's withheld install to install the MCP server again, not to pull (#882)

* fix(mcp): replace a Copilot server's bare copy when pull or the local agent writes it again under mcpServers (#882)

* fix(mcp): count a local-agent install's arguments, URL user or query and command line as credentials, and scope the rebuild's claims by key (#882)

* fix(mcp): count any URL in a local-agent install as a credential, and have doctor judge a recorded HTTP-team file with no record (#882)

* fix(mcp): read OpenCode's command array, remove only teamai's own bare Copilot copy, hold a shadowed one, and judge a partly lost HTTP record by its entries (#882)

* fix(mcp): list a project config an older local agent wrote a credential into on the next sync or pull of an HTTP-backed team (#882)

* fix(mcp): document that the local agent's sync and pull list an older install's credential file (#882)

* fix(mcp): have the local agent's sync say a new session tries again and call the file's content a credential (#882)

* fix(mcp): run the local agent's sync protection after an uninstall_teamai too: a failed or partial one leaves what to keep out of git (#882)

* fix(mcp): judge a local-agent entry a failed write left by what it holds, not by the new install's resolved: false (#882)

install_mcp records the new entry before writing the config. When that
write fails, the older entry, credential included, stays in the file
while the record says resolved: false. localAgentCredentialFiles now
trusts resolved: false only while the entry on disk has the recorded
hash; otherwise it checks the entry itself. The doctor fixture that
used a placeholder hash now records the hash an install writes.

* fix(mcp): keep a moved Copilot's stale bare server apart from a name another tool owns under mcpServers (#882)

recordedMcpFileEvidence merged a Copilot project file's bare servers with
those under mcpServers by name, so Claude owning a nested jira read as
owning a bare jira Copilot wrote with a token before it moved. Bare
servers now count as owned only by a Copilot record: no other tool
writes there.

* fix(mcp): have the local agent's protection read CodeBuddy's former default and a bare Copilot entry apart (#882)

An HTTP team has no teamai.yaml history, so localAgentCredentialFiles never
visited a built-in default teamai has since changed: a credential a local
agent from before 57636a2 wrote to .codebuddy/mcp.json stayed committable.
It now also reads EARLIER_BUILTIN_MCP_PROJECT (earlierMappedMcpTargets with
history: false), in sync and doctor alike.

It also judged a Copilot project file through installedMcpEntries, which
merges bare servers with mcpServers by name, the keyed one winning. A
tokenized bare entry beside a credential-free mcpServers entry of its
name, both under one record, went unseen. Each entry is now judged on its
own (mcpEntriesByPlacement, which recordedMcpFileEvidence uses too).

* fix(mcp): prove bare ownership and persist installs before exclusions (#882)

* fix(mcp): keep bare ownership from claiming keyed Copilot entries (#882)

* fix(mcp): require evidence for unmarked Copilot ownership (#882)

* fix(mcp): preserve ownership across failed config updates (#882)
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