Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/workspace-settings-injection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
53 changes: 52 additions & 1 deletion src/workspace/manager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
}
}
Loading