Skip to content

[feat] Workspace-scoped env overrides, so one machine can point each repo at its own database #1429

Open

Description

@bladestone01

Problem

A team can declare environment variables and secrets in the team repo (env/env.yaml, env/<ns>/env.yaml), and each member resolves their own value into one flat store. But that store is keyed by team + machine only:

// src/env.ts
function getTeamSecretsPath(localConfig) {
  const identity = configured ? repoIdentity(configured) : localPath;
  const hash = sha256(identity);
  return path.join(getTeamaiHomeDir(), "secrets", "teams", `${hash}.json`);
}

There is no workspace/project component in the identity, and teamai env set has no --project or --role flag. So a given key can hold exactly one value on a machine.

That breaks the most common multi-repo setup: one machine, several repos, each pointing at its own database instance. Concretely, a member with

~/work/service-a   -> postgresql://...@127.0.0.1:5432/a
~/work/service-b   -> postgresql://...@127.0.0.1:7432/b

cannot express this. PG_DATABASE_URI resolves to whichever value they set last, in both directories. The rendered MCP server inherits that single URI, so one of the two repos silently gets the wrong database — and because the schema still looks plausible, the failure is a wrong answer rather than an error.

The current workarounds are all unsatisfying:

  • Per-instance namespaces (mcp/postgresql-a, mcp/postgresql-b, each reading PG_URI_A / PG_URI_B). This works, but it grows one namespace and one key per instance. A team with 10+ database instances ends up with 10+ near-identical mcp/ namespaces differing only in an env key name, purely to work around the flat store.
  • Injecting values from the shell. teamai env set KEY --from-env VAR reads the live environment each time, so a direnv/dotenv setup can give each directory its own value. This is the closest thing to a solution today, but it is undocumented as a multi-directory pattern, it is invisible to teamai env list, and pull reports the variable as set without any hint about which directory produced the value.
  • Rewriting the partition env.sh from a postPull script. Works, but bypasses the delivery record, so pull has no idea what it just wrote.

Proposed Solution

Make env values layered, with a workspace-scoped override layer between the team declaration and the resolved value — the same model as application-local.yml or .env.local:

1. team declaration      env/env.yaml, env/<ns>/env.yaml      (in repo, shared)
2. workspace override    <partition>/env.local.yaml           (this machine, this directory)
3. member value          ~/.teamai/secrets/teams/<hash>.json  (this machine, this team)
4. resolved -> rendered into MCP / env.sh

Three design points I think matter more than the mechanism itself:

a. Overrides must be set, not declare.
A workspace override should only be allowed to supply a value for a key the team already declares. Overriding an undeclared key would silently add environment surface the team cannot see, and hand any repo a way to inject arbitrary variables. Reject unknown keys the way keepScopedEntry already rejects unknown YAML keys today.

b. An override is not a second declaration.
The existing rule — the same key in two simultaneously-active env namespaces rejects the whole env set (env was not applied this run) — must not fire for an override. An override is a precedence fact about one key, not a competing declaration. Otherwise the feature contradicts itself the first time it is used.

c. Overrides live outside the repo, keyed the same way the partition is.
~/.teamai/projects/<slug>-<hash>/env.local.yaml, alongside the existing config.yaml and env.sh in that directory. A file inside the workspace would eventually get committed — every repo would need its own correct .gitignore entry, and one missing entry is a leaked password.

Sketch of the CLI surface:

# in a workspace, override the value for this directory only
teamai env set PG_DATABASE_URI --workspace

# inspect the layering, showing which layer won
teamai env list --origin
#   PG_DATABASE_URI  ...  (workspace)   ~/.teamai/projects/<slug>-<hash>/env.local.yaml
#   DB_HOST          ...  (env.yaml)    env/env.yaml
#   STITCH_API_KEY   ...  (secret)      ~/.teamai/secrets/teams/<hash>.json

# drop the override and fall back to the member value
teamai env unset PG_DATABASE_URI --workspace

teamai env list --origin is the part I would care about most as an operator: right now, when an MCP server connects to the wrong database, there is no single command that tells you which of the four layers produced the value.

Alternatives Considered

  1. Do nothing; document per-instance namespaces as the answer. This is what a downstream team is doing today. It works for 2–3 databases and becomes unmaintainable past that; it also conflates "which server to run" with "which value to inject", which are independent concerns.
  2. --project on teamai env set. This would key the store by project id instead of workspace path. It is closer to the existing model, but a project id is a team-repo concept — the same project id can be checked out at several paths, and a member may want two checkouts of the same project pointed at different databases. Directory-scoped is the honest granularity.
  3. Resolve values from the shell only (--from-env everywhere). Zero new surface in teamai, but it moves the configuration out of teamai entirely; env list can no longer explain a value, and onboarding now requires the member to install and configure direnv.

Additional Context

  • The README of our team repo already calls out the constraint explicitly ("teamai env set 的值按「团队 + 本机」唯一存储…同一键名在整台机器上只能有一个值"), and routes people to per-instance namespaces. We would rather not ship that guidance as the long-term answer.
  • Related: [feat] Support Antigravity CLI (agy) as a delivery target #1426 (Antigravity CLI support) is from the same deployment; the multi-repo, multi-stack directory layout there is exactly the situation this issue describes.
  • Happy to prototype the env.local.yaml read path and the --origin listing if the layering approach is acceptable.

Published from a team-level teamai deployment (user scope, several repos on one machine, each with its own database instance).

Activity

  1. SaulMoro commented on Oct 26, 2026

    @SaulMoro
    Collaborator

    This was generated by AI during triage.

    Thanks for the detailed write-up, and for offering to prototype.

    Triage outcome: new enhancement. No existing issue or open PR covers it. The core behavior reproduces on 0.28.0 and on main: two repos with their own project scope share one member store, so both resolve PG_DATABASE_URI to the value set last, in teamai env --reveal, in each .mcp.json and in env exec. Labels: enhancement, question.

    Corrections to the premises

    • The store path comes from getTeamSecretsPath in src/secret-store.ts:60-68. There is no src/env.ts. Your description of the key (repo identity, no scope or directory) is right.
    • The partition is per repository, not per directory. It is keyed by the repository identity, "so all worktrees of one repo share the partition" (src/utils/partition.ts:164-170). Two separate clones get two partitions; two linked worktrees of one repo get one.
    • In user scope today, both repos get one MCP value. In our user-scope run, pull wrote no .mcp.json in either repo, and the pg server in ~/.claude.json held whichever value was pulled last. A per-repo value there needs per-repo scopes, or per-project entries in ~/.claude.json (which teamai already uses for local scopes, [feat] keep the resources pull delivers in project scope out of git #915).
    • env list already shows where a value comes from. Each variable shows team or env.yaml, each secret shows team, global, environment, missing or unreadable, and both show their namespace (root or <ns>) (src/env-listing.ts:86-104). What it does not show is the --from-env variable name or the directory.
    • Workaround 2 (--from-env) already works per repo for MCP in project scope. Each repo's .mcp.json got the value exported at that repo's pull. No env.sh line is written for a --from-env key, by design (src/env-resolution.ts:38-45), so a shell gets the value only from its own environment.

    Approach, at a high level

    • One read path. The override layer belongs where the member value is read (resolveTeamEnv), so env list, env.sh, the MCP variables and env exec all agree on the value.
    • Set-only. An override supplies a value for a key the team already declares, and never adds one. env set already refuses undeclared keys, which is the precedent to follow.
    • Not a declaration. An override stays out of the namespace conflict check, as your point b says.
    • Outside the repo, in the machine-data area next to the partition's config.yaml and env.sh, as you propose.
    • Listing. Extend the source column env list already prints with the new layer, before adding a flag.

    Questions to settle before a PR

    1. @bladestone01: does each repo in your setup have its own project or workspace scope, or do you run user scope only? The answer decides what an override can key on and where MCP gets the value.
    2. Should an override key on the partition (one per repository, shared by its linked worktrees) or on the exact directory?
    3. What wins when several sources apply: the override, the member value, a --from-env entry, and for secrets --global and the environment? Keys a project inherits from user scope are resolved separately, so the order has to cover them too.
    4. In workspace mode (Proposal: a workspace mode for features that span several repos #913), should a child repo's override take part in the workspace's merged env, or be ignored at the workspace level?

    Once these are answered, this can be opened for a contributor.

    Testing

    This changes runtime behavior, so AGENTS.md ("Testing before a PR") asks for one end-to-end record from the real CLI after npm run build: two repos with their own scope, a different PG_DATABASE_URI override in each, then pull. Each repo should then show its own value in teamai env --reveal, .mcp.json, env exec and its partition env.sh. Use env --reveal rather than env list --reveal, which currently keeps values masked (#1432).

    Related: #875 and #879 (team secrets design), #1018 / #1023 (team env by directory, per scope), #913 (workspace children).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquestionFurther information is requested

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions