Skip to content

fix(models): key team values by repo identity, migrating legacy slug names (#894) - #895

Merged
jeff-r2026 merged 27 commits into
Tencent:mainfrom
scs0209:fix/team-values-hash-894
Sep 29, 2026
Merged

jeff-r2026 merged 27 commits into
Tencent:mainfrom
scs0209:fix/team-values-hash-894

Conversation

@scs0209

@scs0209 scs0209 commented Sep 29, 2026 •

Copy link
Copy Markdown

Summary

Team-profile API keys live in ~/.teamai/models/teams/<name>.json. The old file name combined a slug of the teamai.yaml team: value with a hash of the repository identity, so renaming the team orphaned the keys and the name was never a stable binding to a repository. This PR names the file after the repository identity alone and reads legacy files in place.

  • getTeamValuesPath resolves the repository identity in strict order — a non-origin/upstream remote that names a repository, then repo.url, then the repo: claim, then the local path — and hashes it via repoIdentity (URL-shaped), provider:identity (path-shaped claim, provider from the claim, else the local override, else the team default), or provider:slug:path (path-only, where no repository identity exists and the team slug keeps differently named teams sharing one checkout path apart).
  • Legacy <slug>-<digest>.json files are read where they lie — never renamed, linked, or copied. A legacy file matches when its digest is one the old implementation could have produced from this checkout, mirroring the old single-identity precedence. Digests whose identity names a repository (a URL, a URL-shaped remote or claim, a path-shaped claim) match by digest alone; a bare-alias digest or a path-only digest additionally requires this checkout's slug, the discriminator the old scheme kept those files apart by. The next save writes the hash-only name, which shadows the legacy file.
  • sameTeamIdentity applies the same rules to switch records: digests that name a repository match by digest alone, so existing switches survive team renames; bare-alias and path-only digests additionally require the stored slug, so cross-team records are rejected.
  • docs/designs/model-profiles.md and model-profiles.zh-CN.md are in sync.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Test Plan

  • npx tsc --noEmit passes
  • npm run lint passes (0 warnings / 0 errors)
  • npm test passes (5280 passed / 1 skipped)
  • npm run build passes
  • npx vitest run --config vitest.e2e.config.ts model-cli-switch passes
  • Added/updated tests for the change

Tests cover: hash-only naming stable across teamai.yaml renames; identity precedence (claim over path, provider qualification, alias exclusion, path-only slug hashing); sameTeamIdentity accepting only digests this checkout could have produced; legacy read-through (newest file wins, foreign digests ignored, alias files read only under this team's slug, a path-shaped claim file read by digest across renames, hash-only shadowing after the first save).

Real-CLI verification (head efd4ae5): sandbox $HOME, built CLI (dist/index.js), a bare local remote, a member config whose remote is an HTTPS URL while teamai.yaml carries a different canonical repo: claim, and a legacy hai-platform-<repo:-claim digest>.json values file:

  1. teamai pull --dry-run — legacy secrets file untouched, no hash-only file created, profile offered.
  2. teamai models list team:corp — reads the legacy key in place, API key: configured locally; models switch --dry-run likewise, no rename.
  3. Real models switch — the unbound beta-era key is bound to the gateway origin, the hash-only file is created holding it, and the legacy file remains in place shadowed; models restore clean.

Related Issues

Fixes #894

Notes for Reviewers

  • Identity uses repoIdentity, not normalizeRepoUrlForCompare: the latter drops the port on purpose, while values files must distinguish https://host:8443/team from https://host/team.
  • Matching is by digest for identities that name a repository because the slug drifts on team renames. A path-shaped claim digest still names the one checkout its keys are gateway-origin-bound to, so its values file is read by digest; its switch records need the slug, since the claim names no single repository across teams. Bare-alias and path-only digests name no repository at all and need the slug everywhere.
  • Reading a foreign provider-relative claim file cannot leak keys: stored keys are bound to their gateway origin first (bindLegacyTeamKeys), and a foreign team key is only rewritten onto a profile id whose origin matches this team's.
  • Path-only configs hash provider:slug:path into the name itself: a rename there still orphans keys (no repository identity exists to key on), but a checkout path reused by a differently named team can no longer read the previous team's file.
  • Concurrency needs no migration lock: reads never mutate, and writes go through the existing atomic writeJsonAtomic.

@scs0209
scs0209 force-pushed the fix/team-values-hash-894 branch from eb4b302 to f60428f Compare September 29, 2026 01:32
@jeff-r2026 jeff-r2026 self-assigned this Sep 29, 2026
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] The PR description lists typecheck, unit tests, and build, but no representative end-to-end/real-CLI verification. This runtime behavior change violates the repository’s explicit Code Review Rule requiring one real-CLI record.
  • [P1 blocking] --dry-run can rename the legacy secrets file because loadTeamValues always calls migrateTeamValuesPath, regardless of options.dryRun. For example, teamai pull --dry-run mutates ~/.teamai/models/teams/, contradicting dry-run semantics. src/models-cmd.ts:90
  • [P1 blocking] Legacy matching omits the exact teamai.yaml repo: identity that the old implementation used. If local config contains an SSH remote while teamai.yaml contains the canonical HTTPS URL, the old values file and manifest identity use the HTTPS digest; neither migration nor sameTeamIdentity recognizes it, so keys and existing switches remain orphaned. src/models/profile.ts:190
  • [P1 blocking] When a team was renamed and the user subsequently re-entered keys, multiple legacy files can share the same digest. The migration renames the first matching readdir entry, whose order is unspecified, so it may silently adopt stale keys instead of the latest file. src/models/profile.ts:235
  • [P1 blocking] The English design document was updated, but its required bilingual counterpart still documents ~/.teamai/models/teams/<团队名>-<哈希>.json. This breaks the repository rule requiring bilingual docs to remain synchronized. docs/designs/model-profiles.zh-CN.md:18

…nt#894)

- never migrate the legacy values file in a dry run: `pull --dry-run` and
  `models switch --dry-run` thread { dryRun } into migrateTeamValuesPath
- match the legacy digest the old implementation actually keyed on — the
  repo: claim in teamai.yaml overrode the identity there, so an SSH remote
  beside an HTTPS repo: no longer orphans keys and switches
- when several legacy files share a digest, migrate the newest one, so
  keys re-entered after a team rename win over stale copies
- sync docs/designs/model-profiles.zh-CN.md with the hash-only file name

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] The PR description lists typecheck, unit tests, and build, but no representative end-to-end/real-CLI verification. This runtime behavior change violates the repository’s explicit Code Review Rule requiring one real-CLI record.
  • [P1 blocking] Dry-run skips the rename but still returns the nonexistent hash-only path. For an upgraded user with only a legacy secrets file, models switch --dry-run reports a missing API key and pull --dry-run skips the update, while the equivalent real command migrates and uses that key. Dry-run therefore does not preview actual behavior. src/models/profile.ts:250
  • [P1 blocking] Hashing a nonstandard repo.remote value directly causes cross-repository secret collisions. For example, two supported/legacy configs using the alias fork for different repository URLs both resolve to the same hash-only file and team identity, so one team can read and apply the other team’s API key. The removed slug previously kept these files distinct. src/models/profile.ts:175
  • [P1 blocking] Migration is vulnerable to concurrent overwrites. If two pulls see no target, both scan the same legacy files; after one moves the newest file, the other skips that missing source and renames an older candidate over the newly created target on POSIX, silently replacing the latest API key with stale data. src/models/profile.ts:274

Earlier findings concerning mutation during dry-run, teamai.yaml repo: matching, deterministic newest-file selection, and the Chinese design document are resolved in the current diff.

…ce-safe link

- dry run returns the newest legacy file's path so it reads exactly the
  keys the real run would migrate and read, instead of reporting a key
  missing from a not-yet-existing hash-only file
- a bare remote alias (`fork`) no longer names the values file: two
  checkouts sharing the alias hashed to one file and could read each
  other's keys, so hashing falls back to the URL, then the local path
- migration links the legacy file to the target instead of renaming:
  a concurrent migration that links first makes ours fail with EEXIST,
  so stale keys can never overwrite fresh ones on POSIX

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] The PR description lists typecheck, unit tests, and build, but no representative end-to-end/real-CLI verification. This runtime behavior change violates the repository’s explicit Code Review Rule requiring one real-CLI record.
  • [P1 blocking] Legacy matching includes localPath even when the old implementation would have keyed on a configured remote URL. For example, a previous team using remote: origin, no url, and the default ~/.teamai/team-repo path leaves a local-path-digest file; after initializing a different team at that same path with an HTTPS remote, this code can migrate the previous team’s API keys and treat its managed switches as belonging to the new team. src/models/profile.ts:228
  • [P1 blocking] Migration silently fails on filesystems that reject hard links. If fs.promises.link returns EPERM, ENOTSUP, or similar, every candidate is skipped and the nonexistent target is returned, so upgraded users appear to have lost their stored API key. Use an exclusive-copy or other no-overwrite fallback instead of swallowing unsupported-link errors. src/models/profile.ts:298

Resolved

  • Earlier findings covering dry-run behavior, exact teamai.yaml repo: matching, newest-file selection, bare-alias collisions, concurrent overwrites, and Chinese design-document synchronization are resolved.

…inks fail

- the legacy local-path digest is a candidate only when the old
  implementation would have keyed on the path itself (no repo: claim,
  no configured remote, no URL): a default-path checkout re-initialized
  for another team with a URL no longer adopts the previous team keys
- when a filesystem rejects hard links (EPERM, ENOTSUP, ...), migration
  falls back to an exclusive, never-overwriting copy (open wx mode,
  partial writes removed) instead of skipping every candidate and
  returning a path with no file behind it

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] The no-hard-link fallback exposes the destination before its contents are written. If process A opens target with 'wx' and process B concurrently calls migration, B sees the empty/partial target via existsSync or EEXIST, returns it, and loadModelInputs fails to parse it. Write to a temporary file and publish atomically instead. src/models/profile.ts:333

Resolved

  • All previously reported findings are fixed in the current diff.
  • The updated PR description includes sufficient representative real-CLI verification at head 1f76879.

The copy fallback wrote the target in place after an exclusive open, so
a concurrent reader could observe the empty or partially written values
file and fail to parse it. The content is now written to a unique temp
file and linked into place: until the publish link succeeds the target
does not exist, so a concurrent reader sees either the complete previous
file or none, and two migrations still cannot overwrite each other (the
loser publish link fails and its temp file is removed).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] The “no-hard-link” fallback still publishes with fs.promises.link. On a filesystem where hard links are unsupported and return EPERM/ENOTSUP for every link, both the initial migration and the temp-to-target link fail; migration returns a nonexistent target and upgraded users appear to lose their stored API key. The test masks this by rejecting links only when the source is the legacy file. src/models/profile.ts:338

Resolved

  • The earlier dry-run, identity matching, ordering, collision, concurrency, documentation, and partial-target findings are resolved.
  • The PR description now includes sufficient real-CLI verification at head c45966e.

Rename-and-migrate had to be atomic, no-clobber, link-optional, and
partial-write-free all at once, and each fix round surfaced another way
the migration could strand or clobber a secrets file. Drop the machine:
while the hash-only file does not exist, findTeamValuesPath returns the
newest legacy file and it is read where it lies; the next save writes
the hash-only name, which then shadows the legacy file. Reads no longer
mutate anything — a dry run needs no special casing — and no link,
rename, or copy stands between the keys and their reader.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] Bare non-origin aliases are still included in legacyTeamValueHashes. If two repositories previously used remote: fork, both legacy files have the same digest; because findTeamValuesPath ignores the slug and selects the newest matching file, one team can read and apply the other team’s API keys. Exclude non-repository aliases from legacy matching or require an additional safe discriminator. src/models/profile.ts:234

Resolved

  • The dry-run, identity-claim, ordering, filesystem race, hard-link, partial-target, and bilingual-documentation issues are resolved.
  • The PR description includes sufficient real-CLI verification for head d744c1f.

Two checkouts that shared a bare non-origin alias (`fork`) hashed the
same legacy digest, so findTeamValuesPath could read one team's values
for the other. A repository-bound digest (URL, repo: claim, local path)
still matches by digest alone, since the slug drifts on team renames;
a file keyed by an alias digest is adopted only when its slug is this
checkout's too, the discriminator the old scheme kept those files
apart by.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] Alias-based legacy manifest identities still match by digest alone. Two teams configured with remote: fork share that digest; pulling team B can treat an agent switched to team-a-<digest> as belonging to team B and overwrite its settings. Apply the same slug check used by findTeamValuesPath when the matching candidate is not repository-bound. src/models/profile.ts:283
  • [P1 blocking] For legacy configs with remote: origin/upstream and no repo.url, the new filename hashes only localPath, ignoring an available teamai.yaml repo: identity. Replacing the checkout at the same path with a different team therefore reuses the first team’s hash-only secrets file and exposes its API keys. src/models/profile.ts:190
  • [P1 blocking] The legacy candidate set includes localPath whenever the config lacks a URL, even when teamai.yaml has a repo: claim. If an old team left a path-digest file and a different team is later checked out at that path with its own claim, the new team adopts the old team’s keys although the old implementation would have hashed the claim. src/models/profile.ts:242

Resolved

  • The previously reported dry-run, migration race/filesystem, alias-file matching, ordering, and bilingual-documentation findings are fixed.
  • The PR description includes sufficient real-CLI verification for head 22066cf.

…old precedence

- sameTeamIdentity applied the alias slug check only when reading a
  values file, not when matching a switch record: two teams sharing
  remote fork could treat each other managed switches as their own.
  The slug requirement now covers every non-repository-bound digest.
- legacyTeamValueHashes mirrored the old precedence too loosely: with a
  repo claim present, the old implementation hashed only the claim,
  so the remote, URL, and path candidates never applied. A replaced
  checkout at the same path can no longer adopt a path-digest record
  the old code would never have produced for it.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] Hash-only files still collide for legacy configs with remote: origin/upstream and no repo.url. getTeamValuesPath hashes only localPath, ignoring the available teamai.yaml repo: claim. If team A creates the hash-only file and that checkout path is later reused for team B, team B reads team A’s API keys and stored === getTeamIdentity(...) also claims team A’s managed switches. src/models/profile.ts:190

Resolved

  • The dry-run, legacy-file race/filesystem, alias-digest, ordering, and bilingual-documentation issues are resolved.
  • The PR description includes sufficient representative real-CLI verification; its older recorded head is non-blocking under the review rules.

…s file name

A config with remote origin and no repo.url hashed the checkout path,
so reusing that path for another team shared the first team's hash-only
values file and its identity: team B read team A's API keys and claimed
its managed switches. The claim is the remaining repository identity
before the path, so it now wins whenever the remote and the URL name
no repository.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] Provider-relative identities such as owner/repo are hashed without the configured provider. If a user has, for example, GitHub and GitCode teams with the same repo: value and no URL-bearing local remote, both resolve to the same hash-only file; opening team B reads team A’s API key and also accepts its managed-switch identity. Include the provider/host when the selected identity is not a URL. src/models/profile.ts:194

Resolved

  • All findings from earlier review passes are fixed in the current diff.
  • The PR description includes sufficient real-CLI verification for head e428261.

A path-shaped identity like owner/repo names a different repository on
each provider, so two teams with the same repo value on GitHub and
GitCode hashed to one values file and one identity: opening team B read
team A keys and accepted its managed-switch identity. The provider now
qualifies path-shaped identities; host-bearing ones (URLs) already
carry the host and stay as they are.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] Provider-relative identities use only localConfig.provider, defaulting to tgit, instead of the effective provider from teamai.yaml. Since the local override is normally absent, GitHub and GitCode teams both declaring repo: acme/widgets resolve to the same hash-only file and can read each other’s API keys and managed switches. src/models/profile.ts:200
  • [P1 blocking] Legacy provider-relative claims are treated as globally repository-bound. With explicit GitHub and GitCode providers using the same repo: acme/widgets, both legacy files share the raw claim digest; findTeamValuesPath ignores their slugs and selects the newest file, while sameTeamIdentity can adopt the other team’s switches. src/models/profile.ts:250

Resolved

  • All findings from earlier review passes are fixed in the current diff.
  • The PR description includes sufficient representative real-CLI verification.

…igests

- the values file identity qualified a path-shaped source with the local
  provider override, normally absent, so two teams declaring the same
  repo: value on GitHub and GitCode still hashed to one file; the
  teamai.yaml provider now wins for its own claim, and the local
  override applies only when the claim carries none
- legacy candidates marked a path-shaped claim digest repository-bound,
  so sameTeamIdentity adopted another team's switches and
  findTeamValuesPath read its file under the matching digest; a
  URL-shaped claim stays repository-bound while a path-shaped one is
  matched under this team's slug, like an alias
- also fixes the candidate guard inverted while landing the above:
  with a claim present the remote, URL, and path candidates were
  dropped, and without one they were duplicated

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] A legacy file keyed by a path-shaped repo: claim (for example repo: acme/widgets) is marked non-repository-bound, so both values-file lookup and switch matching require the current team slug. Renaming team: therefore still orphans the legacy API keys and managed switches—the primary scenario this PR claims to fix. src/models/profile.ts:264
  • [P1 blocking] Path-only configurations still use localPath as the hash-only identity. If /home/user/.teamai/team-repo is replaced by another legacy team without a usable URL or repo: claim, the new team resolves to the same file and can read the previous team’s API keys and claim its managed switches; the removed slug previously separated differently named teams. src/models/profile.ts:195

Review Status

  • The PR description includes sufficient real-CLI verification for head e908cbc.
  • The previously reported dry-run, provider qualification, alias matching, race/filesystem, and bilingual-documentation findings are resolved.

scs0209 and others added 2 commits September 29, 2026 16:14
…ly records

Path-shaped repo claims now adopt their legacy values files by digest
alone, so a team rename no longer orphans its keys; the slug check
moves to switch-record matching, where a claim digest names no single
repository. Path-only configs mix the team slug into the hash-only
identity, so a checkout path reused by a differently named team can no
longer read the previous teams keys.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
…ages

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] Legacy path-shaped claims remain provider-ambiguous when reading secrets. GitHub team Alpha and GitCode team Beta can both have repo: acme/widgets, producing the same old digest; because fileNeedsSlug is false, Beta may select Alpha’s legacy file and use its API key when profile ID and gateway origin match. src/models/profile.ts:277
  • [P1 blocking] Renaming a team with a path-shaped repo: claim still orphans its managed switches. The legacy record retains the old slug, but recordNeedsSlug requires the new current slug, so sameTeamIdentity returns false and subsequent pull operations stop updating previously switched agents. src/models/profile.ts:277

Review Status

  • The prior path-only checkout-reuse issue is resolved.
  • The PR description includes sufficient representative real-CLI verification for head efd4ae5.

…es by gateway origin

A legacy file's digest hashes the bare path-shaped claim name with no
provider, so GitHub team Alpha and GitCode team Beta can produce the same
old digest. The previous fix read such files by digest across renames,
letting the second provider adopt the first's file and API key; slug-guarding
records instead orphaned a renamed team's own switches. Gate both on the
one evidence that survives both a rename and a provider change: the
gateway origin each stored key is bound to under `<team:<id>@<origin>`.

Path-shaped claim digests now require this team's slug for files and
switch records, and are re-admitted under a different slug only when the
legacy file provably stores keys bound to this team's gateways — which a
rename keeps and another provider cannot claim. Keys still in a
beta-era `team:<id>` form (no origin) are not evidence, so their files
are left unread rather than guessed at. Callers thread the resolved team
profiles through `findTeamValuesPath`, `sameTeamIdentity`,
`switchedGatewayOrigins`, `activeAgentsFor`, and the pull re-apply.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] Gateway origin is not sufficient provenance for provider-ambiguous legacy files. If two teams use the same repo: acme/widgets claim and shared gateway origin but have different providers/slugs, legacyFileBoundToTeam accepts the first team’s file for the second team. The second team can then read the first team’s API key and claim its managed switches. src/models/profile.ts:375
  • [P1 blocking] Renaming a team still orphans beta-era unbound keys for a path-shaped repo: claim. A differently slugged legacy file containing team:<id> has no origin, so it is rejected before loadTeamValues can run bindLegacyTeamKeys; subsequent list/switch commands report the key missing. src/models/profile.ts:455
  • [P1 blocking] Provider-relative repo.remote values such as owner/repo are treated like bare aliases and ignored when selecting the new identity. With no repo.url or claim, two repositories using the same checkout path and team slug receive the same hash-only file, allowing one repository to read the other’s keys. src/models/profile.ts:193
  • [P1 blocking] The bilingual design documents are no longer synchronized. The English document explains the provider-ambiguous legacy-file restriction, while the Chinese counterpart omits it and still broadly states that renaming does not lose keys. This violates the repository’s explicit bilingual-documentation rule. docs/designs/model-profiles.zh-CN.md:21

Review Status

  • The PR description includes sufficient representative real-CLI verification.
  • The earlier dry-run, ordering, migration race/filesystem, and hash-only provider-qualification findings are resolved.

…wn switch history

Gateway-origin key binding is not a team identity: two teams sharing a
profile id and gateway URL behind the same path-shaped claim would pass
the provenance check, letting the second read the first's file and claim
its switches. But refusing differ-slug files entirely orphans a renamed
team, whose legacy `<slug>-<hash>.json` carries its own old slug.

The trustworthy, machine-local evidence is managed.json: this checkout
records the exact identity each past `team:` switch used. A differ-slug
provider-ambiguous file or switch record is re-admitted only when that
identity appears in this checkout's own switch history — a renamed team's
file, recognized without trusting a gateway a foreign team could share, a
key binding another team's file may also carry, or a slug a rename just
moved. Foreign teams hold only their own slug in managed.json and are
refused; beta-era unbound keys need no binding to be recognized this way.

Provider-relative remotes (`owner/repo`) also get the provider
qualification already given to path-shaped claims, so two providers using
the same checkout path and slug no longer share one values file.
Design docs synced in both languages.
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] unadoptedLegacyFiles scans legacy candidates even when the hash-only target already exists. Because adoption deliberately leaves the legacy file in place and the in-memory adoption set resets on every CLI invocation, every later interactive command prompts to adopt the same file again; non-interactive and --dry-run commands emit a misleading warning. Return no candidates when target exists. src/models/profile.ts:357

Review Status

  • All previously reported identity-collision, switch-matching, filesystem-race, dry-run, and bilingual-documentation findings are resolved.
  • The PR description contains representative real-CLI verification; its recorded head efd4ae5 predates the current head but is non-blocking under the review rules.

…exists

A past adoption and migration is the permanent record: the
provider-qualified hash-only target shadows every legacy name forever, so
unadoptedLegacyFiles returns no candidates when getTeamValuesPath exists.
Without this, the in-memory adoption set (which resets per invocation)
combined with the intentionally-kept legacy file re-prompted on every later
interactive command and misled non-interactive/--dry-run runs with a stale
warning. Test: after migration the file is shadowed and never offered again.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] adoptedLegacyValues is global and keyed only by the legacy basename. During a project pull with inheritUserScope, user scope runs before project scope; if two provider-ambiguous teams share the same slug and claim but use different providers, confirming the user-scope file also silently authorizes the project scope to read and migrate that API key. Adoption must be scoped to the current provider-qualified team identity. src/models-cmd.ts:98
  • [P1 blocking] adoptMigrated tests whether any file was adopted during the process. After adopting a user-scope file, declining a different project-scope candidate still makes this true; the project loads {} from its nonexistent target and writes an empty hash-only file, permanently shadowing its legacy keys and suppressing future adoption prompts. Check whether readFrom is an adopted legacy file for this specific config instead. src/models-cmd.ts:124

Review Status

  • The previously reported repeated-adoption prompt is resolved.
  • The PR description includes sufficient representative real-CLI verification; the older recorded head is non-blocking under the review rules.

… config

Two review findings on the adoption layer:

1. adoptedLegacyValues was global, keyed only by the legacy basename. In a
   pull with inheritUserScope, user scope runs first; a same-slug, same-claim
   team on a DIFFERENT provider resolves to the same adoption key, so
   confirming the user-scope file also silently authorized the project scope
   to read and migrate that key in its own provider space. Adoption is now
   keyed `${target}::<slug>-<digest>` — the adopting scope's own
   provider-qualified file path — so a confirmation scopes to exactly that
   team identity, and any other scope/provider must opt in separately.

2. adoptMigrated was a process-global boolean ("any adoption happened"). After
   adopting a user-scope file, declining a project-scope candidate still set
   it true, so the project loaded {} from its nonexistent target and wrote an
   empty hash-only file, permanently shadowing its legacy keys and killing
   future prompts. The write is now decided per config: only when readFrom
   differs from the target (a real legacy read) is the migration saved; a
   declined candidate is read nothing, written nothing, and re-offered next
   run.

Tests: cross-provider scope regression (GitHub adoption under its target never
admits the file for the GitCode scope; same-scope adoption of the same
basename does), namespaced adoption keys everywhere, migration-shadow check.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] A non-interactive models configure team:<id> --from-env ... skips every unadopted legacy file and returns an empty value set. The later save creates the hash-only target containing only the configured profile, permanently shadows the legacy file, and makes its other API keys unreachable without another warning. Refuse the write while adoption is pending or preserve/migrate the legacy contents explicitly. src/models-cmd.ts:127
  • [P1 blocking] Provider-ambiguous legacy switch identities still match when the slug happens to be equal. For example, GitHub and GitCode teams both named Alpha with repo: acme/widgets share alpha-<digest>; if Team B has its own hash-only key, pulling Team B treats an agent switched to Team A as its own and overwrites that agent with Team B’s profile. The slug cannot establish provider ownership here. src/models/profile.ts:414

Review Status

  • Previously reported dry-run mutation, migration races, adoption scoping, repeated prompts, identity precedence, and bilingual-documentation issues are resolved.
  • The PR description contains sufficient representative real-CLI verification; recording an older head is non-blocking under the review rules.

…aim ambiguous records by slug

Two review findings:

1. A non-interactive `models configure team:<id> --from-env X` reads {} past an
   unadopted legacy file, then writes ONLY the configured key to the
   hash-only target — permanently shadowing the legacy file (the target's
   existence silences every future adoption prompt) and orphaning its other
   API keys without a warning anyone can act on. configure and switch now
   refuse the write while unadopted legacy files remain, naming the
   interactive adoption path. A pull is unaffected: its read-time save is the
   migration itself and already requires a real legacy read.

2. A same-slug provider-ambiguous switch record matched Team B's checkout. The
   old name never encoded the provider, so a GitHub and a GitCode team both
   named `Alpha` on the bare claim `acme/widgets` share the identical
   `alpha-<digest>` form: with Team B holding its own hash-only key, pulling
   B claimed an agent switched to Team A and overwrote it. A legacy record
   under a provider-ambiguous digest never matches now — slug equality is not
   ownership across providers, consistent with the differ-slug refusal.
   Repository-bound records still match by digest (the host is in the
   identity). Adoption of the values file re-establishes the team; its
   switched agents are re-recorded by the next `models switch`.

Docs synced (en + zh-CN). Tests: path-only/alias/claim records all refused
even under the exact slug, cross-provider same-slug record regression, and
the 4 prior adoption-scoping expectations.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] Provider-relative remotes do not actually receive the documented highest precedence. Since isRepoReference('owner/repo') is false, the code selects repo.url or the teamai.yaml repo: claim instead. Two checkouts with different remotes such as acme/team-a and acme/team-b, but the same claim/provider, therefore share one hash-only secrets file and can consume each other’s API keys. src/models/profile.ts:194
  • [P1 blocking] Windows drive paths are treated as repository URLs because new URL('C:\\teams\\repo') accepts c: as a scheme. A path-only configuration therefore skips the provider-and-team-slug fallback, so replacing that checkout with a differently named team reuses the same hash-only file and exposes the previous team’s keys. src/models/profile.ts:174

Review Status

  • The findings from earlier review passes are resolved in the current diff.
  • The PR description contains sufficient representative real-CLI verification; the older recorded head is non-blocking under the review rules.

…Windows drive as a URL

Two review findings on identity derivation:

1. A provider-relative remote never got its documented precedence.
   isRepoReference('owner/repo') is false, so the selector picked the URL or
   teamai.yaml claim instead; two checkouts with DIFFERENT remotes
   (acme/team-a vs acme/team-b) but the same claim/provider therefore shared
   one hash-only secrets file and could consume each other's keys. A
   configured non-origin remote now names the repository with the highest
   precedence whether URL-shaped or provider-relative (which names one repo
   together with its provider); only a bare alias (no slash, no scheme) falls
   through. Different remotes -> different files, always.

2. new URL('C:\\teams\\repo') accepts c: as a scheme, so Windows drive paths
   were hashed as phantom URLs and a path-only config skipped the
   provider-and-team-slug fallback; replacing that checkout with a
   differently named team reused the file. URL parsing now rejects
   single-letter schemes, keeping drive paths in the path form whose slug
   separates teams at one checkout.

Tests: two different provider-relative remotes with a shared claim collide no
more; same remote+provider names one repo across paths; bare aliases and
drive paths never hash as URLs. Full suite 5285 passed.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] Provider-relative remotes ignore the provider declared in teamai.yaml when the local override is absent. Existing configs commonly store remote: owner/repo without localConfig.provider; GitHub and GitCode teams using that same path both hash tgit:owner/repo, allowing one team to read the other’s API keys and claim its switches. Use the team provider as the fallback here. src/models/profile.ts:226
  • [P1 blocking] A bare remote alias is still used directly when neither repo.url nor a readable repo: claim exists. Two checkouts configured with remote: fork therefore both hash tgit:fork, despite different paths and team names, exposing one team’s hash-only secrets to the other. Bare aliases must fall through to the provider/slug/path identity. src/models/profile.ts:212

Review Status

  • The PR description includes sufficient representative real-CLI verification.
  • Other previously reported issues remain resolved; the bare-alias target collision is still present in the current diff.

…es; bare aliases never key the file

Two more identity findings:

1. A provider-relative remote ignored the provider declared in teamai.yaml
   when no local override existed, so GitHub and GitCode teams sharing
   `remote: owner/repo` both hashed `tgit:owner/repo`. The effective provider
   now consults the teamai.yaml `provider:` (via the claim or, when the claim
   is absent, directly) before the local override and the tgit default — the
   same qualification the path-shaped claim already applied. Provider survives
   a team rename, so the file stays bound to the repository.

2. A bare remote alias (`remote: fork`, no URL, no claim) was still hashed
   directly as `tgit:fork`, so two checkouts sharing the alias shared one
   hash-only secrets file. A bare alias names no repository, so it now falls
   through to the provider/slug/path identity, whose slug keeps teams sharing
   a checkout path apart. Legacy `<slug>-<digest>` files for alias-keyed
   checkouts remain migration candidates.

Tests: each checkout declares its own teamai.yaml provider and stays distinct;
a bare alias no longer changes the file over the path-only form and two teams
sharing it stay separated by the slug. Full suite 5285 passed.
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] Partial adoption permanently hides declined candidates. When multiple provider-ambiguous legacy files match, accepting one and declining another makes findTeamValuesPath read the accepted file, then readFrom !== target immediately creates the hash-only target. Future runs return no adoption candidates because that target exists, so the declined file and any keys unique to it become unreachable. src/models-cmd.ts:148

Review Status

  • The two findings from the previous pass—provider selection for relative remotes and bare-alias target collisions—are resolved.
  • The PR description contains sufficient representative real-CLI verification; the older recorded head is non-blocking under the review rules.

…doption merges all adopted keys

Two defects in the read-time migration:

1. Declining one of several matching provider-ambiguous legacy files then
   saving the hash-only target stranded the declined file forever: the very
   existence of the target silences every future adoption candidate, so keys
   unique to the declined file became unreachable with no further prompt
   possible. The migration save now happens only when NO candidate remains
   for this team (adopted or gone); a declined file stays a candidate, keeps
   its keys reachable, and is re-offered on the next run.

2. A single-file read kept only the newest adopted identity's keys. When
   several files shared this checkout's digest, adopting all of them still
   dropped every non-newest identity's unique keys before the target
   shadowed them. Adoption now merges the keys of every adopted identity into
   the migrated target (new mergeModelInputs; the later map wins).

Tests: with two matching files, adopting one still surfaces the other to the
migration guard; adopting both leaves no candidates and the merge keeps both
identities' keys. Full suite 5286 passed.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] The team provider overrides the member’s explicit local provider because the lookup uses claim?.provider ?? teamProvider(...) ?? localConfig.provider. For example, a team declaring GitHub with repo: acme/widgets and a member initialized with --provider gitlab receives the GitHub hash, so it can share API keys and switch ownership with the actual GitHub checkout. Local provider overrides must take precedence, as elsewhere in the CLI. src/models/profile.ts:217
  • [P1 blocking] Migration now requires adopting every provider-ambiguous file sharing the digest. If GitHub Alpha and GitCode Beta have legacy files for acme/widgets, accepting Alpha’s file but declining Beta’s leaves canMigrate false; legacy keys remain unbound/unmigrated and subsequent configure/switch writes are blocked. The only CLI path forward is adopting Beta’s foreign file too, which merges its keys into Alpha’s target. src/models-cmd.ts:152

Review Status

  • The previous partial-adoption shadowing issue is resolved, but the all-or-nothing replacement introduces the blocker above.
  • The PR description includes sufficient representative real-CLI verification; the older recorded head is non-blocking under the review rules.

…le and no longer block migration

Two review findings, one shared root: the migration's consent model could not
distinguish "not yet decided" from "deliberately declined".

1. The effective provider put teamai.yaml's provider before the member's own,
   so a team declaring GitHub with the same bare claim made a member
   initialized with --provider gitlab share the GitHub team's hash-only file.
   The member's own provider now wins (localConfig.provider ?? team provider ??
   tgit), as everywhere else in the CLI.

2. Requiring every same-digest file to be adopted blocked migration behind a
   foreign team's file: declining GitHub Alpha to adopt GitCode Beta left an
   unadopted candidate forever, so no target could ever be created. Declining
   now records a durable, target-scoped ${target}::<slug>-<digest> refusal in
   the models manifest. A declined identity is neither re-offered on every run
   (no re-prompt loop) nor silently shadowed when migration proceeds (the
   choice is explicit, visible state, reversible by removing the key or the
   file) and does not count toward the migration guard — adopting only your
   own team's file no longer forces you to merge a foreign team's keys.

unadoptedLegacyFiles and findTeamValuesPath now take { adopted, declined };
findTeamValuesPath reads an adopted identity and skips a declined one.

Tests: found the regression itself (adopt one of two, decline the other leaves
no candidate and reads the adopted file only), the provider-override case, and
a refuseLegacyValue/refusedLegacyValueKeys manifest round-trip with a
fail-closed schema check. Full suite 5288 passed.
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] Legacy migration is not serialized with other values writes. If a background pull reads the legacy file while a concurrent models configure creates the hash-only target, the pull can subsequently replace that target with its stale legacy snapshot, deleting the newly configured key. writeJsonAtomic prevents partial files but not lost updates; lock the migration or re-read and merge an existing target before saving. src/models-cmd.ts:178

Review Status

  • Previously reported identity, adoption, dry-run, filesystem, and bilingual-documentation issues are resolved.
  • The PR description contains sufficient representative real-CLI verification; the recorded older head is non-blocking under the repository rules.

…er by merging before save

The migration write into the new hash-only target is the one place a values
file is created from a different file, so it must not clobber a target a
concurrent process wrote between the legacy read and the save: writeJsonAtomic
prevents torn files, not lost updates. A pull reading the legacy file while a
configure creates the target could otherwise overwrite the newly configured
key with its stale snapshot. The migration now re-reads the target and merges
before saving (the concurrent content wins collisions).

The merge runs only in the migration branch (readFrom !== target). When the
run merely re-saves the file it already read, the target holds the same
content this bind just consumed — merging would re-inject the raw legacy
entries the bind renamed, so there is nothing to guard against there.

Full suite 5288 passed.
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] The migration/write race remains because re-reading and saving the target are separate operations. For example, a pull can re-read the newly created target at line 189, then a concurrent models configure saves a new API key, and finally the pull overwrites it with its stale merged snapshot at line 191. Serialize this read-modify-write or perform it under the same lock as other values writes. src/models-cmd.ts:188

Review Status

  • The other previously reported findings are resolved in the current diff.
  • The PR description includes sufficient representative real-CLI verification; its older recorded head is non-blocking under the repository rules.

…rite

Re-reading the migration target and saving it were still two separate
operations, so the race the review flagged only shrank: a pull could re-read
the target, a concurrent configure could save a new key, and the pull could
then overwrite it with its stale merged snapshot. Team values writers now
share an advisory lock on the target file, and each of them runs its entire
read-modify-write cycle inside it:

- the migration's was already read-merge-save; it now runs that whole cycle
  under the lock instead of only the merge step;
- models configure and the models switch first-use prompt re-read the target
  inside the lock, so their write cannot clobber a migration or a sibling
  configure that landed in the same window.

withTeamValuesLock reuses the existing owner-verified acquireLock/releaseLock
primitive (dead-owner stale locks are reclaimed), so a crashed holder does not
block pulls or configures. Local values writes are untouched; no legacy
migration can race them.

Full suite 5288 passed.
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] On the first interactive switch to a team profile, the lock callback stores the entered key only in current, while resolveProfile still receives the original keyless values. The command saves the key but then fails with “has no API key” without switching; it succeeds only when rerun. Update values or return the locked snapshot. src/models-cmd.ts:501

Review Status

  • All previously reported findings are resolved in the current diff.
  • The PR description includes sufficient representative real-CLI verification; the older recorded head is non-blocking under the review rules.

…ractive switch

The lock round-trip saved the first-use key into the freshly re-read snapshot
(current), but the rest of the command resolved the profile against the
original in-memory values, which had no key yet — so the switch stored the key
and then failed with "no API key", demanding a rerun. Apply the same key to
the in-memory copy after the locked write, and add a regression test for the
first interactive team switch (ask, save, and resolve in one run) so the
behaviour cannot silently split again.

Full suite 5289 passed.
@github-actions

Copy link
Copy Markdown

Finding

  • [P1 blocking] The first interactive switch to a local profile saves values before adding the entered key. The switch succeeds using the later in-memory update, but the key is absent from values.json, so the next invocation reports it missing. Move setStoredApiKey before the local saveModelInputs call. src/models-cmd.ts:507

Review Status

  • The previously reported team-profile first-switch issue is resolved.
  • The PR description includes sufficient representative real-CLI verification; the older recorded head is non-blocking under the review rules.

…itch

Moving the getStoredApiKey update after the branch left the local path saving
values.json without the key the prompt returned: the switch succeeded from the
later in-memory copy, but the next invocation found values.json missing the
key and reported it not configured. Put the key in the in-memory copy before
the branch, so the local save already carries it; regression tests now cover
the first interactive switch for both the personal and the team profile.

Full suite 5290 passed.
@github-actions

Copy link
Copy Markdown

Review Status

  • No new findings in the current diff.
  • All previously reported findings are resolved, including first-switch persistence for local profiles.
  • The PR description includes sufficient representative real-CLI verification. Its recorded head predates the current head, which is non-blocking under the repository review rules.
  • Per instructions, I reviewed the diff only and did not run tests or builds.

@jeff-r2026
jeff-r2026 merged commit 92e6e2f into Tencent:main Sep 29, 2026
13 checks passed
@scs0209

scs0209 commented Sep 29, 2026

Copy link
Copy Markdown
Author

Thanks for the merge!

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.

[bug] renaming team: in teamai.yaml orphans members' model gateway keys

2 participants