diff --git a/src/__tests__/workspace-settings-injection.test.ts b/src/__tests__/workspace-settings-injection.test.ts new file mode 100644 index 0000000..1aaec76 --- /dev/null +++ b/src/__tests__/workspace-settings-injection.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomBytes } from 'node:crypto'; +import { injectLocalSettings } from '../workspace/manager.js'; + +function createTempWorkspace(): string { + const dir = resolve(tmpdir(), `ws-test-${randomBytes(4).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe('injectLocalSettings', () => { + let workspacePath: string; + + beforeEach(() => { + workspacePath = createTempWorkspace(); + }); + + afterEach(() => { + rmSync(workspacePath, { recursive: true, force: true }); + }); + + it('should create .claude/settings.local.json with permission whitelist', () => { + injectLocalSettings(workspacePath); + + const settingsPath = resolve(workspacePath, '.claude', 'settings.local.json'); + expect(existsSync(settingsPath)).toBe(true); + + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(content.permissions.allow).toContain('Bash(git *)'); + expect(content.permissions.allow).toContain('Bash(npm *)'); + expect(content.permissions.allow).toContain('Bash(npx *)'); + expect(content.permissions.allow).toContain('Bash(gh *)'); + }); + + it('should create .claude directory if it does not exist', () => { + const claudeDir = resolve(workspacePath, '.claude'); + expect(existsSync(claudeDir)).toBe(false); + + injectLocalSettings(workspacePath); + + expect(existsSync(claudeDir)).toBe(true); + expect(existsSync(resolve(claudeDir, 'settings.local.json'))).toBe(true); + }); + + it('should not overwrite existing settings.local.json', () => { + const claudeDir = resolve(workspacePath, '.claude'); + const settingsPath = resolve(claudeDir, 'settings.local.json'); + + mkdirSync(claudeDir, { recursive: true }); + const customSettings = { permissions: { allow: ['Bash(custom *)'] } }; + writeFileSync(settingsPath, JSON.stringify(customSettings)); + + injectLocalSettings(workspacePath); + + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(content.permissions.allow).toEqual(['Bash(custom *)']); + }); + + it('should include all critical bot workflow commands', () => { + injectLocalSettings(workspacePath); + + const settingsPath = resolve(workspacePath, '.claude', 'settings.local.json'); + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + + const required = ['Bash(git *)', 'Bash(npm *)', 'Bash(npx *)', 'Bash(node *)', 'Bash(gh *)']; + for (const pattern of required) { + expect(content.permissions.allow).toContain(pattern); + } + }); +}); diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 85d2c94..2681698 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, realpathSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, realpathSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { basename, resolve } from 'node:path'; import { config } from '../config.js'; @@ -162,6 +162,12 @@ export function setupWorkspace(options: SetupWorkspaceOptions): SetupWorkspaceRe throw new Error(`git clone 失败: ${msg}`); } + // 注入 .claude/settings.local.json — 确保 bot 场景下基础命令不被项目级白名单拦截 + // SDK 的 permissionMode: 'acceptEdits' 只自动批准 Edit/Write,Bash 命令若不在 + // 项目 settings.json 的 permissions.allow 中会触发交互式审批,在无终端的 bot 环境下 + // 直接报 "Stream closed"。注入 local 设置覆盖项目级限制。 + injectLocalSettings(workspacePath); + if (mode === 'writable') { // writable: 设置 remote origin 为原始远程地址 (剥离认证信息) if (repoUrl) { @@ -223,3 +229,48 @@ export function parseRepoNameFromWorkspaceDir(dirName: string): string { // fallback: 原始目录名 return dirName; } + +/** + * 注入 .claude/settings.local.json 覆盖项目级权限限制。 + * 确保 bot 无终端场景下 git/npm/npx 等基础命令不会触发交互式权限审批。 + */ +export function injectLocalSettings(workspacePath: string): void { + const claudeDir = resolve(workspacePath, '.claude'); + const settingsPath = resolve(claudeDir, 'settings.local.json'); + + // 如果已存在则不覆盖(用户或其他流程可能已配置) + if (existsSync(settingsPath)) { + logger.info({ settingsPath }, 'settings.local.json already exists, skipping injection'); + return; + } + + const settings = { + permissions: { + allow: [ + 'Bash(git *)', + 'Bash(npm *)', + 'Bash(npx *)', + 'Bash(node *)', + 'Bash(cat *)', + 'Bash(ls *)', + 'Bash(find *)', + 'Bash(grep *)', + 'Bash(echo *)', + 'Bash(pwd)', + 'Bash(which *)', + 'Bash(gh *)', + ], + }, + }; + + try { + if (!existsSync(claudeDir)) { + mkdirSync(claudeDir, { recursive: true }); + } + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + logger.info({ settingsPath }, 'Injected .claude/settings.local.json for bot permission bypass'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn({ err: msg, settingsPath }, 'Failed to inject settings.local.json, continuing'); + } +}