diff --git a/docs/designs/model-profiles.md b/docs/designs/model-profiles.md index 48aff322..ad088347 100644 --- a/docs/designs/model-profiles.md +++ b/docs/designs/model-profiles.md @@ -15,10 +15,10 @@ Model profiles let a team publish one gateway catalog that every supported agent | Team profiles | `/models/models.yaml`, plus `models//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/-.json` | No, mode `0600` | Key or env-var name | +| Team-profile API keys | `~/.teamai/models/teams/.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:@`; 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 `-.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 `-` 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-` 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:@`; see [Namespaces and key binding](#namespaces-and-key-binding). ## Catalog and protocols diff --git a/docs/designs/model-profiles.zh-CN.md b/docs/designs/model-profiles.zh-CN.md index b87f52a9..2c0632ca 100644 --- a/docs/designs/model-profiles.zh-CN.md +++ b/docs/designs/model-profiles.zh-CN.md @@ -15,10 +15,10 @@ | 团队配置 | `<团队仓库>/models/models.yaml`,以及每个 namespace 的 `models//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:@` 下,见 [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-` 形式。绝不把任何机器级记录当作归属证明,因为同一机器上被各 checkout 共享的记录对别的团队同样成立;只有对遗留 values 文件的显式采用才能重新确立本团队的存在,其已切换的 agent 随后通过下一次 `models switch` 以 provider 限定的 identity 重新记录。provider 可确切的 switch 记录仍按 digest 直接匹配。每次切换 `team:` 配置时也会记录这个身份,`pull` 只会重新应用当前团队的配置。文件内每个密钥保存在 `team:@` 下,见 [Namespace 与密钥绑定](#namespace-与密钥绑定)。 ## 目录与协议 diff --git a/src/__tests__/model-profile.test.ts b/src/__tests__/model-profile.test.ts index 6ea9ec8a..104067d7 100644 --- a/src/__tests__/model-profile.test.ts +++ b/src/__tests__/model-profile.test.ts @@ -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-` 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-`, 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, diff --git a/src/__tests__/model-switch.test.ts b/src/__tests__/model-switch.test.ts index 1102a57a..b4047bea 100644 --- a/src/__tests__/model-switch.test.ts +++ b/src/__tests__/model-switch.test.ts @@ -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/); + }); }); diff --git a/src/__tests__/models-cmd.test.ts b/src/__tests__/models-cmd.test.ts index 11acb15a..d48a06f5 100644 --- a/src/__tests__/models-cmd.test.ts +++ b/src/__tests__/models-cmd.test.ts @@ -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()), + isInteractive: vi.fn(() => false), + askSecret: vi.fn(), +})); + let home: string; let originalEnv: Record; @@ -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(); diff --git a/src/__tests__/pull-model-namespaces.test.ts b/src/__tests__/pull-model-namespaces.test.ts index d6764c2e..835a27a8 100644 --- a/src/__tests__/pull-model-namespaces.test.ts +++ b/src/__tests__/pull-model-namespaces.test.ts @@ -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()), 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()), + 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'); diff --git a/src/models-cmd.ts b/src/models-cmd.ts index 7331f25e..d2358504 100644 --- a/src/models-cmd.ts +++ b/src/models-cmd.ts @@ -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-.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 + * `::-`, 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(); + +/** + * 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 { + 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 { - 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 { - if (ref.source === 'team' && context.localConfig) return loadTeamValues(context.localConfig, context.team); +async function loadValuesFor(ref: ProfileRef, context: TeamModelsContext, options: { dryRun?: boolean } = {}): Promise { + 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>, -): ModelAgent[] { + localConfig?: LocalConfig, +): Promise { 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 { 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 { } 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(); 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') { diff --git a/src/models/profile.ts b/src/models/profile.ts index 843b9251..1c8da887 100644 --- a/src/models/profile.ts +++ b/src/models/profile.ts @@ -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(); + 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: `-` — what the user adopts. */ + identity: string; + /** Last-modified time, used to order candidates newest-first. */ + mtime: number; +} + +/** + * The `-.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; declined?: ReadonlySet } = {}, +): Promise { + 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 + * `-` 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-` 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 + * `-.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 `-` identity for THIS provider-qualified team + * (`options.adopted` — keyed `${target}::-`, 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; declined?: ReadonlySet } = {}, +): Promise { + 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 | 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(filePath: string, run: () => Promise): Promise { + 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, diff --git a/src/models/switch.ts b/src/models/switch.ts index 6ffcd291..7eaa866e 100644 --- a/src/models/switch.ts +++ b/src/models/switch.ts @@ -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>; + /** + * Legacy team values identities the user explicitly declined to adopt, as + * `${target}::-` 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 { 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 { await writeJsonAtomic(manifestPath(), manifest, { mode: 0o600 }); } +/** The legacy team values identities this machine declined to adopt, keyed `${target}::-`. */ +export async function refusedLegacyValueKeys(): Promise> { + 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 { + 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> { +export async function switchedGatewayOrigins(localConfig: LocalConfig): Promise> { const manifest = await loadManifest(); const byProfile = new Map(); 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]; diff --git a/src/utils/git.ts b/src/utils/git.ts index c0547d50..b2b0bfeb 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -237,6 +237,43 @@ export function remotesMatch(a: string, b: string): boolean { return normalizeRepoUrlForCompare(a) === normalizeRepoUrlForCompare(b); } +export const SCHEMES: ReadonlyMap = 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.