From adff6c84723662252703620177e126bd53cb8df1 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Wed, 1 Jul 2026 16:59:48 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20config-loader=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=20getConfigPaths()=20+=20isOwner=20=E9=98=B2=E5=BE=A1=E6=80=A7?= =?UTF-8?q?=E7=A9=BA=E5=80=BC=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getConfigPaths() 返回运行实例真实使用的 agents.json/personas/knowledge 路径, 供自配置工具定位 LIVE 配置文件(而非隔离 clone 里的副本) - isOwner 改为 config.security?.ownerUserId,security 缺失时按'未配 owner'处理, 与'空则全员 owner'语义一致,避免调用方在 config 不全时抛错 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agent/config-loader.ts | 17 +++++++++++++++++ src/utils/security.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/agent/config-loader.ts b/src/agent/config-loader.ts index 7ab06a93..45e15b92 100644 --- a/src/agent/config-loader.ts +++ b/src/agent/config-loader.ts @@ -384,6 +384,23 @@ export function startConfigWatcher(): void { logger.info({ path: configFilePath }, 'Watching agent config file for hot reload'); } +/** + * 获取运行实例真实使用的配置路径(config-admin 自配置工具用此定位 LIVE 文件)。 + * + * 返回启动时 loadAgentConfig 解析并缓存的单例路径: + * - configFile: agents.json 的绝对路径(fs.watchFile 监听的正是此文件) + * - configDir: 配置文件所在目录(personas 相对此解析) + * - knowledgeDir: 知识文件根目录(可能 undefined) + * + * 单例未初始化时(如无配置文件的兜底场景 / 测试),fallback 到 + * loadAgentConfig 的默认约定:process.cwd()/config/agents.json。 + */ +export function getConfigPaths(): { configFile: string; configDir: string; knowledgeDir?: string } { + const configFile = configFilePath ?? resolve(process.cwd(), 'config', 'agents.json'); + const configDir = configFileDir ?? dirname(configFile); + return { configFile, configDir, knowledgeDir: knowledgeDirPath }; +} + /** 获取指定 agent 的配置文件路径信息(用于注入 prompt) */ export function getAgentConfigInfo(agentId: string): { configFile: string; knowledgeDir?: string; personaFile?: string; knowledgeFiles: string[] } | undefined { if (!configFilePath) return undefined; diff --git a/src/utils/security.ts b/src/utils/security.ts index 7523395b..bbaf1406 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -19,7 +19,7 @@ export function isUserAllowed(userId: string): boolean { * 未配置 OWNER_USER_ID 时所有人都是 owner(向后兼容) */ export function isOwner(userId: string): boolean { - const { ownerUserId } = config.security; + const ownerUserId = config.security?.ownerUserId; if (!ownerUserId) return true; return userId === ownerUserId; } From 1eb99ccd2b3ae20ff82f7dee77a98bf5998c1965 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Wed, 1 Jul 2026 17:00:01 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20self-config=20?= =?UTF-8?q?=E8=87=AA=E9=85=8D=E7=BD=AE=20MCP=20=E5=B7=A5=E5=85=B7(?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95+=E5=86=99=E5=89=8D=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C+=E8=87=AA=E5=8A=A8=E5=A4=87=E4=BB=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manage_config 工具让 anycode 在 owner 授权下安全编辑自己的运行配置: - 白名单: agents.json / personas/*.md / knowledge/*.md / .env / ~/.claude.json 的 MCP - 防路径穿越、拒 .example 模板、拒非 .md - 写前校验: agents.json 走 Zod schema,.env 走 KEY=VALUE 行校验,非法内容不落盘 - 写前自动备份到 data/config-backups/,可回滚 - agents.json 写后立即 reloadAgentConfig,热加载即时生效;.env/MCP 提示需重启 - set_mcp/remove_mcp 浅合并 mcpServers,保留其它 server 与顶层其它键 工具运行在服务进程内(cwd=部署目录),命中的是运行实例真实读取的 LIVE 配置。 附 29 个单测覆盖白名单/校验/备份/写入/reload/MCP 合并。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config-admin/__tests__/manager.test.ts | 290 +++++++++++++++++ src/config-admin/manager.ts | 347 +++++++++++++++++++++ src/config-admin/tool.ts | 175 +++++++++++ 3 files changed, 812 insertions(+) create mode 100644 src/config-admin/__tests__/manager.test.ts create mode 100644 src/config-admin/manager.ts create mode 100644 src/config-admin/tool.ts diff --git a/src/config-admin/__tests__/manager.test.ts b/src/config-admin/__tests__/manager.test.ts new file mode 100644 index 00000000..cc43919b --- /dev/null +++ b/src/config-admin/__tests__/manager.test.ts @@ -0,0 +1,290 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; +import { + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + readdirSync, +} from 'node:fs'; + +// 路径在 hoist 阶段以字符串形式确定(不碰 fs),mock 工厂引用它们。 +const H = vi.hoisted(() => { + const ROOT = '/tmp/cfgadmin-test-' + Math.random().toString(36).slice(2); + return { + ROOT, + CONFIG_DIR: ROOT + '/config', + PERSONAS_DIR: ROOT + '/config/personas', + KNOWLEDGE_DIR: ROOT + '/config/knowledge', + HOME: ROOT + '/home', + reloadMock: vi.fn(() => ({ loaded: true })), + }; +}); + +vi.mock('../../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('../../agent/config-loader.js', () => ({ + getConfigPaths: () => ({ + configFile: H.CONFIG_DIR + '/agents.json', + configDir: H.CONFIG_DIR, + knowledgeDir: H.KNOWLEDGE_DIR, + }), + reloadAgentConfig: H.reloadMock, +})); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => H.HOME }; +}); + +import { + resolveTarget, + validateContent, + readConfig, + writeConfig, + listTargets, + backupFile, + setMcpServer, + removeMcpServer, + getMcpServer, + listMcpServerNames, + claudeJsonPath, +} from '../manager.js'; + +const VALID_AGENTS = JSON.stringify({ + knowledgeDir: './knowledge/', + defaults: { model: 'claude-opus-4-8' }, + agents: [{ id: 'pm', displayName: '土豆儿', model: 'claude-opus-4-8' }], +}, null, 2); + +const BACKUP_DIR = H.ROOT + '/data/config-backups'; + +beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue(H.ROOT); + H.reloadMock.mockClear(); + H.reloadMock.mockReturnValue({ loaded: true }); + // 重建干净的临时配置树 + rmSync(H.ROOT, { recursive: true, force: true }); + mkdirSync(H.PERSONAS_DIR, { recursive: true }); + mkdirSync(H.KNOWLEDGE_DIR, { recursive: true }); + mkdirSync(H.HOME, { recursive: true }); + writeFileSync(H.CONFIG_DIR + '/agents.json', VALID_AGENTS); + writeFileSync(H.PERSONAS_DIR + '/pm.md', '# 土豆儿人设\n'); + writeFileSync(H.PERSONAS_DIR + '/assistant.example.md', '# 模板\n'); + writeFileSync(H.KNOWLEDGE_DIR + '/team.md', '# team\n'); + writeFileSync(H.KNOWLEDGE_DIR + '/team.example.md', '# team 模板\n'); + writeFileSync(H.ROOT + '/.env', 'FEISHU_APP_ID=abc\n# 注释\nCLAUDE_MODEL=claude-opus-4-8\n'); + writeFileSync(H.HOME + '/.claude.json', JSON.stringify({ + projects: { '/foo': { history: [] } }, + mcpServers: { existing: { type: 'http', url: 'https://a' } }, + })); +}); + +afterAll(() => { + rmSync(H.ROOT, { recursive: true, force: true }); +}); + +describe('resolveTarget — 白名单与安全', () => { + it('接受 agents.json(热加载 + agents 校验)', () => { + const r = resolveTarget('agents.json'); + expect(r).toMatchObject({ label: 'agents.json', effect: 'hot-reload', validate: 'agents' }); + expect(r!.absPath).toBe(H.CONFIG_DIR + '/agents.json'); + }); + + it('接受 ./agents.json(去除 ./ 前缀)', () => { + expect(resolveTarget('./agents.json')?.label).toBe('agents.json'); + }); + + it('接受 .env(需重启 + env 校验)', () => { + const r = resolveTarget('.env'); + expect(r).toMatchObject({ effect: 'restart', validate: 'env' }); + expect(r!.absPath).toBe(H.ROOT + '/.env'); + }); + + it('接受 personas/*.md 与 knowledge/*.md(热加载)', () => { + expect(resolveTarget('personas/pm.md')).toMatchObject({ effect: 'hot-reload', validate: 'none' }); + expect(resolveTarget('knowledge/team.md')).toMatchObject({ effect: 'hot-reload', validate: 'none' }); + }); + + it('拒绝路径穿越', () => { + const reason = {}; + expect(resolveTarget('personas/../../../etc/passwd', reason)).toBeNull(); + expect(reason.msg).toMatch(/越出|只允许/); + }); + + it('拒绝 .example 模板', () => { + const reason = {}; + expect(resolveTarget('personas/assistant.example.md', reason)).toBeNull(); + expect(reason.msg).toMatch(/example/); + }); + + it('拒绝非 .md 文件', () => { + const reason = {}; + expect(resolveTarget('personas/pm.txt', reason)).toBeNull(); + expect(reason.msg).toMatch(/\.md/); + }); + + it('拒绝白名单外目标', () => { + const reason = {}; + expect(resolveTarget('package.json', reason)).toBeNull(); + expect(reason.msg).toMatch(/白名单/); + }); +}); + +describe('validateContent', () => { + it('agents.json: 合法内容通过', () => { + expect(validateContent('agents', VALID_AGENTS).ok).toBe(true); + }); + + it('agents.json: 非法 JSON 被拒', () => { + const r = validateContent('agents', '{ not json'); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/JSON 解析失败/); + }); + + it('agents.json: schema 不合法被拒(agents 为空数组)', () => { + const r = validateContent('agents', JSON.stringify({ agents: [] })); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/schema 校验失败/); + }); + + it('.env: 合法 KEY=VALUE 通过(含注释与空行)', () => { + expect(validateContent('env', 'A=1\n\n# c\nexport B=2\n').ok).toBe(true); + }); + + it('.env: 非 KEY=VALUE 行被拒', () => { + const r = validateContent('env', 'A=1\nthis is not valid\n'); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/第 2 行/); + }); + + it('none: 任意内容通过', () => { + expect(validateContent('none', '随便什么').ok).toBe(true); + }); +}); + +describe('writeConfig', () => { + it('写 personas:新文件不备份,写入成功,不触发 reload', () => { + const out = writeConfig('personas/new.md', '# 新人设\n'); + expect(out.ok).toBe(true); + expect(out.result.backup).toBeUndefined(); + expect(readFileSync(H.PERSONAS_DIR + '/new.md', 'utf-8')).toBe('# 新人设\n'); + expect(H.reloadMock).not.toHaveBeenCalled(); + }); + + it('写 agents.json:备份旧文件 + 落盘 + 触发 reload', () => { + const next = VALID_AGENTS.replace('claude-opus-4-8', 'claude-sonnet-4-6'); + const out = writeConfig('agents.json', next); + expect(out.ok).toBe(true); + expect(out.result.effect).toBe('hot-reload'); + expect(out.result.reloaded).toBe(true); + expect(out.result.backup).toBeTruthy(); + expect(existsSync(out.result.backup)).toBe(true); + expect(readFileSync(H.CONFIG_DIR + '/agents.json', 'utf-8')).toContain('claude-sonnet-4-6'); + expect(H.reloadMock).toHaveBeenCalledTimes(1); + }); + + it('agents.json 校验失败:不备份、不落盘、不 reload', () => { + const before = readFileSync(H.CONFIG_DIR + '/agents.json', 'utf-8'); + const out = writeConfig('agents.json', '{ bad json'); + expect(out.ok).toBe(false); + expect(readFileSync(H.CONFIG_DIR + '/agents.json', 'utf-8')).toBe(before); + expect(existsSync(BACKUP_DIR)).toBe(false); + expect(H.reloadMock).not.toHaveBeenCalled(); + }); + + it('reload 失败时如实回报 reloaded=false + reloadError', () => { + H.reloadMock.mockReturnValue({ loaded: false, error: '重复的 agent id' }); + const out = writeConfig('agents.json', VALID_AGENTS); + expect(out.ok).toBe(true); + expect(out.result.reloaded).toBe(false); + expect(out.result.reloadError).toMatch(/重复/); + }); + + it('.env:备份 + 落盘,effect=restart', () => { + const out = writeConfig('.env', 'A=1\nB=2\n'); + expect(out.ok).toBe(true); + expect(out.result.effect).toBe('restart'); + expect(out.result.backup).toBeTruthy(); + expect(readFileSync(H.ROOT + '/.env', 'utf-8')).toBe('A=1\nB=2\n'); + }); + + it('拒绝白名单外写入', () => { + const out = writeConfig('../../evil.sh', 'rm -rf /'); + expect(out.ok).toBe(false); + }); +}); + +describe('readConfig', () => { + it('读取白名单文件', () => { + expect(readConfig('personas/pm.md')).toContain('土豆儿人设'); + }); + it('非法目标返回 null 并给出原因', () => { + const reason = {}; + expect(readConfig('/etc/passwd', reason)).toBeNull(); + expect(reason.msg).toBeTruthy(); + }); +}); + +describe('listTargets', () => { + it('列出 agents.json / 现有人设(排除 example) / 知识 / .env / mcp', () => { + const labels = listTargets().map((t) => t.label); + expect(labels).toContain('agents.json'); + expect(labels).toContain('personas/pm.md'); + expect(labels).not.toContain('personas/assistant.example.md'); + expect(labels).toContain('knowledge/team.md'); + expect(labels).not.toContain('knowledge/team.example.md'); + expect(labels).toContain('.env'); + expect(labels).toContain('mcp:existing'); + }); +}); + +describe('MCP server 管理(浅合并保留其它键)', () => { + it('set_mcp 新增 server,保留已有 server 与顶层其它键', () => { + const out = setMcpServer('newone', { type: 'http', url: 'https://b' }); + expect(out.ok).toBe(true); + expect(out.result.effect).toBe('restart'); + const json = JSON.parse(readFileSync(claudeJsonPath(), 'utf-8')); + expect(json.mcpServers.newone).toEqual({ type: 'http', url: 'https://b' }); + expect(json.mcpServers.existing).toBeTruthy(); // 未丢失其它 server + expect(json.projects).toBeTruthy(); // 未丢失顶层其它键 + }); + + it('set_mcp 更新已有 server', () => { + setMcpServer('existing', { type: 'http', url: 'https://changed' }); + expect(getMcpServer('existing')).toMatchObject({ url: 'https://changed' }); + }); + + it('set_mcp 拒绝非对象 config', () => { + expect(setMcpServer('x', 'not-an-object').ok).toBe(false); + expect(setMcpServer('x', ['a']).ok).toBe(false); + expect(setMcpServer('', { a: 1 }).ok).toBe(false); + }); + + it('remove_mcp 删除 server;删不存在的报错', () => { + expect(listMcpServerNames()).toContain('existing'); + const out = removeMcpServer('existing'); + expect(out.ok).toBe(true); + expect(listMcpServerNames()).not.toContain('existing'); + expect(removeMcpServer('nope').ok).toBe(false); + }); + + it('set_mcp 写前备份原 ~/.claude.json', () => { + const out = setMcpServer('backuptest', { type: 'http', url: 'https://c' }); + expect(out.result.backup).toBeTruthy(); + expect(existsSync(out.result.backup)).toBe(true); + }); +}); + +describe('backupFile', () => { + it('文件存在则备份到 data/config-backups/,不存在返回 undefined', () => { + expect(backupFile(H.ROOT + '/nope.txt')).toBeUndefined(); + const b = backupFile(H.CONFIG_DIR + '/agents.json'); + expect(b).toBeTruthy(); + expect(b.startsWith(BACKUP_DIR)).toBe(true); + expect(readdirSync(BACKUP_DIR).length).toBeGreaterThan(0); + }); +}); diff --git a/src/config-admin/manager.ts b/src/config-admin/manager.ts new file mode 100644 index 00000000..b5078540 --- /dev/null +++ b/src/config-admin/manager.ts @@ -0,0 +1,347 @@ +/** + * 自配置管理器 — 纯逻辑层(白名单解析 + 写前校验 + 自动备份 + 落盘) + * + * 让 anycode 在 owner 授权下安全地编辑「自己的」配置文件: + * - 只允许改白名单内的目标(agents.json / personas / knowledge / .env / MCP) + * - 写前按类型校验(agents.json 走 Zod,.env 走 KEY=VALUE 行校验,.md 不校验) + * - 写前自动备份到 data/config-backups/,支持回滚 + * - agents.json 写后立即 reload,热加载即时生效 + * + * 关键:本模块运行在服务进程内(cwd = 部署目录),因此定位到的是运行实例 + * 真实读取的 LIVE 配置文件,而非隔离工作区里的副本。owner 门禁在 tool 层做。 + */ +import { + existsSync, + readFileSync, + writeFileSync, + copyFileSync, + mkdirSync, + readdirSync, +} from 'node:fs'; +import { resolve, dirname, join, basename } from 'node:path'; +import { homedir } from 'node:os'; +import { logger } from '../utils/logger.js'; +import { getConfigPaths, reloadAgentConfig } from '../agent/config-loader.js'; +import { AgentConfigFileSchema } from '../agent/config-schema.js'; + +// ─── 类型 ────────────────────────────────────────────────── + +/** 生效方式:热加载即时生效 / 需重启服务 */ +export type EffectMode = 'hot-reload' | 'restart'; + +/** 校验类型 */ +type ValidateKind = 'agents' | 'env' | 'none'; + +/** 已解析的目标 */ +interface ResolvedTarget { + /** 逻辑标签(回显给用户) */ + label: string; + /** LIVE 文件绝对路径 */ + absPath: string; + /** 生效方式 */ + effect: EffectMode; + /** 写前校验类型 */ + validate: ValidateKind; +} + +/** 校验结果 */ +export interface ValidationResult { + ok: boolean; + error?: string; +} + +/** 写入结果 */ +export interface WriteResult { + label: string; + absPath: string; + effect: EffectMode; + /** 备份文件路径(原文件不存在则 undefined) */ + backup?: string; + /** agents.json 写后的 reload 结果 */ + reloaded?: boolean; + reloadError?: string; +} + +// ─── 白名单解析 ──────────────────────────────────────────── + +/** 判断 abs 是否严格位于 base 目录内(防路径穿越) */ +function withinDir(abs: string, base: string): boolean { + return abs.startsWith(base + '/'); +} + +/** 是否是 .example 模板文件(不可编辑) */ +function isExampleFile(p: string): boolean { + return basename(p).includes('.example.'); +} + +/** .env 文件的 LIVE 路径(dotenv 从服务进程 cwd 加载) */ +function envPath(): string { + return resolve(process.cwd(), '.env'); +} + +/** ~/.claude.json 的 LIVE 路径(Claude Code CLI 的 MCP 配置) */ +export function claudeJsonPath(): string { + return resolve(homedir(), '.claude.json'); +} + +/** + * 把逻辑 target 字符串解析为白名单内的具体文件。 + * 返回 null 表示不在白名单 / 路径穿越 / .example / 非 .md。 + * 通过 reason 输出参数返回拒绝原因。 + */ +export function resolveTarget(target: string, reason?: { msg?: string }): ResolvedTarget | null { + const t = target.trim().replace(/^\.\//, ''); + const paths = getConfigPaths(); + + if (t === 'agents.json') { + return { label: 'agents.json', absPath: paths.configFile, effect: 'hot-reload', validate: 'agents' }; + } + if (t === '.env') { + return { label: '.env', absPath: envPath(), effect: 'restart', validate: 'env' }; + } + + // personas/.md + if (t.startsWith('personas/')) { + const base = resolve(paths.configDir, 'personas'); + const abs = resolve(base, t.slice('personas/'.length)); + if (!withinDir(abs, base)) { if (reason) reason.msg = '路径越出 personas/ 目录'; return null; } + if (!abs.endsWith('.md')) { if (reason) reason.msg = '只允许编辑 .md 文件'; return null; } + if (isExampleFile(abs)) { if (reason) reason.msg = '.example 是模板,不可编辑'; return null; } + return { label: t, absPath: abs, effect: 'hot-reload', validate: 'none' }; + } + + // knowledge/.md + if (t.startsWith('knowledge/')) { + const base = paths.knowledgeDir ? resolve(paths.knowledgeDir) : resolve(paths.configDir, 'knowledge'); + const abs = resolve(base, t.slice('knowledge/'.length)); + if (!withinDir(abs, base)) { if (reason) reason.msg = '路径越出 knowledge/ 目录'; return null; } + if (!abs.endsWith('.md')) { if (reason) reason.msg = '只允许编辑 .md 文件'; return null; } + if (isExampleFile(abs)) { if (reason) reason.msg = '.example 是模板,不可编辑'; return null; } + return { label: t, absPath: abs, effect: 'hot-reload', validate: 'none' }; + } + + if (reason) reason.msg = `不在白名单内: ${target}(允许: agents.json / personas/*.md / knowledge/*.md / .env)`; + return null; +} + +// ─── 校验 ────────────────────────────────────────────────── + +/** .env 行格式:允许空行、# 注释、KEY=... 或 export KEY=... */ +const ENV_LINE_RE = /^(export\s+)?[A-Za-z_][A-Za-z0-9_]*=/; + +/** 按目标类型校验待写入内容。校验失败时不落盘。 */ +export function validateContent(kind: ValidateKind, content: string): ValidationResult { + if (kind === 'none') return { ok: true }; + + if (kind === 'agents') { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (err) { + return { ok: false, error: `JSON 解析失败: ${(err as Error).message}` }; + } + const result = AgentConfigFileSchema.safeParse(parsed); + if (!result.success) { + const msg = result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '); + return { ok: false, error: `agents.json schema 校验失败: ${msg}` }; + } + return { ok: true }; + } + + if (kind === 'env') { + const bad: string[] = []; + content.split('\n').forEach((raw, idx) => { + const line = raw.trim(); + if (line === '' || line.startsWith('#')) return; + if (!ENV_LINE_RE.test(line)) bad.push(`第 ${idx + 1} 行: ${raw.slice(0, 60)}`); + }); + if (bad.length > 0) { + return { ok: false, error: `.env 存在非 KEY=VALUE 行(不支持多行值):\n${bad.join('\n')}` }; + } + return { ok: true }; + } + + return { ok: true }; +} + +// ─── 备份 ────────────────────────────────────────────────── + +/** 备份目录(部署目录下 data/config-backups/) */ +function backupDir(): string { + return resolve(process.cwd(), 'data', 'config-backups'); +} + +/** 备份原文件到 data/config-backups/。原文件不存在(新建)返回 undefined。 */ +export function backupFile(absPath: string): string | undefined { + if (!existsSync(absPath)) return undefined; + const dir = backupDir(); + mkdirSync(dir, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const backup = join(dir, `${basename(absPath)}.${ts}.bak`); + copyFileSync(absPath, backup); + logger.info({ absPath, backup }, 'config-admin: backup created'); + return backup; +} + +// ─── 读 ──────────────────────────────────────────────────── + +/** 列出的目标信息 */ +export interface TargetInfo { + label: string; + effect: EffectMode; + exists: boolean; + kind: 'file' | 'mcp'; +} + +/** 列出所有可编辑目标(含现有 personas/knowledge 文件 + MCP servers) */ +export function listTargets(): TargetInfo[] { + const paths = getConfigPaths(); + const out: TargetInfo[] = []; + + out.push({ label: 'agents.json', effect: 'hot-reload', exists: existsSync(paths.configFile), kind: 'file' }); + + const personasDir = resolve(paths.configDir, 'personas'); + for (const f of listMdFiles(personasDir)) { + out.push({ label: `personas/${f}`, effect: 'hot-reload', exists: true, kind: 'file' }); + } + + const knowledgeDir = paths.knowledgeDir ? resolve(paths.knowledgeDir) : resolve(paths.configDir, 'knowledge'); + for (const f of listMdFiles(knowledgeDir)) { + out.push({ label: `knowledge/${f}`, effect: 'hot-reload', exists: true, kind: 'file' }); + } + + out.push({ label: '.env', effect: 'restart', exists: existsSync(envPath()), kind: 'file' }); + + for (const name of listMcpServerNames()) { + out.push({ label: `mcp:${name}`, effect: 'restart', exists: true, kind: 'mcp' }); + } + + return out; +} + +/** 列出目录下非 .example 的 .md 文件名 */ +function listMdFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + try { + return readdirSync(dir).filter((f) => f.endsWith('.md') && !f.includes('.example.')).sort(); + } catch { + return []; + } +} + +/** 读取白名单文件内容。返回 null 表示 target 非法(reason 输出原因)。 */ +export function readConfig(target: string, reason?: { msg?: string }): string | null { + const r = resolveTarget(target, reason); + if (!r) return null; + if (!existsSync(r.absPath)) { + if (reason) reason.msg = `文件不存在: ${r.label}`; + return null; + } + return readFileSync(r.absPath, 'utf-8'); +} + +// ─── 写 ──────────────────────────────────────────────────── + +/** 写入结果或错误 */ +export type WriteOutcome = { ok: true; result: WriteResult } | { ok: false; error: string }; + +/** + * 校验 → 备份 → 写入白名单文件。agents.json 写后立即 reload。 + * 任一步失败均不落盘(校验失败直接返回,不备份不写)。 + */ +export function writeConfig(target: string, content: string): WriteOutcome { + const reason: { msg?: string } = {}; + const r = resolveTarget(target, reason); + if (!r) return { ok: false, error: reason.msg ?? '非法目标' }; + + const validation = validateContent(r.validate, content); + if (!validation.ok) return { ok: false, error: validation.error ?? '校验失败' }; + + const backup = backupFile(r.absPath); + mkdirSync(dirname(r.absPath), { recursive: true }); + writeFileSync(r.absPath, content, 'utf-8'); + logger.info({ label: r.label, absPath: r.absPath }, 'config-admin: file written'); + + const result: WriteResult = { label: r.label, absPath: r.absPath, effect: r.effect, backup }; + + if (r.validate === 'agents') { + const reload = reloadAgentConfig(); + result.reloaded = reload.loaded; + if (!reload.loaded) result.reloadError = reload.error; + } + + return { ok: true, result }; +} + +// ─── MCP servers(~/.claude.json 的 mcpServers 段) ────────── + +/** 读取并解析 ~/.claude.json。不存在返回空对象。 */ +function readClaudeJson(): { data: Record; error?: string } { + const p = claudeJsonPath(); + if (!existsSync(p)) return { data: {} }; + try { + return { data: JSON.parse(readFileSync(p, 'utf-8')) as Record }; + } catch (err) { + return { data: {}, error: `~/.claude.json 解析失败: ${(err as Error).message}` }; + } +} + +/** 列出 ~/.claude.json 中已配置的 MCP server 名称 */ +export function listMcpServerNames(): string[] { + const { data } = readClaudeJson(); + const servers = data.mcpServers; + if (servers && typeof servers === 'object') return Object.keys(servers as Record).sort(); + return []; +} + +/** 读取单个 MCP server 配置(不存在返回 null) */ +export function getMcpServer(name: string): unknown | null { + const { data } = readClaudeJson(); + const servers = data.mcpServers as Record | undefined; + return servers && name in servers ? servers[name] : null; +} + +/** + * 新增/更新单个 MCP server(浅合并到 mcpServers,保留其它 server 与顶层其它键)。 + * 写前备份整份 ~/.claude.json。需重启会话才能生效。 + */ +export function setMcpServer(name: string, serverConfig: unknown): WriteOutcome { + if (!name || !name.trim()) return { ok: false, error: 'mcp_name 不能为空' }; + if (serverConfig === null || typeof serverConfig !== 'object' || Array.isArray(serverConfig)) { + return { ok: false, error: 'mcp_config 必须是 JSON 对象(如 { "type":"http", "url":"...", "headers":{...} } 或 { "command":"...", "args":[...] })' }; + } + const { data, error } = readClaudeJson(); + if (error) return { ok: false, error }; + + const p = claudeJsonPath(); + const backup = backupFile(p); + + const servers = (data.mcpServers && typeof data.mcpServers === 'object' + ? data.mcpServers + : {}) as Record; + servers[name] = serverConfig; + data.mcpServers = servers; + + writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); + logger.info({ name, backup }, 'config-admin: mcp server set'); + return { ok: true, result: { label: `mcp:${name}`, absPath: p, effect: 'restart', backup } }; +} + +/** 删除单个 MCP server。写前备份。需重启会话才能生效。 */ +export function removeMcpServer(name: string): WriteOutcome { + if (!name || !name.trim()) return { ok: false, error: 'mcp_name 不能为空' }; + const { data, error } = readClaudeJson(); + if (error) return { ok: false, error }; + + const servers = data.mcpServers as Record | undefined; + if (!servers || !(name in servers)) return { ok: false, error: `MCP server 不存在: ${name}` }; + + const p = claudeJsonPath(); + const backup = backupFile(p); + delete servers[name]; + data.mcpServers = servers; + + writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); + logger.info({ name, backup }, 'config-admin: mcp server removed'); + return { ok: true, result: { label: `mcp:${name}`, absPath: p, effect: 'restart', backup } }; +} diff --git a/src/config-admin/tool.ts b/src/config-admin/tool.ts new file mode 100644 index 00000000..a637a98b --- /dev/null +++ b/src/config-admin/tool.ts @@ -0,0 +1,175 @@ +/** + * 自配置 MCP 工具 — manage_config + * + * 让 anycode 在 owner 授权下编辑「自己的」运行配置:agents.json(模型/工具策略/ + * bindings/预算)、人设、知识、.env、以及 ~/.claude.json 里的 MCP server。 + * + * 安全边界: + * - owner 门禁:整只工具仅 OWNER_USER_ID 可用(配置含 appSecret / API key 等机密) + * - 白名单:只能改固定几类配置文件,杜绝路径穿越与源码改动 + * - 写前校验:agents.json 走 Zod,.env 走行格式校验,非法内容不落盘 + * - 自动备份:每次写入前备份到 data/config-backups/,可回滚 + * + * 生效方式:agents.json / 人设 / 知识 热加载即时生效;.env / MCP 需重启服务。 + */ +import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { logger } from '../utils/logger.js'; +import { isOwner } from '../utils/security.js'; +import { + listTargets, + readConfig, + writeConfig, + getMcpServer, + setMcpServer, + removeMcpServer, + type WriteResult, +} from './manager.js'; + +function text(content: string) { + return { content: [{ type: 'text' as const, text: content }] }; +} +function error(message: string) { + return { content: [{ type: 'text' as const, text: `错误: ${message}` }], isError: true }; +} + +/** 写入成功后的统一回执(含生效提示) */ +function writeReceipt(r: WriteResult): string { + const lines = [`✅ 已写入 ${r.label}`, ` 路径: ${r.absPath}`]; + if (r.backup) lines.push(` 备份: ${r.backup}`); + if (r.effect === 'hot-reload') { + if (r.reloaded === false) { + lines.push(`⚠️ 已落盘但热加载失败(已保留旧配置生效): ${r.reloadError ?? '未知错误'}`); + lines.push(' 请修正后重新写入。'); + } else { + lines.push('🔄 热加载已生效,无需重启。'); + } + } else { + lines.push('⚠️ 此改动需重启服务才能生效(.env / MCP 仅在启动时加载)。'); + lines.push(' 完成后我会在最后一步执行 pm2 restart feishu-claude 让它生效。'); + } + return lines.join('\n'); +} + +/** + * 创建自配置 MCP 服务器。通过闭包绑定当前 userId 做 owner 门禁。 + * 调用方(executor)应仅在 isOwner(userId) 时创建本服务器。 + */ +export function createConfigAdminMcpServer(params: { userId: string }) { + const { userId } = params; + + return createSdkMcpServer({ + name: 'self-config', + version: '1.0.0', + tools: [ + tool( + 'manage_config', + [ + '编辑 anycode 服务自身的运行配置(自改自)。改的是运行实例真实读取的 LIVE 配置。', + '', + '⚠️ 仅管理员(owner)可用。', + '', + 'Actions:', + '- list: 列出所有可编辑目标(配置文件 + 已配置的 MCP server)', + '- read: 读取某个配置文件内容(target 必填)', + '- write: 覆盖写入某个配置文件(target + content 必填,先读后改再整份写回)', + '- set_mcp: 新增/更新一个 MCP server(mcp_name + mcp_config 必填)', + '- remove_mcp: 删除一个 MCP server(mcp_name 必填)', + '', + '可写 target(白名单):', + '- agents.json → 模型/工具策略/bindings/预算等(写前 Zod 校验,热加载即时生效)', + '- personas/.md → 人设提示词(热加载,每次 query 重读)', + '- knowledge/.md→ 知识文件(热加载,每次 query 重读)', + '- .env → 环境变量(KEY=VALUE,⚠️ 需重启生效)', + '', + 'MCP server(~/.claude.json 的 mcpServers 段,⚠️ 需重启生效):', + '- set_mcp 只改指定 server,保留其它 server 与顶层其它键;mcp_config 为该 server 的 JSON 对象', + ' (如 {"type":"http","url":"https://...","headers":{"Authorization":"Bearer xxx"}})', + '', + '用法建议:write 前先 read 拿到当前完整内容,本地改好再整份写回(write 是覆盖语义)。', + '所有写入自动备份到 data/config-backups/,可回滚。', + ].join('\n'), + { + action: z.enum(['list', 'read', 'write', 'set_mcp', 'remove_mcp']).describe('操作类型'), + target: z.string().optional().describe('配置文件目标(read/write 必填): agents.json | personas/.md | knowledge/.md | .env'), + content: z.string().optional().describe('write 时的完整新内容(覆盖写入)'), + mcp_name: z.string().optional().describe('MCP server 名称(set_mcp/remove_mcp 必填)'), + mcp_config: z.record(z.string(), z.unknown()).optional().describe('MCP server 配置 JSON 对象(set_mcp 必填)'), + }, + async (args) => { + // ── owner 门禁(防御性二次校验,创建时已 gate) ── + if (!isOwner(userId)) { + logger.warn({ userId, action: args.action }, 'manage_config denied — not owner'); + return error('仅管理员可以编辑服务配置。'); + } + + logger.info({ action: args.action, target: args.target, mcp_name: args.mcp_name }, 'manage_config invoked'); + + try { + switch (args.action) { + case 'list': { + const targets = listTargets(); + const files = targets.filter((t) => t.kind === 'file'); + const mcps = targets.filter((t) => t.kind === 'mcp'); + const fmt = (label: string, effect: string, exists: boolean) => + `${exists ? '•' : '○'} ${label} [${effect === 'hot-reload' ? '热加载' : '需重启'}]${exists ? '' : ' (不存在)'}`; + const lines = ['可编辑配置文件:']; + for (const t of files) lines.push(' ' + fmt(t.label, t.effect, t.exists)); + lines.push('', `已配置 MCP server (${mcps.length}):`); + if (mcps.length === 0) lines.push(' (无)'); + for (const t of mcps) lines.push(' ' + fmt(t.label, t.effect, t.exists)); + return text(lines.join('\n')); + } + + case 'read': { + if (!args.target) return error('read 需要 target 参数'); + const reason: { msg?: string } = {}; + const content = readConfig(args.target, reason); + if (content === null) return error(reason.msg ?? '无法读取'); + return text(`# ${args.target}\n\n${content}`); + } + + case 'write': { + if (!args.target) return error('write 需要 target 参数'); + if (typeof args.content !== 'string') return error('write 需要 content 参数(完整新内容)'); + const outcome = writeConfig(args.target, args.content); + if (!outcome.ok) return error(outcome.error); + return text(writeReceipt(outcome.result)); + } + + case 'set_mcp': { + if (!args.mcp_name) return error('set_mcp 需要 mcp_name 参数'); + if (!args.mcp_config) return error('set_mcp 需要 mcp_config 参数(该 server 的 JSON 对象)'); + const outcome = setMcpServer(args.mcp_name, args.mcp_config); + if (!outcome.ok) return error(outcome.error); + return text(writeReceipt(outcome.result)); + } + + case 'remove_mcp': { + if (!args.mcp_name) return error('remove_mcp 需要 mcp_name 参数'); + if (!getMcpServer(args.mcp_name)) return error(`MCP server 不存在: ${args.mcp_name}`); + const outcome = removeMcpServer(args.mcp_name); + if (!outcome.ok) return error(outcome.error); + return text(writeReceipt(outcome.result)); + } + + default: + return error(`未知操作: ${args.action}`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ err: msg, action: args.action }, 'manage_config failed'); + return error(msg); + } + }, + { + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, + }, + }, + ), + ], + }); +} From 5119f416df3770cc501cccd1e2aad383b927679f Mon Sep 17 00:00:00 2001 From: lishuceo Date: Wed, 1 Jul 2026 17:00:09 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20executor=20=E6=8E=A5=E5=85=A5=20sel?= =?UTF-8?q?f-config=20=E5=B7=A5=E5=85=B7(owner=20=E9=97=A8=E7=A6=81=20+=20?= =?UTF-8?q?readonly=20=E6=94=BE=E8=A1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按 sessionKey 解析 userId,仅当 isOwner(userId) 时创建 self-config MCP server (非 owner 完全不可见,fail-closed) - 在 readonly MCP 放行块加入 mcp__self-config__ 分支,否则土豆儿(pm, readonly) 调不了自配置工具 - 附 executor owner 门禁正/反用例 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/claude/__tests__/executor.test.ts | 25 +++++++++++++++++++++++++ src/claude/executor.ts | 16 ++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts index 789d0e36..f3f877b2 100644 --- a/src/claude/__tests__/executor.test.ts +++ b/src/claude/__tests__/executor.test.ts @@ -18,6 +18,9 @@ vi.mock('../../config.js', () => ({ feishu: { tools: { enabled: false, doc: true, wiki: true, drive: true, bitable: true } }, cron: { enabled: false }, websearch: { enabled: false, apiKey: '', baseUrl: 'https://api.tavily.com', maxResults: 5, searchDepth: 'basic', timeoutMs: 15000 }, + // self-config 工具仅 owner 可用;默认 sessionKey(user1) 非 owner,故默认不创建, + // 现有断言(mcpServers 仅 workspace-manager / undefined)不受影响。 + security: { ownerUserId: 'owner-xyz' }, }, })); @@ -70,6 +73,12 @@ vi.mock('../../cron/init.js', () => ({ getCronScheduler: vi.fn(() => null), })); +// Mock self-config tool module(自改自配置,owner-gated) +const mockCreateConfigAdminMcpServer = vi.fn(() => ({ type: 'mock-self-config-mcp' })); +vi.mock('../../config-admin/tool.js', () => ({ + createConfigAdminMcpServer: (...args: unknown[]) => mockCreateConfigAdminMcpServer(...args), +})); + import { ClaudeExecutor, buildWorkspaceSystemPrompt, classifyCompactOutcome, shouldExtendIdleTimer } from '../executor.js'; // ============================================================ @@ -207,6 +216,22 @@ describe('ClaudeExecutor', () => { }); }); + describe('self-config MCP tool — owner gating', () => { + it('creates self-config server when session user is owner', async () => { + await executor.execute(makeInput({ sessionKey: 'agent:pm:chat1:owner-xyz' })); + expect(mockCreateConfigAdminMcpServer).toHaveBeenCalledWith({ userId: 'owner-xyz' }); + const opts = mockQuery.mock.calls[0][0].options; + expect(opts.mcpServers).toHaveProperty('self-config'); + }); + + it('does NOT create self-config server for non-owner users', async () => { + await executor.execute(makeInput({ sessionKey: 'agent:pm:chat1:user1' })); + expect(mockCreateConfigAdminMcpServer).not.toHaveBeenCalled(); + const opts = mockQuery.mock.calls[0][0].options; + if (opts.mcpServers) expect(opts.mcpServers).not.toHaveProperty('self-config'); + }); + }); + describe('options overrides', () => { it('should use default maxTurns and maxBudgetUsd', async () => { await executor.execute(makeInput()); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 00c502e1..dcf829e0 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -13,6 +13,8 @@ import { getMemoryStore, getHybridSearch, isMemoryEnabled } from '../memory/init import { createCronMcpServer } from '../cron/tool.js'; import { getCronScheduler } from '../cron/init.js'; import { createWebSearchMcpServer } from '../websearch/tool.js'; +import { createConfigAdminMcpServer } from '../config-admin/tool.js'; +import { isOwner } from '../utils/security.js'; import { feishuClientContext } from '../feishu/client.js'; import { isAutoWorkspacePath, isServiceOwnRepo, isInsideSourceRepo } from '../workspace/isolation.js'; import { detectRuntime } from '../utils/runtime.js'; @@ -804,6 +806,15 @@ export class ClaudeExecutor { mcpServers['web-search'] = createWebSearchMcpServer(); } + // Self-config MCP tool(自改自配置)— 仅 owner 可用,非 owner 不创建(工具不可见) + { + const scKeyParts = sessionKey.split(':'); + const scUserId = scKeyParts.length >= 4 ? scKeyParts[3] : scKeyParts[1] || ''; + if (scUserId && isOwner(scUserId)) { + mcpServers['self-config'] = createConfigAdminMcpServer({ userId: scUserId }); + } + } + // 合并调用方传入的额外 MCP servers(如 discussion-tools) if (input.additionalMcpServers) { Object.assign(mcpServers, input.additionalMcpServers); @@ -1017,6 +1028,11 @@ export class ClaudeExecutor { logger.info({ toolName, readOnly }, 'canUseTool allowed — web search tool in read-only mode'); return { behavior: 'allow' as const, updatedInput: inputObj }; } + // self-config: 自改自配置工具,创建时已做 owner 门禁 + 白名单 + 写前校验,readonly 下允许 + if (toolName.startsWith('mcp__self-config__')) { + logger.info({ toolName, readOnly }, 'canUseTool allowed — self-config tool in read-only mode'); + return { behavior: 'allow' as const, updatedInput: inputObj }; + } // 所有其他 MCP 工具(含 workspace-manager、未来新增):deny logger.info({ toolName, readOnly }, 'canUseTool denied — read-only mode (MCP tool)'); return { behavior: 'deny' as const, message: '当前用户处于只读模式,无法使用此工具。' };