From 93a336538368fad0a7c953fd8c125f94e3a92fbd Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:36:28 +0800 Subject: [PATCH] fix(config): refuse unreadable project scope fallback --- src/__tests__/config-not-initialized.test.ts | 52 ++++++++- .../e2e/auto-detect-unreadable-scope.test.ts | 107 ++++++++++++++++++ src/config.ts | 17 ++- src/push.ts | 3 +- src/uninstall.ts | 5 +- 5 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/e2e/auto-detect-unreadable-scope.test.ts diff --git a/src/__tests__/config-not-initialized.test.ts b/src/__tests__/config-not-initialized.test.ts index e23eb4c11..933bdc849 100644 --- a/src/__tests__/config-not-initialized.test.ts +++ b/src/__tests__/config-not-initialized.test.ts @@ -9,7 +9,14 @@ vi.mock('../utils/logger.js', () => ({ setStderrOnly: vi.fn(() => false), })); -import { NotInitializedError, detectProjectConfig, findUnreadableProjectConfig, requireInit } from '../config.js'; +import { + autoDetectInit, + NotInitializedError, + detectProjectConfig, + findUnreadableProjectConfig, + requireInit, + UnreadableProjectConfigError, +} from '../config.js'; import { projectDataHome } from '../utils/partition.js'; import { log } from '../utils/logger.js'; @@ -106,6 +113,49 @@ describe('requireInit: missing config versus unreadable config', () => { }); }); +describe('autoDetectInit: unreadable project config', () => { + let sandbox: string; + let home: string; + + beforeEach(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-auto-detect-config-')); + home = path.join(sandbox, 'home'); + fs.mkdirSync(home); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('reports a broken project partition instead of loading a valid user scope', async () => { + const userRepo = path.join(home, '.teamai', 'team-repo'); + fs.mkdirSync(userRepo, { recursive: true }); + fs.writeFileSync(path.join(userRepo, 'teamai.yaml'), 'team: user-team\nrepo: https://example.test/user-team.git\n'); + const userConfigPath = path.join(home, '.teamai', 'config.yaml'); + fs.writeFileSync(userConfigPath, + `repo:\n localPath: ${userRepo}\n remote: https://example.test/user-team.git\nusername: tester\nscope: user\n`); + const userConfigBefore = fs.readFileSync(userConfigPath, 'utf8'); + + const repo = path.join(sandbox, 'project'); + fs.mkdirSync(repo); + for (const args of [['init', '-q'], ['config', 'user.email', 't@e'], ['config', 'user.name', 'T'], ['commit', '--allow-empty', '-q', '-m', 'init']]) { + execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); + } + const partitionConfig = path.join(projectDataHome(realpathSync(repo)), 'config.yaml'); + fs.mkdirSync(path.dirname(partitionConfig), { recursive: true }); + fs.writeFileSync(partitionConfig, 'repo: [unclosed\n'); + + const error = await autoDetectInit(repo).catch((e: unknown) => e); + expect(error).toBeInstanceOf(UnreadableProjectConfigError); + expect(String(error)).toContain(`${partitionConfig}:`); + expect(String(error)).toContain('Fix the file'); + expect(fs.readFileSync(userConfigPath, 'utf8')).toBe(userConfigBefore); + }); +}); + describe('findUnreadableProjectConfig', () => { let dir: string; diff --git a/src/__tests__/e2e/auto-detect-unreadable-scope.test.ts b/src/__tests__/e2e/auto-detect-unreadable-scope.test.ts new file mode 100644 index 000000000..597d14132 --- /dev/null +++ b/src/__tests__/e2e/auto-detect-unreadable-scope.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { projectSlug } from '../../utils/partition.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +interface RunResult { + code: number | null; + output: string; +} + +function runCLI(args: string[], cwd: string, home: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CLI, ...args], { + cwd, + env: { ...process.env, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', NO_COLOR: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, output })); + child.stdin.end(); + }); +} + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'pipe' }); +} + +function setup(): { sandbox: string; home: string; project: string; partitionConfig: string; userConfigPath: string; userRepo: string } { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-unreadable-scope-e2e-'))); + const home = path.join(sandbox, 'home'); + const project = path.join(sandbox, 'project'); + const seed = path.join(sandbox, 'seed'); + const remote = path.join(sandbox, 'team-remote.git'); + const userRepo = path.join(home, '.teamai', 'team-repo'); + fs.mkdirSync(home); + + git(sandbox, 'init', '--bare', remote); + fs.mkdirSync(seed); + git(seed, 'init', '-b', 'main'); + git(seed, 'config', 'user.name', 'TeamAI CI'); + git(seed, 'config', 'user.email', 'ci@teamai.test'); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), YAML.stringify({ team: 'user-team', repo: remote, provider: 'git' })); + git(seed, 'add', '.'); + git(seed, 'commit', '-m', 'fixture'); + git(seed, 'remote', 'add', 'origin', remote); + git(seed, 'push', '-u', 'origin', 'main'); + fs.mkdirSync(path.dirname(userRepo), { recursive: true }); + git(sandbox, 'clone', remote, userRepo); + + const userConfigPath = path.join(home, '.teamai', 'config.yaml'); + fs.writeFileSync(userConfigPath, YAML.stringify({ + repo: { kind: 'git', localPath: userRepo, remote }, + username: 'fixture', + scope: 'user', + })); + + fs.mkdirSync(project); + git(project, 'init', '-b', 'main'); + git(project, 'config', 'user.name', 'TeamAI CI'); + git(project, 'config', 'user.email', 'ci@teamai.test'); + fs.writeFileSync(path.join(project, 'README.md'), '# project\n'); + git(project, 'add', '.'); + git(project, 'commit', '-m', 'fixture'); + + const partitionConfig = path.join(home, '.teamai', 'projects', projectSlug(fs.realpathSync(project)), 'config.yaml'); + fs.mkdirSync(path.dirname(partitionConfig), { recursive: true }); + fs.writeFileSync(partitionConfig, 'repo: [unclosed\n'); + return { sandbox, home, project, partitionConfig, userConfigPath, userRepo }; +} + +let sandbox = ''; +afterEach(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + sandbox = ''; +}); + +describe('unreadable project config scope isolation', () => { + it.each([ + ['push', ['--dry-run', 'push']], + ['status', ['status']], + ['uninstall', ['uninstall', '--dry-run', '--force']], + ])('makes %s fail with the project config path instead of using user scope', async (_name, args) => { + const fixture = setup(); + sandbox = fixture.sandbox; + const userConfigBefore = fs.readFileSync(fixture.userConfigPath, 'utf8'); + const userRepoStatusBefore = execFileSync('git', ['status', '--porcelain'], { cwd: fixture.userRepo, encoding: 'utf8' }); + + const result = await runCLI(args, fixture.project, fixture.home); + + expect(result.code, result.output).toBe(1); + expect(result.output).toContain(fixture.partitionConfig); + expect(result.output).not.toContain('Scope: user'); + expect(fs.readFileSync(fixture.userConfigPath, 'utf8')).toBe(userConfigBefore); + expect(execFileSync('git', ['status', '--porcelain'], { cwd: fixture.userRepo, encoding: 'utf8' })).toBe(userRepoStatusBefore); + }); +}); diff --git a/src/config.ts b/src/config.ts index 5dd1e7ee7..299f2c151 100644 --- a/src/config.ts +++ b/src/config.ts @@ -173,6 +173,13 @@ export function describeUnreadableConfig(problem: string): string { return `${problem.trim().split('\n')[0].trim().replace(/:$/, '')}. ${BROKEN_CONFIG_ADVICE}`; } +export class UnreadableProjectConfigError extends Error { + constructor(problem: string) { + super(describeUnreadableConfig(problem)); + this.name = 'UnreadableProjectConfigError'; + } +} + /** * Require that teamai is initialized (local config exists) */ @@ -640,11 +647,17 @@ export async function requireInitForScope( /** * Auto-detect scope and return { localConfig, teamConfig }. - * If cwd has a project-scope config, uses that; otherwise falls back to user scope. + * If cwd has a project-scope config, uses it. If a project config exists but + * cannot be read, throws instead of falling back to another team's config. + * Otherwise falls back to user scope. * This is the recommended entry point for commands that support both scopes. */ export async function autoDetectInit(cwd?: string, options: LoadOptions = {}): Promise { - const projectConfig = await detectProjectConfig(cwd, undefined, options); + let unreadable: string | undefined; + const projectConfig = await detectProjectConfig(cwd, (configPath, error) => { + unreadable ??= `${configPath}: ${error}`; + }, options); + if (unreadable) throw new UnreadableProjectConfigError(unreadable); if (projectConfig) { const teamConfig = await loadTeamConfig(projectConfig.repo.localPath); if (!teamConfig) return throwTeamConfigMissingOrInvalid(projectConfig.repo.localPath); diff --git a/src/push.ts b/src/push.ts index 25772efa8..181c3bb31 100644 --- a/src/push.ts +++ b/src/push.ts @@ -744,7 +744,8 @@ export async function push( */ result?: { completed: boolean }, ): Promise { - // Auto-detect scope: project scope if cwd has project config, else user scope + // Auto-detect scope: project scope if cwd has project config, else user scope. + // An unreadable project config stops here instead of selecting another team. const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); assertNotReadOnly(localConfig, 'teamai push'); diff --git a/src/uninstall.ts b/src/uninstall.ts index 9a6607865..a4338065c 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { autoDetectInit, saveLocalConfig, saveLocalConfigForScope } from './config.js'; +import { autoDetectInit, saveLocalConfig, saveLocalConfigForScope, UnreadableProjectConfigError } from './config.js'; import { reconcileHooks, hasTeamaiHooks } from './hooks.js'; import { removeOpenClawHooks, @@ -1129,7 +1129,8 @@ export async function uninstall(opts: UninstallOptions): Promise { const result = await autoDetectInit(); localConfig = result.localConfig; teamConfig = result.teamConfig; - } catch { + } catch (e) { + if (e instanceof UnreadableProjectConfigError) throw e; log.warn('teamai configuration not found or invalid'); }