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

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

* fix(models): address review on the repo-identity values scheme (#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>

* fix(models): preview migration in dry runs, stop alias collisions, race-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>

* fix(models): narrow the path digest to path-only configs, copy when links 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>

* fix(models): publish the copy fallback atomically via a temp file

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>

* refactor(models): read legacy values in place instead of migrating files

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>

* fix(models): identify alias-digest legacy files by slug as well

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>

* fix(models): align switch-record matching with file matching, mirror 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>

* fix(models): prefer the repo: claim over the local path for the values 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>

* fix(models): qualify provider-relative identities with the provider

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>

* fix(models): use the effective provider and slug-check legacy claim digests

- 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>

* fix(models): read legacy claim files by digest and slug-guard path-only 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>

* docs(models): describe the path-only slug-fallback hash in both languages

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

* fix(models): slug-guard provider-ambiguous legacy files, repair renames 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.

* fix(models): prove rename-repaired legacy files from this machine's own 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.

* fix(models): never auto-adopt provider-ambiguous legacy values — adopt explicitly

Two review rounds proved no silent rule can attribute a legacy file or
switch record whose digest never encoded the provider:

- a same-slug provider-ambiguous file is read across providers (a GitHub and
  a GitCode 'Alpha' on the claim 'acme/widgets' hash the same file; the slug
  is not provenance), and
- machine switch history (managed.json) is global and self-validating: one
  file is shared by every checkout on the machine, so the record being
  proved sits in the same store a foreign team's checkout reads, letting a
  differently named team claim a same-digest record or file.

Provider-ambiguous legacy values files (path-shaped claim, provider-relative
remote, bare alias, path-only) are now never read by a silent rule, same slug
or not. The CLI lists them once (unadoptedLegacyFiles) and the user
explicitly adopts the exact `<slug>-<digest>` identity for this checkout;
the read then happens where the file lies and the next save migrates the
keys to the provider-qualified hash-only name, which shadows the legacy file
forever (no re-ask, no ambiguity left). Non-interactive and --dry-run runs
never adopt: they report the file and leave it unread. Differ-slug
provider-ambiguous switch records are refused — no machine-global proof is
ever consulted again. URL-bound legacy identities still match by digest
alone: the host is in the identity.

Design docs synced in both languages. Tests: same-slug cross-provider never
auto-reads, foreign file/record refusal, explicit-adoption read + unbound-key
migration, alias digest under this team's adoption.

* fix(models): do not offer a legacy file for adoption once its target 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.

* fix(models): scope legacy adoption to the provider-qualified team and 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.

* fix(models): refuse shadowing writes while adoption pending; never claim 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.

* fix(models): a provider-relative remote is the top identity; never a 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.

* fix(models): use the teamai.yaml provider for provider-relative remotes; 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.

* fix(models): partial adoption never orphans a declined legacy file; adoption 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.

* fix(models): member provider overrides the team's; declines are durable 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.

* fix(models): serialize the migration target against a concurrent writer 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.

* fix(models): hold the team values lock across the whole read-modify-write

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.

* fix(models): resolve the profile from the key saved by the first interactive 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.

* fix(models): save the awaited key with the first interactive local switch

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.

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
This commit is contained in:
Changsu Seong
2026-09-29 21:16:59 +08:00
committed by GitHub
co-authored by Codebuff
parent 1d945b6f36
commit 92e6e2f060
10 changed files with 1286 additions and 66 deletions
+2 -2
View File
@@ -15,10 +15,10 @@ Model profiles let a team publish one gateway catalog that every supported agent
| Team profiles | `<team repo>/models/models.yaml`, plus `models/<ns>/models.yaml` per namespace | Yes | No |
| Personal profiles | `~/.teamai/models/models.yaml` | No | No |
| Personal API keys | `~/.teamai/models/values.json` | No, mode `0600` | Key or env-var name |
| Team-profile API keys | `~/.teamai/models/teams/<team-name>-<hash>.json` | No, mode `0600` | Key or env-var name |
| Team-profile API keys | `~/.teamai/models/teams/<repo-identity-hash>.json` | No, mode `0600` | Key or env-var name |
| Ownership and restore state | `~/.teamai/models/managed.json` | No, mode `0600` | May hold previous and written keys |
A key is either stored or referenced as an environment variable; it is never accepted as a command-line argument. `0600` is not encryption. The team-key file name combines the sanitized `teamai.yaml` team name with a hash of the repository identity, and the same identity is recorded with each `team:` switch so `pull` only re-applies the current team's profiles. Inside it, each key is stored under `team:<id>@<origin>`; see [Namespaces and key binding](#namespaces-and-key-binding).
A key is either stored or referenced as an environment variable; it is never accepted as a command-line argument. `0600` is not encryption. The team-key file name is a hash of the repository identity; the sanitized `teamai.yaml` team name plays no part in it, so renaming the team never orphans the keys. When no repository identity exists at all (no usable remote, URL, or `repo:` claim), the name hashes the team slug with the path instead — there is nothing repository-shaped to key on, and the slug keeps differently named teams that share a checkout path apart. While the hash-only file does not exist yet, a legacy `<slug>-<hash>.json` from an older version is read where it lies — nothing is renamed — and the next save writes the hash-only name. A legacy file under a repository-bound digest (a URL, a URL-shaped `repo:` claim or remote) carries its host in the identity, so it is read by digest alone; a legacy file under a **provider-ambiguous** digest (a path-shaped `repo:` claim, a provider-relative remote, a bare alias, or no repository identity) names no single repository — the old name scheme never encoded the provider, so two providers' same-named teams hash the same file, and the slug cannot prove ownership across providers. Such a file is never read by a silent rule: the CLI surfaces it once and the user explicitly adopts the exact `<slug>-<hash>` identity for this checkout, after which the read happens and the next save migrates the keys to the provider-qualified hash-only name. Non-interactive and `--dry-run` runs never adopt: they report the file and leave it unread. Switch records under a provider-ambiguous identity are never claimed either — the old name never encoded the provider, so no slug (not even this checkout's exact slug) proves ownership: a GitHub and a GitCode team both named `Alpha` on the bare claim `acme/widgets` share the identical `alpha-<digest>` form. No machine-global record is ever used as proof, because a store shared by every checkout on the machine would equally belong to a foreign team; the explicit adoption of the values file re-establishes this team's presence, and its switched agents are re-recorded by the next `models switch` under the provider-qualified identity. Repository-bound switch records still match by digest alone. The identity is recorded with each `team:` switch so `pull` only re-applies the current team's profiles. Inside it, each key is stored under `team:<id>@<origin>`; see [Namespaces and key binding](#namespaces-and-key-binding).
## Catalog and protocols
+2 -2
View File
@@ -15,10 +15,10 @@
| 团队配置 | `<团队仓库>/models/models.yaml`,以及每个 namespace 的 `models/<ns>/models.yaml` | 是 | 否 |
| 个人配置 | `~/.teamai/models/models.yaml` | 否 | 否 |
| 个人配置的 API key | `~/.teamai/models/values.json` | 否,权限 `0600` | 密钥或环境变量名 |
| 团队配置的 API key | `~/.teamai/models/teams/<团队名>-<哈希>.json` | 否,权限 `0600` | 密钥或环境变量名 |
| 团队配置的 API key | `~/.teamai/models/teams/<仓库身份哈希>.json` | 否,权限 `0600` | 密钥或环境变量名 |
| ownership 与恢复状态 | `~/.teamai/models/managed.json` | 否,权限 `0600` | 可能包含原值和写入的密钥 |
密钥要么保存在本地,要么引用环境变量,不接受命令行参数传入。`0600` 并非加密。团队密钥文件名由 `teamai.yaml` 中清理后的团队名和仓库身份哈希组成;每次切换 `team:` 配置时也会记录这个身份,`pull` 只会重新应用当前团队的配置。文件内每个密钥保存在 `team:<id>@<origin>` 下,见 [Namespace 与密钥绑定](#namespace-与密钥绑定)。
密钥要么保存在本地,要么引用环境变量,不接受命令行参数传入。`0600` 并非加密。团队密钥文件名只是仓库身份的哈希,`teamai.yaml` 中的团队名不参与其中,因此重命名团队不会导致密钥失效。当完全不存在仓库身份(可用的 remote、URL 或 `repo:` claim 都没有)时,文件名改为哈希团队 slug 与路径的组合——此时没有任何仓库形态的信息可以依赖,靠 slug 区分共享同一检出路径、名字不同的团队;旧版本遗留的 `<团队名>-<哈希>.json` 会在纯哈希文件尚不存在时被原位读取,不做任何改名,下一次保存才会写入纯哈希文件名。在 provider 可确定的 digest(URL 或 URL 形态的 `repo:` claim/remote)下写出的遗留文件,identity 自带主机,直接按 digest 读取;在 **provider 不明确** 的 digest(形如 `owner/repo` 的 `repo:` claim、provider 相对的 remote、裸 alias,或没有仓库身份)下写出的遗留文件既不指向唯一仓库——旧命名从未编码 provider,两个 provider 的同名团队会哈希出同一个文件,slug 无法在 provider 之间证明归属。这类文件绝不按静默规则读取:CLI 会展示一次,由用户显式确认采用这个确切的 `<团队名>-<哈希>` identity 后才读取,随后下一次保存会把密钥迁移到 provider 限定的纯哈希文件名下。非交互与 `--dry-run` 运行一律不采用:只报告该文件并保持不读。provider 不明确 identity 下的 switch 记录同样绝不采用——旧命名从未编码 provider,任何 slug(即使与本 checkout 完全相同的 slug)都无法证明归属:GitHub 与 GitCode 上都叫 `Alpha`、裸 claim 同为 `acme/widgets` 的两支团队会共享完全相同的 `alpha-<digest>` 形式。绝不把任何机器级记录当作归属证明,因为同一机器上被各 checkout 共享的记录对别的团队同样成立;只有对遗留 values 文件的显式采用才能重新确立本团队的存在,其已切换的 agent 随后通过下一次 `models switch` 以 provider 限定的 identity 重新记录。provider 可确切的 switch 记录仍按 digest 直接匹配。每次切换 `team:` 配置时也会记录这个身份,`pull` 只会重新应用当前团队的配置。文件内每个密钥保存在 `team:<id>@<origin>` 下,见 [Namespace 与密钥绑定](#namespace-与密钥绑定)。
## 目录与协议
+596 -5
View File
@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import fse from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
@@ -5,13 +6,20 @@ import { describe, expect, it } from 'vitest';
import {
ModelProfileSchema,
ModelProfilesFileSchema,
getTeamIdentity,
getTeamValuesPath,
findTeamValuesPath,
loadModelInputs,
mergeModelInputs,
profileAgents,
profileRoutes,
sameTeamIdentity,
saveLocalProfiles,
resolveProfile,
resolveProfileRef,
saveModelInputs,
unadoptedLegacyFiles,
isRepoReference,
type ModelProfilesFile,
} from '../models/profile.js';
import type { LocalConfig } from '../types.js';
@@ -29,21 +37,604 @@ function profile(id: string) {
}
describe('model profiles', () => {
it('names local team secrets with a readable, collision-resistant team identity', async () => {
it('names team secrets by repo identity alone, surviving teamai.yaml renames', async () => {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-team-'));
try {
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: HAI Platform\n');
const file = getTeamValuesPath({ repo: { localPath: repo, remote: 'origin', url: 'https://example.test/hai' } } as LocalConfig);
expect(path.basename(file)).toMatch(/^hai-platform-[a-f0-9]{10}\.json$/);
const config = { repo: { localPath: repo, remote: 'origin', url: 'https://example.test/hai' } } as LocalConfig;
const file = getTeamValuesPath(config);
expect(path.basename(file)).toMatch(/^[a-f0-9]{10}\.json$/);
expect(path.dirname(file)).toContain(path.join('models', 'teams'));
// The team display name and a repo: claim must not move the file (#894).
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: Relocated\nrepo: https://example.test/elsewhere\n');
expect(getTeamValuesPath(config)).toBe(file);
// A different repository URL names a different file.
const other = getTeamValuesPath({ repo: { localPath: `${repo}-other`, remote: 'origin', url: 'https://example.test/other' } } as LocalConfig);
expect(path.basename(other).match(/-([a-f0-9]{10})\.json$/)?.[1])
.not.toBe(path.basename(file).match(/-([a-f0-9]{10})\.json$/)?.[1]);
expect(path.basename(other)).not.toBe(path.basename(file));
// scp and ssh URLs for the same repository name one file (#880 semantics).
const scp = getTeamValuesPath({ repo: { localPath: repo, remote: 'origin', url: 'git@example.test:acme/team.git' } } as LocalConfig);
const ssh = getTeamValuesPath({ repo: { localPath: repo, remote: 'origin', url: 'ssh://git@example.test:22/acme/team' } } as LocalConfig);
expect(scp).toBe(ssh);
} finally {
await fse.remove(repo);
}
});
it('matches the current hash name and only legacy digests this config could have produced', async () => {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
const config = { repo: { localPath: '/tmp/example/hai', remote: 'origin', url: 'https://example.test/hai.git' } } as LocalConfig;
expect(sameTeamIdentity(getTeamIdentity(config), config)).toBe(true);
// A legacy name keyed by the URL digest names the same team.
expect(sameTeamIdentity(`hai-platform-${digest('https://example.test/hai.git')}`, config)).toBe(true); // A legacy name keyed by a non-origin REMOTE URL digest names the same team.
const fork = { repo: { localPath: '/tmp/example/hai', remote: 'fork', url: 'https://example.test/hai.git' } } as LocalConfig;
expect(sameTeamIdentity(`hai-${digest('https://example.test/hai.git')}`, fork)).toBe(true);
// But a bare alias never names a file: two checkouts sharing it stay distinct.
const forkA = getTeamValuesPath({ repo: { localPath: '/tmp/example/a', remote: 'fork', url: 'https://example.test/a' } } as LocalConfig);
const forkB = getTeamValuesPath({ repo: { localPath: '/tmp/example/b', remote: 'fork', url: 'https://example.test/b' } } as LocalConfig);
expect(forkA).not.toBe(forkB);
// A digest from another repository, or one this config never produced, must not match.
expect(sameTeamIdentity(`hai-platform-${digest('https://example.test/other.git')}`, config)).toBe(false);
expect(sameTeamIdentity('hai-platform-0000000000', config)).toBe(false);
expect(sameTeamIdentity(undefined, config)).toBe(false);
// The repo: claim in teamai.yaml overrode the identity in the old implementation,
// so the stored digest is its: it must match (src/models/profile.ts).
const claimRepo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-claim-'));
await fse.writeFile(path.join(claimRepo, 'teamai.yaml'), 'team: HAI Platform\nrepo: https://git.example.test/acme/team.git\n');
const withClaim = { repo: { localPath: claimRepo, remote: 'origin', url: 'https://example.test/hai.git' } } as LocalConfig;
try {
expect(sameTeamIdentity(`hai-platform-${digest('https://git.example.test/acme/team.git')}`, withClaim)).toBe(true);
} finally {
await fse.remove(claimRepo);
}
// The local path was hashed only when nothing better was configured. It
// names no single repository (the path is reused), and — like every
// provider-ambiguous digest — no record under it is attributed by slug
// alone, because a same-named team on another provider shares the slug.
const pathOnly = { repo: { localPath: '/tmp/example/hai', remote: 'origin' } } as LocalConfig;
expect(sameTeamIdentity(`hai-${digest('/tmp/example/hai')}`, pathOnly)).toBe(false);
expect(sameTeamIdentity(`hai-platform-${digest('/tmp/example/hai')}`, pathOnly)).toBe(false);
});
it('rejects every alias-digest switch record — the slug is not provenance', async () => {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
// Two teams shared the bare alias `fork`; each keyed records under its own
// slug, but the old name never encoded the provider, so a same-named team
// on another provider would share team B's exact form. No alias record is
// attributed by slug alone — only the owner's explicit adoption and a
// re-switch re-records it under the provider-qualified identity.
const teamB = { repo: { localPath: '/tmp/example/team-b', remote: 'fork', url: 'https://example.test/b.git' } } as LocalConfig;
expect(sameTeamIdentity(`team-a-${digest('fork')}`, teamB)).toBe(false);
expect(sameTeamIdentity(`team-b-${digest('fork')}`, teamB)).toBe(false);
// A repository-bound digest needs no slug check: the host is in the identity.
expect(sameTeamIdentity(`renamed-team-${digest('https://example.test/b.git')}`, teamB)).toBe(true);
});
it('matches the repo: claim, not the path, when the config has no url', async () => {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
const claimRepo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-claim-nourl-'));
await fse.writeFile(path.join(claimRepo, 'teamai.yaml'), 'team: HAI\nrepo: https://git.example.test/canonical.git\n');
const config = { repo: { localPath: claimRepo, remote: 'origin' } } as LocalConfig;
try {
// The claim, not the path, is what the old implementation hashed here;
// the claim digest (post-repoIdentity) is a matching legacy candidate...
expect(sameTeamIdentity(`hai-${digest('https://git.example.test/canonical.git')}`, config)).toBe(true);
// ...and a path-digest record from another team at this path is not.
expect(sameTeamIdentity(`old-team-${digest(claimRepo)}`, config)).toBe(false);
const target = getTeamValuesPath(config);
const dir = path.dirname(target);
await fse.ensureDir(dir);
const oldTeam = path.join(dir, `old-team-${digest(claimRepo)}.json`);
await fse.writeFile(oldTeam, '{"team:old":{"API_KEY":{"value":"old"}}}');
expect(await findTeamValuesPath(config)).toBe(target);
expect(await fse.pathExists(oldTeam)).toBe(true);
} finally {
await fse.remove(claimRepo);
}
});
it('separates teams that reuse a path when a repo: claim is available', async () => {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-reuse-'));
const previous = process.env.HOME;
process.env.HOME = home;
try {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-reuse-repo-'));
const teamA = { repo: { localPath: repo, remote: 'origin' } } as LocalConfig;
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: Alpha\nrepo: https://git.example.test/alpha.git\n');
const fileA = getTeamValuesPath(teamA);
expect(path.basename(fileA)).toBe(`${digest('https://git.example.test/alpha')}.json`);
// The same path, now checked out for team B with its own claim: no
// shared file, no adopted switches.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: Beta\nrepo: https://git.example.test/beta.git\n');
const teamB = { repo: { localPath: repo, remote: 'origin' } } as LocalConfig;
const fileB = getTeamValuesPath(teamB);
expect(fileB).not.toBe(fileA);
expect(path.basename(fileB)).toBe(`${digest('https://git.example.test/beta')}.json`);
// Team A's file and switch record belong to team A alone.
await fse.ensureDir(path.dirname(fileA));
await saveModelInputs(fileA, { 'team:gw': { API_KEY: { value: 'alpha-key' } } });
expect(await findTeamValuesPath(teamB)).toBe(fileB);
expect(await fse.pathExists(fileB)).toBe(false);
expect(sameTeamIdentity(path.basename(fileA, '.json'), teamB)).toBe(false);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('separates provider-relative identities by provider', async () => {
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-provider-'));
const previous = process.env.HOME;
process.env.HOME = home;
try {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-provider-repo-'));
// Same owner/repo claim, no URL anywhere: only the provider tells the teams apart.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: GH\nrepo: acme/widgets\n');
const onGithub = { repo: { localPath: repo, remote: 'origin' }, provider: 'github' } as LocalConfig;
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: GC\nrepo: acme/widgets\n');
const onGitcode = { repo: { localPath: repo, remote: 'origin' }, provider: 'gitcode' } as LocalConfig;
const githubFile = getTeamValuesPath(onGithub);
const gitcodeFile = getTeamValuesPath(onGitcode);
expect(githubFile).not.toBe(gitcodeFile);
// A path-shaped identity is provider-qualified; a URL is not (it carries its host).
expect(sameTeamIdentity(path.basename(githubFile, '.json'), onGithub)).toBe(true);
expect(sameTeamIdentity(path.basename(githubFile, '.json'), onGitcode)).toBe(false);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('reads a path-shaped claim legacy file only after the user adopts the identity', async () => {
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-effective-'));
const previous = process.env.HOME;
process.env.HOME = home;
try {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-effective-repo-'));
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
// Team GH keyed its file on the bare, provider-ambiguous claim digest.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: GH\nrepo: acme/widgets\nprovider: github\n');
const dir = path.dirname(getTeamValuesPath({ repo: { localPath: repo, remote: 'origin' } } as LocalConfig));
const legacy = path.join(dir, `gh-${digest('acme/widgets')}.json`);
await fse.ensureDir(dir);
await fse.writeFile(legacy, '{"team:gw":{"API_KEY":{"value":"legacy-key"}}}');
const onGithub = { repo: { localPath: repo, remote: 'origin' } } as LocalConfig;
// Same slug, same bare claim: the provider is missing from the identity,
// so the file is NEVER auto-read — not even under this checkout's own slug.
expect(await findTeamValuesPath(onGithub)).toBe(getTeamValuesPath(onGithub));
expect(await unadoptedLegacyFiles(onGithub, { adopted: new Set() })).toContainEqual(
expect.objectContaining({ entry: `gh-${digest('acme/widgets')}.json`, identity: `gh-${digest('acme/widgets')}` }),
);
// The user's explicit adoption of THIS exact identity reads it in place.
expect(await findTeamValuesPath(onGithub, { adopted: new Set([`${getTeamValuesPath(onGithub)}::gh-${digest('acme/widgets')}`]) })).toBe(legacy);
// Switch records are never provenance: a different slug is another team,
// and the same slug could be a same-named team on another provider —
// the bare claim made both digests identical. So neither matches.
expect(sameTeamIdentity(`gc-${digest('acme/widgets')}`, onGithub)).toBe(false);
expect(sameTeamIdentity(`gh-${digest('acme/widgets')}`, onGithub)).toBe(false);
// A rename (same provider, new slug) keeps the same provider-qualified
// target, and still needs the same explicit opt-in for the legacy file.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: GC\nrepo: acme/widgets\nprovider: github\n');
const renamed = { repo: { localPath: repo, remote: 'origin' } } as LocalConfig;
expect(getTeamValuesPath(renamed)).toBe(getTeamValuesPath(onGithub));
expect(await findTeamValuesPath(renamed)).toBe(getTeamValuesPath(renamed));
expect(await findTeamValuesPath(renamed, { adopted: new Set([`${getTeamValuesPath(renamed)}::gh-${digest('acme/widgets')}`]) })).toBe(legacy);
// Migration writes the provider-qualified target; from then on the legacy
// file is shadowed and is never offered for adoption again — no re-prompts.
await saveModelInputs(getTeamValuesPath(onGithub), { 'team:gw': { API_KEY: { value: 'legacy-key' } } });
expect(await unadoptedLegacyFiles(onGithub, { adopted: new Set() })).toEqual([]);
expect(await findTeamValuesPath(onGithub)).toBe(getTeamValuesPath(onGithub));
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('never reads another team\'s path-shaped claim file, keys or switches', async () => {
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-noleak-'));
const previous = process.env.HOME;
process.env.HOME = home;
try {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-noleak-repo-'));
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
// GitHub team Alpha keyed its values on the bare claim digest.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: Alpha\nrepo: acme/widgets\nprovider: github\n');
const alphaFile = getTeamValuesPath({ repo: { localPath: repo, remote: 'origin' } } as LocalConfig);
await fse.ensureDir(path.dirname(alphaFile));
const alphaLegacy = path.join(path.dirname(alphaFile), `alpha-${digest('acme/widgets')}.json`);
await fse.writeFile(alphaLegacy, '{"team:gw":{"API_KEY":{"value":"alpha-key"}}}');
// GitCode team Beta has the same claim: the same old digest, a foreign file.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: Beta\nrepo: acme/widgets\nprovider: gitcode\n');
const onGitcode = { repo: { localPath: repo, remote: 'origin' }, provider: 'gitcode' } as LocalConfig;
// The file surfaces in the disclosure but nothing is adopted silently.
expect(await unadoptedLegacyFiles(onGitcode, { adopted: new Set() })).toEqual([
expect.objectContaining({ entry: `alpha-${digest('acme/widgets')}.json` }),
]);
expect(await findTeamValuesPath(onGitcode)).toBe(getTeamValuesPath(onGitcode));
expect(sameTeamIdentity(`alpha-${digest('acme/widgets')}`, onGitcode)).toBe(false);
// Beta adopting its OWN identity only still never selects Alpha's file.
expect(await findTeamValuesPath(onGitcode, { adopted: new Set([`${getTeamValuesPath(onGitcode)}::beta-${digest('acme/widgets')}`]) })).toBe(getTeamValuesPath(onGitcode));
// Nor do Beta's own legacy records attribute Beta: the same slug on
// another provider shares the exact `beta-<digest>` form.
expect(sameTeamIdentity(`beta-${digest('acme/widgets')}`, onGitcode)).toBe(false);
// Finding 1 regression: a GitCode team ALSO named Alpha shares the slug —
// the slug is not provenance, so Alpha's file is still never auto-read.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: Alpha\nrepo: acme/widgets\nprovider: gitcode\n');
const alphaOnGitcode = { repo: { localPath: repo, remote: 'origin' }, provider: 'gitcode' } as LocalConfig;
expect(await findTeamValuesPath(alphaOnGitcode)).toBe(getTeamValuesPath(alphaOnGitcode));
expect(await fse.readFile(alphaLegacy, 'utf8')).toContain('alpha-key');
// Finding 2 regression: the same slug REALLY matches today — GitHub Alpha
// and GitCode Alpha share `alpha-<digest>`, so an agent GitCode "owns"
// must never be claimed from GitHub Alpha's record, even when GitCode
// has its own provider-qualified key around. The record is refused,
// same slug or not.
expect(sameTeamIdentity(`alpha-${digest('acme/widgets')}`, alphaOnGitcode)).toBe(false);
// Scope regression: adoption is keyed to the adopting scope's own
// provider-qualified target. A separate GitHub checkout (its own
// teamai.yaml) having adopted the same-basename file under ITS github
// target authorizes nothing for this GitCode checkout — the targets
// differ, so the key does not match.
const repoGithub = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-noleak-gh-'));
await fse.writeFile(path.join(repoGithub, 'teamai.yaml'), 'team: Alpha\nrepo: acme/widgets\nprovider: github\n');
const onGithubScope = { repo: { localPath: repoGithub, remote: 'origin' } } as LocalConfig;
expect(getTeamValuesPath(onGithubScope)).not.toBe(getTeamValuesPath(alphaOnGitcode));
const adoptedUnderGithub = new Set([`${getTeamValuesPath(onGithubScope)}::alpha-${digest('acme/widgets')}`]);
expect(await findTeamValuesPath(alphaOnGitcode, { adopted: adoptedUnderGithub })).toBe(getTeamValuesPath(alphaOnGitcode));
// The GitCode scope adopting the same basename under ITS OWN target does
// read it — that is the user's explicit consent — and next save migrates
// it to the gitcode-qualified name.
expect(await findTeamValuesPath(alphaOnGitcode, { adopted: new Set([`${getTeamValuesPath(alphaOnGitcode)}::alpha-${digest('acme/widgets')}`]) })).toBe(alphaLegacy);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('adopts a renamed team\'s legacy file only by explicit opt-in, never machine state', async () => {
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-rename-'));
const previous = process.env.HOME;
process.env.HOME = home;
try {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-rename-repo-'));
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
// Team GH keyed its values on the bare claim digest; a beta-era file with
// keys bound to no gateway.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: GH\nrepo: acme/widgets\nprovider: github\n');
const dir = path.dirname(getTeamValuesPath({ repo: { localPath: repo, remote: 'origin' } } as LocalConfig));
const legacy = path.join(dir, `gh-${digest('acme/widgets')}.json`);
await fse.ensureDir(dir);
await fse.writeFile(legacy, '{"team:gw":{"API_KEY":{"value":"gh-key"}}}');
// A rename under the same provider: nothing silently proves the file is
// this checkout's former self — machine-global records would equally
// belong to a same-digest foreign team (global store, self-validating).
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: GC\nrepo: acme/widgets\nprovider: github\n');
const renamed = { repo: { localPath: repo, remote: 'origin' }, provider: 'github' } as LocalConfig;
expect(await findTeamValuesPath(renamed)).toBe(getTeamValuesPath(renamed));
expect(sameTeamIdentity(`gh-${digest('acme/widgets')}`, renamed)).toBe(false);
// The user's explicit adoption of the former identity reads the file in
// place; the next save migrates it to the provider-qualified name.
expect(await findTeamValuesPath(renamed, { adopted: new Set([`${getTeamValuesPath(renamed)}::gh-${digest('acme/widgets')}`]) })).toBe(legacy);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('a declined legacy file stays reachable and adoption merges every adopted identity\'s keys', async () => {
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-partial-'));
const previous = process.env.HOME;
process.env.HOME = home;
try {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-partial-repo-'));
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: T\nrepo: acme/widgets\nprovider: github\n');
const config = { repo: { localPath: repo, remote: 'origin' } } as LocalConfig;
const dir = path.dirname(getTeamValuesPath(config));
await fse.ensureDir(dir);
// Two provider-ambiguous files share this checkout's digest — two teams'
// former lives that both keyed on the bare claim, each with its own keys.
const alpha = path.join(dir, `alpha-${digest('acme/widgets')}.json`);
const beta = path.join(dir, `beta-${digest('acme/widgets')}.json`);
await fse.writeFile(alpha, '{"team:gw":{"API_KEY":{"value":"alpha-key"}}}');
await fse.writeFile(beta, '{"team:hw":{"API_KEY":{"value":"beta-key"}}}');
// The migration guard sees BOTH before any target exists.
expect(await unadoptedLegacyFiles(config, { adopted: new Set() })).toHaveLength(2);
// Adopting only alpha must not hide beta: it still surfaces to the next
// run, so the caller will not create the hash-only target (whose very
// existence silences candidates) and beta's keys stay recoverable.
const adoptedAlpha = new Set([`${getTeamValuesPath(config)}::alpha-${digest('acme/widgets')}`]);
const afterPartial = await unadoptedLegacyFiles(config, { adopted: adoptedAlpha });
expect(afterPartial.map((file) => file.entry)).toEqual([`beta-${digest('acme/widgets')}.json`]);
// Adopting beta too: no candidate remains; the read picks one file, and
// merging the other keeps every identity's keys instead of dropping them.
const adoptedBoth = new Set([
...adoptedAlpha,
`${getTeamValuesPath(config)}::beta-${digest('acme/widgets')}`,
]);
expect(await unadoptedLegacyFiles(config, { adopted: adoptedBoth })).toEqual([]);
const readFrom = await findTeamValuesPath(config, { adopted: adoptedBoth });
const values = mergeModelInputs(await loadModelInputs(alpha), await loadModelInputs(readFrom));
expect(values).toMatchObject({
'team:gw': { API_KEY: { value: 'alpha-key' } },
'team:hw': { API_KEY: { value: 'beta-key' } },
});
// A declining member records the file's identity durably instead of
// letting it block migration: with beta declined, no candidate remains
// (the guard proceeds) and the read skips beta — adopting alpha alone
// no longer forces this team to absorb a foreign team's keys.
const declinedBeta = new Set([`${getTeamValuesPath(config)}::beta-${digest('acme/widgets')}`]);
expect(await unadoptedLegacyFiles(config, { adopted: adoptedAlpha, declined: declinedBeta })).toEqual([]);
expect(await findTeamValuesPath(config, { adopted: adoptedAlpha, declined: declinedBeta })).toBe(alpha);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('a foreign team\'s own state never admits another team\'s legacy file', async () => {
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-unbound-'));
const previous = process.env.HOME;
process.env.HOME = home;
try {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-unbound-repo-'));
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: GH\nrepo: acme/widgets\nprovider: github\n');
const dir = path.dirname(getTeamValuesPath({ repo: { localPath: repo, remote: 'origin' } } as LocalConfig));
const legacy = path.join(dir, `gh-${digest('acme/widgets')}.json`);
await fse.ensureDir(dir);
await fse.writeFile(legacy, '{"team:gw":{"API_KEY":{"value":"gh-key"}}}');
// A GitCode Beta team's presence and even its own claimed identity never
// make GH's file readable without GH's identity being explicitly adopted.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: GC\nrepo: acme/widgets\nprovider: gitcode\n');
const onGitcode = { repo: { localPath: repo, remote: 'origin' }, provider: 'gitcode' } as LocalConfig;
expect(await findTeamValuesPath(onGitcode, { adopted: new Set([`${getTeamValuesPath(onGitcode)}::beta-${digest('acme/widgets')}`]) })).toBe(getTeamValuesPath(onGitcode));
expect(sameTeamIdentity(`gh-${digest('acme/widgets')}`, onGitcode)).toBe(false);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('provides distinct identities for the same provider-relative remote across providers', async () => {
const dir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-remote-'));
// A provider-relative remote names a repository only with its provider: the
// same `owner/repo` remote on two providers must not share one values file.
// Each team declares its own provider in teamai.yaml — the local override
// is commonly absent.
const githubPath = path.join(dir, 'github');
const gitcodePath = path.join(dir, 'gitcode');
await fse.mkdir(githubPath);
await fse.mkdir(gitcodePath);
await fse.writeFile(path.join(githubPath, 'teamai.yaml'), 'team: T\nprovider: github\n');
await fse.writeFile(path.join(gitcodePath, 'teamai.yaml'), 'team: T\nprovider: gitcode\n');
const onGithub = { repo: { localPath: githubPath, remote: 'owner/repo' } } as LocalConfig;
const onGitcode = { repo: { localPath: gitcodePath, remote: 'owner/repo' } } as LocalConfig;
expect(getTeamValuesPath(onGithub)).not.toBe(getTeamValuesPath(onGitcode));
// Same provider-relative remote and provider name the same repository
// regardless of path.
expect(getTeamValuesPath(onGithub)).toBe(getTeamValuesPath({ repo: { localPath: '/elsewhere', remote: 'owner/repo' }, provider: 'github' } as LocalConfig));
});
it('a member\'s explicit provider overrides the team\'s declared provider', async () => {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-provider-override-'));
// The team declares GitHub; a member initialized with `--provider gitlab`
// must NOT receive the GitHub hash — the member's own initializer/override
// wins, as everywhere else in the CLI. Otherwise that member would share
// API keys and switch ownership with the actual GitHub checkout.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: T\nrepo: acme/widgets\nprovider: github\n');
const onGithub = { repo: { localPath: repo } } as LocalConfig;
const memberGitlab = { repo: { localPath: repo }, provider: 'gitlab' } as LocalConfig;
expect(getTeamValuesPath(memberGitlab)).not.toBe(getTeamValuesPath(onGithub));
// The override is the identity: another checkout with no declared provider
// but the same gitlab override shares the file with the member.
expect(getTeamValuesPath(memberGitlab)).toBe(getTeamValuesPath({ repo: { localPath: '/elsewhere', remote: 'acme/widgets' }, provider: 'gitlab' } as LocalConfig));
});
it('keys different provider-relative remotes separately and never Windows drive paths as URLs', async () => {
const repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-remote-prec-'));
// The remote is the top identity: two checkouts with different
// provider-relative remotes must never share one file — even when both
// carry the same teamai.yaml claim and provider.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: T\nrepo: acme/widgets\n');
const remoteA = { repo: { localPath: repo, remote: 'acme/team-a' } } as LocalConfig;
const remoteB = { repo: { localPath: repo, remote: 'acme/team-b' } } as LocalConfig;
expect(getTeamValuesPath(remoteA)).not.toBe(getTeamValuesPath(remoteB));
// Same provider-relative remote and provider name the same repository
// regardless of checkout path.
expect(getTeamValuesPath(remoteA)).toBe(getTeamValuesPath({ repo: { localPath: '/elsewhere', remote: 'acme/team-a' } } as LocalConfig));
// A provider-relative remote outranks a DIFFERENT claim: the remote names
// the repo, the claim merely repeats it (matching remote keys the claim's).
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: T\nrepo: acme/team-b\n');
expect(getTeamValuesPath(remoteB)).toBe(getTeamValuesPath({ repo: { localPath: repo, remote: 'acme/team-b' } } as LocalConfig));
// A bare alias names no repository: it falls through to the
// provider/slug/path identity, so adding it cannot change the file and
// two teams sharing the alias stay separated by the slug.
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: Alpha\n');
const pathOnly = getTeamValuesPath({ repo: { localPath: repo } } as LocalConfig);
expect(getTeamValuesPath({ repo: { localPath: repo, remote: 'sharing' } } as LocalConfig)).toBe(pathOnly);
await fse.writeFile(path.join(repo, 'teamai.yaml'), 'team: Beta\n');
expect(getTeamValuesPath({ repo: { localPath: repo, remote: 'sharing' } } as LocalConfig)).not.toBe(pathOnly);
// Windows drive paths are paths, not URL schemes: a path-only config must
// keep its provider-and-slug fallback instead of hashing a phantom URL.
expect(isRepoReference('C:\\teams\\repo')).toBe(false);
expect(isRepoReference('c:/teams/repo')).toBe(false);
expect(isRepoReference('D:\\teams\\repo')).toBe(false);
expect(isRepoReference('https://example.test/team.git')).toBe(true);
expect(isRepoReference('git@example.test:acme/team.git')).toBe(true);
});
it('reads an alias-digest legacy file only under this team\'s slug', async () => {
const previous = process.env.HOME;
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-alias-'));
process.env.HOME = home;
try {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
// Two teams keyed files on the same bare alias; only the slug told them apart.
const config = { repo: { localPath: '/tmp/example/hai', remote: 'fork', url: 'https://example.test/hai.git' } } as LocalConfig;
const target = getTeamValuesPath(config);
const dir = path.dirname(target);
await fse.ensureDir(dir);
const foreign = path.join(dir, `other-team-${digest('fork')}.json`);
await fse.writeFile(foreign, '{"team:other":{"API_KEY":{"value":"other-team-key"}}}');
// Newest file, wrong slug: must not be adopted.
await fse.utimes(foreign, new Date(2_000_000_000), new Date(2_000_000_000));
expect(await findTeamValuesPath(config)).toBe(target);
// No teamai.yaml here, so the old scheme fell back to the basename slug.
const ours = path.join(dir, `hai-${digest('fork')}.json`);
await fse.writeFile(ours, '{"team:gw":{"API_KEY":{"value":"our-key"}}}');
await fse.utimes(ours, new Date(1_000_000_000), new Date(1_000_000_000));
// Even our own slug needs the explicit opt-in: the alias never encoded
// the provider, so the slug alone cannot prove ownership.
expect(await findTeamValuesPath(config)).toBe(target);
expect(await findTeamValuesPath(config, { adopted: new Set([`${getTeamValuesPath(config)}::hai-${digest('fork')}`]) })).toBe(ours);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('reads the newest legacy file in place when several match', async () => {
const previous = process.env.HOME;
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-legacy-'));
process.env.HOME = home;
try {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
const config = { repo: { localPath: '/tmp/example/hai', remote: 'origin', url: 'https://example.test/hai.git' } } as LocalConfig;
const target = getTeamValuesPath(config);
const dir = path.dirname(target);
await fse.ensureDir(dir);
const stale = path.join(dir, `hai-platform-${digest('https://example.test/hai.git')}.json`);
await fse.writeFile(stale, '{"team:gw":{"API_KEY":{"value":"stale"}}}');
const newer = path.join(dir, `relocated-${digest('https://example.test/hai.git')}.json`);
await fse.writeFile(newer, '{"team:gw":{"API_KEY":{"value":"latest"}}}');
// Deterministic mtimes: the stale file is the older one, whatever readdir order returns.
await fse.utimes(stale, new Date(1_000_000_000), new Date(1_000_000_000));
await fse.utimes(newer, new Date(2_000_000_000), new Date(2_000_000_000));
// Newer wins even when readdir sorts the stale file first; nothing is renamed.
expect(await findTeamValuesPath(config)).toBe(newer);
expect(await fse.pathExists(stale)).toBe(true);
expect(await fse.pathExists(newer)).toBe(true);
expect(await fse.pathExists(target)).toBe(false);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('never reads a local-path digest when a URL was configured', async () => {
const previous = process.env.HOME;
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-path-'));
process.env.HOME = home;
try {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
const config = { repo: { localPath: '/tmp/example/hai', remote: 'origin', url: 'https://example.test/hai.git' } } as LocalConfig;
const target = getTeamValuesPath(config);
const dir = path.dirname(target);
await fse.ensureDir(dir);
// What a previous team keyed on the default path left behind.
const stale = path.join(dir, `old-team-${digest('/tmp/example/hai')}.json`);
await fse.writeFile(stale, '{"team:old":{"API_KEY":{"value":"old-team-key"}}}');
expect(await findTeamValuesPath(config)).toBe(target);
expect(await fse.pathExists(target)).toBe(false);
expect(await fse.pathExists(stale)).toBe(true);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('never reads a foreign team digest', async () => {
const previous = process.env.HOME;
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-foreign-'));
process.env.HOME = home;
try {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
const config = { repo: { localPath: '/tmp/example/hai', remote: 'origin', url: 'https://example.test/hai.git' } } as LocalConfig;
const target = getTeamValuesPath(config);
const dir = path.dirname(target);
await fse.ensureDir(dir);
// Another team's values file shares the slug but not the digest.
const foreign = path.join(dir, `hai-platform-${digest('https://example.test/foreign.git')}.json`);
await fse.writeFile(foreign, 'foreign');
expect(await findTeamValuesPath(config)).toBe(target);
expect(await fse.pathExists(target)).toBe(false);
expect(await fse.pathExists(foreign)).toBe(true);
expect(sameTeamIdentity(`hai-platform-${digest('https://example.test/foreign.git')}`, config)).toBe(false);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('reads the legacy file in place and lets the next save shadow it', async () => {
const previous = process.env.HOME;
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-shadow-'));
process.env.HOME = home;
try {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
const config = { repo: { localPath: '/tmp/example/hai', remote: 'origin', url: 'https://example.test/hai.git' } } as LocalConfig;
const target = getTeamValuesPath(config);
const dir = path.dirname(target);
await fse.ensureDir(dir);
const legacy = path.join(dir, `hai-platform-${digest('https://example.test/hai.git')}.json`);
await fse.writeFile(legacy, '{"team:gw":{"API_KEY":{"value":"old"}}}');
// Reads go to the legacy file; nothing is created or renamed.
expect(await findTeamValuesPath(config)).toBe(legacy);
expect(await fse.pathExists(target)).toBe(false);
// Once saved, the hash-only file exists and shadows the legacy one.
await saveModelInputs(target, { 'team:gw': { API_KEY: { value: 'fresh' } } });
expect(await findTeamValuesPath(config)).toBe(target);
expect(JSON.parse(await fse.readFile(target, 'utf8'))['team:gw']['API_KEY'].value).toBe('fresh');
expect(await fse.pathExists(legacy)).toBe(true);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('returns the hash-only path when present, absent, or without a teams dir', async () => {
const previous = process.env.HOME;
const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-model-hash-'));
process.env.HOME = home;
try {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest('hex').slice(0, 10);
const config = { repo: { localPath: '/tmp/example/hai', remote: 'origin', url: 'https://example.test/hai' } } as LocalConfig;
const target = getTeamValuesPath(config);
const dir = path.dirname(target);
await fse.ensureDir(dir);
const foreign = path.join(dir, `hai-platform-${digest('https://example.test/foreign.git')}.json`);
await fse.writeFile(foreign, 'foreign');
// Target exists: it wins over any legacy file.
await fse.writeFile(target, 'current');
expect(await findTeamValuesPath(config)).toBe(target);
expect(await fse.readFile(target, 'utf8')).toBe('current');
// No matching legacy file: the hash-only path, nothing created.
await fse.remove(target);
expect(await findTeamValuesPath(config)).toBe(target);
expect(await fse.pathExists(target)).toBe(false);
expect(await fse.pathExists(foreign)).toBe(true);
// Missing teams directory entirely: same graceful return.
await fse.remove(dir);
expect(await findTeamValuesPath(config)).toBe(target);
} finally {
if (previous === undefined) delete process.env.HOME;
else process.env.HOME = previous;
await fse.remove(home);
}
});
it('resolves model groups into protocol routes without repeating model IDs', () => {
const parsed = ModelProfileSchema.parse({
...TOKENHUB,
+24 -1
View File
@@ -3,7 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ModelProfileSchema, resolveProfile } from '../models/profile.js';
import { activeModelProfiles, restoreModelProfiles, switchModelProfile } from '../models/switch.js';
import { activeModelProfiles, refusedLegacyValueKeys, refuseLegacyValue, restoreModelProfiles, switchModelProfile } from '../models/switch.js';
import { entryHash } from '../resources/mcp-format.js';
let home: string;
@@ -628,4 +628,27 @@ describe('model switch bookkeeping', () => {
expect((await restoreModelProfiles(['codex']))[0].status).toBe('restored');
expect(await fse.readFile(path.join(first, 'config.toml'), 'utf8')).toContain('model = "personal"');
});
it('records a legacy values decline durably and scoped to its target', async () => {
expect(await refusedLegacyValueKeys()).toEqual(new Set());
await refuseLegacyValue('/scope-a/teams/1234567890.json::alpha-abcdef1234');
await refuseLegacyValue('/scope-b/teams/1234567890.json::beta-abcdef1234');
// Both survive across reads, and a third write persists alongside them.
expect(await refusedLegacyValueKeys()).toEqual(new Set([
'/scope-a/teams/1234567890.json::alpha-abcdef1234',
'/scope-b/teams/1234567890.json::beta-abcdef1234',
]));
await refuseLegacyValue('/scope-a/teams/1234567890.json::gamma-abcdef1234');
expect(await refusedLegacyValueKeys()).toEqual(new Set([
'/scope-a/teams/1234567890.json::alpha-abcdef1234',
'/scope-b/teams/1234567890.json::beta-abcdef1234',
'/scope-a/teams/1234567890.json::gamma-abcdef1234',
]));
// A malformed declines record fails closed rather than being trusted.
const managed = path.join(home, '.teamai', 'models', 'managed.json');
const manifest = await fse.readJson(managed);
manifest.legacyValueDeclines = ['not-a-string', 7];
await fse.outputJson(managed, manifest);
await expect(refusedLegacyValueKeys()).rejects.toThrow(/Invalid legacy value declines/);
});
});
+22
View File
@@ -4,11 +4,18 @@ import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { modelsAdd, modelsConfigure, modelsList, modelsRemove, modelsRestore, modelsSwitch } from '../models-cmd.js';
import { getLocalValuesPath, loadLocalProfiles, loadModelInputs } from '../models/profile.js';
import { askSecret, isInteractive } from '../utils/prompt.js';
vi.mock('../utils/logger.js', () => ({
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('../utils/prompt.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/prompt.js')>()),
isInteractive: vi.fn(() => false),
askSecret: vi.fn(),
}));
let home: string;
let originalEnv: Record<string, string | undefined>;
@@ -142,6 +149,21 @@ describe('models commands', () => {
await expect(modelsSwitch('mine', {})).rejects.toThrow(/has no API key. Run `teamai models configure local:mine`/);
});
it('persists the key asked for on the first interactive switch to a personal profile', async () => {
await fse.outputJson(path.join(home, '.claude', 'settings.json'), {});
await addMine();
await fse.writeJson(getLocalValuesPath(), {});
vi.mocked(isInteractive).mockReturnValue(true);
vi.mocked(askSecret).mockResolvedValue('sk-interactive');
try {
await captureOutput(() => modelsSwitch('mine', { agent: ['claude'] }));
expect(await loadModelInputs(getLocalValuesPath())).toEqual({ 'local:mine': { API_KEY: { value: 'sk-interactive' } } });
expect((await fse.readJson(path.join(home, '.claude', 'settings.json'))).model).toBe('glm-5.3');
} finally {
vi.mocked(isInteractive).mockReturnValue(false);
}
});
it('restores every managed agent by default and reports when nothing is managed', async () => {
await fse.outputJson(path.join(home, '.claude', 'settings.json'), { model: 'personal' });
await addMine();
+23 -1
View File
@@ -25,7 +25,8 @@ vi.mock('../config.js', async (importOriginal) => ({
saveStateForScope: vi.fn(),
}));
vi.mock('../utils/git.js', () => ({
vi.mock('../utils/git.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/git.js')>()),
pullRepo: vi.fn().mockResolvedValue('Already up to date.'),
}));
@@ -55,10 +56,17 @@ vi.mock('../update.js', () => ({
releaseLock: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../utils/prompt.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/prompt.js')>()),
isInteractive: vi.fn(() => false),
askSecret: vi.fn(),
}));
import { pull } from '../pull.js';
import { modelsConfigure, modelsList, modelsSwitch } from '../models-cmd.js';
import { getTeamValuesPath, saveModelInputs } from '../models/profile.js';
import { autoDetectInit, loadLocalConfigForScope, loadTeamConfig, requireInit } from '../config.js';
import { askSecret, isInteractive } from '../utils/prompt.js';
import { log } from '../utils/logger.js';
import type { TeamaiConfig, LocalConfig } from '../types.js';
@@ -370,6 +378,20 @@ describe('pull: team model profiles by namespace', () => {
expect(output).toContain(' API key: not configured for https://gw.elsewhere.test (one is stored for another gateway)');
});
it('stores a key on the first interactive switch to a team profile and resolves it in the same run', async () => {
// No stored key: the first `switch` must ask for one, save it under its
// own origin lock, and resolve the profile the same run — not fail with
// "has no API key" and demand a rerun for the key to take effect.
vi.mocked(isInteractive).mockReturnValue(true);
vi.mocked(askSecret).mockResolvedValue('switched-secret');
try {
await captureOutput(() => modelsSwitch('team:gw', { agent: ['claude'] }));
expect(await claude()).toEqual({ url: COMPANY, token: 'switched-secret', model: 'company-model' });
} finally {
vi.mocked(isInteractive).mockReturnValue(false);
}
});
it('reads the root catalog only in legacy mode', async () => {
await fse.remove(path.join(repoPath, 'manifest'));
await switchTo('gw');
+159 -25
View File
@@ -3,13 +3,14 @@ import { autoDetectInit } from './config.js';
import { describeEntryFailure, describeOrigin, reportEntryResolution, resolveEntriesFor } from './namespaced-entries.js';
import { pathExists } from './utils/fs.js';
import { log } from './utils/logger.js';
import { askQuestion, askSecret, isInteractive } from './utils/prompt.js';
import { askConfirmation, askQuestion, askSecret, isInteractive } from './utils/prompt.js';
import type { LocalConfig } from './types.js';
import {
API_KEY_PLACEHOLDER,
ModelAgentSchema,
ModelProfileSchema,
ModelProtocolSchema,
findTeamValuesPath,
getLocalValuesPath,
gatewaySuffix,
getTeamIdentity,
@@ -20,6 +21,7 @@ import {
isApiKeyConfigured,
loadLocalProfiles,
loadModelInputs,
mergeModelInputs,
profileAgents,
profileModels,
profileOrigin,
@@ -29,9 +31,12 @@ import {
modelsEntryReader,
saveLocalProfiles,
saveModelInputs,
sameTeamIdentity,
setStoredApiKey,
storedApiKey,
teamProfilesFrom,
unadoptedLegacyFiles,
withTeamValuesLock,
type ModelAgent,
type ModelGroup,
type ModelProtocol,
@@ -44,6 +49,8 @@ import {
import {
ALL_MODEL_AGENTS,
activeModelProfiles,
refusedLegacyValueKeys,
refuseLegacyValue,
switchedGatewayOrigins,
restoreModelProfiles,
switchModelProfile,
@@ -78,19 +85,116 @@ async function teamContext(options: { dryRun?: boolean } = {}): Promise<TeamMode
}
/**
* This team's stored keys, with any key a 0.26.0 beta stored bound to its
* gateway first (`bindLegacyTeamKeys`) and saved that way, unless `dryRun`.
* This team's stored keys, read from the hash-only file or, while it does not
* exist yet, the newest legacy `<slug>-<digest>.json` an older version wrote —
* read where it lies, never renamed. Any key a 0.26.0 beta stored is bound to
* its gateway first (`bindLegacyTeamKeys`) and saved — to the hash-only file —
* unless `dryRun`. A legacy file under a provider-ambiguous digest (a
* path-shaped claim, a bare alias, a path-only path) is never read silently:
* the old name never encoded the provider, so neither the slug nor any
* machine-global artifact can attribute the file to this checkout across
* providers. An interactive run asks the user once per candidate identity;
* on "yes" the file is read and immediately migrated — saved to the
* provider-qualified hash-only name of THAT config, which then shadows it (no
* re-ask, no ambiguity left). The adoption is keyed
* `<target>::<slug>-<digest>`, so it is scoped to this scope's provider
* identity: a user-scope confirmation never authorizes a project scope, even
* under the same slug and claim on a different provider. A declined candidate
* is read nothing and writes nothing — the empty hash-only file is never
* created, so the next run still offers it. Non-interactive and dry runs
* never adopt: they note the file and read nothing it owns.
*/
const adoptedLegacyValues = new Set<string>();
/**
* A values write to a team's hash-only target would permanently shadow any
* unadopted provider-ambiguous legacy file (the target's existence silences
* every future adoption prompt), so — unlike a pull, whose read-time save is
* migration — a command that writes the team target is refused while one
* remains. Adoption is intentional and is only possible interactively, so
* the refusal names that path.
*/
async function assertNoShadowingLegacyWrite(localConfig: LocalConfig): Promise<void> {
const declined = await refusedLegacyValueKeys();
const pending = await unadoptedLegacyFiles(localConfig, { adopted: adoptedLegacyValues, declined });
if (pending.length === 0) return;
throw new Error(
`Unadopted legacy team values file(s) still exist for this team (${pending.map((file) => file.entry).join(', ')}); ` +
`writing ${getTeamValuesPath(localConfig)} would shadow and permanently orphan their keys. ` +
`Re-run interactively to adopt and migrate them first.`,
);
}
async function loadTeamValues(
localConfig: LocalConfig,
team: TeamModelProfiles,
options: { dryRun?: boolean } = {},
): Promise<StoredModelInputs> {
const file = getTeamValuesPath(localConfig);
const values = await loadModelInputs(file);
const sentTo = await switchedGatewayOrigins(getTeamIdentity(localConfig));
if (bindLegacyTeamKeys(values, team, (id) => sentTo.get(`team:${id}`) ?? []) && !options.dryRun) {
await saveModelInputs(file, values);
const target = getTeamValuesPath(localConfig);
const declined = new Set(await refusedLegacyValueKeys());
const pending = await unadoptedLegacyFiles(localConfig, { adopted: adoptedLegacyValues, declined });
if (pending.length > 0) {
if (!options.dryRun && isInteractive()) {
for (const file of pending) {
const key = `${target}::${file.identity}`;
const adopt = await askConfirmation(
`Legacy team values file '${file.entry}' names this team under a provider-ambiguous identity (${file.identity}). Adopt it as this team's keys (migrated to the provider-qualified name once read)? [y/N] `,
);
if (adopt) adoptedLegacyValues.add(key);
else {
// The decline is a durable, visible decision — recorded per target so
// the file is neither re-offered on every run nor silently shadowed
// and orphans when the migration proceeds. Its keys stay on disk; a
// foreign team's same-digest file no longer blocks this team's migration.
declined.add(key);
await refuseLegacyValue(key);
}
}
} else {
log.warn(
`Legacy team values file(s) not adopted: ${pending.map((file) => file.entry).join(', ')}. ` +
`They are never read without an explicit opt-in; re-run interactively to adopt, or write the keys to ${target}.`,
);
}
}
// A declined or unprompted file must stay reachable: once the hash-only
// target exists, its candidates are silenced, so those keys would be orphaned
// permanently with no later prompt ever possible. The migration save below
// therefore happens only when every matching ambiguous file for this team is
// either adopted (merged into the values), durably declined, or gone —
// never while one is still left to decide on.
const remaining = await unadoptedLegacyFiles(localConfig, { adopted: adoptedLegacyValues, declined });
const canMigrate = remaining.length === 0;
const readFrom = await findTeamValuesPath(localConfig, { adopted: adoptedLegacyValues, declined });
const valuesDir = path.dirname(target);
let values = await loadModelInputs(readFrom);
// Adoption merges EVERY adopted identity's keys, not only the newest file a
// single read selects: several files can share this checkout's digest, and
// their unique keys must all reach the migrated target.
for (const file of pending) {
if (!adoptedLegacyValues.has(`${target}::${file.identity}`)) continue;
values = mergeModelInputs(await loadModelInputs(path.join(valuesDir, file.entry)), values);
}
const sentTo = await switchedGatewayOrigins(localConfig);
if (canMigrate && (bindLegacyTeamKeys(values, team, (id) => sentTo.get(`team:${id}`) ?? []) || readFrom !== target) && !options.dryRun) {
if (readFrom !== target) {
// Save to the current name, which then shadows the legacy file. The
// migration creates the hash-only target, so another process may be
// writing it in the same window (writeJsonAtomic prevents torn files,
// not lost updates). Hold the target's lock and re-read it inside the
// critical section: the concurrent content wins collisions, no key it
// added is silently discarded, and the read-merge-write cannot be
// interleaved with another writer's. When we are merely re-saving the
// file we already read (readFrom === target), the target holds the same
// content this bind just consumed — merging would re-inject raw entries
// the bind renamed, and there is no creation race to guard.
await withTeamValuesLock(target, async () => {
values = mergeModelInputs(values, await loadModelInputs(target));
await saveModelInputs(target, values);
});
} else {
await saveModelInputs(target, values);
}
}
return values;
}
@@ -150,19 +254,25 @@ function valuesPathFor(ref: ProfileRef, context: TeamModelsContext): string {
}
/** The stored keys `ref` reads its key from; `loadTeamValues` for a team profile. */
async function loadValuesFor(ref: ProfileRef, context: TeamModelsContext): Promise<StoredModelInputs> {
if (ref.source === 'team' && context.localConfig) return loadTeamValues(context.localConfig, context.team);
async function loadValuesFor(ref: ProfileRef, context: TeamModelsContext, options: { dryRun?: boolean } = {}): Promise<StoredModelInputs> {
if (ref.source === 'team' && context.localConfig) return loadTeamValues(context.localConfig, context.team, options);
return loadModelInputs(valuesPathFor(ref, context));
}
function activeAgentsFor(
async function activeAgentsFor(
ref: ProfileRef,
active: Partial<Record<ModelAgent, ActiveModelProfile>>,
): ModelAgent[] {
localConfig?: LocalConfig,
): Promise<ModelAgent[]> {
const name = profileRefName(ref);
return (Object.entries(active) as Array<[ModelAgent, ActiveModelProfile]>)
.filter(([, state]) => state.profile === name && (ref.source === 'local' || !state.team || state.team === ref.team))
.map(([agent]) => agent);
const agents: ModelAgent[] = [];
for (const [agent, state] of Object.entries(active) as Array<[ModelAgent, ActiveModelProfile]>) {
if (state.profile !== name) continue;
if (ref.source !== 'local' && state.team
&& !(localConfig ? sameTeamIdentity(state.team, localConfig) : state.team === ref.team)) continue;
agents.push(agent);
}
return agents;
}
function printResults(results: ModelSwitchResult[], explicitAgents: boolean): void {
@@ -206,10 +316,10 @@ export async function modelsList(reference?: string): Promise<void> {
team: context.localConfig && refs.some((ref) => ref.source === 'team') ? await loadTeamValues(context.localConfig, context.team, { dryRun: true }) : {},
local: refs.some((ref) => ref.source === 'local') ? await loadModelInputs(getLocalValuesPath()) : {},
};
refs.forEach((ref, index) => {
for (const [index, ref] of refs.entries()) {
if (index > 0) console.log('');
const secret = storedApiKey(ref, values[ref.source]);
const activeAgents = activeAgentsFor(ref, active);
const activeAgents = await activeAgentsFor(ref, active, context.localConfig);
console.log(`${profileRefName(ref)} — ${ref.profile.name}`);
if (ref.from) console.log(` From: ${ref.from.source} (${describeOrigin(ref.from)})`);
const missing = hasApiKeyForAnotherGateway(ref, values[ref.source])
@@ -223,7 +333,7 @@ export async function modelsList(reference?: string): Promise<void> {
}
console.log(` Agents: ${profileAgents(ref.profile).join(', ')}`);
console.log(` Active: ${activeAgents.length ? activeAgents.join(', ') : 'none'}`);
});
}
}
interface AddOptions extends ApiKeyOptions {
@@ -345,14 +455,25 @@ export async function modelsConfigure(reference: string, options: ConfigureOptio
}
if (secret) {
setStoredApiKey(ref, values, secret);
await saveModelInputs(file, values);
if (ref.source === 'team' && context.localConfig) await assertNoShadowingLegacyWrite(context.localConfig);
if (ref.source === 'team') {
// Hold the target's lock and re-read inside it, so this write cannot
// clobber a migration or another configure this window holds.
await withTeamValuesLock(file, async () => {
const current = await loadModelInputs(file);
setStoredApiKey(ref, current, secret);
await saveModelInputs(file, current);
});
} else {
setStoredApiKey(ref, values, secret);
await saveModelInputs(file, values);
}
}
if (edited) {
local.profiles[local.profiles.findIndex((profile) => profile.id === edited!.id)] = edited;
await saveLocalProfiles(local);
}
const activeAgents = activeAgentsFor(ref, await activeModelProfiles());
const activeAgents = await activeAgentsFor(ref, await activeModelProfiles(), context.localConfig);
log.success(activeAgents.length
? `Configured ${key}${gatewaySuffix(ref, 'at')}. Run \`teamai models switch ${key}\` to apply it to ${activeAgents.join(', ')}.`
: `Configured ${key}${gatewaySuffix(ref, 'at')}. Agent settings were not changed.`);
@@ -370,15 +491,28 @@ export async function modelsSwitch(reference: string, options: SwitchOptions): P
const { ref, context } = found;
const key = profileRefName(ref);
const file = valuesPathFor(ref, context);
const values = await loadValuesFor(ref, context);
const values = await loadValuesFor(ref, context, options);
const stored = storedApiKey(ref, values);
// First use of a profile, or of its current gateway: ask for the key here
// instead of requiring a separate `configure` step.
if (!stored && !options.dryRun && isInteractive()) {
const answer = await askSecret(`API key for ${key}${gatewaySuffix(ref, 'at')}: `);
if (!answer) throw new Error(`Profile ${key} needs an API key`);
if (ref.source === 'team' && context.localConfig) await assertNoShadowingLegacyWrite(context.localConfig);
// Put the key in the copy the rest of this command resolves against first,
// so every save below already carries it (the lock re-read full the team
// snapshot from disk).
setStoredApiKey(ref, values, { value: answer });
await saveModelInputs(file, values);
if (ref.source === 'team') {
// Hold the target's lock and re-read inside it, like `configure`.
await withTeamValuesLock(file, async () => {
const current = await loadModelInputs(file);
setStoredApiKey(ref, current, { value: answer });
await saveModelInputs(file, current);
});
} else {
await saveModelInputs(file, values);
}
} else if (!isApiKeyConfigured(stored) && !stored?.env) {
throw new Error(`Profile ${key} has no API key${gatewaySuffix(ref, 'for')}. Run \`teamai models configure ${key}\`.`);
}
@@ -437,7 +571,7 @@ export async function syncTeamModelProfiles(localConfig: LocalConfig, options: {
const identity = getTeamIdentity(localConfig);
const groups = new Map<string, { profile: string; model?: string; agents: ModelAgent[] }>();
for (const [agent, state] of Object.entries(await activeModelProfiles()) as Array<[ModelAgent, ActiveModelProfile]>) {
if (!state.profile.startsWith('team:') || state.team !== identity) continue;
if (!state.profile.startsWith('team:') || !sameTeamIdentity(state.team, localConfig)) continue;
const groupKey = `${state.profile}\0${state.model ?? ''}`;
const group = groups.get(groupKey) ?? { profile: state.profile, model: state.model, agents: [] };
group.agents.push(agent);
@@ -487,7 +621,7 @@ export async function syncTeamModelProfiles(localConfig: LocalConfig, options: {
continue;
}
const onlyIfActive = { profile: name, team: identity, ...(model ? { model } : {}) };
for (const result of await switchModelProfile(resolved, agents, { ...options, onlyIfActive })) {
for (const result of await switchModelProfile(resolved, agents, { ...options, onlyIfActive, localConfig })) {
if (result.status === 'switched') {
log.success(options.dryRun ? `Would update ${result.agent} to the latest ${name}` : `Updated ${result.agent} to the latest ${name}`);
} else if (result.status !== 'unchanged' && result.status !== 'not-installed') {
+374 -23
View File
@@ -5,7 +5,9 @@ import YAML from 'yaml';
import { z } from 'zod';
import type { LocalConfig } from '../types.js';
import { getTeamaiHomeDir } from '../types.js';
import { repoIdentity } from '../utils/git.js';
import { writeFileAtomic, writeJsonAtomic } from '../utils/fs.js';
import { acquireLock, releaseLock } from '../update.js';
import { caseFoldKey } from '../manifest-schema.js';
import {
listEntryFiles,
@@ -167,33 +169,79 @@ export function getLocalValuesPath(): string {
return path.join(getTeamaiHomeDir(), 'models', 'values.json');
}
/** A path-shaped `owner/repo`: not a URL, yet it names a single repository — together with its provider. */
function isProviderRelative(value: string): boolean {
return /\//.test(value);
}
/** Whether a value names a repository for `repoIdentity`: a URL or scp-like remote. A bare alias like `fork` names nothing on its own, and a Windows drive path (`C:\teams\repo`) is no more a scheme than it is a repository. */
export function isRepoReference(value: string): boolean {
if (/^[^/@]+@[^:/]+:.+$/.test(value)) return true;
try {
const parsed = new URL(value);
// `new URL('C:\\teams\\repo')` accepts `c:` as a scheme; drive letters
// are paths, not hosts — reject them so a path-only config keeps the
// provider-and-team-slug fallback instead of hashing a phantom URL.
if (/^[a-z]:$/.test(parsed.protocol)) return false;
return true;
} catch {
return false;
}
}
export function getTeamValuesPath(localConfig: LocalConfig): string {
// Team inputs may contain credentials. Keep them under the user home even
// when project scope places dataHome inside a Git workspace.
const remote = localConfig.repo.remote;
let identity = remote && remote !== 'origin' && remote !== 'upstream'
? remote
: localConfig.repo.url || localConfig.repo.localPath;
let teamName = '';
try {
const raw = YAML.parse(fs.readFileSync(path.join(localConfig.repo.localPath, 'teamai.yaml'), 'utf8')) as unknown;
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
const candidate = (raw as { team?: unknown; repo?: unknown }).team;
if (typeof candidate === 'string') teamName = candidate;
const repo = (raw as { repo?: unknown }).repo;
if (typeof repo === 'string' && repo.trim()) identity = repo.trim();
}
} catch {
// Older team repositories may not have teamai.yaml. Use the repository name.
const { remote, url, localPath } = localConfig.repo;
const named = remote && remote !== 'origin' && remote !== 'upstream' ? remote : undefined;
// Hash only what names a repository. A remote alias (`fork`) does not: two
// checkouts sharing the alias would share one values file and read each
// other's keys. A provider-relative remote (`owner/repo`) DOES name one
// repository, just only together with its provider — exactly like a
// path-shaped `repo:` claim. The same holds for the local path — it is
// reused across teams, so it is the last resort and keeps the slug.
const claim = repoClaim(localPath);
// A configured non-origin remote names the repository with the highest
// precedence — whether it is URL-shaped or a provider-relative `owner/repo`
// (which names one repository together with its provider). Two checkouts
// with DIFFERENT remotes therefore never share one file, even if they carry
// the same `repo:` claim; the claim decides only when it is the best
// identity the checkout actually has.
const namedRemote = named !== undefined && (isRepoReference(named) || isProviderRelative(named));
const source = namedRemote ? named
: url && isRepoReference(url) ? url
: claim?.claim ?? localPath;
// The effective provider: the member's own initializer/override wins — the
// provider a checkout was initialized with (`--provider`) overrides the
// team's declared one, as everywhere else in the CLI — then the team's own
// `provider:` in teamai.yaml, then the team default. Provider, remote, and
// claim survive a team rename, keeping the file bound to the repository
// rather than the display name.
const provider = localConfig.provider ?? teamProvider(localPath) ?? 'tgit';
let identity: string;
if (isRepoReference(source)) {
// Host-bearing: repoIdentity normalizes scheme family, host, and path.
identity = repoIdentity(source);
} else if (source === claim?.claim) {
// Path-shaped claim: provider-relative, so the effective provider (the
// claim's own, else the local override, else the team default) qualifies
// it. Claim and provider survive a team rename, keeping the file bound
// to the repository rather than the display name.
identity = `${provider}:${source}`;
} else if (source === named) {
// Provider-relative remote: the same ambiguity the path-shaped claim
// handles, so the same provider qualification applies.
identity = `${provider}:${source}`;
} else {
// Path-only: no repository identity exists — and a bare remote alias
// like `fork` names no repository either — so the local path is the
// last resort and the team slug, the old scheme's discriminator, joins
// the hash to separate teams sharing a checkout path. Renaming such a
// team orphans its keys, as before.
identity = `${provider}:${legacyTeamSlug(localPath)}:${source}`;
}
const fallback = path.basename(localConfig.repo.localPath) || 'team';
const digest = crypto.createHash('sha256').update(identity).digest('hex');
const slug = (teamName || fallback).normalize('NFKC').toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '') || 'team';
return path.join(getTeamaiHomeDir(), 'models', 'teams', `${slug}-${digest.slice(0, 10)}.json`);
const digest = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 10);
return path.join(getTeamaiHomeDir(), 'models', 'teams', `${digest}.json`);
}
/** Stable identity of the team repository, recorded with `team:` switches. */
@@ -201,6 +249,277 @@ export function getTeamIdentity(localConfig: LocalConfig): string {
return path.basename(getTeamValuesPath(localConfig), '.json');
}
/**
* The `repo:` claim in teamai.yaml, or null when it is missing or not a
* non-empty string. Its `provider` accompanies the claim: the same
* `owner/repo` claim names a different repository per provider, and the
* local `provider` override is normally absent, so the team's own provider
* is the authoritative one for a path-shaped claim.
*/
function repoClaim(localPath: string): { claim: string; provider?: string } | null {
try {
const raw = YAML.parse(fs.readFileSync(path.join(localPath, 'teamai.yaml'), 'utf8')) as unknown;
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
const repo = (raw as { repo?: unknown }).repo;
if (typeof repo === 'string' && repo.trim()) {
const provider = (raw as { provider?: unknown }).provider;
return { claim: repo.trim(), ...(typeof provider === 'string' && provider.trim() ? { provider: provider.trim() } : {}) };
}
}
} catch {
// teamai.yaml may be absent or unreadable.
}
return null;
}
/** The team's own `provider:` in teamai.yaml — authoritative for path-shaped identities even when the `repo:` claim is absent. */
function teamProvider(localPath: string): string | undefined {
try {
const raw = YAML.parse(fs.readFileSync(path.join(localPath, 'teamai.yaml'), 'utf8')) as unknown;
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
const provider = (raw as { provider?: unknown }).provider;
if (typeof provider === 'string' && provider.trim()) return provider.trim();
}
} catch {
// teamai.yaml may be absent or unreadable.
}
return undefined;
}
/**
* The 10-hex digests older versions of `getTeamValuesPath` hashed for this
* checkout, and how tightly each may be matched. The old implementation
* hashed exactly one identity — the teamai.yaml `repo:` claim when present,
* else the configured remote, else the URL, else the local path — so the
* candidates mirror that precedence: the claim (when present) and the
* configured remote exclude the identities below them, and the path is a
* candidate only when the old implementation would have keyed on it (no
* claim, no configured remote, no URL). That keeps a checkout replaced at
* the same path by another team from adopting the previous team's file.
* Repository-bound digests (a URL, a URL-shaped claim or remote) match by
* digest alone — the identity carries its host, so the slug can drift with
* team renames with no ambiguity. A path-shaped claim, a provider-relative
* remote, a bare alias, or a path-only local path names no single repository,
* and — the old name scheme never encoded the provider — the slug cannot
* tell two providers' same-named teams apart. Files under such a digest are
* read only when the user explicitly adopts that exact identity
* (`unadoptedLegacyFiles` / `findTeamValuesPath`); switch records under it
* match only under this checkout's slug.
*/
interface LegacyDigest {
digest: string;
/** Files under this digest are read only when the user adopted the exact identity. */
fileNeedsSlug: boolean;
/** Switch records under this digest match only when this checkout's slug matches. */
recordNeedsSlug: boolean;
}
function legacyTeamValueHashes(localConfig: LocalConfig): LegacyDigest[] {
const { remote, url, localPath } = localConfig.repo;
const configuredRemote = remote && remote !== 'origin' && remote !== 'upstream' ? remote : undefined;
const claim = repoClaim(localPath);
const urlShaped = (value?: string) => value !== undefined && isRepoReference(value);
const candidates: Array<{ identity?: string; fileNeedsSlug: boolean; recordNeedsSlug: boolean }> = [
// A claim overrode everything below it; with a claim, the old
// implementation never hashed the remote, URL, or path. A URL-shaped
// claim names one repository (its host is in it) and matches by digest.
// A path-shaped claim is provider-ambiguous — the legacy digest hashed
// the bare claim — so files need the user's explicit adoption and switch
// records need this checkout's slug.
{ identity: claim?.claim, fileNeedsSlug: !urlShaped(claim?.claim), recordNeedsSlug: !urlShaped(claim?.claim) },
...(claim === null ? [
{ identity: configuredRemote, fileNeedsSlug: !urlShaped(configuredRemote), recordNeedsSlug: !urlShaped(configuredRemote) },
{ identity: url, fileNeedsSlug: false, recordNeedsSlug: false },
// Only when the old implementation would have keyed on the path itself:
// no repository identity exists there, so the slug separates teams.
{ identity: configuredRemote === undefined && !url ? localPath : undefined, fileNeedsSlug: true, recordNeedsSlug: true },
] : []),
];
const seen = new Set<string>();
const digests: LegacyDigest[] = [];
for (const { identity, fileNeedsSlug, recordNeedsSlug } of candidates) {
if (!identity) continue;
const digest = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 10);
if (seen.has(digest)) continue;
seen.add(digest);
digests.push({ digest, fileNeedsSlug, recordNeedsSlug });
}
return digests;
}
/** The team slug the old implementation prefixed, as this checkout computes it now. */
function legacyTeamSlug(localPath: string): string {
let teamName = '';
try {
const raw = YAML.parse(fs.readFileSync(path.join(localPath, 'teamai.yaml'), 'utf8')) as unknown;
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
const candidate = (raw as { team?: unknown }).team;
if (typeof candidate === 'string') teamName = candidate;
}
} catch {
// Older team repositories may not have a readable teamai.yaml.
}
const fallback = path.basename(localPath) || 'team';
return (teamName || fallback).normalize('NFKC').toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '') || 'team';
}
/** A provider-ambiguous legacy values file that only an explicit user confirmation may adopt. */
export interface UnadoptedLegacyFile {
/** Basename including the `.json` extension, e.g. `alpha-a1b2c3d4e5.json`. */
entry: string;
/** The stored identity without the extension: `<slug>-<digest>` — what the user adopts. */
identity: string;
/** Last-modified time, used to order candidates newest-first. */
mtime: number;
}
/**
* The `<slug>-<digest>.json` files under a provider-ambiguous digest (a
* path-shaped `repo:` claim, a provider-relative remote, a bare alias, or a
* path-only local path) that this checkout could read but has not been told
* to. The old name scheme never encoded the provider, so the slug cannot
* prove ownership across providers — a GitHub and a GitCode team both named
* `Alpha` on the bare claim `acme/widgets` hash the same file — and no
* machine-global artifact distinguishes this checkout from another team on
* the same machine. Adopting such a file is therefore the user's explicit
* choice, never a silent guess. Repository-bound legacy files (a URL or
* URL-shaped identity) are not listed: the identity carries its host, so
* matching by digest alone is safe. When the provider-qualified hash-only
* target already exists it shadows every legacy file — a past adoption and
* migration is the permanent record, so nothing is offered again.
*/
export async function unadoptedLegacyFiles(
localConfig: LocalConfig,
options: { adopted?: ReadonlySet<string>; declined?: ReadonlySet<string> } = {},
): Promise<UnadoptedLegacyFile[]> {
const target = getTeamValuesPath(localConfig);
if (fs.existsSync(target)) return [];
const candidates = legacyTeamValueHashes(localConfig);
const dir = path.dirname(target);
let entries: string[];
try {
entries = await fs.promises.readdir(dir);
} catch {
return [];
}
const files: UnadoptedLegacyFile[] = [];
for (const entry of entries) {
const legacy = /^(.+)-([0-9a-f]{10})\.json$/.exec(entry);
if (legacy === null) continue;
const digest = legacy[2] ?? '';
const candidate = candidates.find((match) => match.digest === digest);
if (candidate === undefined || !candidate.fileNeedsSlug) continue;
const identity = entry.replace(/\.json$/, '');
const key = `${target}::${identity}`;
if (options.adopted?.has(key) || options.declined?.has(key)) continue;
try {
const { mtimeMs } = await fs.promises.stat(path.join(dir, entry));
files.push({ entry, identity, mtime: mtimeMs });
} catch {
// Removed by a concurrent process between readdir and stat.
}
}
return files.sort((a, b) => b.mtime - a.mtime || b.entry.localeCompare(a.entry));
}
/**
* Whether a stored team identity (the current hash-only name or a legacy
* `<slug>-<digest>` form) names the repository `localConfig` describes, and
* so may own the switches recorded under it. Digests whose identity names a
* single repository (a URL, a URL-shaped claim or remote) match by digest
* alone — the identity is the repository, so the slug can drift with team
* renames. A path-shaped claim, a path-shaped provider-relative remote, a
* bare alias, or a path-only local path names no single repository — the
* old name never encoded the provider, so NEITHER the slug nor any
* machine-global artifact can attribute a record under it to this checkout:
* a GitHub and a GitCode team both named `Alpha` on the bare claim
* `acme/widgets` share the exact `alpha-<digest>` form. A record under such
* a digest therefore never matches — the same reason a differently named
* team is refused (no shared store tells a renamed team from another team)
* closes an equal-slug claim too, because the slug proves nothing across
* providers. Only the values file's explicit adoption, which migrates the
* keys to the provider-qualified name, re-establishes this team's presence;
* its switched agents are re-recorded by the next `models switch`.
*/
export function sameTeamIdentity(stored: string | undefined, localConfig: LocalConfig): boolean {
if (!stored) return false;
if (stored === getTeamIdentity(localConfig)) return true;
const legacy = /^(.+)-([0-9a-f]{10})$/.exec(stored);
if (legacy === null) return false;
const digest = legacy[2] ?? '';
for (const candidate of legacyTeamValueHashes(localConfig)) {
if (candidate.digest !== digest) continue;
if (!candidate.recordNeedsSlug) return true;
return false;
}
return false;
}
/**
* The file to read this team's values from, and where the next save lands:
* the hash-only name, or — when it does not exist yet — a legacy
* `<slug>-<digest>.json` an older version wrote for this checkout. Legacy
* files are read where they lie; nothing is renamed, linked, or copied, so a
* dry run needs no special casing and no filesystem quirk (races, unsupported
* hard links, partial targets) can strand the keys. The next save writes the
* hash-only file, which then shadows the legacy one. Values files under a
* repository-bound digest (a URL, a URL-shaped remote, a `repo:` claim) match
* by digest alone — the slug drifts with team renames, and the identity
* carries its host with no ambiguity. A provider-ambiguous digest (a
* path-shaped claim, a path-shaped remote, a bare alias, a path-only local
* path) names no single repository: the old name never encoded the provider,
* so the slug cannot tell two providers' same-named teams apart and no
* machine-global artifact can tell this checkout from another team. A file
* under such a digest is read only when the user has explicitly adopted that
* exact `<slug>-<digest>` identity for THIS provider-qualified team
* (`options.adopted` — keyed `${target}::<slug>-<digest>`, so a same-named
* identity adopted in another scope or for another provider never
* authorizes this team); the caller surfaces the candidates via
* `unadoptedLegacyFiles` and turns the user's word into that set. Without it
* the file is never guessed into read.
*/
export async function findTeamValuesPath(
localConfig: LocalConfig,
options: { adopted?: ReadonlySet<string>; declined?: ReadonlySet<string> } = {},
): Promise<string> {
const target = getTeamValuesPath(localConfig);
if (fs.existsSync(target)) return target;
const candidates = legacyTeamValueHashes(localConfig);
const dir = path.dirname(target);
let entries: string[];
try {
entries = await fs.promises.readdir(dir);
} catch {
return target; // no teams directory yet — nothing to read
}
const matching: Array<{ entry: string; mtime: number }> = [];
for (const entry of entries) {
const legacy = /^(.+)-([0-9a-f]{10})\.json$/.exec(entry);
if (legacy === null) continue;
const digest = legacy[2] ?? '';
const candidate = candidates.find((match) => match.digest === digest);
if (candidate === undefined) continue;
if (candidate.fileNeedsSlug) {
const key = `${target}::${entry.replace(/\.json$/, '')}`;
if (options.declined?.has(key)) continue;
if (!options.adopted?.has(key)) continue;
}
try {
const { mtimeMs } = await fs.promises.stat(path.join(dir, entry));
matching.push({ entry, mtime: mtimeMs });
} catch {
// Removed by a concurrent process between readdir and stat; nothing to read.
}
}
// newest first; equal timestamps take the lexicographically last name
matching.sort((a, b) => b.mtime - a.mtime || b.entry.localeCompare(a.entry));
return matching.length > 0 ? path.join(dir, matching[0]?.entry ?? '') : target;
}
/** One profiles file, or why it cannot be used; null when it does not exist. `label` names it in the reason. */
async function readProfilesFile(filePath: string, label: string): Promise<EntryFileRead<ModelProfile> | null> {
const file = await readEntryFileText(filePath, label);
@@ -302,6 +621,38 @@ export async function saveModelInputs(filePath: string, values: StoredModelInput
await writeJsonAtomic(filePath, StoredModelInputsSchema.parse(values), { mode: 0o600 });
}
/**
* Mutually exclude concurrent read-modify-write cycles of one team values
* file. writeJsonAtomic makes a single write atomic, but the migration that
* creates the hash-only target does read-merge-write across what a
* concurrent configure or switch may be writing in the same window; holding
* the target's lock here lets every writer serialize that whole cycle. All
* team-target writers (the migration in loadTeamValues, `models configure`,
* and `models switch` key prompting) must go through it, or the lock only
* serializes against itself.
*/
export async function withTeamValuesLock<T>(filePath: string, run: () => Promise<T>): Promise<T> {
const lockPath = `${filePath}.lock`;
for (let attempt = 0; attempt < 100; attempt++) {
if (await acquireLock(lockPath)) {
try { return await run(); }
finally { await releaseLock(lockPath); }
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Another model operation is writing ${filePath}; retry shortly`);
}
/**
* Union of two stored-inputs maps for the migration save. Legacy values files
* under one provider-ambiguous digest can differ in which team gateways they
* carry keys for, so adopting several of them must keep every identity's keys:
* the later map wins when both hold the same gateway key.
*/
export function mergeModelInputs(into: StoredModelInputs, later: StoredModelInputs): StoredModelInputs {
return { ...into, ...later };
}
export function resolveProfileRef(
reference: string,
team: TeamModelProfiles,
+47 -7
View File
@@ -6,7 +6,8 @@ import { getUserHome } from '../utils/home.js';
import { pathExists, writeFileAtomic, writeJsonAtomic } from '../utils/fs.js';
import { acquireLock, releaseLock } from '../update.js';
import { entryHash } from '../resources/mcp-format.js';
import { ALL_MODEL_AGENTS, type ModelAgent, type ResolvedModelProfile } from './profile.js';
import type { LocalConfig } from '../types.js';
import { ALL_MODEL_AGENTS, type ModelAgent, type ResolvedModelProfile, sameTeamIdentity } from './profile.js';
export { ALL_MODEL_AGENTS } from './profile.js';
@@ -40,6 +41,14 @@ interface AgentState {
interface ModelSwitchManifest {
version: 1;
agents: Partial<Record<ModelAgent, AgentState>>;
/**
* Legacy team values identities the user explicitly declined to adopt, as
* `${target}::<slug>-<digest>` keys scoped to the provider-qualified team
* target. A durable record — not silent shadowing — is what lets the
* migration proceed past a foreign team's same-digest file while keeping the
* choice visible and reversible (remove the key to re-offer the file).
*/
legacyValueDeclines?: string[];
}
export interface ModelSwitchResult {
@@ -143,6 +152,11 @@ async function loadManifest(): Promise<ModelSwitchManifest | null> {
throw new Error(`Invalid pending model ownership entry for ${agent}: ${file}`);
}
}
if (parsed.legacyValueDeclines !== undefined
&& (!Array.isArray(parsed.legacyValueDeclines)
|| parsed.legacyValueDeclines.some((key) => typeof key !== 'string'))) {
throw new Error(`Invalid legacy value declines: ${file}`);
}
return parsed as unknown as ModelSwitchManifest;
}
@@ -150,6 +164,23 @@ async function saveManifest(manifest: ModelSwitchManifest): Promise<void> {
await writeJsonAtomic(manifestPath(), manifest, { mode: 0o600 });
}
/** The legacy team values identities this machine declined to adopt, keyed `${target}::<slug>-<digest>`. */
export async function refusedLegacyValueKeys(): Promise<ReadonlySet<string>> {
const manifest = await loadManifest();
return new Set(manifest?.legacyValueDeclines ?? []);
}
/** Record a durable, target-scoped decline so the file is neither re-offered on every run nor silently shadowed. */
export async function refuseLegacyValue(key: string): Promise<void> {
await withManifestLock(async () => {
const manifest = await loadManifest() ?? { version: 1, agents: {} };
const declines = new Set(manifest.legacyValueDeclines ?? []);
declines.add(key);
manifest.legacyValueDeclines = [...declines].sort();
await saveManifest(manifest);
});
}
function hash(value: unknown): string {
function canonical(item: unknown): unknown {
if (Array.isArray(item)) return item.map(canonical);
@@ -697,6 +728,12 @@ export interface SwitchOptions {
* to re-apply before taking the lock; a switch in between must win.
*/
onlyIfActive?: ActiveModelProfile;
/**
* The repo the manifest is understood against. Lets `onlyIfActive` match a
* team recorded under its legacy identity, so a `pull` can update agents
* whose switch predates the hash-only team naming (#894).
*/
localConfig?: LocalConfig;
}
export async function switchModelProfile(
@@ -733,7 +770,9 @@ async function switchModelProfileUnlocked(
}
const expected = options.onlyIfActive;
const active = manifest.agents[agent];
if (expected && (active?.profile !== expected.profile || active.team !== expected.team || active.model !== expected.model)) {
const teamMatches = active?.team === expected?.team
|| (expected?.team !== undefined && options.localConfig !== undefined && sameTeamIdentity(active?.team, options.localConfig));
if (expected && (active?.profile !== expected.profile || !teamMatches || active.model !== expected.model)) {
results.push({ agent, status: 'unchanged', message: `${agent} no longer uses ${expected.profile}` });
continue;
}
@@ -936,16 +975,17 @@ export async function activeModelProfiles(): Promise<Partial<Record<ModelAgent,
}
/**
* For each `team:` profile of team `team` an agent is switched to, the gateway
* origins TeamAI last wrote into those agents' settings: where the API key of
* that profile has actually been sent.
* For each `team:` profile of the team `localConfig` describes, an agent is
* switched to: the gateway origins TeamAI last wrote into those agents'
* settings, where the API key of that profile has actually been sent. Agents
* recorded under a legacy team identity (#894) count toward the same team.
*/
export async function switchedGatewayOrigins(team: string): Promise<Map<string, string[]>> {
export async function switchedGatewayOrigins(localConfig: LocalConfig): Promise<Map<string, string[]>> {
const manifest = await loadManifest();
const byProfile = new Map<string, string[]>();
for (const agent of ALL_MODEL_AGENTS) {
const state = manifest?.agents[agent];
if (!state || !state.profile.startsWith('team:') || state.team !== team) continue;
if (!state || !state.profile.startsWith('team:') || !sameTeamIdentity(state.team, localConfig)) continue;
const origins = writtenGatewayUrls(agent, state.lastWritten).flatMap((url) => {
try {
return [new URL(url).origin];
+37
View File
@@ -237,6 +237,43 @@ export function remotesMatch(a: string, b: string): boolean {
return normalizeRepoUrlForCompare(a) === normalizeRepoUrlForCompare(b);
}
export const SCHEMES: ReadonlyMap<string, { readonly family: string; readonly defaultPort: string }> = new Map([
['ssh', { family: 'ssh', defaultPort: '22' }],
['git+ssh', { family: 'ssh', defaultPort: '22' }],
['ssh+git', { family: 'ssh', defaultPort: '22' }],
['https', { family: 'https', defaultPort: '443' }],
['http', { family: 'http', defaultPort: '80' }],
['git', { family: 'git', defaultPort: '9418' }],
]);
/**
* A team repo URL as the part of it that says which repo it is: scheme family
* (ssh, https or http), lowercased host, a port other than the scheme's default,
* and the path as written. Only credentials, the ssh user, a trailing `.git`
* and slashes are dropped, so `git@host:acme/team.git` and
* `ssh://git@host:22/acme/team` name one file, while two repos on one host
* with different ports, or behind http and https, never share values. Not `normalizeRepoUrlForCompare`:
* it drops the port, and its callers compare loosely on purpose.
*/
export function repoIdentity(url: string): string {
const trimmed = url.trim();
const key = (family: string, host: string, port: string, repoPath: string): string => {
const name = repoPath.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, '');
return `${family}://${host.toLowerCase()}${port ? `:${port}` : ''}/${name}`;
};
const scp = /^[^/@]+@([^:/]+):(.+)$/.exec(trimmed);
if (scp) return key('ssh', scp[1] ?? '', '', scp[2] ?? '');
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return trimmed;
}
const scheme = parsed.protocol.slice(0, -1).toLowerCase();
const known = SCHEMES.get(scheme);
return key(known?.family ?? scheme, parsed.hostname, parsed.port === known?.defaultPort ? '' : parsed.port, parsed.pathname);
}
/**
* Whether the repo at localPath has at least one commit reachable from HEAD.
* A freshly `git init`'d repo (HEAD points at an unborn branch) returns false.