Skip to content

feat: scope hooks, MCP servers and env variables by logical project - #700

Merged
jeff-r2026 merged 12 commits into
Tencent:mainfrom
SaulMoro:feat/668-project-scoped-delivery
Sep 22, 2026
Merged

jeff-r2026 merged 12 commits into
Tencent:mainfrom
SaulMoro:feat/668-project-scoped-delivery

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

TeamAI resolves delivery on two membership axes, roles and projects. Four resource types read both. The three whose delivery costs something on every session read neither or only one, so a project's hook, MCP server or env variable reaches every member of the role.

 per-entry scoping
-  hooks    tools: yes   roles: yes   projects: no
+  hooks    tools: yes   roles: yes   projects: yes
-  mcp      tools: yes   roles: yes   projects: no
+  mcp      tools: yes   roles: yes   projects: yes
-  env      tools: -     roles: no    projects: no
+  env      tools: -     roles: yes   projects: yes

One check reads both axes, so a delivery path cannot filter on one and forget the other:

resolveMembership(localConfig) -> { roles, projects }
  activeRoleIds       src/roles.ts       primaryRole + additionalRoles
  activeProjectIds    src/projects.ts    localConfig.projects

matchesMembership(entry, membership)    src/membership.ts
  omitted   -> everyone
  []        -> nobody among members who use that axis
  otherwise -> the entry and the member share at least one id
  the two axes AND

Matching is an intersection on both sides. A member holds several roles and a directory is bound to several projects, so neither side is a single value to compare against.

The axes AND, the way tools: and roles: already do. A roles: [frontend] projects: [checkout] server reaches frontend members of checkout, not everyone on either. That is deliberately not the union role and project resource namespaces take, which answers the different question of which directories to sync.

Nothing changes for anyone until a maintainer adds a projects: key. A directory bound to no project has a null projects axis and keeps receiving every entry, the same fallback a member with no role already gets.

Three call sites share the one check:

 src/resources/hooks.ts   resolveTeamHooks
-  matchesRoles(d.roles, opts.activeRoles)
+  matchesMembership(d, membership)
 src/mcp-reconcile.ts     desiredMcpForTarget
-  matchesRoles(raw.roles, ctx.activeRoles)
+  matchesMembership(raw, ctx.membership)
 src/resources/env.ts     pullItem            (had no filter at all)
+  resolveDeliverableEnvVariables(variables, membership)
 src/doctor-delivery.ts   envDeliveryProblems (same filter, or it reports
+  resolveDeliverableEnvVariables(...)         a withheld variable as undelivered)

matchesRoles and warnUnknownRoleIds had no callers left afterwards, so they are deleted rather than kept alive by their own tests. Their truth table moved to membership.test.ts beside the new projects and AND rows.

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)
  • Documentation only
  • Refactor / internal cleanup

Three fixes ride along, described below: a swallowed manifest load error, env delivery skipped by the revision fast path, and a failure in that fast-path delivery that was logged at debug level under Already synced. This PR does not touch how resource namespace strings are validated in manifest/roles.yaml or manifest/projects.yaml; that hardening was removed from this branch at review request and is #710.

Test Plan

  • npm run build passes
  • npx tsc --noEmit passes
  • npx vitest run passes — 3834 passed, 1 pre-existing flake unrelated to this branch (local-agent.test.ts, reproduced on clean origin/main; see Suites)
  • npm run test:e2e passes — run against a real GitHub-hosted team repo with TEAMAI_TEST_PROVIDER=github, 197 passed / 0 failed, because the e2e CI job skips on a fork. Details and the real-CLI walkthrough in Agents and provider
  • Added/updated tests for the change

Providers exercised end to end through the compiled CLI: git (local team repo) and github (private fixture repo, full e2e surface plus the scoping walkthrough). gitlab is not — no credentials on this machine, the same reason this repo's own gitlab-provider-live.test.ts is opt-in on GITLAB_TOKEN. The filter runs on the local clone after pullRepo returns, so no provider or transport code is involved on any of the three paths.

Before and after, same team repo, real CLI

A directory bound to project checkout, member primaryRole: frontend. The team declares four MCP servers, three hooks and four env variables, scoped across checkout and billing.

Before, on origin/main at 2c0ae96:

pull:      Synced 4 env variable(s) / Applying 3 team hook(s) / MCP: 4 change(s) across 4 server(s)
.mcp.json: billing-api, checkout-api, fe-checkout-api, shared-api
hooks:     echo billing, echo checkout, echo shared
env.sh:    CHECKOUT_URL, BILLING_URL, DEVOPS_ONLY, SHARED_URL

DEVOPS_ONLY carries roles: [devops] and still reaches a frontend member, because env had no role axis either.

After, this branch:

pull:      Synced 2 of 4 env variable(s) / Applying 2 team hook(s) / MCP: 3 change(s) across 3 server(s)
.mcp.json: checkout-api, fe-checkout-api, shared-api
hooks:     echo checkout, echo shared
env.sh:    CHECKOUT_URL, SHARED_URL

fe-checkout-api carries both roles: [frontend] and projects: [checkout] and arrives, since both match.

Rebinding removes what the previous project delivered

teamai projects set billing then teamai pull --force:

.mcp.json: billing-api, shared-api          (checkout-api and fe-checkout-api gone)
hooks:     echo billing, echo shared
env.sh:    BILLING_URL, SHARED_URL

fe-checkout-api leaves although the role still matches, which is the AND. An OR would have kept it.

Upgrade with the team repo unchanged, no --force

Hooks and MCP reconcile outside pullForScope, so the "Already synced" fast path never hides a scoping change from them. Env was delivered inside the loop that path skips, so a machine upgrading from a CLI that ignored roles:/projects: on env kept a withheld variable exported until --force or a repo change. The fast path now delivers env too. Real CLI, a frontend member, env.yaml with SHARED_URL and a roles: [devops] variable:

$ teamai pull --force
  ✔ [project] Synced 1 of 2 env variable(s) to <project>/.teamai/env.sh
$ echo "export DEVOPS_ONLY='devops-secret'" >> .teamai/env.sh    # what the older CLI left behind
$ teamai pull
  ✔ [project] Already synced at 8ea0c3a, skipping
$ cat .teamai/env.sh
export SHARED_URL='https://shared.example.com'

The shell profile's mtime is unchanged across that second pull: injectShellProfile now skips an unchanged write, since this delivery runs on every session start. The same step is asserted in the project-scoped-delivery e2e and in a pull-skip-sync unit row.

That delivery can fail — a read-only env.sh, a full disk — and it runs after Already synced has printed, so a failure caught at debug level would leave the withheld variable exported with nothing on screen. It warns instead, naming the file and the way out:

$ teamai pull
  ✔ [project] Already synced at 59fa2d3, skipping
  ⚠ [project] Could not refresh env variables: EISDIR: illegal operation on a directory,
    open '<project>/.teamai/env.sh'. <project>/.teamai/env.sh may still export variables
    env.yaml no longer delivers to this directory. Fix the cause, run `teamai pull --force`,
    then open a new shell.

It is not rethrown: the pull it runs beside has already succeeded, and taking that down would trade one silent failure for a louder one. teamai doctor reports the same leftover independently. The unit row makes env.sh a directory, so the write throws on every platform and as root, unlike a permission bit.

Restrictions are visible

teamai mcp list     checkout-api    ... projects: checkout
                    fe-checkout-api ... roles: frontend / projects: checkout
teamai hooks list   [checkout-guard] Stop -> echo checkout  (tools: all, projects: checkout)
teamai env list     CHECKOUT_URL=ht****  (projects: checkout)
                    DEVOPS_ONLY=de****   (roles: devops)

Warnings, including on --dry-run

teamai pull --force --dry-run
  projects: unknown project id "chekout" in env.yaml variable "TYPO_URL". Valid projects: checkout, billing
  [project] [dry-run] Would sync 1 of 2 env variable(s)
  no env.sh written

doctor

MCP servers delivered to claude        pass
Env variables injected in shell profile pass
All checks passed

A variable the pull correctly withheld is not reported as undelivered, because doctor applies the same filter.

Between teamai projects set billing and the next pull, env.sh still exports checkout's variable. doctor no longer passes on "nothing owed" there:

$ teamai projects set billing
$ teamai doctor
  ✖ Env variables injected in shell profile
    → <project>/.teamai/env.sh still exports CHECKOUT_URL, which env.yaml no longer delivers to this directory (its roles: or projects: do not match). Run `teamai pull` after fixing the cause, then open a new shell.
  exit 1
$ teamai pull --force
$ teamai doctor
  All checks passed

That sequence is asserted in project-scoped-delivery.test.ts through the compiled CLI, and the unit rows in doctor-env-delivery.test.ts cover a leftover beside delivered variables, a leftover when nothing is deliverable, and nothing deliverable with no env.sh (still a pass).

Suites

npm run build       ESM dist/index.js 1.76 MB, Build success
npx tsc --noEmit    clean
npx vitest run      3834 passed, 1 skipped, 1 failed   (269 files)  — the failure is pre-existing, below
npm run test:e2e    197 passed, 5 skipped, 0 failed    (43 files, GitHub provider); opencode-recall.test.ts errors in beforeAll

Both suites were run with SHELL unset, because three shell-profile.test.ts Windows rows do not stub SHELL and resolve the host's zsh profile otherwise; that is a test-hygiene fix for its own PR.

The unit failure is local-agent.test.ts > emits hint only once per sessionId, and it is not this branch. The full suite was run four times on a clean origin/main worktree at 9ce8a0e and failed there in two of the four runs — once on this same row, once on it plus its sibling outputs hookSpecificOutput with choices when project is unbound. Both pass in isolation on both trees. The two rows drive reportAndSyncLocalAgent through a five-second fetch path and share a fixed marker file in os.tmpdir(), so a call that outlives its test is the likely mechanism; that is a hypothesis, not something I chased down. Worth its own issue.

opencode-recall.test.ts fails before its one test runs: it executes node_modules/opencode-ai/postinstall.mjs, which the installed opencode-ai@1.18.23 package does not ship on this machine. That is the test environment, not this branch; the file touches nothing this PR changes.

Agents and provider

Driven through the compiled CLI against a local git team repo, for Claude, Codex, CodeBuddy and OpenCode. Each received the filtered set and each dropped the other project's entries on rebind. Both MCP renderers are covered: Claude's JSON in project scope, and Codex's TOML in user scope, since Codex has no project-scope MCP location.

github is exercised end to end. The e2e CI job skips on this fork (it is gated on vars.TEAMAI_TEST_REPO_URL), so its setup was reproduced locally: a throwaway private GitHub repo seeded as a team repo with this PR's mcp.yaml, hooks.yaml, env.yaml and both manifests, cloned into an isolated HOME, and the full vitest.e2e.config.ts surface run against it with TEAMAI_TEST_PROVIDER=github. 197 passed, 0 failed. The same fixture then drove the scoping walkthrough through the compiled CLI with provider: github:

$ teamai projects set checkout && teamai pull --force
  ✔ [project] Synced 2 of 4 env variable(s)
  ℹ Applying 2 team hook(s)
  ℹ MCP: 3 change(s) across 3 server(s)
  .mcp.json: checkout-api, fe-checkout-api, shared-api
  hooks:     echo checkout, echo shared
  env.sh:    CHECKOUT_URL, SHARED_URL

$ teamai projects set billing && teamai pull --force
  .mcp.json: billing-api, shared-api
  hooks:     echo billing, echo shared
  env.sh:    BILLING_URL, SHARED_URL

$ echo "export DEVOPS_ONLY='devops-secret'" >> .teamai/env.sh && teamai pull
  ✔ [project] Already synced at 59fa2d3, skipping
  env.sh:    BILLING_URL, SHARED_URL          # the fast path took it back out

gitlab is still not exercised: there are no GitLab credentials on this machine, and the repository's own gitlab-provider-live.test.ts is opt-in on GITLAB_TOKEN for the same reason. This change adds no provider or transport code — the filter runs on the local clone after pullRepo returns, so every provider reaches it through the identical path, which is why the git and github runs exercise the same lines. A maintainer with GitLab credentials can repeat the walkthrough above against a GitLab-hosted repo with the same seed files; say the word and I will run it against any test group you can point me at.

Related Issues

Closes #668

Notes for Reviewers

Merge danger

Two-way door. Every commit is additive behind an optional key, and reverting restores the previous delivery on the next pull, since hooks, MCP and env are reconciled from scratch each time rather than migrated.

Blast radius: delivery. What could go wrong is an entry reaching fewer members than intended, which a maintainer sees the moment they add the key and which teamai mcp list, hooks list and env list explain. Nothing is deleted from a team repo and no member data is rewritten.

The manifest namespace hardening, the one change that could make something that parses today stop parsing, is now #710.

One question for maintainers, not settled here

roles: [] has never meant "nobody" in the way the docs say. matchesRoles returns true when the member's axis is null, and roles.test.ts on main asserts exactly that, so an entry scoped [] still reaches every member who has not configured that axis. The three list commands print nobody for such an entry, and the docs say "ships to nobody".

I kept the behaviour and made the docs precise instead. Changing it is three lines, but it would alter shipped #563 semantics for role-less members, which is a maintainer decision rather than a detail of this issue. Say the word and I will flip it in a follow-up, or here.

Four things the real CLI and the review caught, not the unit tests

The first teamai pull on this branch printed Synced 2 env variable(s) while writing one. countEnvVars gates the #662 malformed-env.yaml probe, where a count of zero means the file may be broken, so filtering it would fire that warning at a member scoped out of every variable on a good file. It stays unfiltered, and the summary line now reads Synced 1 of 3 env variable(s) when the two differ.

The no-projects-manifest warning claimed the key "restricts nothing" and that entries "are delivered to every member". The pull printed that next to Synced 2 of 4 env variable(s), disproving it in its own output. A directory's active projects come from its own config.yaml, so a directory bound to billing still filters out a projects: [checkout] entry with the manifest missing. The warning now says what is true. The case that disproved it is a test.

loadProjectsManifest returns null only when the file is absent and throws for every invalid one, so catching to null told a team with a broken manifest to "define the projects there" and threw away the loader's own message. Each axis now reports three outcomes, and the roles axis no longer handles the same failure silently twelve lines away. Both loaders read through readFileIfExists, which returns null on ENOENT alone and throws on anything else, so a manifest that exists but cannot be read is one of the reported outcomes rather than "no manifest"; readFileSafe folded every error into null.

The env unknown-id warning lived in EnvHandler.pullItem, which --dry-run never reaches. Checking a scoping edit is what a maintainer runs --dry-run for, and hooks and MCP warned there already. It moved to pullForScope.

Decisions worth disagreeing with

countEnvVars stays unfiltered while the summary line is filtered. Two counts answering two questions, which is a seam a reader can trip on. The alternative was a false #662 warning.

A member scoped out of every variable still gets an env.sh written, an empty one. That is what removes variables an earlier pull gave them, so the write is the point rather than a wasted step.

Env keeps its pre-existing behaviour where one shell profile block points at one env.sh, so a machine that pulls in several project-scoped directories ends up with the last-pulled directory's variables in new shells. Each directory's own env.sh is correct. Fixing the shell profile is a wider change than this issue, and the usage guide now states the consequence.

teamai env add gains no --roles or --projects flags. Those keys are hand-edited in env.yaml, as they are in hooks.yaml and mcp.yaml. env add does preserve them when updating an existing key, with a test.

Hooks gain no doctor delivery check, because there is none to extend. doctor checks hook plumbing only. Building a hooks delivery check is its own issue.

Follow-ups, not in this PR

src/status.ts parses env.yaml by hand in two places rather than going through EnvHandler, so it ignores any new per-variable key, and its status counts for hooks and MCP are unfiltered in the same way pull's env count was. It only prints and counts, so nothing misdelivers, but it is a second reader that will drift again.

src/env-commands.ts:36 writes a Chinese warning to stderr in production code, against the rule in CLAUDE.md.

@jeff-r2026 jeff-r2026 self-assigned this Sep 22, 2026
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/doctor-delivery.ts:572 — When every variable is scoped out, doctor returns success without inspecting env.sh. After rebinding projects, an old project’s secrets can remain exported while “Env variables injected in shell profile” passes. Parse the delivered file and report variables that are declared but no longer deliverable.
  • [P1 blocking] src/projects.ts:40 / src/roles.ts:15 — The namespace validation is an unrelated, potentially breaking manifest change. Previously accepted namespaces now fail parsing, despite the PR being marked non-breaking. Per the surgical-change rule, move this hardening into a separate PR.
  • [P1 blocking] PR Test Plan — Required validation is incomplete/contradictory: npx vitest run is checked as passing but the recorded run has three failures; gitlab and github provider checks were explicitly not performed; and there is no explicit successful npm run build record. The repository requires these checks and a fully passed Test Plan before merge.
  • [P2 non-blocking] CHANGELOG.md:9 — It says projects: “restricts nothing” without projects.yaml, contradicting the implementation and other documentation: a directory with project IDs in local config.yaml is still filtered; only ID validation is unavailable.

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Addressed all four points. Branch rebased and force-pushed (52344a0bb117c2); the PR body is updated to match.

P1 doctor-delivery.ts – early return on an empty deliverable set. Fixed in bb117c2. envDeliveryProblems now parses env.sh whenever the file exists and reports every variable env.yaml declares but no longer delivers to this directory, beside the missing and stale ones:

$ teamai projects set billing      # env.sh still holds checkout's variable
$ teamai doctor
  ✖ Env variables injected in shell profile
    → .teamai/env.sh still exports CHECKOUT_URL, which env.yaml no longer delivers to this directory (its roles: or projects: do not match)

Nothing deliverable and no env.sh is still a pass, since nothing is owed and nothing was left behind. Three unit rows in doctor-env-delivery.test.ts and a doctor-between-rebind-and-pull step in the project-scoped-delivery e2e cover it. CHANGELOG and both usage guides state the new report.

P1 namespace validation is unrelated. Agreed. The commit is dropped from this branch (src/projects.ts and src/roles.ts are back to the origin/main schemas) and opened on its own as #710, with its own test plan.

P1 Test Plan. Re-run on the pushed head, all green:

npm run build       ok
npx tsc --noEmit    clean
npx vitest run      3793 passed, 1 skipped, 0 failed
npm run test:e2e    175 passed, 26 skipped, 0 failed

The three shell-profile.test.ts failures in the earlier record were a host artifact: those Windows rows do not stub SHELL, so a zsh login shell makes them resolve .zshrc on origin/main as well. The suite above was run with SHELL unset. That test-hygiene fix belongs in its own PR rather than this one.

Providers: stated plainly in the body now. A provider: github run against a throwaway private repo got through teamai init authentication, but the clone hung in git-remote-https inside the sandboxed shell I have here, before any code from this branch runs. No GitLab credentials are available on this machine. The change adds no provider or transport code; the filter runs on the local clone after pullRepo returns. If a maintainer with either provider wants to repeat it, the seed files are the same mcp.yaml, hooks.yaml and env.yaml the e2e test writes.

P2 CHANGELOG "restricts nothing". Reworded: with no projects.yaml, the key still filters against the ids in the directory's config.yaml; only their validation is lost.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Env scoping is skipped after upgrading when the repository revision is unchanged. The new filtering occurs only during the full resource loop at src/resources/env.ts:280, but pullForScope returns early on a revision-cache hit at src/pull.ts:833. A user upgrading from a version that ignored roles/projects will retain previously delivered secrets indefinitely unless they use --force or change membership. The E2E test always uses --force, masking this case. Reconcile env outside the revision fast path or invalidate state for this behavior change.
  • [P1 blocking] The PR testing record does not satisfy repository requirements. It simultaneously marks npx vitest run as passing and reports 3 failures, provides no explicit successful npm run build result, and explicitly says the required gitlab and github provider E2E checks were not performed. The required validation matrix must be completed and accurately documented before merge.
  • [P2 non-blocking] loadRolesManifestIfPresent still cannot distinguish an absent manifest from a read failure. src/roles.ts:121 uses readFileSafe, which catches every filesystem error and returns null; permission/I/O failures therefore remain silently treated as “no roles manifest,” contradicting the new three-outcome warning contract.
  • [P2 non-blocking] The PR description claims namespace path hardening that is absent from the diff. src/projects.ts:16 and src/roles.ts:10 still accept arbitrary non-empty resource namespace strings, including path separators and ... Either implement the stated validation with tests or remove the claim from the PR description.

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Addressed in 9a78a66, pushed on top of bb117c2. The PR body is updated to match.

P1 env scoping skipped on the revision fast path. Correct, and env was the only axis with that hole: hooks and MCP reconcile outside pullForScope, so the fast path never hides a change from them, while env was delivered inside the loop the fast path returns before. The fast path now delivers env beside the env.yaml shape check it already ran there (reconcileEnvForUnchangedRepo in src/pull.ts), so env.sh is rewritten from the filtered set on a plain teamai pull with an unchanged repo. Real CLI, frontend member, env.yaml with SHARED_URL and a roles: [devops] variable:

$ teamai pull --force
  ✔ [project] Synced 1 of 2 env variable(s) to <project>/.teamai/env.sh
$ echo "export DEVOPS_ONLY='devops-secret'" >> .teamai/env.sh    # what the older CLI left behind
$ teamai pull
  ✔ [project] Already synced at 8ea0c3a, skipping
$ cat .teamai/env.sh
export SHARED_URL='https://shared.example.com'

Because that delivery now runs on every session start, injectShellProfile skips the write when the profile block is unchanged; the profile's mtime is identical across the second pull above. Asserted through the compiled CLI in project-scoped-delivery.test.ts (plain pull after --force, no rebind), in a pull-skip-sync.test.ts row that checks env.sh and that the revision cache is not rewritten, and in an env-handler.test.ts row that makes the profile read-only before the repeat pull.

P1 testing record. Re-run on 9a78a66:

npm run build       ESM dist/index.js 1.75 MB, Build success
npx tsc --noEmit    clean
npx vitest run      3798 passed, 1 skipped, 0 failed   (269 files)
npm run test:e2e    174 passed, 27 skipped, 0 failed   (41 files); opencode-recall.test.ts errors in beforeAll

The body no longer carries the earlier record with the three shell-profile failures; it states that both suites run with SHELL unset and why. opencode-recall.test.ts fails before its test runs because node_modules/opencode-ai/postinstall.mjs is not shipped by the installed opencode-ai@1.18.23 here; it exercises nothing this branch touches.

On providers, the body says what was and was not done rather than claiming a matrix that was not run: Claude, Codex, CodeBuddy and OpenCode through the compiled CLI against a git team repo; github blocked at the clone step inside this sandbox before any code from this branch runs; no GitLab credentials on this machine. The change adds no provider or transport code, and the filter runs on the local clone after pullRepo returns. If the maintainers require a gitlab/github run before merge, I need someone with those credentials to repeat the mcp.yaml/hooks.yaml/env.yaml seed from the e2e test, or a pointer to a test team I can use.

P2 loadRolesManifestIfPresent cannot tell absent from unreadable. Fixed for both axes. A new readFileIfExists in src/utils/fs.ts returns null on ENOENT only and throws on everything else; loadRolesManifestIfPresent and loadProjectsManifest read through it, so a manifest that exists but cannot be read surfaces as the third outcome the warning contract promised instead of "no manifest". Rows in roles.test.ts and projects.test.ts revoke read permission on the file and expect the loader to throw (skipped as root and on Windows, where the mode bits do not apply).

P2 PR description claims namespace path hardening. The description does not intend to claim it. The hardening was removed from this branch in the previous round and is #710; the two remaining mentions said so, but one could be read as describing this diff. Reworded at the top of the body: this PR does not touch how resource namespace strings are validated in either manifest.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/pull.ts:707reconcileEnvForUnchangedRepo catches write/injection failures and logs them only at debug level after already printing “Already synced.” If env.sh or the backup cannot be rewritten after a role/project change or CLI upgrade, scoped-out secrets remain active with no user-visible failure. Propagate the error or emit a visible warning.
  • [P1 blocking] PR Test Plan — Required provider coverage is incomplete. The repository instructions require real-CLI end-to-end verification for git, gitlab, and github, but the description explicitly says GitLab and GitHub were not successfully exercised. Complete and document those runs before merge.

Adds src/membership.ts: one check covering an entry's optional roles: and
projects: keys, so a delivery path cannot filter one axis and forget the other.
The two axes compose as AND, which is how tools: and roles: already compose.

activeProjectIds lives in projects.ts beside the manifest it reads, mirroring
activeRoleIds: an absent or empty projects list collapses to null, meaning no
project filter, so nothing about today's delivery changes until a maintainer
adds a projects: key.

No call site uses it yet.
An mcp.yaml server accepts an optional projects: list beside roles:, and
reaches a directory only when that directory is bound to one of them. Both
axes AND, so 'roles: [frontend] projects: [checkout]' reaches frontend
members of checkout.

This is what the issue's five-projects-three-servers case costs today: every
member of a role starts fifteen server processes and carries fifteen tool
lists in every session.

desiredMcpForTarget now takes both axes as one membership value, so the
filter cannot be applied on one axis and forgotten on the other. teamai
doctor inherits the filter through buildDesiredMcpContext, and 'teamai mcp
list' prints the restriction next to the roles one.

A non-matching server is skipped silently with no change record, exactly as
the roles and tools filters already do.
A hooks.yaml hook accepts an optional projects: list beside roles:, filtered
in resolveTeamHooks before the security gates so the transparency print keeps
listing only the hooks this member will actually run.

resolveTeamHooks now takes both axes as one membership value instead of
activeRoles. With that, matchesRoles and warnUnknownRoleIds have no callers
left, so they are deleted rather than kept alive by their own tests; their
truth table moved to membership.test.ts as the roles-axis rows beside the new
projects-axis and AND rows.

'teamai hooks list' prints the projects restriction after the roles one.
env.yaml variables accept optional roles: and projects: lists, the axes
env delivery had neither of. A variable lands in the member's shell profile,
so an unscoped one reaches every member of the team.

resolveDeliverableEnvVariables is the single filter: pullItem writes env.sh
and the KEY=value backup from it, and doctor diffs env.sh against it. Without
the doctor half, a project-scoped variable a pull correctly withheld would be
reported as undelivered.

countEnvVars stays UNFILTERED on purpose. It gates the Tencent#662 "no top-level
variables: key" warning, where a count of 0 means the file may be malformed;
filtering it would fire that warning at a member scoped out of every
variable, on a valid file.

A member scoped out of everything still gets an env.sh written — an empty one
— because that is what removes the variables an earlier pull gave them.

'teamai env list' prints both restrictions. 'teamai env add' has no flags for
them (env.yaml is hand-edited for scoping, as hooks.yaml and mcp.yaml are)
but round-trips them when updating a variable's value.

pull-skip-sync.test.ts partially mocks ../roles.js; env delivery now reads
activeRoleIds through it, so the mock carries it.
…t env count

Adds src/__tests__/e2e/project-scoped-delivery.test.ts: a directory bound to
one project receives that project's MCP server, hook and env variable and not
the other's; an entry scoping both axes reaches only a member matching both;
and 'projects set' to another project REMOVES what the first delivered. Both
MCP render paths are covered — Claude's JSON in project scope, Codex's TOML in
user scope, since Codex has no project-scope MCP location.

The e2e run found a reporting bug this branch introduced: pull printed the
DECLARED count ('Synced 2 env variable(s)') while delivering one. Adds
countDeliverableEnvVars and reports 'Synced 1 of 2 env variable(s)' when the
two differ, so a member who expected a variable can see it was scoped away
rather than lost. countEnvVars stays unfiltered for the Tencent#662 probe.
The resource table moved out of the five READMEs into docs/product-overview.md
(EN + zh) when Tencent#722 slimmed the README to a landing page, so its Env, Hooks and
MCP rows extend their existing one-line note there rather than gaining rows.
usage-guide (EN + zh) gains the projects: line in the mcp and hooks snippets,
the hooks field-table row, a projects paragraph mirroring the canonical roles
one, and an explicit note that the two axes AND — which readers would otherwise
assume mirrors the role-project union that resource namespaces take.

env.yaml's schema was documented in no language before this: its section only
showed 'teamai env add'. It now carries the first env.yaml snippet, the AND
rule, the removal-on-rebind behaviour, and the last-pull-wins consequence of a
shell profile that holds one block pointing at one env.sh.

docs/designs/multi-project-management.md listed hooks/mcp/env in neither its
affected surface nor its out-of-scope section; both now name Tencent#668, and the
out-of-scope note records what stays unscoped (packages, docs, culture.md).
The warning said a projects: key with no manifest 'restricts nothing — they
are delivered to every member'. The real-CLI run disproved it in its own
output: the pull printed that warning next to 'Synced 2 of 4 env variable(s)'.

A directory's active projects come from its own config.yaml, not from the
manifest, so a directory bound to billing still filters out a
projects: [checkout] entry with the manifest missing. What a missing manifest
actually means is that no id can be validated, and that a directory bound to
no project receives every entry. Says that instead.

Adds the case that disproved the claim as a test.
…y-run

Four findings from the review pass.

loadProjectsManifest returns null only when the file is ABSENT; it throws for
bad YAML, a bad shape, a duplicate id or an unsafe namespace. The catch-to-null
conflated the two, so a team whose projects.yaml fails validation was told it
'defines no projects. Define the projects there, or drop the key' — false, and
it discarded the loader's own message naming the fault. Each axis now reports
three outcomes: ids to check against, no manifest, or the real load error.

The roles axis had the same failure handled differently twelve lines away,
silently. Both axes now read alike, via a new loadRolesManifestIfPresent that
mirrors loadProjectsManifest's contract: absent is a value, invalid is an error.
A team with no roles.yaml is ordinary and stays silent; a broken one is named.

The env unknown-id warning moved from EnvHandler.pullItem to pullForScope.
--dry-run skips pullItem, so the one command a maintainer runs to check a
scoping edit was the one that never warned, while hooks and MCP warned there
already. countDeliverableEnvVars folded into that call site and is gone.

SAFE_SEGMENT_MESSAGE was over-escaped and printed a doubled backslash.

Also: the two axis loops were one shape, now one loop; MembershipScope reads as
EntryScope, the mirror of Membership rather than a near-synonym; and the
pull-skip-sync mock takes activeRoleIds from vi.importActual instead of
restating its body.
Two claims the spec review caught, both inherited from the roles docs rather
than introduced here.

"`roles: []` ships to nobody" has never been the whole rule. matchesRoles
returns true when the member's axis is null, and roles.test.ts asserts exactly
that on main, so an entry scoped `[]` still reaches a member who has not
configured that axis. The guide now says the rule holds among members who use
the axis, and points at `tools: []` for reaching no one at all. The behaviour
is untouched: changing it would alter shipped Tencent#563 semantics, which is a call
for the maintainers rather than a detail of this issue.

The missing-manifest sentence claimed the key stops restricting. It does not.
A directory's active projects come from its own config.yaml, so a directory
bound to billing still filters out a projects: [checkout] entry with no
manifest present. What the manifest provides is id validation.
After 'teamai projects set' or a role change, env.sh keeps exporting the
previous project's variables until the next pull rewrites it. doctor
returned success before reading the file whenever the member was scoped
out of every variable, so those secrets stayed live in every new shell
behind a passing 'Env variables injected in shell profile'.

The check now parses env.sh whenever it exists and reports every variable
env.yaml declares but no longer delivers to this directory, beside the
missing and stale ones. Nothing deliverable and no env.sh is still a pass:
nothing is owed and nothing was left behind.

CHANGELOG: a projects: key with no projects manifest still filters against
the directory's config.yaml ids; only their validation is lost.
…fest that cannot be read

`pullForScope` returns early when the team repo revision matches the last
pull, and env is delivered inside the loop that return skips. Hooks and MCP
reconcile outside `pullForScope`, so a scoping change reaches them on every
pull; env did not. A machine upgrading from a CLI that ignored `roles:` and
`projects:` on env variables kept the withheld variables exported until
`--force` or a repo change. The fast path now runs the env delivery beside
the env.yaml shape check it already ran, rewriting `env.sh` from the
filtered set.

That delivery now runs on every session start, so `injectShellProfile`
leaves an unchanged shell profile alone instead of rewriting it each time.

`loadRolesManifestIfPresent` and `loadProjectsManifest` read through
`readFileSafe`, which folds a permission or I/O failure into "no manifest".
Both read through a new `readFileIfExists`, which returns null on ENOENT
alone and throws otherwise, so a manifest that cannot be read is reported
rather than treated as a team without one.

Rows: the fast path in pull-skip-sync and the project-scoped-delivery e2e
through the compiled CLI, the read failure in roles.test and projects.test,
the untouched profile in env-handler.test.
The fast-path env delivery caught every failure and logged it at debug level,
after "Already synced" had already printed. That delivery is what REMOVES a
variable the member is no longer scoped to, so a write that fails left the
withheld variable exported with nothing on screen to say so.

The catch now warns, naming the env.sh that may still be stale and the way out
(`teamai pull --force`, then a new shell). Still not rethrown: the pull it runs
beside has already succeeded, and taking that down would trade one silent
failure for a louder one. `teamai doctor` reports the same leftover on its own.

The test makes env.sh a directory so the write throws on every platform and as
root, unlike a permission bit, and asserts the warning carries the path and the
command.
@SaulMoro
SaulMoro force-pushed the feat/668-project-scoped-delivery branch from 9a78a66 to 9a7b147 Compare September 22, 2026 10:40
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Addressed both findings. Branch rebased onto origin/main (9ce8a0e) and force-pushed (9a78a669a7b147); the PR body is updated to match.

P1 src/pull.ts:707 — the fast-path env refresh swallowed its failure. Fixed in 9a7b147. The catch now warns instead of logging at debug level, naming the env.sh that may still be stale and the way out. Real CLI, provider: github, with env.sh made unwritable before a plain pull:

$ teamai pull
  ✔ [project] Already synced at 59fa2d3, skipping
  ⚠ [project] Could not refresh env variables: EISDIR: illegal operation on a directory,
    open '<project>/.teamai/env.sh'. <project>/.teamai/env.sh may still export variables
    env.yaml no longer delivers to this directory. Fix the cause, run `teamai pull --force`,
    then open a new shell.

It is still not rethrown: the pull it runs beside has already succeeded, and taking that down would trade one silent failure for a louder one that hides the rest of the sync. teamai doctor reports the same leftover independently, so the member has two ways to see it. The pull-skip-sync row makes env.sh a directory, so the write throws on every platform and as root, unlike a permission bit, and asserts the warning carries both the path and the command; flipping the log.warn back to log.debug fails it.

P1 provider coverage. github is now exercised end to end.

The e2e CI job skips on this fork — it is gated on vars.TEAMAI_TEST_REPO_URL — so its setup was reproduced locally: a throwaway private GitHub repo seeded as a team repo with this PR's mcp.yaml, hooks.yaml, env.yaml and both manifests, cloned into an isolated HOME, and the full vitest.e2e.config.ts surface run against it with TEAMAI_TEST_PROVIDER=github:

npx vitest run --config vitest.e2e.config.ts
  Test Files  39 passed | 3 skipped | 1 failed (43)
  Tests       197 passed | 5 skipped | 0 failed (202)

The failing file is opencode-recall.test.ts, which errors in beforeAll because the installed opencode-ai@1.18.23 does not ship the postinstall.mjs it executes. That is this machine, not the branch, and it touches nothing this PR changes.

The same fixture then drove the scoping walkthrough through the compiled CLI:

$ teamai projects set checkout && teamai pull --force
  ✔ [project] Synced 2 of 4 env variable(s)
  ℹ Applying 2 team hook(s)
  ℹ MCP: 3 change(s) across 3 server(s)
  .mcp.json: checkout-api, fe-checkout-api, shared-api
  hooks:     echo checkout, echo shared
  env.sh:    CHECKOUT_URL, SHARED_URL

$ teamai projects set billing && teamai pull --force
  .mcp.json: billing-api, shared-api
  hooks:     echo billing, echo shared
  env.sh:    BILLING_URL, SHARED_URL

$ echo "export DEVOPS_ONLY='devops-secret'" >> .teamai/env.sh && teamai pull
  ✔ [project] Already synced at 59fa2d3, skipping
  env.sh:    BILLING_URL, SHARED_URL          # the fast path took it back out

gitlab is still not exercised: there are no GitLab credentials on this machine, which is the same reason the repository's own gitlab-provider-live.test.ts is opt-in on GITLAB_TOKEN. This change adds no provider or transport code — the filter runs on the local clone after pullRepo returns, so every provider reaches it through the identical path, which is why the git and github runs exercise the same lines. Point me at a test group or hand me a token and I will run the walkthrough above against a GitLab-hosted repo; the seed files are in the PR body.

Rebase. #722 slimmed all five READMEs to a landing page and moved the resource table into docs/product-overview.md, which is where this PR's projects: rows for Env, Hooks and MCP now live (EN + zh). No README is touched any more, and the follow-up this PR listed about README.ja/ko/th drift is obsolete — that table no longer exists in them.

One unrelated unit failure, so the record stays honest. local-agent.test.ts > emits hint only once per sessionId fails intermittently in the full unit suite (3834 passed, 1 failed). It is not this branch: the full suite was run four times on a clean origin/main worktree at 9ce8a0e and failed there in two of the four runs — once on this row, once on it plus its sibling outputs hookSpecificOutput with choices when project is unbound. Both pass in isolation on both trees. The two rows drive reportAndSyncLocalAgent through a five-second fetch path and share a fixed marker file in os.tmpdir(), so a call outliving its test is the likely mechanism; that is a hypothesis I did not chase down. Happy to open an issue for it.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Empty scopes can deliver secrets and executable hooks despite being displayed as “nobody.” matchesAxis returns true whenever the member’s axis is null, before checking whether the entry explicitly specifies []. Thus projects: [] is delivered to every directory without configured projects, even though schemas, list commands, and documentation describe it as reaching nobody. This is newly introduced for project-scoped hooks, MCP servers, and environment variables and can expose sensitive resources. Check empty entry lists before the null-membership fallback. src/membership.ts:52
  • [P1 blocking] The required provider end-to-end matrix is incomplete. The PR description explicitly says the gitlab and github providers were not exercised end to end. Repository instructions require real-CLI verification for git, gitlab, and github before the PR; reasoning that filtering occurs after cloning does not replace those required runs. Add successful E2E records for both providers.

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.

[feat] Scope hooks, MCP servers and env variables by project: a project's entries reach every member of the role

2 participants