diff --git a/apps/desktop/src/main/__tests__/codexGlobalPlugins.test.ts b/apps/desktop/src/main/__tests__/codexGlobalPlugins.test.ts index 5dc7ae8d242..7e42e0d5e65 100644 --- a/apps/desktop/src/main/__tests__/codexGlobalPlugins.test.ts +++ b/apps/desktop/src/main/__tests__/codexGlobalPlugins.test.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import { promises as fs } from 'node:fs'; import { afterEach, describe, expect, it } from 'vitest'; import { parse as parseToml } from 'smol-toml'; +import yaml from 'js-yaml'; +import type { CapabilityRoutingPolicy } from '@cindy/maker-core'; import { codexGlobalPluginsPaths, @@ -30,6 +32,20 @@ async function writePluginCache( await fs.writeFile(path.join(dir, 'plugin.json'), `{"name":"${plugin}"}`, 'utf8'); } +async function writePluginEnabledState( + configFile: string, + plugin: string, + marketplace: string, + enabled: boolean, +): Promise { + await fs.mkdir(path.dirname(configFile), { recursive: true }); + await fs.writeFile( + configFile, + `[plugins."${plugin}@${marketplace}"]\nenabled = ${enabled}\n`, + 'utf8', + ); +} + async function sameRealPath(a: string, b: string): Promise { const [ra, rb] = await Promise.all([fs.realpath(a), fs.realpath(b)]); const normalize = (value: string) => @@ -48,6 +64,57 @@ interface SetupResult { paths: ReturnType; } +function explicitOnlySkillPolicy( + plugin = 'feishu-delegate', + marketplace = 'personal', + skill = 'message-feishu-coworkers', +): CapabilityRoutingPolicy { + return { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'skill', + id: `${plugin}:${skill}`, + artifactId: skill, + containerId: `${plugin}@${marketplace}`, + }, + invocation: 'explicit-only', + replacement: { + kind: 'cindy-plugin', + id: 'xd-feishu', + }, + }, + ], + }; +} + +function isolatedFeishuPolicy(): CapabilityRoutingPolicy { + return { + overrides: [ + ...explicitOnlySkillPolicy().overrides, + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'mcp', + id: 'cindy-routed-feishu-delegate', + artifactId: 'feishu-delegate', + containerId: 'feishu-delegate@personal', + }, + invocation: 'explicit-only', + replacement: { + kind: 'cindy-plugin', + id: 'xd-feishu', + }, + }, + ], + }; +} + async function setup(): Promise { const root = await makeTmpDir(); const homeDir = path.join(root, 'home'); @@ -63,6 +130,752 @@ afterEach(async () => { }); describe('prepareCodexGlobalPluginsBridge', () => { + it('makes a colliding plugin skill explicit-only inside Cindy without changing the user cache', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const version = '0.1.0'; + const skill = 'message-feishu-coworkers'; + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, version); + await writePluginCache(paths.sourceCacheDir, marketplace, 'unrelated-plugin', version); + const sourceSkillDir = path.join( + paths.sourceCacheDir, + marketplace, + plugin, + version, + 'skills', + skill, + ); + await fs.mkdir(sourceSkillDir, { recursive: true }); + await fs.writeFile( + path.join(sourceSkillDir, 'SKILL.md'), + `---\nname: ${skill}\ndescription: Feishu\n---\n`, + 'utf8', + ); + await fs.writeFile( + paths.sourceConfigFile, + `[plugins."${plugin}@${marketplace}"]\nenabled = true\n`, + 'utf8', + ); + const capabilityRouting = explicitOnlySkillPolicy(plugin, marketplace, skill); + + const first = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting, + }); + + expect(first.changed).toBe(true); + expect(first.warnings).toEqual([]); + expect(first.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'linked' }), + ]); + const isolatedMarketplace = path.join(paths.cacheDir, marketplace); + expect((await fs.lstat(isolatedMarketplace)).isSymbolicLink()).toBe(false); + const isolatedMetadata = path.join( + isolatedMarketplace, + plugin, + version, + 'skills', + skill, + 'agents', + 'openai.yaml', + ); + const metadata = yaml.load(await fs.readFile(isolatedMetadata, 'utf8')) as { + policy?: { allow_implicit_invocation?: boolean }; + }; + expect(metadata.policy?.allow_implicit_invocation).toBe(false); + await expect( + fs.lstat(path.join(sourceSkillDir, 'agents', 'openai.yaml')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect( + await sameRealPath( + path.join(isolatedMarketplace, 'unrelated-plugin'), + path.join(paths.sourceCacheDir, marketplace, 'unrelated-plugin'), + ), + ).toBe(true); + + const second = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting, + }); + expect(second.changed).toBe(false); + expect(second.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'kept' }), + ]); + + await fs.writeFile( + isolatedMetadata, + 'policy:\n allow_implicit_invocation: true\n', + 'utf8', + ); + const repaired = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting, + }); + expect(repaired.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'linked' }), + ]); + const repairedMetadata = yaml.load( + await fs.readFile(isolatedMetadata, 'utf8'), + ) as { policy?: { allow_implicit_invocation?: boolean } }; + expect(repairedMetadata.policy?.allow_implicit_invocation).toBe(false); + + const restored = await prepareCodexGlobalPluginsBridge(codexHome, { homeDir }); + expect(restored.changed).toBe(true); + expect( + await sameRealPath( + path.join(paths.cacheDir, marketplace), + path.join(paths.sourceCacheDir, marketplace), + ), + ).toBe(true); + }); + + it('gives a plugin MCP a Cindy-only runtime id without changing the user cache', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const version = '0.1.0'; + const pluginDir = path.join(paths.sourceCacheDir, marketplace, plugin, version); + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, version); + await fs.mkdir(path.join(pluginDir, '.codex-plugin'), { recursive: true }); + await fs.writeFile( + path.join(pluginDir, '.codex-plugin', 'plugin.json'), + JSON.stringify({ + name: plugin, + skills: './skills/', + mcpServers: './.mcp.json', + }), + 'utf8', + ); + await fs.mkdir(path.join(pluginDir, 'skills', 'message-feishu-coworkers'), { + recursive: true, + }); + await fs.writeFile( + path.join(pluginDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'feishu-delegate': { + command: 'node', + args: ['./mcp/server.mjs'], + default_tools_approval_mode: 'approve', + tools: { + feishu_read_messages: { + approval_mode: 'approve', + }, + }, + }, + }, + }), + 'utf8', + ); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: isolatedFeishuPolicy(), + }); + + expect(result.routingFailures).toEqual([]); + const isolatedMcpFile = path.join( + paths.cacheDir, + marketplace, + plugin, + version, + '.mcp.json', + ); + const isolatedMcp = JSON.parse( + await fs.readFile(isolatedMcpFile, 'utf8'), + ) as { + mcpServers: Record< + string, + { + default_tools_approval_mode?: string; + tools?: Record; + } + >; + }; + expect(isolatedMcp.mcpServers).toHaveProperty('cindy-routed-feishu-delegate'); + expect(isolatedMcp.mcpServers).not.toHaveProperty('feishu-delegate'); + expect( + isolatedMcp.mcpServers['cindy-routed-feishu-delegate']?.default_tools_approval_mode, + ).toBe('prompt'); + expect( + isolatedMcp.mcpServers['cindy-routed-feishu-delegate']?.tools + ?.feishu_read_messages?.approval_mode, + ).toBe('prompt'); + + const userMcp = JSON.parse(await fs.readFile(path.join(pluginDir, '.mcp.json'), 'utf8')) as { + mcpServers: Record< + string, + { + default_tools_approval_mode?: string; + tools?: Record; + } + >; + }; + expect(userMcp.mcpServers).toHaveProperty('feishu-delegate'); + expect(userMcp.mcpServers).not.toHaveProperty('cindy-routed-feishu-delegate'); + expect(userMcp.mcpServers['feishu-delegate']?.default_tools_approval_mode).toBe('approve'); + expect( + userMcp.mcpServers['feishu-delegate']?.tools?.feishu_read_messages?.approval_mode, + ).toBe('approve'); + + await fs.writeFile( + isolatedMcpFile, + JSON.stringify({ + mcpServers: { + 'feishu-delegate': { + command: 'node', + default_tools_approval_mode: 'approve', + }, + }, + }), + 'utf8', + ); + const repaired = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: isolatedFeishuPolicy(), + }); + expect(repaired.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'linked' }), + ]); + const repairedMcp = JSON.parse( + await fs.readFile(isolatedMcpFile, 'utf8'), + ) as { mcpServers: Record }; + expect(repairedMcp.mcpServers).toHaveProperty( + 'cindy-routed-feishu-delegate', + ); + expect(repairedMcp.mcpServers).not.toHaveProperty('feishu-delegate'); + }); + + it('rebuilds the isolated overlay when the source plugin changes', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const version = '0.1.0'; + const skill = 'message-feishu-coworkers'; + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, version); + const sourceSkillDir = path.join( + paths.sourceCacheDir, + marketplace, + plugin, + version, + 'skills', + skill, + ); + await fs.mkdir(sourceSkillDir, { recursive: true }); + await fs.writeFile(path.join(sourceSkillDir, 'SKILL.md'), 'initial', 'utf8'); + const capabilityRouting = explicitOnlySkillPolicy(plugin, marketplace, skill); + + await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting, + }); + await fs.writeFile(path.join(sourceSkillDir, 'reference.md'), 'new source content', 'utf8'); + const rebuilt = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting, + }); + + expect(rebuilt.warnings).toEqual([]); + expect(rebuilt.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'linked' }), + ]); + await expect( + fs.readFile( + path.join(paths.cacheDir, marketplace, plugin, version, 'skills', skill, 'reference.md'), + 'utf8', + ), + ).resolves.toBe('new source content'); + }); + + it('skips cached plugin versions that predate the routed Skill and MCP', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const oldVersion = '0.1.0'; + const currentVersion = '0.2.0'; + const skill = 'message-feishu-coworkers'; + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, oldVersion); + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, currentVersion); + const currentPluginDir = path.join( + paths.sourceCacheDir, + marketplace, + plugin, + currentVersion, + ); + await fs.mkdir(path.join(currentPluginDir, 'skills', skill), { recursive: true }); + await fs.writeFile( + path.join(currentPluginDir, 'skills', skill, 'SKILL.md'), + `---\nname: ${skill}\n---\n`, + 'utf8', + ); + await fs.mkdir(path.join(currentPluginDir, '.codex-plugin'), { recursive: true }); + await fs.writeFile( + path.join(currentPluginDir, '.codex-plugin', 'plugin.json'), + JSON.stringify({ + name: plugin, + skills: './skills/', + mcpServers: './.mcp.json', + }), + 'utf8', + ); + await fs.writeFile( + path.join(currentPluginDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'feishu-delegate': { command: 'node', args: ['./mcp/server.mjs'] }, + }, + }), + 'utf8', + ); + await writePluginEnabledState( + paths.sourceConfigFile, + plugin, + marketplace, + true, + ); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: isolatedFeishuPolicy(), + }); + + expect(result.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'linked' }), + ]); + expect(result.routingFailures).toEqual([]); + expect(result.warnings).toEqual([]); + await expect( + fs.readFile( + path.join(paths.cacheDir, marketplace, plugin, oldVersion, 'plugin.json'), + 'utf8', + ), + ).resolves.toContain(plugin); + const metadata = yaml.load( + await fs.readFile( + path.join( + paths.cacheDir, + marketplace, + plugin, + currentVersion, + 'skills', + skill, + 'agents', + 'openai.yaml', + ), + 'utf8', + ), + ) as { policy?: { allow_implicit_invocation?: boolean } }; + expect(metadata.policy?.allow_implicit_invocation).toBe(false); + const isolatedMcp = JSON.parse( + await fs.readFile( + path.join(paths.cacheDir, marketplace, plugin, currentVersion, '.mcp.json'), + 'utf8', + ), + ) as { mcpServers: Record }; + expect(isolatedMcp.mcpServers).toHaveProperty('cindy-routed-feishu-delegate'); + expect(isolatedMcp.mcpServers).not.toHaveProperty('feishu-delegate'); + }); + + it.skipIf(process.platform === 'win32')( + 'fails closed instead of following symlinks out of a protected plugin', + async () => { + const { homeDir, codexHome, paths } = await setup(); + const pluginDir = path.join( + paths.sourceCacheDir, + 'personal', + 'feishu-delegate', + '1.0.0', + ); + const outsideSkill = path.join(homeDir, 'outside-skill'); + await fs.mkdir(path.join(pluginDir, 'skills'), { recursive: true }); + await fs.mkdir(outsideSkill, { recursive: true }); + await fs.writeFile( + path.join(outsideSkill, 'SKILL.md'), + '---\nname: message-feishu-coworkers\n---\n', + 'utf8', + ); + await fs.symlink( + outsideSkill, + path.join(pluginDir, 'skills', 'message-feishu-coworkers'), + 'dir', + ); + await writePluginEnabledState( + paths.sourceConfigFile, + 'feishu-delegate', + 'personal', + true, + ); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(), + }); + + expect(result.routingFailures).toEqual([ + expect.stringContaining('feishu-delegate@personal'), + ]); + await expect( + fs.lstat(path.join(outsideSkill, 'agents', 'openai.yaml')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'fails closed when the protected plugin root itself is a symlink', + async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplaceDir = path.join(paths.sourceCacheDir, 'personal'); + const realPluginDir = path.join(homeDir, 'real-feishu-plugin'); + const realSkillDir = path.join( + realPluginDir, + '1.0.0', + 'skills', + 'message-feishu-coworkers', + ); + await fs.mkdir(realSkillDir, { recursive: true }); + await fs.writeFile( + path.join(realSkillDir, 'SKILL.md'), + '---\nname: message-feishu-coworkers\n---\n', + 'utf8', + ); + await fs.mkdir(marketplaceDir, { recursive: true }); + await fs.symlink( + realPluginDir, + path.join(marketplaceDir, 'feishu-delegate'), + 'dir', + ); + await writePluginEnabledState( + paths.sourceConfigFile, + 'feishu-delegate', + 'personal', + true, + ); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(), + }); + + expect(result.routingFailures).toEqual([ + expect.stringContaining('feishu-delegate@personal'), + ]); + expect(result.warnings).toEqual([ + expect.stringContaining( + 'protected plugin root is an unsupported symlink', + ), + ]); + await expect( + fs.lstat(path.join(realSkillDir, 'agents', 'openai.yaml')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }, + ); + + it('never replaces a real marketplace directory that was not created by Cindy routing', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const version = '0.1.0'; + const skill = 'message-feishu-coworkers'; + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, version); + const sourceSkillDir = path.join( + paths.sourceCacheDir, + marketplace, + plugin, + version, + 'skills', + skill, + ); + await fs.mkdir(sourceSkillDir, { recursive: true }); + const isolatedMarketplace = path.join(paths.cacheDir, marketplace); + await fs.mkdir(isolatedMarketplace, { recursive: true }); + await fs.writeFile(path.join(isolatedMarketplace, 'keep.txt'), 'unmanaged', 'utf8'); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(plugin, marketplace, skill), + }); + + expect(result.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'conflict' }), + ]); + expect(result.routingFailures).toEqual([]); + expect(result.warnings).toEqual([]); + await expect(fs.readFile(path.join(isolatedMarketplace, 'keep.txt'), 'utf8')).resolves.toBe( + 'unmanaged', + ); + }); + + it('reports a routing failure when an unmanaged marketplace contains the protected plugin', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const version = '0.1.0'; + const skill = 'message-feishu-coworkers'; + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, version); + await fs.mkdir( + path.join(paths.sourceCacheDir, marketplace, plugin, version, 'skills', skill), + { recursive: true }, + ); + await fs.mkdir(path.join(paths.cacheDir, marketplace, plugin, version), { + recursive: true, + }); + await writePluginEnabledState( + paths.sourceConfigFile, + plugin, + marketplace, + true, + ); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(plugin, marketplace, skill), + }); + + expect(result.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'conflict' }), + ]); + expect(result.routingFailures).toEqual([ + expect.stringContaining(`installed Codex plugin ${plugin}@${marketplace}`), + ]); + }); + + it('does not fail routing for a protected plugin that exists only in cache', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const version = '0.1.0'; + const skill = 'message-feishu-coworkers'; + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, version); + await fs.mkdir( + path.join(paths.sourceCacheDir, marketplace, plugin, version, 'skills', skill), + { recursive: true }, + ); + await fs.mkdir(path.join(paths.cacheDir, marketplace, plugin, version), { + recursive: true, + }); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(plugin, marketplace, skill), + }); + + expect(result.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'conflict' }), + ]); + expect(result.routingFailures).toEqual([]); + }); + + it('does not fail routing when the isolated config keeps the plugin disabled', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const version = '0.1.0'; + const skill = 'message-feishu-coworkers'; + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, version); + await fs.mkdir( + path.join(paths.sourceCacheDir, marketplace, plugin, version, 'skills', skill), + { recursive: true }, + ); + await fs.mkdir(path.join(paths.cacheDir, marketplace, plugin, version), { + recursive: true, + }); + await writePluginEnabledState( + paths.sourceConfigFile, + plugin, + marketplace, + true, + ); + await writePluginEnabledState( + paths.configFile, + plugin, + marketplace, + false, + ); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(plugin, marketplace, skill), + }); + + expect(result.marketplaces).toEqual([ + expect.objectContaining({ name: marketplace, status: 'conflict' }), + ]); + expect(result.addedPluginEntries).toEqual([]); + expect(result.routingFailures).toEqual([]); + expect( + pluginsTableOf(await fs.readFile(paths.configFile, 'utf8'))[ + `${plugin}@${marketplace}` + ], + ).toEqual({ enabled: false }); + }); + + it.skipIf(process.platform === 'win32')( + 'does not block Codex when a disabled protected plugin cannot be snapshotted', + async () => { + const { homeDir, codexHome, paths } = await setup(); + const unreadableDir = path.join( + paths.sourceCacheDir, + 'personal', + 'feishu-delegate', + '1.0.0', + 'unreadable', + ); + await writePluginCache( + paths.sourceCacheDir, + 'personal', + 'feishu-delegate', + ); + await fs.mkdir(unreadableDir, { recursive: true }); + await fs.chmod(unreadableDir, 0o000); + await writePluginEnabledState( + paths.configFile, + 'feishu-delegate', + 'personal', + false, + ); + + let result: Awaited>; + try { + result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(), + }); + } finally { + await fs.chmod(unreadableDir, 0o700); + } + + expect(result.marketplaces).toEqual([ + expect.objectContaining({ name: 'personal', status: 'error' }), + ]); + expect(result.routingFailures).toEqual([]); + expect(result.warnings).toEqual([ + expect.stringContaining('cannot snapshot Codex capability-routing source'), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'fails closed when an enabled protected plugin cannot be snapshotted', + async () => { + const { homeDir, codexHome, paths } = await setup(); + const unreadableDir = path.join( + paths.sourceCacheDir, + 'personal', + 'feishu-delegate', + '1.0.0', + 'unreadable', + ); + await writePluginCache( + paths.sourceCacheDir, + 'personal', + 'feishu-delegate', + ); + await fs.mkdir(unreadableDir, { recursive: true }); + await fs.chmod(unreadableDir, 0o000); + await writePluginEnabledState( + paths.sourceConfigFile, + 'feishu-delegate', + 'personal', + true, + ); + + let result: Awaited>; + try { + result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(), + }); + } finally { + await fs.chmod(unreadableDir, 0o700); + } + + expect(result.marketplaces).toEqual([ + expect.objectContaining({ name: 'personal', status: 'error' }), + ]); + expect(result.routingFailures).toEqual([ + expect.stringContaining('feishu-delegate@personal'), + ]); + }, + ); + + it('fails closed when enablement is unknown and a protected plugin is uncontrolled', async () => { + const { homeDir, codexHome, paths } = await setup(); + await writePluginCache( + paths.sourceCacheDir, + 'personal', + 'feishu-delegate', + ); + await fs.mkdir( + path.join(paths.cacheDir, 'personal', 'feishu-delegate', '1.0.0'), + { recursive: true }, + ); + await fs.mkdir(codexHome, { recursive: true }); + await fs.writeFile(paths.configFile, '[plugins."broken\n', 'utf8'); + + const result = await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(), + }); + + expect(result.marketplaces).toEqual([ + expect.objectContaining({ name: 'personal', status: 'conflict' }), + ]); + expect(result.routingFailures).toEqual([ + expect.stringContaining('feishu-delegate@personal'), + ]); + expect(result.warnings).toEqual([ + expect.stringContaining('cannot confirm enabled plugins'), + ]); + }); + + it.skipIf(process.platform === 'win32')( + 'gates unreadable cache inventories by the isolated plugin enablement', + async () => { + const prepare = async ( + unreadable: 'source' | 'isolated', + enabled: boolean, + ) => { + const { homeDir, codexHome, paths } = await setup(); + const cacheRoot = + unreadable === 'source' ? paths.sourceCacheDir : paths.cacheDir; + await writePluginCache( + cacheRoot, + 'personal', + 'feishu-delegate', + ); + await writePluginEnabledState( + paths.configFile, + 'feishu-delegate', + 'personal', + enabled, + ); + await fs.chmod(cacheRoot, 0o000); + try { + return await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(), + }); + } finally { + await fs.chmod(cacheRoot, 0o700); + } + }; + + for (const unreadable of ['source', 'isolated'] as const) { + const disabled = await prepare(unreadable, false); + expect(disabled.routingFailures).toEqual([]); + expect(disabled.warnings).toEqual([ + expect.stringContaining(`cannot inspect ${unreadable === 'source' ? 'user' : 'isolated'} Codex plugin cache`), + ]); + + const enabled = await prepare(unreadable, true); + expect(enabled.routingFailures).toEqual([ + expect.stringContaining('feishu-delegate@personal'), + ]); + } + }, + ); + it('links marketplace cache dirs and appends missing [plugins] entries', async () => { const { homeDir, codexHome, paths } = await setup(); await writePluginCache(paths.sourceCacheDir, 'superpowers-dev', 'superpowers'); @@ -154,7 +967,7 @@ describe('prepareCodexGlobalPluginsBridge', () => { 'utf8', ); await fs.mkdir(codexHome, { recursive: true }); - const existing = "[projects.'D:\\workspace\\demo']\ntrust_level = \"trusted\"\n"; + const existing = '[projects.\'D:\\workspace\\demo\']\ntrust_level = "trusted"\n'; await fs.writeFile(paths.configFile, existing, 'utf8'); await prepareCodexGlobalPluginsBridge(codexHome, { homeDir }); @@ -202,6 +1015,33 @@ describe('prepareCodexGlobalPluginsBridge', () => { await expect(fs.lstat(link)).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('removes a Cindy-managed overlay when its source marketplace disappears', async () => { + const { homeDir, codexHome, paths } = await setup(); + const marketplace = 'personal'; + const plugin = 'feishu-delegate'; + const version = '0.1.0'; + const skill = 'message-feishu-coworkers'; + await writePluginCache(paths.sourceCacheDir, marketplace, plugin, version); + await fs.mkdir(path.join(paths.sourceCacheDir, marketplace, plugin, version, 'skills', skill), { + recursive: true, + }); + await prepareCodexGlobalPluginsBridge(codexHome, { + homeDir, + capabilityRouting: explicitOnlySkillPolicy(plugin, marketplace, skill), + }); + const overlay = path.join(paths.cacheDir, marketplace); + expect((await fs.lstat(overlay)).isDirectory()).toBe(true); + + await fs.rm(path.join(paths.sourceCacheDir, marketplace), { + recursive: true, + force: true, + }); + const result = await prepareCodexGlobalPluginsBridge(codexHome, { homeDir }); + + expect(result.changed).toBe(true); + await expect(fs.lstat(overlay)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('skips plugin entries whose marketplace has no cache dir', async () => { const { homeDir, codexHome, paths } = await setup(); await writePluginCache(paths.sourceCacheDir, 'superpowers-dev', 'superpowers'); @@ -263,7 +1103,11 @@ describe('prepareCodexGlobalPluginsBridge', () => { const snapshot = 'model = "a"\n'; await fs.writeFile(file, 'model = "a"\n\n[projects.x]\ntrust_level = "trusted"\n', 'utf8'); - const applied = await writeFileAtomicIfUnchanged(file, `${snapshot}\n[plugins."p@m"]\nenabled = true\n`, snapshot); + const applied = await writeFileAtomicIfUnchanged( + file, + `${snapshot}\n[plugins."p@m"]\nenabled = true\n`, + snapshot, + ); expect(applied).toBe(false); // 并发写入者的内容原样保留,tmp 文件不残留 @@ -319,9 +1163,9 @@ describe('prepareCodexGlobalPluginsBridge', () => { const result = await prepareCodexGlobalPluginsBridge(codexHome, { homeDir }); expect(result.addedPluginEntries).toEqual([]); - expect( - result.warnings.some((w) => w.includes('cannot parse isolated codex config')), - ).toBe(true); + expect(result.warnings.some((w) => w.includes('cannot parse isolated codex config'))).toBe( + true, + ); await expect(fs.readFile(paths.configFile, 'utf8')).resolves.toBe(broken); }); }); diff --git a/apps/desktop/src/main/maker-host/auth-adapters.ts b/apps/desktop/src/main/maker-host/auth-adapters.ts index 0a95d58cb47..14792c0fe15 100644 --- a/apps/desktop/src/main/maker-host/auth-adapters.ts +++ b/apps/desktop/src/main/maker-host/auth-adapters.ts @@ -33,6 +33,7 @@ import { createLogger } from '../logger.js'; import { prepareCodexGlobalSkillsLinks } from './codex-global-skills.js'; import { prepareCodexGlobalRulesCopy } from './codex-global-rules.js'; import { prepareCodexGlobalPluginsBridge } from './codex-global-plugins.js'; +import { DESKTOP_CAPABILITY_ROUTING_POLICY } from './capability-routing.js'; import { prepareSharedGlobalSkillLinks } from './shared-global-skills.js'; import { relinkSharedCodexAuth } from './codex-auth-link.js'; import { claudeOAuthSpawnEnv } from './claude-oauth-spawn-env.js'; @@ -895,8 +896,15 @@ export class DesktopCodexAuthAdapter implements AuthAdapter { (r) => ({ ok: true as const, label: 'rules' as const, warnings: r.warnings }), (err: Error) => ({ ok: false as const, label: 'rules' as const, err }), ), - prepareCodexGlobalPluginsBridge(this.codexHome).then( - (r) => ({ ok: true as const, label: 'plugins' as const, warnings: r.warnings }), + prepareCodexGlobalPluginsBridge(this.codexHome, { + capabilityRouting: DESKTOP_CAPABILITY_ROUTING_POLICY, + }).then( + (r) => ({ + ok: true as const, + label: 'plugins' as const, + warnings: r.warnings, + routingFailures: r.routingFailures, + }), (err: Error) => ({ ok: false as const, label: 'plugins' as const, err }), ), ]); @@ -913,6 +921,22 @@ export class DesktopCodexAuthAdapter implements AuthAdapter { log.warn('Codex global asset warning', { asset: outcome.label, warning }); } } + if (!pluginsOutcome.ok) { + // Expected cache/config I/O failures are normalized by the bridge and + // gated against the isolated plugin enablement. A rejection here is an + // unexpected invariant failure, so it must remain fail-closed. + throw new Error( + `Cannot start Codex safely because Cindy could not inspect downstream plugin capabilities: ${pluginsOutcome.err.message}`, + ); + } + if (pluginsOutcome.ok && pluginsOutcome.routingFailures.length > 0) { + for (const failure of pluginsOutcome.routingFailures) { + log.error('Codex capability routing enforcement failed', { failure }); + } + throw new Error( + `Cannot start Codex safely because Cindy could not isolate a downstream plugin capability: ${pluginsOutcome.routingFailures.join('; ')}`, + ); + } } /** maker-host 在构造完 codexAgent 后调一次, 注入 dispose 回调。 */ diff --git a/apps/desktop/src/main/maker-host/capability-routing.ts b/apps/desktop/src/main/maker-host/capability-routing.ts new file mode 100644 index 00000000000..293e06f1811 --- /dev/null +++ b/apps/desktop/src/main/maker-host/capability-routing.ts @@ -0,0 +1,114 @@ +import type { CapabilityRoutingPolicy } from '@cindy/maker-core'; + +/** + * Product-level arbitration for capability sources that collide inside Cindy. + * + * User/project Skills and normal plugin capabilities remain available. Only a + * named overlapping downstream source is narrowed, and explicit selectors keep + * that source reachable when the user deliberately chooses it. + */ +export const DESKTOP_CAPABILITY_ROUTING_POLICY = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'skill', + id: 'feishu-delegate:message-feishu-coworkers', + artifactId: 'message-feishu-coworkers', + containerId: 'feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { + kind: 'cindy-plugin', + id: 'xd-feishu', + }, + reason: 'Natural-language Feishu requests should use the Cindy-connected account.', + }, + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'skill', + id: 'feishu-delegate:message-feishu-coworkers', + artifactId: 'message-feishu-coworkers', + containerId: 'feishu-delegate@personal', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '$feishu-delegate:message-feishu-coworkers', + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { + kind: 'cindy-plugin', + id: 'xd-feishu', + }, + reason: 'Natural-language Feishu requests should use the Cindy-connected account.', + }, + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + containerId: 'feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { + kind: 'cindy-plugin', + id: 'xd-feishu', + }, + reason: 'The downstream Feishu account must not be used without an explicit source choice.', + }, + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'mcp', + // Codex does not expose plugin provenance in the MCP approval request + // itself. Give the plugin server a Cindy-only runtime name in the + // isolated overlay, then verify its owning pluginId from the preceding + // mcpToolCall item. A user MCP may legally reuse either server name and + // must remain unaffected. + id: 'cindy-routed-feishu-delegate', + artifactId: 'feishu-delegate', + containerId: 'feishu-delegate@personal', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '$feishu-delegate:message-feishu-coworkers', + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { + kind: 'cindy-plugin', + id: 'xd-feishu', + }, + reason: 'The downstream Feishu account must not be used without an explicit source choice.', + }, + { + capabilityId: 'computer-use', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'plugin', + id: 'computer-use@openai-bundled', + }, + invocation: 'disabled', + replacement: { + kind: 'cindy-host', + id: 'cindy_computer', + }, + reason: 'Cindy owns desktop-control enablement, permissions, and execution.', + }, + ], +} as const satisfies CapabilityRoutingPolicy; diff --git a/apps/desktop/src/main/maker-host/codex-global-plugins.ts b/apps/desktop/src/main/maker-host/codex-global-plugins.ts index f56d1e5d838..6535cbb4bba 100644 --- a/apps/desktop/src/main/maker-host/codex-global-plugins.ts +++ b/apps/desktop/src/main/maker-host/codex-global-plugins.ts @@ -1,7 +1,10 @@ import os from 'node:os'; import path from 'node:path'; import { promises as fsp } from 'node:fs'; +import { createHash } from 'node:crypto'; import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; +import yaml from 'js-yaml'; +import type { CapabilityRoutingPolicy } from '@cindy/maker-core'; import { ensureDirectoryLink, @@ -25,6 +28,12 @@ import { * (Windows junction / POSIX dir symlink)。隔离 home 里已被 codex 自建的 * 真实目录(如 remote 插件的 openai-curated-remote)是预期 conflict,跳过 * 不告警 —— 那类插件由 codex 的 remote-install 机制在隔离 home 内自愈。 + * capability routing 若收紧某个插件能力,则只在隔离 home 内为该 + * marketplace 建 overlay:目标插件复制后可把 Skill 写成 + * allow_implicit_invocation=false,也可把 MCP server 改成 Cindy-only + * runtime id 以保留来源归属;同 marketplace 其他插件仍链接原缓存。 + * 用户 ~/.codex 下的插件文件始终不改。overlay 无法可靠生成时调用方 + * fail closed,不退回未经收紧的下游能力继续启动会话。 * - config:把 ~/.codex/config.toml 的 [plugins] 条目**只增不改**地追加进隔离 * config.toml(原子写:临时文件 + rename)。已存在的条目一律不动 —— 用户在 * xdt-maker 侧的启用 / 禁用选择优先,与 auth reconcile 的"各管各"哲学一致。 @@ -53,11 +62,121 @@ export interface CodexGlobalPluginsPrepareResult { marketplaces: CodexGlobalPluginsMarketplaceResult[]; /** 本轮新追加进隔离 config.toml 的插件 key(`name@marketplace`)。 */ addedPluginEntries: string[]; + /** + * 已启用或启用状态无法可靠确认、且 Cindy 无法在隔离 home 中可靠收紧的下游能力。 + * + * 调用方必须把非空结果当成 session 启动失败,不能静默退回用户原始 + * marketplace 链接,否则 explicit-only Skill 会重新变成隐式可调用。 + */ + routingFailures: string[]; warnings: string[]; } interface PrepareOptions { homeDir?: string; + capabilityRouting?: CapabilityRoutingPolicy; +} + +type PluginEnablementSnapshot = + | { status: 'known'; enabledPluginKeys: ReadonlySet } + | { status: 'unknown' }; + +interface CodexPluginOverlayBase { + pluginKey: string; + pluginName: string; + marketplace: string; +} + +interface CodexSkillOverlay extends CodexPluginOverlayBase { + kind: 'skill'; + skillName: string; +} + +interface CodexMcpOverlay extends CodexPluginOverlayBase { + kind: 'mcp'; + sourceServerName: string; + runtimeServerName: string; +} + +type CodexPluginOverlay = CodexSkillOverlay | CodexMcpOverlay; + +interface ManagedOverlayMarker { + schemaVersion: 1; + source: string; + sourceSnapshot: string; + /** Digest of routing-critical files in the isolated, derived overlay. */ + overlaySnapshot?: string; + skills: Array<{ pluginKey: string; skillName: string }>; + mcpServers?: Array<{ + pluginKey: string; + sourceServerName: string; + runtimeServerName: string; + }>; +} + +const MANAGED_OVERLAY_MARKER = '.cindy-capability-routing.json'; +const DISCOVERABLE_PLUGIN_MANIFEST_PATHS = [ + '.codex-plugin/plugin.json', + '.claude-plugin/plugin.json', + '.cursor-plugin/plugin.json', +] as const; + +function codexCapabilityOverlays( + policy: CapabilityRoutingPolicy | undefined, +): CodexPluginOverlay[] { + if (!policy) return []; + const overlays: CodexPluginOverlay[] = []; + for (const directive of policy.overrides) { + if ( + directive.invocation !== 'explicit-only' || + directive.source.harness !== 'codex' || + directive.source.kind !== 'harness-plugin' + ) { + continue; + } + const pluginKey = directive.source.containerId; + if (!pluginKey) continue; + const marketplace = marketplaceOfPluginKey(pluginKey); + if (!marketplace) continue; + const base = { + pluginKey, + pluginName: pluginKey.slice(0, -(marketplace.length + 1)), + marketplace, + }; + if (directive.source.surface === 'skill') { + overlays.push({ + ...base, + kind: 'skill', + skillName: directive.source.artifactId ?? directive.source.id, + }); + continue; + } + if ( + directive.source.surface === 'mcp' && + directive.source.artifactId && + directive.source.artifactId !== directive.source.id + ) { + overlays.push({ + ...base, + kind: 'mcp', + sourceServerName: directive.source.artifactId, + runtimeServerName: directive.source.id, + }); + } + } + return overlays; +} + +function groupOverlaysByMarketplace( + overlays: readonly CodexPluginOverlay[], +): Map { + const grouped = new Map(); + for (const overlay of overlays) { + const current = grouped.get(overlay.marketplace) ?? []; + current.push(overlay); + grouped.set(overlay.marketplace, current); + } + return grouped; } export function codexGlobalPluginsPaths(codexHome: string, homeDir = os.homedir()) { @@ -87,14 +206,662 @@ async function listSourceMarketplaces(sourceCacheDir: string): Promise return names; } +async function listDirectoryNames(dir: string): Promise { + let entries: import('node:fs').Dirent[]; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + return entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => entry.name) + .sort(); +} + +async function treeSnapshot(root: string, relative = ''): Promise { + const dir = relative ? path.join(root, relative) : root; + const entries = await fsp.readdir(dir, { withFileTypes: true }); + const snapshot: unknown[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const childRelative = relative ? path.join(relative, entry.name) : entry.name; + const child = path.join(root, childRelative); + if (entry.isDirectory()) { + snapshot.push({ + path: childRelative, + kind: 'directory', + children: await treeSnapshot(root, childRelative), + }); + continue; + } + const stat = await fsp.lstat(child); + snapshot.push({ + path: childRelative, + kind: entry.isSymbolicLink() ? 'symlink' : 'file', + size: stat.size, + mtimeMs: stat.mtimeMs, + ...(entry.isSymbolicLink() ? { target: await fsp.readlink(child) } : {}), + }); + } + return snapshot; +} + +async function assertOverlaySourceHasNoSymlinks( + root: string, + relative = '', +): Promise { + if (!relative && (await fsp.lstat(root)).isSymbolicLink()) { + throw new Error('protected plugin root is an unsupported symlink'); + } + const dir = relative ? path.join(root, relative) : root; + const entries = await fsp.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const childRelative = relative + ? path.join(relative, entry.name) + : entry.name; + if (entry.isSymbolicLink()) { + throw new Error( + `protected plugin contains unsupported symlink: ${childRelative}`, + ); + } + if (entry.isDirectory()) { + await assertOverlaySourceHasNoSymlinks(root, childRelative); + } + } +} + +async function sourceMarketplaceSnapshot( + source: string, + overlays: readonly CodexPluginOverlay[], +): Promise { + const plugins = await listDirectoryNames(source); + const overlaidPlugins = new Set(overlays.map((overlay) => overlay.pluginName)); + const snapshot = await Promise.all( + plugins.map(async (plugin) => { + const pluginDir = path.join(source, plugin); + return { + plugin, + ...(overlaidPlugins.has(plugin) + ? { tree: await treeSnapshot(pluginDir) } + : { versions: await listDirectoryNames(pluginDir) }), + }; + }), + ); + return JSON.stringify(snapshot); +} + +async function readManagedOverlayMarker( + marketplaceDir: string, +): Promise { + try { + const raw = await fsp.readFile(path.join(marketplaceDir, MANAGED_OVERLAY_MARKER), 'utf8'); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.schemaVersion !== 1 || + typeof parsed.source !== 'string' || + typeof parsed.sourceSnapshot !== 'string' || + (parsed.overlaySnapshot !== undefined && + typeof parsed.overlaySnapshot !== 'string') || + !Array.isArray(parsed.skills) + ) { + return null; + } + return parsed as ManagedOverlayMarker; + } catch { + return null; + } +} + +function stableOverlaySkills(overlays: readonly CodexSkillOverlay[]) { + return overlays + .map(({ pluginKey, skillName }) => ({ pluginKey, skillName })) + .sort((a, b) => + a.pluginKey === b.pluginKey + ? a.skillName.localeCompare(b.skillName) + : a.pluginKey.localeCompare(b.pluginKey), + ); +} + +function stableOverlayMcpServers(overlays: readonly CodexMcpOverlay[]) { + return overlays + .map(({ pluginKey, sourceServerName, runtimeServerName }) => ({ + pluginKey, + sourceServerName, + runtimeServerName, + })) + .sort((a, b) => { + if (a.pluginKey !== b.pluginKey) return a.pluginKey.localeCompare(b.pluginKey); + if (a.sourceServerName !== b.sourceServerName) { + return a.sourceServerName.localeCompare(b.sourceServerName); + } + return a.runtimeServerName.localeCompare(b.runtimeServerName); + }); +} + +function sameOverlayConfiguration( + marker: ManagedOverlayMarker, + desired: ManagedOverlayMarker, +): boolean { + return ( + marker.schemaVersion === desired.schemaVersion && + marker.source === desired.source && + marker.sourceSnapshot === desired.sourceSnapshot && + JSON.stringify(marker.skills) === JSON.stringify(desired.skills) && + JSON.stringify(marker.mcpServers ?? []) === + JSON.stringify(desired.mcpServers ?? []) + ); +} + +async function routingArtifactDigest( + marketplaceDir: string, + file: string, +): Promise<{ path: string; sha256: string } | { path: string; missing: true }> { + const relativePath = path.relative(marketplaceDir, file); + try { + const content = await fsp.readFile(file); + return { + path: relativePath, + sha256: createHash('sha256').update(content).digest('hex'), + }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { path: relativePath, missing: true }; + } + throw err; + } +} + /** - * 清理悬空的受管链接:隔离 cache 里指向已消失 source marketplace 的 symlink。 - * 仅动 symlink(受管形态);codex 自建的真实目录永不触碰。 + * Snapshot only files that decide whether routed Skills and MCP servers stay + * constrained. This keeps the reuse check cheap while still detecting a + * deleted/rewritten policy, manifest pointer, or MCP configuration. */ -async function cleanupStaleLinks( - cacheDir: string, - liveNames: Set, +async function mcpRoutingArtifactDigests( + marketplaceDir: string, + pluginVersionDir: string, +): Promise { + const artifacts: unknown[] = []; + let manifest: Record | null = null; + for (const relativeManifest of DISCOVERABLE_PLUGIN_MANIFEST_PATHS) { + const candidate = path.join(pluginVersionDir, relativeManifest); + try { + await fsp.access(candidate); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw err; + } + manifest = await readJsonObject(candidate); + artifacts.push(await routingArtifactDigest(marketplaceDir, candidate)); + break; + } + + const declaration = manifest?.['mcpServers']; + if (isRecord(declaration)) return artifacts; + const configFile = + typeof declaration === 'string' + ? resolvePluginOwnedPath(pluginVersionDir, declaration) + : path.join(pluginVersionDir, '.mcp.json'); + artifacts.push(await routingArtifactDigest(marketplaceDir, configFile)); + return artifacts; +} + +async function capabilityRoutingPluginSnapshot( + marketplaceDir: string, + pluginName: string, + overlays: readonly CodexPluginOverlay[], +): Promise { + const pluginDir = path.join(marketplaceDir, pluginName); + const versions = await listDirectoryNames(pluginDir); + const artifacts: unknown[] = []; + const skillOverlays = overlays.filter( + (overlay): overlay is CodexSkillOverlay => overlay.kind === 'skill', + ); + const hasMcpOverlays = overlays.some((overlay) => overlay.kind === 'mcp'); + + for (const version of versions) { + const pluginVersionDir = path.join(pluginDir, version); + for (const overlay of skillOverlays) { + const skillDir = path.join( + pluginVersionDir, + 'skills', + overlay.skillName, + ); + if (!(await isDirectory(skillDir))) continue; + artifacts.push( + await routingArtifactDigest( + marketplaceDir, + path.join(skillDir, 'agents', 'openai.yaml'), + ), + ); + } + if (hasMcpOverlays) { + artifacts.push( + ...(await mcpRoutingArtifactDigests( + marketplaceDir, + pluginVersionDir, + )), + ); + } + } + return { pluginName, versions, artifacts }; +} + +async function capabilityRoutingOverlaySnapshot( + marketplaceDir: string, + overlays: readonly CodexPluginOverlay[], +): Promise { + const overlaysByPlugin = new Map(); + for (const overlay of overlays) { + const current = overlaysByPlugin.get(overlay.pluginName) ?? []; + current.push(overlay); + overlaysByPlugin.set(overlay.pluginName, current); + } + const plugins = await Promise.all( + [...overlaysByPlugin.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([pluginName, pluginOverlays]) => + capabilityRoutingPluginSnapshot( + marketplaceDir, + pluginName, + pluginOverlays, + ), + ), + ); + return JSON.stringify(plugins); +} + +async function applyExplicitOnlySkillPolicy( + pluginDir: string, + overlays: readonly CodexSkillOverlay[], +): Promise { + const versions = await listDirectoryNames(pluginDir); + for (const version of versions) { + for (const overlay of overlays) { + const skillDir = path.join(pluginDir, version, 'skills', overlay.skillName); + if (!(await isDirectory(skillDir))) { + // Plugin caches retain old versions. A version that predates this + // Skill exposes nothing to constrain, so only mutate versions that + // actually contain the routed capability. + continue; + } + const agentDir = path.join(skillDir, 'agents'); + const metadataFile = path.join(agentDir, 'openai.yaml'); + let metadata: Record = {}; + try { + const raw = await fsp.readFile(metadataFile, 'utf8'); + const parsed = yaml.load(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + metadata = parsed as Record; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error( + `cannot parse Codex skill metadata ${metadataFile}: ${(err as Error).message}`, + ); + } + } + const existingPolicy = + metadata.policy && typeof metadata.policy === 'object' && !Array.isArray(metadata.policy) + ? (metadata.policy as Record) + : {}; + metadata.policy = { + ...existingPolicy, + allow_implicit_invocation: false, + }; + await fsp.mkdir(agentDir, { recursive: true }); + await fsp.writeFile(metadataFile, yaml.dump(metadata), 'utf8'); + } + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function renameMcpServerKey( + servers: Record, + overlay: CodexMcpOverlay, + sourceLabel: string, +): void { + const source = overlay.sourceServerName; + const target = overlay.runtimeServerName; + if (source in servers && target in servers) { + throw new Error( + `cannot isolate Codex MCP server ${overlay.pluginKey}/${source}: ${target} already exists in ${sourceLabel}`, + ); + } + if (!(source in servers)) { + // As with Skills, cached plugin versions can predate this MCP server. No + // source entry means this version does not expose the target capability. + return; + } + const config = servers[source]; + if (!isRecord(config)) { + throw new Error( + `cannot isolate Codex MCP server ${overlay.pluginKey}/${source}: expected an object in ${sourceLabel}`, + ); + } + // The host guard only runs when Codex emits an MCP approval request. A + // plugin-provided auto/approve policy could otherwise skip that request + // entirely, so the isolated copy must force every declared tool back through + // the host-visible prompt path. + config['default_tools_approval_mode'] = 'prompt'; + const tools = config['tools']; + if (tools !== undefined) { + if (!isRecord(tools)) { + throw new Error( + `cannot isolate Codex MCP server ${overlay.pluginKey}/${source}: expected tools to be an object in ${sourceLabel}`, + ); + } + for (const [toolName, toolPolicy] of Object.entries(tools)) { + if (!isRecord(toolPolicy)) { + throw new Error( + `cannot isolate Codex MCP server ${overlay.pluginKey}/${source}: expected policy for ${toolName} to be an object in ${sourceLabel}`, + ); + } + toolPolicy['approval_mode'] = 'prompt'; + } + } + delete servers[source]; + servers[target] = config; +} + +function resolvePluginOwnedPath(pluginVersionDir: string, declaredPath: string): string { + const resolved = path.resolve(pluginVersionDir, declaredPath); + const relative = path.relative(pluginVersionDir, resolved); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + return resolved; + } + throw new Error(`plugin MCP config path escapes its plugin root: ${declaredPath}`); +} + +async function writeJson(file: string, value: Record): Promise { + await fsp.writeFile(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +async function readJsonObject(file: string): Promise> { + let raw: string; + try { + raw = await fsp.readFile(file, 'utf8'); + } catch (err) { + throw new Error(`cannot read ${file}: ${(err as Error).message}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`cannot parse ${file}: ${(err as Error).message}`); + } + if (!isRecord(parsed)) throw new Error(`expected a JSON object in ${file}`); + return parsed; +} + +async function applyMcpServerRename( + pluginVersionDir: string, + overlays: readonly CodexMcpOverlay[], +): Promise { + let manifestFile: string | null = null; + let manifest: Record | null = null; + for (const relativeManifest of DISCOVERABLE_PLUGIN_MANIFEST_PATHS) { + const candidate = path.join(pluginVersionDir, relativeManifest); + try { + await fsp.access(candidate); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw err; + } + manifestFile = candidate; + manifest = await readJsonObject(candidate); + break; + } + + const declaration = manifest?.['mcpServers']; + if (isRecord(declaration)) { + for (const overlay of overlays) renameMcpServerKey(declaration, overlay, manifestFile!); + await writeJson(manifestFile!, manifest!); + return; + } + + const configFile = + typeof declaration === 'string' + ? resolvePluginOwnedPath(pluginVersionDir, declaration) + : path.join(pluginVersionDir, '.mcp.json'); + try { + await fsp.access(configFile); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + const config = await readJsonObject(configFile); + const servers = isRecord(config['mcpServers']) ? config['mcpServers'] : config; + for (const overlay of overlays) renameMcpServerKey(servers, overlay, configFile); + await writeJson(configFile, config); +} + +async function applyCapabilityRoutingOverlay( + pluginDir: string, + overlays: readonly CodexPluginOverlay[], +): Promise { + const skillOverlays = overlays.filter( + (overlay): overlay is CodexSkillOverlay => overlay.kind === 'skill', + ); + if (skillOverlays.length > 0) { + await applyExplicitOnlySkillPolicy(pluginDir, skillOverlays); + } + const mcpOverlays = overlays.filter( + (overlay): overlay is CodexMcpOverlay => overlay.kind === 'mcp', + ); + if (mcpOverlays.length === 0) return; + for (const version of await listDirectoryNames(pluginDir)) { + await applyMcpServerRename(path.join(pluginDir, version), mcpOverlays); + } +} + +async function removeManagedOverlayDirectory( + marketplaceDir: string, + source: string, + warnings: string[], ): Promise { + const marker = await readManagedOverlayMarker(marketplaceDir); + const sourceReal = await realPathOrNull(source); + if (!marker || !sourceReal || marker.source !== sourceReal) return false; + const backup = `${marketplaceDir}.cindy-overlay-backup-${process.pid}-${Date.now()}`; + await fsp.rename(marketplaceDir, backup); + const linked = await ensureDirectoryLink(marketplaceDir, source); + if (linked.status !== 'linked' && linked.status !== 'kept') { + await removeManagedLink(marketplaceDir).catch(() => false); + try { + await fsp.rename(backup, marketplaceDir); + } catch (err) { + warnings.push( + `cannot restore Codex plugin marketplace overlay ${marketplaceDir}; preserved it at ${backup}: ${(err as Error).message}`, + ); + } + warnings.push( + `cannot restore direct Codex plugin marketplace link ${marketplaceDir}: ${linked.reason ?? linked.status}`, + ); + return false; + } + try { + await fsp.rm(backup, { recursive: true, force: true }); + } catch (err) { + warnings.push( + `restored direct Codex plugin marketplace link but could not remove backup ${backup}: ${(err as Error).message}`, + ); + } + return true; +} + +async function ensureOverlayMarketplace( + source: string, + marketplaceDir: string, + overlays: readonly CodexPluginOverlay[], + warnings: string[], +): Promise { + const sourceReal = await realPathOrNull(source); + if (!sourceReal) return 'missing'; + let desiredMarker: ManagedOverlayMarker; + try { + desiredMarker = { + schemaVersion: 1, + source: sourceReal, + sourceSnapshot: await sourceMarketplaceSnapshot(source, overlays), + skills: stableOverlaySkills( + overlays.filter((overlay): overlay is CodexSkillOverlay => overlay.kind === 'skill'), + ), + mcpServers: stableOverlayMcpServers( + overlays.filter((overlay): overlay is CodexMcpOverlay => overlay.kind === 'mcp'), + ), + }; + } catch (err) { + warnings.push( + `cannot snapshot Codex capability-routing source ${source}: ${(err as Error).message}`, + ); + return 'error'; + } + + const rawCurrentMarker = await readManagedOverlayMarker(marketplaceDir); + const currentMarker = rawCurrentMarker?.source === sourceReal ? rawCurrentMarker : null; + if ( + currentMarker?.overlaySnapshot && + sameOverlayConfiguration(currentMarker, desiredMarker) + ) { + try { + const currentOverlaySnapshot = await capabilityRoutingOverlaySnapshot( + marketplaceDir, + overlays, + ); + if (currentOverlaySnapshot === currentMarker.overlaySnapshot) { + return 'kept'; + } + } catch { + // A broken derived overlay is rebuilt from the already-snapshotted source. + } + } + + try { + const current = await fsp.lstat(marketplaceDir); + if (!current.isSymbolicLink() && !currentMarker) return 'conflict'; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + warnings.push( + `cannot inspect Codex plugin marketplace overlay ${marketplaceDir}: ${(err as Error).message}`, + ); + return 'error'; + } + } + + await fsp.mkdir(path.dirname(marketplaceDir), { recursive: true }); + const staging = await fsp.mkdtemp( + path.join(path.dirname(marketplaceDir), `.${path.basename(marketplaceDir)}.cindy-overlay-`), + ); + let backup: string | null = null; + try { + const pluginNames = await listDirectoryNames(source); + const overlaysByPlugin = new Map(); + for (const overlay of overlays) { + const current = overlaysByPlugin.get(overlay.pluginName) ?? []; + current.push(overlay); + overlaysByPlugin.set(overlay.pluginName, current); + } + for (const pluginName of overlaysByPlugin.keys()) { + if (!pluginNames.includes(pluginName)) { + throw new Error(`overlaid plugin ${pluginName} is missing from ${source}`); + } + } + + for (const pluginName of pluginNames) { + const sourcePlugin = path.join(source, pluginName); + const stagedPlugin = path.join(staging, pluginName); + const pluginOverlays = overlaysByPlugin.get(pluginName); + if (!pluginOverlays || pluginOverlays.length === 0) { + const linked = await ensureDirectoryLink(stagedPlugin, sourcePlugin); + if (linked.status === 'error' || linked.status === 'conflict') { + throw new Error( + `cannot link unchanged plugin ${pluginName}: ${linked.reason ?? linked.status}`, + ); + } + continue; + } + // The protected plugin is copied so Cindy can edit only its isolated + // metadata. Reject symlinks first: following one could copy unrelated + // user files into the overlay, while preserving one could make our + // metadata write escape the staging directory. + await assertOverlaySourceHasNoSymlinks(sourcePlugin); + await fsp.cp(sourcePlugin, stagedPlugin, { + recursive: true, + dereference: true, + errorOnExist: true, + force: false, + }); + await applyCapabilityRoutingOverlay(stagedPlugin, pluginOverlays); + } + desiredMarker.overlaySnapshot = await capabilityRoutingOverlaySnapshot( + staging, + overlays, + ); + await fsp.writeFile( + path.join(staging, MANAGED_OVERLAY_MARKER), + `${JSON.stringify(desiredMarker, null, 2)}\n`, + 'utf8', + ); + + try { + const current = await fsp.lstat(marketplaceDir); + if (current.isSymbolicLink()) { + await removeManagedLink(marketplaceDir); + } else if (currentMarker) { + backup = `${marketplaceDir}.cindy-overlay-backup-${process.pid}-${Date.now()}`; + await fsp.rename(marketplaceDir, backup); + } else { + await fsp.rm(staging, { recursive: true, force: true }); + return 'conflict'; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + await fsp.rename(staging, marketplaceDir); + const replacedOverlay = backup; + backup = null; + if (replacedOverlay) { + try { + await fsp.rm(replacedOverlay, { recursive: true, force: true }); + } catch (err) { + warnings.push( + `updated Codex plugin marketplace overlay but could not remove backup ${replacedOverlay}: ${(err as Error).message}`, + ); + } + } + return 'linked'; + } catch (err) { + warnings.push( + `cannot prepare Codex capability-routing overlay for ${marketplaceDir}: ${(err as Error).message}`, + ); + await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined); + if (backup) { + try { + await fsp.rename(backup, marketplaceDir); + } catch (restoreErr) { + warnings.push( + `cannot restore the previous Codex plugin marketplace overlay ${marketplaceDir}; preserved it at ${backup}: ${(restoreErr as Error).message}`, + ); + } + } else if (!(await isDirectory(marketplaceDir))) { + await ensureDirectoryLink(marketplaceDir, source); + } + return 'error'; + } +} + +/** + * 清理悬空的受管内容: + * - 隔离 cache 里指向已消失 source marketplace 的 symlink; + * - source 已消失、且带本模块 marker 的 capability-routing overlay。 + * codex 自建或用户手工布置的真实目录永不触碰。 + */ +async function cleanupStaleLinks(cacheDir: string, liveNames: Set): Promise { let entries: string[]; try { entries = await fsp.readdir(cacheDir); @@ -106,21 +873,79 @@ async function cleanupStaleLinks( for (const entry of entries) { if (liveNames.has(entry)) continue; const entryPath = path.join(cacheDir, entry); + let stat: import('node:fs').Stats; try { - const stat = await fsp.lstat(entryPath); - if (!stat.isSymbolicLink()) continue; + stat = await fsp.lstat(entryPath); } catch { continue; } - // 只清悬空链接(target 已不存在)。指向仍存在目标的 symlink 可能是用户手工 - // 布置的,保守保留。 - if ((await realPathOrNull(entryPath)) === null) { - changed = (await removeManagedLink(entryPath)) || changed; + if (stat.isSymbolicLink()) { + // 只清悬空链接(target 已不存在)。指向仍存在目标的 symlink 可能是用户手工 + // 布置的,保守保留。 + if ((await realPathOrNull(entryPath)) === null) { + changed = (await removeManagedLink(entryPath)) || changed; + } + continue; + } + if (!stat.isDirectory()) continue; + const marker = await readManagedOverlayMarker(entryPath); + if (marker && (await realPathOrNull(marker.source)) === null) { + await fsp.rm(entryPath, { recursive: true, force: true }); + changed = true; } } return changed; } +async function collectCapabilityRoutingFailures( + cacheDir: string, + sourceCacheDir: string, + overlaysByMarketplace: ReadonlyMap, + marketplaces: readonly CodexGlobalPluginsMarketplaceResult[], + enablement: PluginEnablementSnapshot, + inventory: { sourceReadable: boolean; isolatedReadable: boolean }, +): Promise { + const statusByMarketplace = new Map( + marketplaces.map(({ name, status }) => [name, status] as const), + ); + const failures: string[] = []; + for (const [marketplace, overlays] of overlaysByMarketplace) { + const status = statusByMarketplace.get(marketplace); + if (status === 'linked' || status === 'kept') continue; + const protectedPlugins = new Map( + overlays.map((overlay) => [overlay.pluginKey, overlay.pluginName] as const), + ); + for (const [pluginKey, pluginName] of protectedPlugins) { + // A cache directory alone does not make a plugin active. A readable + // isolated config is authoritative because syncPluginEntries preserves + // `enabled = false`. If config or inventory cannot be read, absence is + // no longer proof of safety: an unenforced overlay must fail closed. + if ( + enablement.status === 'known' && + !enablement.enabledPluginKeys.has(pluginKey) + ) { + continue; + } + const [isolatedPluginExists, sourcePluginExists] = await Promise.all([ + isDirectory(path.join(cacheDir, marketplace, pluginName)), + isDirectory(path.join(sourceCacheDir, marketplace, pluginName)), + ]); + if ( + !isolatedPluginExists && + !sourcePluginExists && + inventory.sourceReadable && + inventory.isolatedReadable + ) { + continue; + } + failures.push( + `cannot enforce Cindy capability routing for installed Codex plugin ${pluginName}@${marketplace} (marketplace status: ${status ?? 'unmanaged'})`, + ); + } + } + return failures; +} + /** 从 `name@marketplace` key 提取 marketplace 段;无 `@` 返回 null。 */ function marketplaceOfPluginKey(key: string): string | null { const at = key.lastIndexOf('@'); @@ -154,6 +979,29 @@ async function readPluginsTable(file: string): Promise<{ }; } +async function readEnabledPluginKeys( + configFile: string, + warnings: string[], +): Promise { + let config: Awaited>; + try { + config = await readPluginsTable(configFile); + } catch (err) { + warnings.push( + `cannot confirm enabled plugins in isolated codex config ${configFile}: ${(err as Error).message}`, + ); + return { status: 'unknown' }; + } + return { + status: 'known', + enabledPluginKeys: new Set( + Object.entries(config.plugins).flatMap(([key, value]) => + isRecord(value) && value['enabled'] !== false ? [key] : [], + ), + ), + }; +} + /** * 条件原子写:仅当 file 当前内容仍等于 expectedText(本轮 merge 所依据的快照) * 时才 rename 覆盖,否则丢弃 tmp 返回 false —— 由调用方拿新内容重算重试。 @@ -265,7 +1113,15 @@ async function syncPluginEntries( const missingKeys = Object.keys(missing); if (missingKeys.length === 0) return []; - const fragment = stringifyToml({ plugins: missing }); + let fragment: string; + try { + fragment = stringifyToml({ plugins: missing }); + } catch (err) { + warnings.push( + `cannot serialize plugin entries for ${paths.configFile}: ${(err as Error).message}`, + ); + return []; + } const base = dest.text !== '' && !dest.text.endsWith('\n') ? `${dest.text}\n` : dest.text; const sep = dest.text === '' ? '' : '\n'; try { @@ -286,41 +1142,180 @@ async function syncPluginEntries( return []; } -export async function prepareCodexGlobalPluginsBridge( - codexHome: string, - opts: PrepareOptions = {}, -): Promise { - const paths = codexGlobalPluginsPaths(codexHome, opts.homeDir); - const warnings: string[] = []; - const marketplaces: CodexGlobalPluginsMarketplaceResult[] = []; - let changed = false; +async function inspectPluginInventories( + paths: ReturnType, + warnings: string[], +): Promise<{ + sourceNames: string[]; + sourceReadable: boolean; + isolatedReadable: boolean; + changed: boolean; +}> { + let sourceNames: string[] = []; + try { + sourceNames = await listSourceMarketplaces(paths.sourceCacheDir); + } catch (err) { + warnings.push( + `cannot inspect user Codex plugin cache ${paths.sourceCacheDir}: ${(err as Error).message}`, + ); + return { + sourceNames, + sourceReadable: false, + isolatedReadable: true, + changed: false, + }; + } - const sourceNames = await listSourceMarketplaces(paths.sourceCacheDir); - const liveNames = new Set(sourceNames); + try { + return { + sourceNames, + sourceReadable: true, + isolatedReadable: true, + changed: await cleanupStaleLinks(paths.cacheDir, new Set(sourceNames)), + }; + } catch (err) { + warnings.push( + `cannot inspect isolated Codex plugin cache ${paths.cacheDir}: ${(err as Error).message}`, + ); + return { + sourceNames, + sourceReadable: true, + isolatedReadable: false, + changed: false, + }; + } +} - changed = (await cleanupStaleLinks(paths.cacheDir, liveNames)) || changed; +async function prepareSourceMarketplace( + paths: ReturnType, + name: string, + overlays: readonly CodexPluginOverlay[] | undefined, + cacheReady: boolean, + warnings: string[], +): Promise<{ result: CodexGlobalPluginsMarketplaceResult; changed: boolean }> { + const source = path.join(paths.sourceCacheDir, name); + const link = path.join(paths.cacheDir, name); + let status: ManagedLinkStatus; + let reason: string | undefined; + let changed = false; - if (sourceNames.length > 0) { - await fsp.mkdir(paths.cacheDir, { recursive: true }); - for (const name of sourceNames) { - const source = path.join(paths.sourceCacheDir, name); - const link = path.join(paths.cacheDir, name); - const result = await ensureDirectoryLink(link, source); - changed = changed || result.changed; - marketplaces.push({ name, source, link, status: result.status, reason: result.reason }); - // conflict 是稳态(codex remote-install 会在隔离 home 自建同名真实目录), - // 不进 warnings 以免每次 session start 刷告警;只有真实错误才告警。 - if (result.status === 'error') { - warnings.push( - `cannot link codex plugin marketplace cache ${name} from ${source}: ${result.reason ?? 'unknown error'}`, - ); + if (!cacheReady) { + status = 'error'; + reason = 'isolated plugin cache is unavailable'; + } else { + try { + if (overlays && overlays.length > 0) { + status = await ensureOverlayMarketplace(source, link, overlays, warnings); + changed = status === 'linked'; + } else { + changed = await removeManagedOverlayDirectory(link, source, warnings); + const linked = await ensureDirectoryLink(link, source); + status = linked.status; + reason = linked.reason; + changed = changed || linked.changed; } + } catch (err) { + status = 'error'; + reason = (err as Error).message; + warnings.push(`cannot prepare Codex plugin marketplace ${name}: ${reason}`); } + } + + if (status === 'error' && (!overlays || overlays.length === 0)) { + warnings.push( + `cannot link codex plugin marketplace cache ${name} from ${source}: ${reason ?? 'unknown error'}`, + ); + } + return { result: { name, source, link, status, reason }, changed }; +} + +async function prepareSourceMarketplaces( + paths: ReturnType, + sourceNames: readonly string[], + overlaysByMarketplace: ReadonlyMap, + warnings: string[], +): Promise<{ + marketplaces: CodexGlobalPluginsMarketplaceResult[]; + added: string[]; + changed: boolean; + isolatedReadable: boolean; +}> { + if (sourceNames.length === 0) { + return { marketplaces: [], added: [], changed: false, isolatedReadable: true }; + } + + let cacheReady = true; + try { + await fsp.mkdir(paths.cacheDir, { recursive: true }); + } catch (err) { + cacheReady = false; + warnings.push( + `cannot prepare isolated Codex plugin cache ${paths.cacheDir}: ${(err as Error).message}`, + ); + } - const added = await syncPluginEntries(paths, liveNames, warnings); - changed = changed || added.length > 0; - return { codexHome, cacheDir: paths.cacheDir, changed, marketplaces, addedPluginEntries: added, warnings }; + const marketplaces: CodexGlobalPluginsMarketplaceResult[] = []; + let changed = false; + for (const name of sourceNames) { + const prepared = await prepareSourceMarketplace( + paths, + name, + overlaysByMarketplace.get(name), + cacheReady, + warnings, + ); + marketplaces.push(prepared.result); + changed = changed || prepared.changed; } + const added = await syncPluginEntries(paths, new Set(sourceNames), warnings); + return { + marketplaces, + added, + changed: changed || added.length > 0, + isolatedReadable: cacheReady, + }; +} - return { codexHome, cacheDir: paths.cacheDir, changed, marketplaces, addedPluginEntries: [], warnings }; +export async function prepareCodexGlobalPluginsBridge( + codexHome: string, + opts: PrepareOptions = {}, +): Promise { + const paths = codexGlobalPluginsPaths(codexHome, opts.homeDir); + const warnings: string[] = []; + const overlaysByMarketplace = groupOverlaysByMarketplace( + codexCapabilityOverlays(opts.capabilityRouting), + ); + const inventory = await inspectPluginInventories(paths, warnings); + const prepared = await prepareSourceMarketplaces( + paths, + inventory.sourceNames, + overlaysByMarketplace, + warnings, + ); + + const enablement: PluginEnablementSnapshot = + overlaysByMarketplace.size > 0 + ? await readEnabledPluginKeys(paths.configFile, warnings) + : { status: 'known', enabledPluginKeys: new Set() }; + const routingFailures = await collectCapabilityRoutingFailures( + paths.cacheDir, + paths.sourceCacheDir, + overlaysByMarketplace, + prepared.marketplaces, + enablement, + { + sourceReadable: inventory.sourceReadable, + isolatedReadable: + inventory.isolatedReadable && prepared.isolatedReadable, + }, + ); + return { + codexHome, + cacheDir: paths.cacheDir, + changed: inventory.changed || prepared.changed, + marketplaces: prepared.marketplaces, + addedPluginEntries: prepared.added, + routingFailures, + warnings, + }; } diff --git a/apps/desktop/src/main/maker-host/index.ts b/apps/desktop/src/main/maker-host/index.ts index 31095c54d75..e1e96b1acf2 100644 --- a/apps/desktop/src/main/maker-host/index.ts +++ b/apps/desktop/src/main/maker-host/index.ts @@ -181,6 +181,7 @@ import { } from './mcp-tool-approval-policy.js'; import { mapCodexAppServerModelsToCatalog } from './codex-model-discovery.js'; import { prepareSharedProjectSkillLinks } from './shared-global-skills.js'; +import { DESKTOP_CAPABILITY_ROUTING_POLICY } from './capability-routing.js'; export { withRehydrateCloseSuppressed }; type RemoteCcQuery = Awaited< @@ -696,6 +697,7 @@ export function getMaker(): Maker { // + mkdir), tailer 再归一化汇入该 session 的 .ndjson。 resolveCcDebugFile: resolveSessionCcDebugFile, mcpProviders: claudeMcpProviders, + capabilityRouting: DESKTOP_CAPABILITY_ROUTING_POLICY, makerMemory: makerMemoryManager, // 智能通讯录 prompt 段的「本会话有效状态」: 与 mcp-providers.ts 的 provider // 包装同一判定链(PluginRegistry 工作区/用户覆盖 → 全局开关), 保证工具面与 @@ -941,6 +943,7 @@ export function getMaker(): Maker { // codex 子进程没法消费 in-process JS instance, prepareCodexExtraSpawnConfig // 起 streamable-HTTP bridge 把 instance 通过 -c 'mcp_servers...=...' 注入。 mcpProviders: codexMcpProviders, + capabilityRouting: DESKTOP_CAPABILITY_ROUTING_POLICY, makerMemory: makerMemoryManager, // 通讯录 prompt 段有效状态(codex 版): 在 claude 的判定链之上再与「实际应用 // 到 running app-server 的 spawn 快照」对齐 —— 开关切换后失效失败(busy, diff --git a/packages/maker-cc-manager/__tests__/protocol.test.ts b/packages/maker-cc-manager/__tests__/protocol.test.ts index aa8f7b87c48..b73942185ba 100644 --- a/packages/maker-cc-manager/__tests__/protocol.test.ts +++ b/packages/maker-cc-manager/__tests__/protocol.test.ts @@ -17,6 +17,10 @@ describe('protocol constants', () => { expect(PROTOCOL_VERSION).toBeGreaterThan(0); }); + it('requires v2 so old daemons cannot ignore host tool guards', () => { + expect(PROTOCOL_VERSION).toBe(2); + }); + it('METHODS has expected method names', () => { expect(METHODS.PROTOCOL_HELLO).toBe('protocol/hello'); expect(METHODS.QUERY_START).toBe('query/start'); diff --git a/packages/maker-cc-manager/__tests__/sdk-handlers.test.ts b/packages/maker-cc-manager/__tests__/sdk-handlers.test.ts index bf10e1d17e3..3d55673628c 100644 --- a/packages/maker-cc-manager/__tests__/sdk-handlers.test.ts +++ b/packages/maker-cc-manager/__tests__/sdk-handlers.test.ts @@ -13,7 +13,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { RpcClient, RpcClientError } from '../src/client.js'; import { ManagerServer } from '../src/server.js'; -import { SessionRegistry, type SdkQueryFactory, type SdkQueryLike } from '../src/session-registry.js'; +import { + SessionRegistry, + type SdkQueryFactory, + type SdkQueryFactoryOptions, + type SdkQueryLike, +} from '../src/session-registry.js'; import { wireSdkHandlers } from '../src/sdk-handlers.js'; import { NOTIFICATIONS } from '../src/protocol.js'; @@ -26,6 +31,7 @@ interface Ctx { } let ctx: Ctx | null = null; +let latestFactoryOptions: SdkQueryFactoryOptions | null = null; function makeIpcPath(): string { const uniq = `cc-mgr-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -37,6 +43,7 @@ function makeIpcPath(): string { function buildFakeFactory(): SdkQueryFactory { return (opts): SdkQueryLike => { + latestFactoryOptions = opts; async function* gen(): AsyncGenerator { yield { type: 'system', @@ -44,6 +51,10 @@ function buildFakeFactory(): SdkQueryFactory { session_id: 'fake-sdk-uuid', cwd: opts.cwd, model: opts.model, + mcp_servers: Object.keys(opts.mcpServers ?? {}).map((name) => ({ + name, + status: 'connected', + })), }; for await (const userMsg of opts.inputStream) { yield { @@ -60,6 +71,13 @@ function buildFakeFactory(): SdkQueryFactory { async setModel() {}, async setPermissionMode() {}, async applyFlagSettings() {}, + async mcpServerStatus() { + return Object.keys(opts.mcpServers ?? {}).map((name) => ({ + name, + status: 'connected', + scope: 'local', + })); + }, async stopTask() {}, async getContextUsage() { return { @@ -82,6 +100,7 @@ function buildFakeFactory(): SdkQueryFactory { } beforeEach(async () => { + latestFactoryOptions = null; const socketPath = makeIpcPath(); const registry = new SessionRegistry({ sdkQueryFactory: buildFakeFactory(), bufferCapacity: 2 }); const server = new ManagerServer({ @@ -302,6 +321,79 @@ describe('sdk-handlers end-to-end', () => { } }); + it('query/start rejects routing guards with ambiguous plain-text selectors', async () => { + try { + await ctx!.client.request('query/start', { + sessionId: 's1', + cwd: '/a', + model: 'm', + env: {}, + toolGuards: [ + { + toolNamePrefix: 'mcp__plugin_guard__', + invocation: 'explicit-only', + explicitSelectors: ['Feishu Delegate'], + }, + ], + }); + throw new Error('expected rejection'); + } catch (err) { + expect(err).toBeInstanceOf(RpcClientError); + expect((err as RpcClientError).rpcError.code).toBe('INVALID_PARAMS'); + } + }); + + it('query/start rejects invalid routing guard prefixes', async () => { + for (const [index, toolNamePrefix] of [' ', 'not-an-mcp-prefix'].entries()) { + try { + await ctx!.client.request('query/start', { + sessionId: `s-invalid-prefix-${index}`, + cwd: '/a', + model: 'm', + env: {}, + toolGuards: [ + { + toolNamePrefix, + invocation: 'disabled', + }, + ], + }); + throw new Error('expected rejection'); + } catch (err) { + expect(err).toBeInstanceOf(RpcClientError); + expect((err as RpcClientError).rpcError.code).toBe('INVALID_PARAMS'); + } + } + }); + + it('query/start normalizes routing guard identities before matching', async () => { + await ctx!.client.request('query/start', { + sessionId: 's1', + cwd: '/a', + model: 'm', + env: {}, + toolGuards: [ + { + toolNamePrefix: ' mcp__plugin_guard__ ', + sourceServerId: ' plugin:guard ', + invocation: 'explicit-only', + }, + ], + }); + await waitFor(() => ctx!.notifications.length >= 1); + + const preToolUse = latestFactoryOptions?.hooks?.PreToolUse?.[0]?.hooks[0]; + expect(preToolUse).toBeDefined(); + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + tool_name: 'mcp__plugin_guard__call', + }), + ).resolves.toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }); + }); + it('query/close ends consume loop → session.alive=false + closed notification', async () => { await ctx!.client.request('query/start', { sessionId: 's1', cwd: '/a', model: 'm', env: {} }); await waitFor(() => ctx!.notifications.length >= 1); diff --git a/packages/maker-cc-manager/__tests__/session-registry.test.ts b/packages/maker-cc-manager/__tests__/session-registry.test.ts index 1e2e3493cc4..3143cd97706 100644 --- a/packages/maker-cc-manager/__tests__/session-registry.test.ts +++ b/packages/maker-cc-manager/__tests__/session-registry.test.ts @@ -154,6 +154,485 @@ describe('SessionRegistry', () => { expect(controlCalls[2].args).toEqual([{ effortLevel: 'high' }]); }); + it('enforces remote tool guards before permission rules while preserving explicit selection', async () => { + const { factory: baseFactory } = buildFakeFactory(); + let captured: SdkQueryFactoryOptions | undefined; + const registry = new SessionRegistry({ + sdkQueryFactory: (opts) => { + captured = opts; + return baseFactory(opts); + }, + }); + const events: Array<{ kind: string; payload: unknown }> = []; + registry.create({ + sessionId: 's-guard', + cwd: '/x', + model: 'm', + env: {}, + toolGuards: [ + { + toolNamePrefix: + 'mcp__plugin_feishu-delegate_feishu-delegate__', + sourceServerId: 'plugin:feishu-delegate:feishu-delegate', + invocation: 'explicit-only', + explicitSelectors: [ + '$feishu-delegate:message-feishu-coworkers', + '/feishu-delegate:message-feishu-coworkers', + ], + denialMessage: 'Use the Cindy Feishu source.', + }, + ], + // A serialized caller cannot replace the daemon-owned guard. + extraOptions: { hooks: { PreToolUse: [] } }, + }); + registry.attach('s-guard', (kind, payload) => + events.push({ kind, payload }), + ); + await waitFor(() => events.length >= 1); + const preToolUse = captured?.hooks?.PreToolUse?.[0]?.hooks[0]; + expect(preToolUse).toBeDefined(); + + registry.sendMessage('s-guard', { + type: 'user', + message: { + role: 'user', + content: '不要使用 Feishu Delegate,查一下消息', + }, + parent_tool_use_id: null, + }); + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + session_id: 'sdk-session', + tool_name: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + tool_input: {}, + }), + ).resolves.toMatchObject({ + hookSpecificOutput: { + permissionDecision: 'deny', + permissionDecisionReason: 'Use the Cindy Feishu source.', + }, + }); + // A user MCP with the ordinary server name is a different source. + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + session_id: 'sdk-session', + tool_name: 'mcp__feishu-delegate__read_messages', + tool_input: {}, + }), + ).resolves.toEqual({ continue: true }); + + await waitFor(() => events.length >= 3); + registry.sendMessage('s-guard', { + type: 'user', + message: { + role: 'user', + content: + '/feishu-delegate:message-feishu-coworkers 查一下康康', + }, + parent_tool_use_id: null, + }); + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + session_id: 'sdk-session', + tool_name: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + tool_input: {}, + }), + ).resolves.toEqual({ continue: true }); + }); + + it('keeps explicit selection across accepted same-turn steering inputs', async () => { + let captured: SdkQueryFactoryOptions | undefined; + const factory: SdkQueryFactory = (opts) => { + captured = opts; + async function* generate(): AsyncGenerator { + yield { + type: 'system', + subtype: 'init', + session_id: 'sdk-guard-steer', + }; + for await (const message of opts.inputStream) { + void message; + yield { type: 'assistant', message: { content: [] } }; + } + } + const gen = generate(); + return { + [Symbol.asyncIterator]: () => gen, + async interrupt() {}, + async setModel() {}, + async setPermissionMode() {}, + async applyFlagSettings() {}, + }; + }; + const registry = new SessionRegistry({ sdkQueryFactory: factory }); + registry.create({ + sessionId: 's-steer', + cwd: '/x', + model: 'm', + env: {}, + toolGuards: [ + { + toolNamePrefix: 'mcp__plugin_guard__', + invocation: 'explicit-only', + explicitSelectors: ['$plugin:guard'], + }, + ], + }); + registry.sendMessage('s-steer', { + type: 'user', + message: { role: 'user', content: '$plugin:guard 开始' }, + }); + registry.sendMessage('s-steer', { + type: 'user', + message: { role: 'user', content: '再补充一点' }, + }); + + const preToolUse = captured?.hooks?.PreToolUse?.[0]?.hooks[0]; + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + tool_name: 'mcp__plugin_guard__call', + }), + ).resolves.toEqual({ continue: true }); + }); + + it('merges plan-review edits and user feedback into remote guard selection', async () => { + const cases = [ + { + label: 'edited-plan', + expectedGuardResult: { continue: true }, + decision: { + kind: 'plan_review' as const, + behavior: 'allow' as const, + editedPlan: '1. use $plugin:guard', + }, + }, + { + label: 'revision-feedback', + expectedGuardResult: { continue: true }, + decision: { + kind: 'plan_review' as const, + behavior: 'deny' as const, + reason: 'please use $plugin:guard instead', + }, + }, + { + label: 'system-dismissal', + expectedGuardResult: { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + }, + }, + decision: { + kind: 'plan_review' as const, + behavior: 'deny' as const, + reason: 'system dismissed $plugin:guard', + dismissed: true, + }, + }, + ]; + + for (const testCase of cases) { + const { factory: baseFactory } = buildFakeFactory(); + let captured: SdkQueryFactoryOptions | undefined; + const registry = new SessionRegistry({ + sdkQueryFactory: (opts) => { + captured = opts; + return baseFactory(opts); + }, + onApprovalRequest: async (_sessionId, request) => + request.kind === 'plan_review' + ? testCase.decision + : { kind: 'permission', behavior: 'allow' }, + }); + const events: Array<{ kind: string; payload: unknown }> = []; + const sessionId = `s-plan-${testCase.label}`; + registry.create({ + sessionId, + cwd: '/x', + model: 'm', + env: {}, + toolGuards: [ + { + toolNamePrefix: 'mcp__plugin_guard__', + invocation: 'explicit-only', + explicitSelectors: ['$plugin:guard'], + }, + ], + }); + registry.attach(sessionId, (kind, payload) => + events.push({ kind, payload }), + ); + await waitFor(() => events.length >= 1); + registry.sendMessage(sessionId, { + type: 'user', + message: { role: 'user', content: 'make a plan' }, + }); + + const canUseTool = captured?.canUseTool; + const preToolUse = captured?.hooks?.PreToolUse?.[0]?.hooks[0]; + expect(canUseTool).toBeDefined(); + expect(preToolUse).toBeDefined(); + await canUseTool!( + 'ExitPlanMode', + { plan: '1. call the capability' }, + { toolUseID: `plan-${testCase.label}` }, + ); + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + tool_name: 'mcp__plugin_guard__call', + }), + ).resolves.toMatchObject(testCase.expectedGuardResult); + } + }); + + it('does not guard a connected settings MCP with an exact or normalized plugin id', async () => { + for (const [label, connectedNames] of [ + ['exact', ['plugin:feishu-delegate:feishu-delegate']], + [ + 'normalized', + [ + 'plugin:feishu-delegate:feishu-delegate', + 'plugin_feishu-delegate_feishu-delegate', + ], + ], + ] as const) { + let captured: SdkQueryFactoryOptions | undefined; + const forwardedApproval = vi.fn(async () => ({ + kind: 'permission' as const, + behavior: 'allow' as const, + })); + const factory: SdkQueryFactory = (opts) => { + captured = opts; + async function* generate(): AsyncGenerator { + yield { + type: 'system', + subtype: 'init', + session_id: `sdk-settings-collision-${label}`, + mcp_servers: connectedNames.map((name) => ({ + name, + status: 'connected', + })), + }; + for await (const message of opts.inputStream) void message; + } + const query = generate(); + return { + [Symbol.asyncIterator]: () => query, + async interrupt() {}, + async setModel() {}, + async setPermissionMode() {}, + async applyFlagSettings() {}, + async mcpServerStatus() { + return connectedNames.map((name) => ({ + name, + status: 'connected', + scope: + name === 'plugin:feishu-delegate:feishu-delegate' && + label === 'normalized' + ? 'dynamic' + : 'project', + })); + }, + }; + }; + const registry = new SessionRegistry({ + sdkQueryFactory: factory, + onApprovalRequest: forwardedApproval, + }); + registry.create({ + sessionId: `s-settings-collision-${label}`, + cwd: '/x', + model: 'm', + env: {}, + toolGuards: [ + { + toolNamePrefix: 'mcp__plugin_feishu-delegate_feishu-delegate__', + sourceServerId: 'plugin:feishu-delegate:feishu-delegate', + invocation: 'explicit-only', + explicitSelectors: ['/feishu-delegate:message-feishu-coworkers'], + }, + ], + }); + registry.attach(`s-settings-collision-${label}`, () => undefined); + await waitFor( + () => + registry.list()[0]?.sdkSessionId === + `sdk-settings-collision-${label}`, + ); + const preToolUse = captured?.hooks?.PreToolUse?.[0]?.hooks[0]; + expect(preToolUse).toBeDefined(); + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + tool_name: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + }), + ).resolves.toEqual({ continue: true }); + const canUseTool = captured?.canUseTool; + expect(canUseTool).toBeDefined(); + await expect( + canUseTool!( + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + {}, + { toolUseID: `tool-settings-collision-${label}` }, + ), + ).resolves.toMatchObject({ behavior: 'allow' }); + expect(forwardedApproval).toHaveBeenCalledWith( + `s-settings-collision-${label}`, + expect.objectContaining({ + metadata: expect.objectContaining({ + capabilityRoutingChecked: true, + }), + }), + ); + } + }); + + it('keeps the guard for the harness plugin MCP itself', async () => { + let captured: SdkQueryFactoryOptions | undefined; + const sourceServerId = 'plugin:feishu-delegate:feishu-delegate'; + const forwardedApproval = vi.fn(async () => ({ + kind: 'permission' as const, + behavior: 'allow' as const, + })); + const factory: SdkQueryFactory = (opts) => { + captured = opts; + async function* generate(): AsyncGenerator { + yield { + type: 'system', + subtype: 'init', + session_id: 'sdk-harness-plugin-source', + mcp_servers: [{ name: sourceServerId, status: 'connected' }], + }; + for await (const message of opts.inputStream) void message; + } + const query = generate(); + return { + [Symbol.asyncIterator]: () => query, + async interrupt() {}, + async setModel() {}, + async setPermissionMode() {}, + async applyFlagSettings() {}, + async mcpServerStatus() { + return [{ + name: sourceServerId, + status: 'connected', + scope: 'dynamic', + }]; + }, + }; + }; + const registry = new SessionRegistry({ + sdkQueryFactory: factory, + onApprovalRequest: forwardedApproval, + }); + registry.create({ + sessionId: 's-harness-plugin-source', + cwd: '/x', + model: 'm', + env: {}, + toolGuards: [{ + toolNamePrefix: 'mcp__plugin_feishu-delegate_feishu-delegate__', + sourceServerId, + invocation: 'explicit-only', + }], + }); + registry.attach('s-harness-plugin-source', () => undefined); + await waitFor( + () => registry.list()[0]?.sdkSessionId === 'sdk-harness-plugin-source', + ); + const preToolUse = captured?.hooks?.PreToolUse?.[0]?.hooks[0]; + expect(preToolUse).toBeDefined(); + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + tool_name: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + }), + ).resolves.toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }); + const canUseTool = captured?.canUseTool; + expect(canUseTool).toBeDefined(); + await expect( + canUseTool!( + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + {}, + { toolUseID: 'tool-harness-plugin-source' }, + ), + ).resolves.toMatchObject({ behavior: 'deny' }); + expect(forwardedApproval).not.toHaveBeenCalled(); + }); + + it('keeps the guard when the only colliding settings MCP failed to connect', async () => { + let captured: SdkQueryFactoryOptions | undefined; + const factory: SdkQueryFactory = (opts) => { + captured = opts; + async function* generate(): AsyncGenerator { + yield { + type: 'system', + subtype: 'init', + session_id: 'sdk-failed-settings-collision', + mcp_servers: [ + { name: 'plugin_feishu-delegate_feishu-delegate', status: 'failed' }, + ], + }; + for await (const message of opts.inputStream) void message; + } + const query = generate(); + return { + [Symbol.asyncIterator]: () => query, + async interrupt() {}, + async setModel() {}, + async setPermissionMode() {}, + async applyFlagSettings() {}, + async mcpServerStatus() { + return [{ + name: 'plugin_feishu-delegate_feishu-delegate', + status: 'failed', + scope: 'project', + }]; + }, + }; + }; + const registry = new SessionRegistry({ sdkQueryFactory: factory }); + registry.create({ + sessionId: 's-failed-settings-collision', + cwd: '/x', + model: 'm', + env: {}, + toolGuards: [ + { + toolNamePrefix: 'mcp__plugin_feishu-delegate_feishu-delegate__', + sourceServerId: 'plugin:feishu-delegate:feishu-delegate', + invocation: 'explicit-only', + explicitSelectors: ['/feishu-delegate:message-feishu-coworkers'], + }, + ], + }); + await waitFor( + () => registry.list()[0]?.sdkSessionId === 'sdk-failed-settings-collision', + ); + const preToolUse = captured?.hooks?.PreToolUse?.[0]?.hooks[0]; + expect(preToolUse).toBeDefined(); + await expect( + preToolUse!({ + hook_event_name: 'PreToolUse', + tool_name: 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + }), + ).resolves.toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }); + }); + it('attach with second client replaces first, notifies the old one', async () => { const { factory } = buildFakeFactory(); const eventsA: Array<{ kind: string; payload: unknown }> = []; diff --git a/packages/maker-cc-manager/src/async-queue.ts b/packages/maker-cc-manager/src/async-queue.ts index 03ff8e54e3a..c3d1bbe2203 100644 --- a/packages/maker-cc-manager/src/async-queue.ts +++ b/packages/maker-cc-manager/src/async-queue.ts @@ -1,13 +1,14 @@ /** * Single-consumer async queue — many-producers push + single end(). * - * Copied verbatim from maker-core/src/agents/shared/async-queue.ts so this + * Kept in sync with maker-core/src/agents/shared/async-queue.ts so this * package stays zero-internal-deps (manager binary is esbuild-bundled and * shipped to remote SSH machines, can't pull in maker-core). */ export interface AsyncQueue extends AsyncIterable { - push(item: T): void; + /** Returns false when the queue is already ended and did not accept input. */ + push(item: T): boolean; end(): void; } @@ -16,14 +17,15 @@ export function createAsyncQueue(): AsyncQueue { const waiters: Array<(done: boolean) => void> = []; let ended = false; - function push(item: T): void { - if (ended) return; + function push(item: T): boolean { + if (ended) return false; if (waiters.length > 0) { items.push(item); waiters.shift()!(false); } else { items.push(item); } + return true; } function end(): void { diff --git a/packages/maker-cc-manager/src/bin/cc-mgr.ts b/packages/maker-cc-manager/src/bin/cc-mgr.ts index 735e800a658..b65ba39457e 100644 --- a/packages/maker-cc-manager/src/bin/cc-mgr.ts +++ b/packages/maker-cc-manager/src/bin/cc-mgr.ts @@ -304,7 +304,7 @@ async function runDaemon(socketPath: string): Promise { // inside the cindy-slack ghost's slack_call_tool.) const sdkQueryFactory = (opts: SdkQueryFactoryOptions): SdkQueryLike => { - const { inputStream, cwd, model, env, mcpServers, permissionMode, systemPrompt, additionalDirectories, allowedTools, disallowedTools, tools, resume, extraOptions, canUseTool, getOAuthToken } = opts; + const { inputStream, cwd, model, env, mcpServers, permissionMode, systemPrompt, additionalDirectories, allowedTools, disallowedTools, tools, resume, extraOptions, hooks, canUseTool, getOAuthToken } = opts; // SDK's `query` accepts `prompt: string | AsyncIterable`. // We pass our inputQueue (push-based AsyncIterable) so the SDK consumes // user messages on demand. SDK's options.* fields are typed strictly — @@ -329,6 +329,9 @@ async function runDaemon(socketPath: string): Promise { // control 分支)—— 与 desktop 本地分支同一注入方式,经 spread 绕过类型检查。 ...(getOAuthToken ? { getOAuthToken: getOAuthToken as any } : {}), ...(extraOptions ?? {}), + // Daemon-owned hooks must win over JSON extraOptions. They enforce + // host routing before Claude's permission mode and setting rules. + ...(hooks ? { hooks: hooks as any } : {}), } as any, }); /* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/packages/maker-cc-manager/src/index.ts b/packages/maker-cc-manager/src/index.ts index d19eed50578..e68d900da87 100644 --- a/packages/maker-cc-manager/src/index.ts +++ b/packages/maker-cc-manager/src/index.ts @@ -41,6 +41,7 @@ export type { HelloParams, HelloResult, QueryStartParams, + QueryToolGuard, QueryStartResult, QuerySendParams, QuerySetModelParams, diff --git a/packages/maker-cc-manager/src/protocol.ts b/packages/maker-cc-manager/src/protocol.ts index 81aeef2a10d..f8d03997739 100644 --- a/packages/maker-cc-manager/src/protocol.ts +++ b/packages/maker-cc-manager/src/protocol.ts @@ -19,12 +19,16 @@ /** * Bump on any breaking change. Minor additive changes don't bump. * + * v2: query/start 增加 host toolGuards,并要求 daemon 在权限规则之前重建 + * PreToolUse 闸门。旧 daemon 会无声忽略未知字段,导致 capability routing + * fail-open,因此这项表面上的字段新增必须按不兼容协议升级处理。 + * * v1 (redesign): 删除 dead-session drain/archive 握手,对齐 codex 模式。 * reattach 只接新 events (live-only subscription),不 replay 旧 ring buffer。 * ring buffer 降级为纯内存 fast-path(同一 daemon 进程生命周期内的 mid-turn 续流)。 * 断开期间跑完的输出暂不自动补回 chat(follow-up: jsonl recovery 统一 cc + codex)。 */ -export const PROTOCOL_VERSION = 1 as const; +export const PROTOCOL_VERSION = 2 as const; /** * cc-mgr bundle 版本号 — 手动 bump。 @@ -33,7 +37,7 @@ export const PROTOCOL_VERSION = 1 as const; * 无关依赖变化而变。desktop 用这个(而非 bundle sha256)判断远端 daemon * 是否需要 upgrade,避免无关的 pnpm install 触发全量远端重装。 */ -export const CC_MGR_BUNDLE_VERSION = '0.0.5' as const; +export const CC_MGR_BUNDLE_VERSION = '0.0.6' as const; export type RpcId = number; @@ -189,10 +193,31 @@ export interface QueryStartParams { disallowedTools?: string[]; /** SDK options.tools (preset). */ tools?: unknown; + /** + * Host-owned, daemon-enforced tool routing guards. + * + * These are materialized as in-process PreToolUse hooks on the remote + * machine. Keeping them outside `extraOptions` prevents remote user/project + * permission allow rules (or bypassPermissions) from silently skipping the + * host's capability-source choice. + */ + toolGuards?: QueryToolGuard[]; /** Any extra SDK options we want to pass through verbatim. */ extraOptions?: Record; } +export interface QueryToolGuard { + /** Exact SDK tool-name prefix, for example `mcp__plugin_x_server__`. */ + toolNamePrefix: string; + /** Exact harness-owned MCP server id before Claude normalizes punctuation. */ + sourceServerId?: string; + invocation: 'auto' | 'explicit-only' | 'disabled'; + /** Explicit command tokens that select this source for the active turn. */ + explicitSelectors?: string[]; + /** Optional user-facing denial reason returned by the PreToolUse hook. */ + denialMessage?: string; +} + export interface QueryStartResult { sessionId: string; /** SDK-assigned session UUID once the first message arrives. May be empty initially. */ @@ -359,6 +384,8 @@ export interface ApprovalRequestResult { answers?: Record; /** For 'plan_review': edited plan text. */ editedPlan?: string; + /** System dismissal rather than user-authored plan feedback. */ + dismissed?: boolean; } /* ============================== Notification shapes ============================== */ diff --git a/packages/maker-cc-manager/src/sdk-handlers.ts b/packages/maker-cc-manager/src/sdk-handlers.ts index c5ecd14eb06..32e0a238195 100644 --- a/packages/maker-cc-manager/src/sdk-handlers.ts +++ b/packages/maker-cc-manager/src/sdk-handlers.ts @@ -27,6 +27,7 @@ import { type QuerySetPermissionModeParams, type QueryStartParams, type QueryStartResult, + type QueryToolGuard, type SessionAttachParams, type SessionAttachResult, type SessionClosedNotification, @@ -125,6 +126,7 @@ export function wireSdkHandlers(server: ManagerServer, registry: SessionRegistry } } } + const toolGuards = validateToolGuards(p.toolGuards); try { const session = registry.create({ sessionId: p.sessionId!, @@ -138,6 +140,7 @@ export function wireSdkHandlers(server: ManagerServer, registry: SessionRegistry ...(p.allowedTools ? { allowedTools: p.allowedTools } : {}), ...(p.disallowedTools ? { disallowedTools: p.disallowedTools } : {}), ...(p.tools !== undefined ? { tools: p.tools } : {}), + ...(toolGuards ? { toolGuards } : {}), ...(p.resumeSdkSessionId ? { resumeSdkSessionId: p.resumeSdkSessionId } : {}), ...(p.extraOptions ? { extraOptions: p.extraOptions } : {}), }); @@ -348,6 +351,71 @@ function requireString(v: unknown, field: string): asserts v is string { } } +function validateToolGuards(value: unknown): QueryToolGuard[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throwInvalid('toolGuards must be an array'); + return value.map((raw, index) => { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throwInvalid(`toolGuards[${index}] must be an object`); + } + const guard = raw as Record; + requireString(guard.toolNamePrefix, `toolGuards[${index}].toolNamePrefix`); + if (guard.toolNamePrefix.trim().length === 0) { + throwInvalid(`toolGuards[${index}].toolNamePrefix must not be whitespace-only`); + } + if (guard.sourceServerId !== undefined) { + requireString(guard.sourceServerId, `toolGuards[${index}].sourceServerId`); + if (guard.sourceServerId.trim().length === 0) { + throwInvalid(`toolGuards[${index}].sourceServerId must not be whitespace-only`); + } + } + if ( + guard.invocation !== 'auto' && + guard.invocation !== 'explicit-only' && + guard.invocation !== 'disabled' + ) { + throwInvalid( + `toolGuards[${index}].invocation must be auto, explicit-only, or disabled`, + ); + } + if ( + guard.explicitSelectors !== undefined && + (!Array.isArray(guard.explicitSelectors) || + guard.explicitSelectors.some( + (selector) => + typeof selector !== 'string' || + (!selector.trim().startsWith('$') && !selector.trim().startsWith('/')), + )) + ) { + throwInvalid( + `toolGuards[${index}].explicitSelectors must contain only $ or / command tokens`, + ); + } + if ( + guard.denialMessage !== undefined && + typeof guard.denialMessage !== 'string' + ) { + throwInvalid(`toolGuards[${index}].denialMessage must be a string`); + } + const toolNamePrefix = guard.toolNamePrefix.trim(); + const sourceServerId = guard.sourceServerId?.trim(); + if (!/^mcp__[A-Za-z0-9_-]+__$/.test(toolNamePrefix)) { + throwInvalid( + `toolGuards[${index}].toolNamePrefix must be a normalized Claude MCP tool prefix`, + ); + } + return { + toolNamePrefix, + ...(sourceServerId ? { sourceServerId } : {}), + invocation: guard.invocation, + ...(guard.explicitSelectors + ? { explicitSelectors: [...guard.explicitSelectors] as string[] } + : {}), + ...(guard.denialMessage ? { denialMessage: guard.denialMessage } : {}), + }; + }); +} + interface ThrowableInvalid extends Error { code: 'INVALID_PARAMS'; } diff --git a/packages/maker-cc-manager/src/session-registry.ts b/packages/maker-cc-manager/src/session-registry.ts index 5d05ca0ee30..9d7109faef3 100644 --- a/packages/maker-cc-manager/src/session-registry.ts +++ b/packages/maker-cc-manager/src/session-registry.ts @@ -16,6 +16,7 @@ import type { QueryEventNotification, + QueryToolGuard, SessionClosedNotification, SessionListEntry, ClientReplacedNotification, @@ -42,6 +43,16 @@ export interface SdkQueryLike extends AsyncIterable { setPermissionMode(mode: string): Promise; applyFlagSettings(settings: Record): Promise; getContextUsage?(): Promise; + /** + * Authoritative MCP registry after settings/plugin discovery. The init event + * only carries names, so it cannot distinguish a harness plugin from a + * user/project/local MCP that normalizes to the same tool prefix. + */ + mcpServerStatus?(): Promise>; /** Optional — stop a single background task (SDK >= 0.2.x). */ stopTask?(taskId: string): Promise; // streamInput(stream: AsyncIterable<...>) is implicit — we pass our queue @@ -73,6 +84,16 @@ export type CanUseToolCallback = ( message?: string; }>; +type SdkHookCallback = (input: unknown) => Promise; + +export type SdkHooks = Record< + string, + Array<{ + matcher?: string; + hooks: SdkHookCallback[]; + }> +>; + export interface SdkQueryFactoryOptions { /** SDK options.prompt — push-based AsyncIterable of user messages. */ inputStream: AsyncIterable; @@ -98,8 +119,13 @@ export interface SdkQueryFactoryOptions { tools?: unknown; /** Resume an SDK session by uuid (Phase 5 reattach). */ resume?: string; - /** Any extra SDK options to merge in last. */ + /** Extra SDK options; daemon-owned hooks are merged after this object. */ extraOptions?: Record; + /** + * Daemon-owned in-process hooks. Unlike callbacks inside extraOptions, these + * are created after the RPC boundary and are never serialized. + */ + hooks?: SdkHooks; /** * canUseTool callback — when SDK needs permission, this is called. * If not provided, SDK uses its own permissionMode logic (acceptEdits default). @@ -135,6 +161,7 @@ export interface CreateSessionOptions { allowedTools?: string[]; disallowedTools?: string[]; tools?: unknown; + toolGuards?: QueryToolGuard[]; resumeSdkSessionId?: string; extraOptions?: Record; } @@ -167,6 +194,14 @@ interface SessionState { sdkSessionId: string | null; /** Whether the consume loop is still running. */ alive: boolean; + /** Text from accepted user inputs in the current turn, used by tool guards. */ + toolGuardSelectionText: string; + /** False after an SDK result; the next accepted input starts a fresh turn. */ + toolGuardTurnActive: boolean; + /** Connected host/user/project/local MCPs that may own a colliding prefix. */ + toolGuardMcpServerNames: ReadonlySet; + /** Host-injected MCPs are non-harness sources even before SDK init. */ + toolGuardHostMcpServerNames: ReadonlySet; /** * forceful kill 已开始 (interrupt 发出、inputQueue 已/将 end) 但 consume * loop 尚未退出 — 此窗口内 alive 仍为 true, sendMessage 必须显式拒绝 @@ -312,10 +347,36 @@ export class SessionRegistry { // sessionRef is captured by the canUseTool closure (called later, after session is assigned). let sessionRef: SessionState | null = null; + const appendToolGuardSelectionText = (text: string | undefined): void => { + if (!sessionRef || !text) return; + sessionRef.toolGuardSelectionText = [sessionRef.toolGuardSelectionText, text] + .filter(Boolean) + .join('\n'); + }; // Build canUseTool callback that routes approval requests to attached client via RPC. const canUseTool: CanUseToolCallback | undefined = this.onApprovalRequest ? async (toolName, input, options) => { + const deniedGuard = findDeniedToolGuard( + opts.toolGuards, + toolName, + sessionRef?.toolGuardSelectionText ?? '', + sessionRef?.toolGuardMcpServerNames ?? EMPTY_MCP_SERVER_NAMES, + ); + if (deniedGuard) { + this.logger.warn('tool denied by host routing guard in canUseTool', { + sessionId: opts.sessionId, + toolName, + toolNamePrefix: deniedGuard.toolNamePrefix, + invocation: deniedGuard.invocation, + }); + return { + behavior: 'deny', + message: + deniedGuard.denialMessage ?? + 'This downstream tool source was not selected.', + }; + } if (!sessionRef?.attachedNotify) { this.logger.warn('canUseTool fired without attached client — denying', { sessionId: opts.sessionId, @@ -340,6 +401,10 @@ export class SessionRegistry { ...(options.blockedPath ? { blockedPath: options.blockedPath } : {}), ...(options.decisionReason ? { decisionReason: options.decisionReason } : {}), ...(options.agentID ? { agentID: options.agentID } : {}), + // The daemon has authoritative scoped MCP provenance. This + // attestation tells the desktop not to repeat the route check + // from the provenance-less init payload. + capabilityRoutingChecked: true, }, }); if (result.kind === 'ask_user_question') { @@ -349,10 +414,21 @@ export class SessionRegistry { }; } if (result.kind === 'plan_review') { + const originalPlan = typeof (input as Record).plan === 'string' + ? (input as Record).plan as string + : ''; if (result.behavior === 'deny') { + if (!result.dismissed) appendToolGuardSelectionText(result.reason); return { behavior: 'deny', message: result.reason ?? 'plan rejected by user' }; } - const finalPlan = result.editedPlan ?? (input as Record).plan; + appendToolGuardSelectionText( + toolGuardSelectionAddedByPlanEdit( + opts.toolGuards, + originalPlan, + result.editedPlan, + ), + ); + const finalPlan = result.editedPlan ?? originalPlan; return { behavior: 'allow', updatedInput: { ...input, plan: finalPlan } }; } // permission kind @@ -408,6 +484,20 @@ export class SessionRegistry { } : undefined; + const hooks = createToolGuardHooks( + opts.toolGuards, + () => sessionRef?.toolGuardSelectionText ?? '', + () => sessionRef?.toolGuardMcpServerNames ?? EMPTY_MCP_SERVER_NAMES, + (toolName, guard) => { + this.logger.warn('tool denied by host routing guard', { + sessionId: opts.sessionId, + toolName, + toolNamePrefix: guard.toolNamePrefix, + invocation: guard.invocation, + }); + }, + ); + const sdkOpts: SdkQueryFactoryOptions = { inputStream: inputQueue, cwd: opts.cwd, @@ -422,10 +512,12 @@ export class SessionRegistry { ...(opts.tools !== undefined ? { tools: opts.tools } : {}), ...(opts.resumeSdkSessionId ? { resume: opts.resumeSdkSessionId } : {}), ...(opts.extraOptions ? { extraOptions: opts.extraOptions } : {}), + ...(hooks ? { hooks } : {}), ...(canUseTool ? { canUseTool } : {}), ...(getOAuthToken ? { getOAuthToken } : {}), }; const query = this.factory(sdkOpts); + const hostMcpServerNames = new Set(Object.keys(opts.mcpServers ?? {})); const session: SessionState = { sessionId: opts.sessionId, cwd: opts.cwd, @@ -438,6 +530,10 @@ export class SessionRegistry { lastEventAt: null, sdkSessionId: null, alive: true, + toolGuardSelectionText: '', + toolGuardTurnActive: false, + toolGuardMcpServerNames: hostMcpServerNames, + toolGuardHostMcpServerNames: hostMcpServerNames, attachedNotify: null, buffer: [], bufferCapacity: this.bufferCapacity, @@ -496,7 +592,18 @@ export class SessionRegistry { `session ${sessionId} is being killed (input closed) — retry shortly or start a fresh query`, ); } - s.inputQueue.push(message); + const accepted = s.inputQueue.push(message); + if (!accepted) { + throw makeRegistryError( + 'SESSION_NOT_FOUND', + `session ${sessionId} input is closed`, + ); + } + const userText = extractUserMessageText(message); + s.toolGuardSelectionText = s.toolGuardTurnActive + ? [s.toolGuardSelectionText, userText].filter(Boolean).join('\n') + : userText; + s.toolGuardTurnActive = true; } async setModel(sessionId: string, model: string): Promise { @@ -762,6 +869,9 @@ export class SessionRegistry { session.consumeLoopDone = (async (): Promise => { try { for await (const message of session.query) { + if (isSdkInitMessage(message)) { + await this.refreshToolGuardMcpServerNames(session); + } this.recordEvent(session, message); } // Generator returned normally — session completed. @@ -790,6 +900,11 @@ export class SessionRegistry { if (!session.sdkSessionId && isSdkInitMessage(message)) { session.sdkSessionId = message.session_id; } + if (isSdkTurnResult(message)) { + // Keep the text until the next accepted input so any SDK-managed + // continuation after the result retains the same explicit selection. + session.toolGuardTurnActive = false; + } // Append to ring buffer for replay. Drop oldest when over capacity. session.buffer.push({ seq, ts, message }); @@ -819,6 +934,41 @@ export class SessionRegistry { } } + private async refreshToolGuardMcpServerNames(session: SessionState): Promise { + const fallbackNames = new Set(session.toolGuardHostMcpServerNames); + if (!session.query.mcpServerStatus) { + session.toolGuardMcpServerNames = fallbackNames; + return; + } + try { + const statuses = await session.query.mcpServerStatus(); + const connectedNonHarnessNames = new Set(); + for (const server of statuses) { + if ( + server.status === 'connected' && + typeof server.name === 'string' && + server.name.length > 0 && + ( + session.toolGuardHostMcpServerNames.has(server.name) || + isUserSettingsMcpScope(server.scope) + ) + ) { + connectedNonHarnessNames.add(server.name); + } + } + session.toolGuardMcpServerNames = connectedNonHarnessNames; + return; + } catch (error) { + // Unknown provenance must not disable a host routing guard. Host-injected + // MCPs remain known non-harness sources; settings MCPs fail closed. + this.logger.warn('failed to read scoped MCP status for tool guards', { + sessionId: session.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + session.toolGuardMcpServerNames = fallbackNames; + } + private notifyClosed(session: SessionState, reason: SessionClosedNotification['reason'], detail?: string): void { if (session.attachedNotify) { try { @@ -874,8 +1024,171 @@ function raceWithTimeout(p: Promise, timeoutMs: number): Promise; +} { if (typeof msg !== 'object' || msg === null) return false; const m = msg as Record; return m.type === 'system' && m.subtype === 'init' && typeof m.session_id === 'string'; } + +function isSdkTurnResult(msg: unknown): boolean { + if (typeof msg !== 'object' || msg === null) return false; + return (msg as Record).type === 'result'; +} + +function extractUserMessageText(message: unknown): string { + if (typeof message !== 'object' || message === null) return ''; + const envelope = message as Record; + if (typeof envelope.text === 'string') return envelope.text; + if (typeof envelope.message !== 'object' || envelope.message === null) return ''; + const content = (envelope.message as Record).content; + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .flatMap((block) => { + if ( + typeof block === 'object' && + block !== null && + (block as Record).type === 'text' && + typeof (block as Record).text === 'string' + ) { + return [(block as Record).text as string]; + } + return []; + }) + .join('\n'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matchesExplicitSelector(text: string, selector: string): boolean { + const normalizedSelector = selector.trim(); + if (!normalizedSelector) return false; + if (normalizedSelector.startsWith('$')) { + return new RegExp( + `(^|[^A-Za-z0-9_:-])${escapeRegExp(normalizedSelector)}(?![A-Za-z0-9_:-])`, + 'iu', + ).test(text); + } + if (normalizedSelector.startsWith('/')) { + return new RegExp( + `(^|\\s)${escapeRegExp(normalizedSelector)}(?=$|\\s|[.,!?;:,。!?;:])`, + 'iu', + ).test(text); + } + return false; +} + +function toolGuardSelectionAddedByPlanEdit( + toolGuards: readonly QueryToolGuard[] | undefined, + originalPlan: string, + editedPlan: string | undefined, +): string { + if (editedPlan === undefined || editedPlan === originalPlan) return ''; + const added = new Set(); + for (const guard of toolGuards ?? []) { + if (guard.invocation !== 'explicit-only') continue; + const selectors = guard.explicitSelectors ?? []; + for (const selector of selectors) { + if ( + !matchesExplicitSelector(originalPlan, selector) && + matchesExplicitSelector(editedPlan, selector) + ) { + added.add(selector.trim()); + } + } + } + return [...added].filter(Boolean).join('\n'); +} + +const EMPTY_MCP_SERVER_NAMES: ReadonlySet = new Set(); + +function isUserSettingsMcpScope(scope: unknown): boolean { + return scope === 'user' || scope === 'project' || scope === 'local'; +} + +function claudeMcpToolPrefix(serverId: string): string { + return `mcp__${serverId.replace(/[^a-zA-Z0-9_-]/g, '_')}__`; +} + +function hasToolGuardMcpPrefixCollision( + guard: QueryToolGuard, + mcpServerNames: ReadonlySet, +): boolean { + if (!guard.sourceServerId) return false; + // The caller passes only connected host/user/project/local MCPs. An exact id + // can therefore be a user MCP shadowing the harness source. + for (const serverId of mcpServerNames) { + if (claudeMcpToolPrefix(serverId) === guard.toolNamePrefix) return true; + } + return false; +} + +function findDeniedToolGuard( + toolGuards: readonly QueryToolGuard[] | undefined, + toolName: string, + selectionText: string, + mcpServerNames: ReadonlySet, +): QueryToolGuard | undefined { + const guard = toolGuards?.find( + (candidate) => + toolName.startsWith(candidate.toolNamePrefix) && + !hasToolGuardMcpPrefixCollision(candidate, mcpServerNames), + ); + if (!guard || guard.invocation === 'auto') return undefined; + if ( + guard.invocation === 'explicit-only' && + guard.explicitSelectors?.some((selector) => + matchesExplicitSelector(selectionText, selector), + ) + ) { + return undefined; + } + return guard; +} + +function createToolGuardHooks( + toolGuards: readonly QueryToolGuard[] | undefined, + getSelectionText: () => string, + getMcpServerNames: () => ReadonlySet, + onDeny: (toolName: string, guard: QueryToolGuard) => void, +): SdkHooks | undefined { + if (!toolGuards || toolGuards.length === 0) return undefined; + + const guardTool: SdkHookCallback = async (rawInput) => { + if (typeof rawInput !== 'object' || rawInput === null) return { continue: true }; + const input = rawInput as Record; + if (input.hook_event_name !== 'PreToolUse' || typeof input.tool_name !== 'string') { + return { continue: true }; + } + const toolName = input.tool_name; + const guard = findDeniedToolGuard( + toolGuards, + toolName, + getSelectionText(), + getMcpServerNames(), + ); + if (!guard) return { continue: true }; + + onDeny(toolName, guard); + return { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + guard.denialMessage ?? 'This downstream tool source was not selected.', + }, + }; + }; + + return { + PreToolUse: [{ hooks: [guardTool] }], + }; +} diff --git a/packages/maker-core/src/agents/base-agent.ts b/packages/maker-core/src/agents/base-agent.ts index cb08740fcbc..0e3f5245b7e 100644 --- a/packages/maker-core/src/agents/base-agent.ts +++ b/packages/maker-core/src/agents/base-agent.ts @@ -28,6 +28,7 @@ import { } from '../types/permissions.js'; import type { AgentKind, Effort, PermissionMode, ReasoningDisplay, UserMessage, WorkspaceKind } from '../types/common.js'; import type { Capabilities, EffortDescriptor, ModelDescriptor } from '../types/capabilities.js'; +import type { CapabilityRoutingPolicy } from '../types/capability-routing.js'; import { NotSupportedError } from '../types/capabilities.js'; import type { AgentCredentialMode, AuthLoginOptions } from '../interfaces/auth-adapter.js'; import type { ContactsPromptState } from '../contacts/system-prompt.js'; @@ -188,6 +189,17 @@ export interface AgentDeps { */ capabilityAdditions?: AgentCapabilityAdditions; + /** + * Host-owned arbitration for capabilities that overlap with harness-native + * plugins, skills, MCP servers, apps, or tools. + * + * Each harness adapter translates the neutral directives it understands and + * leaves unsupported directives untouched. Keeping this out of AgentKind + * conditionals lets a future harness add one adapter without changing the + * product policy. + */ + capabilityRouting?: CapabilityRoutingPolicy; + /** * 解析某条**具体路由**上该模型已核实的上下文窗口上限(host 注入);没有则返回 null。 * @@ -452,8 +464,9 @@ export interface AgentDeps { * in-process JS 回调; ClaudeCodeAgent.startSession 透传给 SDK options.hooks。 * * 设计说明: - * - maker-core 自己**不持有任何 hook 实现** —— 这里只是注入点, 具体业务逻辑 - * (例: 图片 read 检测、Bash 命令白名单、敏感路径拦截) 都在 host 层。 + * - 这里只是 host hook 注入点;图片 read、Bash 并发等产品逻辑都在 host 层。 + * maker-core 的 Claude adapter 会在必要时把窄范围协议闸门(例如能力来源路由) + * 放在这些 host hooks 之前。 * - 类型直接复用 Claude SDK 的 HookCallbackMatcher (含 matcher / hooks / timeout), * host 直接 import @anthropic-ai/claude-agent-sdk 的类型即可, 与 mcpProviders * 用 McpServerConfig 同模式。 diff --git a/packages/maker-core/src/agents/claude-code/__tests__/capability-routing.test.ts b/packages/maker-core/src/agents/claude-code/__tests__/capability-routing.test.ts new file mode 100644 index 00000000000..ffb383d3343 --- /dev/null +++ b/packages/maker-core/src/agents/claude-code/__tests__/capability-routing.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; + +import type { CapabilityRoutingPolicy } from '../../../types/capability-routing.js'; +import { + buildClaudeLocalToolGuardHooks, + buildClaudeRemoteToolGuards, + buildClaudeSkillOverrides, + mergeClaudeHookSets, +} from '../capability-routing.js'; + +describe('buildClaudeSkillOverrides', () => { + it('maps host policy to Claude Code native skill visibility', () => { + const policy = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'skill', + id: 'feishu-delegate:message-feishu-coworkers', + artifactId: 'message-feishu-coworkers', + containerId: 'feishu-delegate', + }, + invocation: 'explicit-only', + }, + { + capabilityId: 'legacy', + source: { + kind: 'user-skill', + surface: 'skill', + id: 'legacy-skill', + }, + invocation: 'disabled', + }, + { + capabilityId: 'normal', + source: { + kind: 'project-skill', + surface: 'skill', + id: 'normal-skill', + }, + invocation: 'auto', + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + expect(buildClaudeSkillOverrides(policy)).toEqual({ + 'feishu-delegate:message-feishu-coworkers': 'user-invocable-only', + }); + }); + + it('ignores user/project skills, other harnesses, and non-skill surfaces', () => { + const policy = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'skill', + id: 'feishu-delegate:message-feishu-coworkers', + }, + invocation: 'explicit-only', + }, + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'feishu', + }, + invocation: 'disabled', + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + expect(buildClaudeSkillOverrides(policy)).toEqual({}); + }); +}); + +describe('buildClaudeRemoteToolGuards', () => { + it('serializes only non-auto Claude MCP routes with the real plugin prefix', () => { + const policy = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '$feishu-delegate:message-feishu-coworkers', + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { kind: 'cindy-plugin', id: 'xd-feishu' }, + }, + { + capabilityId: 'normal', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'normal', + }, + invocation: 'auto', + }, + { + capabilityId: 'codex-only', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'mcp', + id: 'other', + }, + invocation: 'disabled', + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + expect(buildClaudeRemoteToolGuards(policy)).toEqual([ + { + toolNamePrefix: + 'mcp__plugin_feishu-delegate_feishu-delegate__', + sourceServerId: 'plugin:feishu-delegate:feishu-delegate', + invocation: 'explicit-only', + explicitSelectors: [ + '$feishu-delegate:message-feishu-coworkers', + '/feishu-delegate:message-feishu-coworkers', + ], + denialMessage: + 'This downstream source was not explicitly selected. Use Cindy capability xd-feishu.', + }, + ]); + }); +}); + +describe('buildClaudeLocalToolGuardHooks', () => { + const policy = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { kind: 'cindy-plugin', id: 'xd-feishu' }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + it('reads accepted turn state and runs before unrelated host hooks', async () => { + let selectionText = '查一下我和康康的飞书消息'; + const routing = buildClaudeLocalToolGuardHooks( + policy, + () => selectionText, + ); + const hostHook = async () => ({ continue: true }); + const merged = mergeClaudeHookSets(routing, { + PreToolUse: [{ matcher: 'Read', hooks: [hostHook] }], + }); + const preToolUse = merged.PreToolUse?.[0]?.hooks[0]; + if (!preToolUse) throw new Error('expected local routing hook'); + const input = { + hook_event_name: 'PreToolUse' as const, + session_id: 'session-local-route', + transcript_path: '/tmp/transcript', + cwd: '/repo', + tool_name: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + tool_input: {}, + tool_use_id: 'tool-route', + }; + + await expect( + preToolUse( + input, + 'tool-route', + { signal: new AbortController().signal }, + ), + ).resolves.toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }); + expect(merged.PreToolUse?.[1]?.matcher).toBe('Read'); + + selectionText = + '/feishu-delegate:message-feishu-coworkers 查一下康康'; + await expect( + preToolUse( + input, + 'tool-route', + { signal: new AbortController().signal }, + ), + ).resolves.toEqual({ continue: true }); + }); + + it('does not intercept a user MCP with the plugin server base name', async () => { + const preToolUse = buildClaudeLocalToolGuardHooks( + policy, + () => '', + ).PreToolUse?.[0]?.hooks[0]; + if (!preToolUse) throw new Error('expected local routing hook'); + await expect( + preToolUse( + { + hook_event_name: 'PreToolUse', + session_id: 'session-user-mcp', + transcript_path: '/tmp/transcript', + cwd: '/repo', + tool_name: 'mcp__feishu-delegate__read_messages', + tool_input: {}, + tool_use_id: 'tool-user-mcp', + }, + 'tool-user-mcp', + { signal: new AbortController().signal }, + ), + ).resolves.toEqual({ continue: true }); + }); + + it('does not intercept a user MCP whose id aliases the normalized plugin prefix', async () => { + const preToolUse = buildClaudeLocalToolGuardHooks( + policy, + () => '', + undefined, + () => new Set(['plugin_feishu-delegate_feishu-delegate']), + ).PreToolUse?.[0]?.hooks[0]; + if (!preToolUse) throw new Error('expected local routing hook'); + await expect( + preToolUse( + { + hook_event_name: 'PreToolUse', + session_id: 'session-user-mcp-normalized-collision', + transcript_path: '/tmp/transcript', + cwd: '/repo', + tool_name: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + tool_input: {}, + tool_use_id: 'tool-user-mcp-normalized-collision', + }, + 'tool-user-mcp-normalized-collision', + { signal: new AbortController().signal }, + ), + ).resolves.toEqual({ continue: true }); + }); +}); diff --git a/packages/maker-core/src/agents/claude-code/__tests__/flag-settings.test.ts b/packages/maker-core/src/agents/claude-code/__tests__/flag-settings.test.ts index c367024db80..6c43077f3cc 100644 --- a/packages/maker-core/src/agents/claude-code/__tests__/flag-settings.test.ts +++ b/packages/maker-core/src/agents/claude-code/__tests__/flag-settings.test.ts @@ -50,4 +50,29 @@ describe('buildClaudeFlagSettings', () => { 'fastMode' in buildClaudeFlagSettings({ showThinkingSummaries: false, fastMode: false }), ).toBe(false); }); + + it('adds namespaced plugin skill overrides from the host routing policy', () => { + const settings = buildClaudeFlagSettings({ + showThinkingSummaries: false, + fastMode: false, + capabilityRouting: { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'skill', + id: 'feishu-delegate:message-feishu-coworkers', + }, + invocation: 'explicit-only', + }, + ], + }, + }); + + expect(settings.skillOverrides).toEqual({ + 'feishu-delegate:message-feishu-coworkers': 'user-invocable-only', + }); + }); }); diff --git a/packages/maker-core/src/agents/claude-code/__tests__/mcp-approval-policy.test.ts b/packages/maker-core/src/agents/claude-code/__tests__/mcp-approval-policy.test.ts index f92b483ecfd..0d06f8f2055 100644 --- a/packages/maker-core/src/agents/claude-code/__tests__/mcp-approval-policy.test.ts +++ b/packages/maker-core/src/agents/claude-code/__tests__/mcp-approval-policy.test.ts @@ -25,6 +25,7 @@ import type { TurnPermissionPolicy, } from '../../base-agent.js'; import type { PermissionMode } from '../../../types/common.js'; +import type { CapabilityRoutingPolicy } from '../../../types/capability-routing.js'; import type { AuthAdapter } from '../../../interfaces/auth-adapter.js'; import type { InteractionDecision, InteractionRequest } from '../../../types/events.js'; import type { Logger } from '../../../interfaces/logger.js'; @@ -104,14 +105,53 @@ function createDeps( } /** 最小可用的 SDK Query 假实现: 消息流永远挂起, 控制方法全部记录调用。 */ -function createFakeQuery() { +function createFakeQuery( + initMcpServerNames: readonly string[] = [], + failedInitMcpServerNames: readonly string[] = [], + mcpServerStatuses: ReadonlyArray<{ + name: string; + status: string; + scope?: string; + }> = initMcpServerNames.map((name) => ({ + name, + status: 'connected', + scope: 'dynamic', + })), +) { + let initEmitted = false; return { [Symbol.asyncIterator]() { - return { next: () => new Promise>(() => {}) }; + return { + next: () => { + if ( + !initEmitted && + (initMcpServerNames.length > 0 || failedInitMcpServerNames.length > 0) + ) { + initEmitted = true; + return Promise.resolve({ + done: false as const, + value: { + type: 'system', + subtype: 'init', + session_id: 'sdk-mcp-policy', + mcp_servers: initMcpServerNames.map((name) => ({ + name, + status: 'connected', + })).concat(failedInitMcpServerNames.map((name) => ({ + name, + status: 'failed', + }))), + }, + }); + } + return new Promise>(() => {}); + }, + }; }, setPermissionMode: vi.fn(async () => {}), setModel: vi.fn(async () => {}), applyFlagSettings: vi.fn(async () => {}), + mcpServerStatus: vi.fn(async () => [...mcpServerStatuses]), interrupt: vi.fn(async () => {}), close: vi.fn(async () => {}), rewindFiles: vi.fn(async () => ({ canRewind: false })), @@ -146,16 +186,30 @@ async function startSession( /** true 时不注入 resolver,用于验证 fail-closed 分支。 */ bare?: boolean; permissionMode?: PermissionMode; + capabilityRouting?: CapabilityRoutingPolicy; + initMcpServerNames?: readonly string[]; + failedInitMcpServerNames?: readonly string[]; + mcpServerStatuses?: ReadonlyArray<{ + name: string; + status: string; + scope?: string; + }>; }, ) { const configDir = await makeTempDir(); process.env.CLAUDE_CONFIG_DIR = configDir; const workingDir = await makeTempDir(); - const fakeQuery = createFakeQuery(); + const fakeQuery = createFakeQuery( + options?.initMcpServerNames, + options?.failedInitMcpServerNames, + options?.mcpServerStatuses, + ); sdkMock.query.mockReturnValue(fakeQuery); - const agent = new ClaudeCodeAgent(createDeps(policy, options?.mcpServerNames)); + const deps = createDeps(policy, options?.mcpServerNames); + deps.capabilityRouting = options?.capabilityRouting; + const agent = new ClaudeCodeAgent(deps); const handle = await agent.startSession({ sessionId: 'session-mcp-policy', model: 'claude-opus-4-6', @@ -163,7 +217,13 @@ async function startSession( permissionMode: options?.permissionMode ?? 'default', }); const queryOptions = sdkMock.query.mock.calls.at(-1)?.[0]?.options as - | { canUseTool?: CanUseToolFn } + | { + canUseTool?: CanUseToolFn; + hooks?: Record< + string, + Array<{ hooks: Array<(input: unknown) => Promise> }> + >; + } | undefined; if (!queryOptions?.canUseTool) throw new Error('expected sdk query canUseTool'); @@ -179,7 +239,13 @@ async function startSession( }); } - return { agent, handle, canUseTool: queryOptions.canUseTool, seen }; + return { + agent, + handle, + canUseTool: queryOptions.canUseTool, + hooks: queryOptions.hooks, + seen, + }; } /** 取出 resolver 收到的 permission 请求(测试只会产生这一类)。 */ @@ -201,6 +267,204 @@ afterEach(async () => { }); describe('ClaudeCodeAgent canUseTool honors the host MCP approval policy', () => { + it('injects the local route as a PreToolUse guard even in Full access', async () => { + const capabilityRouting = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { kind: 'cindy-plugin', id: 'xd-feishu' }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + const { handle, hooks } = await startSession(undefined, { + permissionMode: 'bypassPermissions', + capabilityRouting, + initMcpServerNames: ['plugin:feishu-delegate:feishu-delegate'], + mcpServerStatuses: [{ + name: 'plugin:feishu-delegate:feishu-delegate', + status: 'connected', + scope: 'dynamic', + }], + }); + const preToolUse = hooks?.PreToolUse?.[0]?.hooks[0]; + if (!preToolUse) throw new Error('expected capability routing hook'); + const toolInput = { + hook_event_name: 'PreToolUse', + tool_name: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + }; + + await handle.send({ type: 'user', content: '查一下康康的飞书消息' }); + await expect(preToolUse(toolInput)).resolves.toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }); + + await handle.steer({ + type: 'user', + content: + '/feishu-delegate:message-feishu-coworkers 改用这个来源', + }); + await expect(preToolUse(toolInput)).resolves.toEqual({ continue: true }); + await handle.close(); + }); + + it('keeps a normalized-prefix-colliding user MCP on the normal permission chain', async () => { + const capabilityRouting = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { kind: 'cindy-plugin', id: 'xd-feishu' }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + const userServerId = 'plugin_feishu-delegate_feishu-delegate'; + const { handle, hooks, canUseTool, seen } = await startSession( + () => 'prompt', + { + capabilityRouting, + mcpServerNames: [userServerId], + }, + ); + const preToolUse = hooks?.PreToolUse?.[0]?.hooks[0]; + if (!preToolUse) throw new Error('expected capability routing hook'); + const toolName = `mcp__${userServerId}__read_messages`; + + await handle.send({ type: 'user', content: '查一下康康的飞书消息' }); + await expect( + preToolUse({ + hook_event_name: 'PreToolUse', + tool_name: toolName, + }), + ).resolves.toEqual({ continue: true }); + await expect( + canUseTool(toolName, {}, { toolUseID: 'tool-user-mcp-collision' }), + ).resolves.toMatchObject({ behavior: 'allow' }); + expect(permissionRequests(seen)).toHaveLength(1); + await handle.close(); + }); + + it('uses the SDK init registry to preserve a colliding settings MCP', async () => { + const capabilityRouting = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: ['/feishu-delegate:message-feishu-coworkers'], + replacement: { kind: 'cindy-plugin', id: 'xd-feishu' }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + const userServerId = 'plugin_feishu-delegate_feishu-delegate'; + const { handle, hooks, canUseTool, seen } = await startSession( + () => 'prompt', + { + capabilityRouting, + mcpServerNames: [], + initMcpServerNames: [ + 'plugin:feishu-delegate:feishu-delegate', + userServerId, + ], + mcpServerStatuses: [ + { + name: 'plugin:feishu-delegate:feishu-delegate', + status: 'connected', + scope: 'dynamic', + }, + { name: userServerId, status: 'connected', scope: 'project' }, + ], + }, + ); + const preToolUse = hooks?.PreToolUse?.[0]?.hooks[0]; + if (!preToolUse) throw new Error('expected capability routing hook'); + const toolName = `mcp__${userServerId}__read_messages`; + + await handle.send({ type: 'user', content: '查一下康康的飞书消息' }); + await vi.waitFor(async () => { + await expect( + preToolUse({ + hook_event_name: 'PreToolUse', + tool_name: toolName, + }), + ).resolves.toEqual({ continue: true }); + }); + await expect( + canUseTool(toolName, {}, { toolUseID: 'tool-settings-mcp-collision' }), + ).resolves.toMatchObject({ behavior: 'allow' }); + expect(permissionRequests(seen)).toHaveLength(1); + await handle.close(); + + const exact = await startSession(() => 'prompt', { + capabilityRouting, + mcpServerNames: [], + initMcpServerNames: ['plugin:feishu-delegate:feishu-delegate'], + mcpServerStatuses: [{ + name: 'plugin:feishu-delegate:feishu-delegate', + status: 'connected', + scope: 'user', + }], + }); + const exactPreToolUse = exact.hooks?.PreToolUse?.[0]?.hooks[0]; + if (!exactPreToolUse) throw new Error('expected capability routing hook'); + await exact.handle.send({ type: 'user', content: '查一下康康的飞书消息' }); + await vi.waitFor(async () => { + await expect( + exactPreToolUse({ + hook_event_name: 'PreToolUse', + tool_name: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + }), + ).resolves.toEqual({ continue: true }); + }); + await exact.handle.close(); + + const failed = await startSession(() => 'prompt', { + capabilityRouting, + mcpServerNames: [], + initMcpServerNames: [], + failedInitMcpServerNames: [userServerId], + }); + const failedPreToolUse = failed.hooks?.PreToolUse?.[0]?.hooks[0]; + if (!failedPreToolUse) throw new Error('expected capability routing hook'); + await failed.handle.send({ type: 'user', content: '查一下康康的飞书消息' }); + await vi.waitFor(async () => { + await expect( + failedPreToolUse({ + hook_event_name: 'PreToolUse', + tool_name: toolName, + }), + ).resolves.toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }); + }); + await failed.handle.close(); + }); + it('runs the per-turn policy before MCP auto-approval and drops session grants', async () => { const { handle, canUseTool, seen } = await startSession( () => 'auto-approve', @@ -578,7 +842,14 @@ describe('remote sessions share the same permission semantics', () => { /** 起一个远端会话并拿到 daemon 侧的 approval 回调。 */ async function startRemoteSession( policy: (context: McpToolApprovalContext) => McpToolApprovalPolicy, - options?: { attachResolver?: (req: InteractionRequest) => InteractionDecision }, + options?: { + attachResolver?: (req: InteractionRequest) => InteractionDecision; + capabilityRouting?: CapabilityRoutingPolicy; + mcpServerNames?: readonly string[]; + permissionMode?: PermissionMode; + initMcpServerNames?: readonly string[]; + failedInitMcpServerNames?: readonly string[]; + }, ) { const configDir = await makeTempDir(); process.env.CLAUDE_CONFIG_DIR = configDir; @@ -586,16 +857,25 @@ describe('remote sessions share the same permission semantics', () => { let onApprovalRequest: ((raw: unknown) => Promise<{ behavior?: string }>) | undefined; const deps = createDeps(policy); + deps.capabilityRouting = options?.capabilityRouting; // 远端只装得到 stdio / sse / http 类 server —— in-process 的会被 filter 掉。 - deps.mcpProviders = [ - { name: 'cindy_browser', toClaudeSdkConfig: () => ({ type: 'http', url: 'https://x/mcp' }) }, - { name: 'cindy_contacts', toClaudeSdkConfig: () => ({ type: 'http', url: 'https://y/mcp' }) }, - ] as McpProvider[]; + deps.mcpProviders = ( + options?.mcpServerNames ?? ['cindy_browser', 'cindy_contacts'] + ).map((name) => ({ + name, + toClaudeSdkConfig: () => ({ type: 'http', url: `https://x/${name}` }), + })) as McpProvider[]; + let remoteStartParams: Record | undefined; deps.remoteCcQueryFactory = (async (args: { onApprovalRequest: (raw: unknown) => Promise<{ behavior?: string }>; + startParams: Record; }) => { onApprovalRequest = args.onApprovalRequest; - return createFakeQuery() as never; + remoteStartParams = args.startParams; + return createFakeQuery( + options?.initMcpServerNames, + options?.failedInitMcpServerNames, + ) as never; }) as NonNullable; const agent = new ClaudeCodeAgent(deps); @@ -604,7 +884,7 @@ describe('remote sessions share the same permission semantics', () => { model: 'claude-opus-4-6', workingDir, remoteHostId: 'remote-1', - permissionMode: 'default', + permissionMode: options?.permissionMode ?? 'default', }); const seen: InteractionRequest[] = []; if (options?.attachResolver) { @@ -614,7 +894,7 @@ describe('remote sessions share the same permission semantics', () => { }); } if (!onApprovalRequest) throw new Error('expected remote onApprovalRequest'); - return { handle, onApprovalRequest, seen }; + return { handle, onApprovalRequest, seen, remoteStartParams }; } it('auto-approves trusted MCP tools without prompting', async () => { @@ -730,4 +1010,261 @@ describe('remote sessions share the same permission semantics', () => { expect(allowed.behavior).toBe('allow'); await handle.close(); }); + + it('guards plugin MCPs on remote sessions, including bypass mode, while preserving explicit selection', async () => { + const capabilityRouting = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: ['/feishu-delegate:message-feishu-coworkers'], + replacement: { + kind: 'cindy-plugin', + id: 'xd-feishu', + }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + const options = { + capabilityRouting, + permissionMode: 'bypassPermissions' as const, + attachResolver: (request: InteractionRequest): InteractionDecision => + request.kind === 'plan_review' + ? { + kind: 'plan_review', + behavior: 'allow', + editedPlan: + '1. /feishu-delegate:message-feishu-coworkers 查询消息', + } + : { kind: 'permission', behavior: 'allow' }, + }; + + const natural = await startRemoteSession(() => 'auto-approve', options); + await natural.handle.send({ type: 'user', content: '查一下我和康康的飞书消息' }); + // The daemon-side PreToolUse guard works even in Full access, so Cindy + // does not need to weaken the user's selected permission mode. + expect(natural.remoteStartParams?.permissionMode).toBe( + 'bypassPermissions', + ); + expect(natural.remoteStartParams?.toolGuards).toEqual([ + { + toolNamePrefix: + 'mcp__plugin_feishu-delegate_feishu-delegate__', + sourceServerId: 'plugin:feishu-delegate:feishu-delegate', + invocation: 'explicit-only', + explicitSelectors: [ + '/feishu-delegate:message-feishu-coworkers', + ], + denialMessage: + 'This downstream source was not explicitly selected. Use Cindy capability xd-feishu.', + }, + ]); + await expect( + natural.onApprovalRequest({ + requestId: 'r-natural-feishu', + kind: 'permission', + toolName: 'mcp__plugin_feishu-delegate_feishu-delegate__feishu_read_messages', + input: {}, + }), + ).resolves.toMatchObject({ behavior: 'deny' }); + await expect( + natural.onApprovalRequest({ + requestId: 'r-plan-feishu', + kind: 'plan_review', + plan: '1. 查询飞书消息', + }), + ).resolves.toMatchObject({ + behavior: 'allow', + editedPlan: + '1. /feishu-delegate:message-feishu-coworkers 查询消息', + }); + await expect( + natural.onApprovalRequest({ + requestId: 'r-edited-plan-feishu', + kind: 'permission', + toolName: 'mcp__plugin_feishu-delegate_feishu-delegate__feishu_read_messages', + input: {}, + }), + ).resolves.toMatchObject({ behavior: 'allow' }); + // Defense-in-depth: if the SDK does request approval despite bypass mode, + // unrelated tools still preserve Full access behavior. + await expect( + natural.onApprovalRequest({ + requestId: 'r-bypass-bash', + kind: 'permission', + toolName: 'Bash', + input: { command: 'pwd' }, + }), + ).resolves.toMatchObject({ behavior: 'allow' }); + await natural.handle.close(); + + const dismissed = await startRemoteSession(() => 'auto-approve', { + ...options, + attachResolver: (request): InteractionDecision => + request.kind === 'plan_review' + ? { + kind: 'plan_review', + behavior: 'deny', + reason: + 'system dismissed /feishu-delegate:message-feishu-coworkers', + dismissed: true, + } + : { kind: 'permission', behavior: 'allow' }, + }); + await dismissed.handle.send({ type: 'user', content: '查一下飞书消息' }); + await dismissed.onApprovalRequest({ + requestId: 'r-dismissed-plan-feishu', + kind: 'plan_review', + plan: '1. 查询飞书消息', + }); + await expect( + dismissed.onApprovalRequest({ + requestId: 'r-after-dismissed-plan-feishu', + kind: 'permission', + toolName: 'mcp__plugin_feishu-delegate_feishu-delegate__feishu_read_messages', + input: {}, + }), + ).resolves.toMatchObject({ behavior: 'deny' }); + await dismissed.handle.close(); + + const explicit = await startRemoteSession(() => 'auto-approve', options); + await explicit.handle.send({ + type: 'user', + content: '/feishu-delegate:message-feishu-coworkers 查一下康康', + }); + await expect( + explicit.onApprovalRequest({ + requestId: 'r-explicit-feishu', + kind: 'permission', + toolName: 'mcp__plugin_feishu-delegate_feishu-delegate__feishu_read_messages', + input: {}, + }), + ).resolves.toMatchObject({ behavior: 'allow' }); + await explicit.handle.close(); + }); + + it('serializes the remote guard while preserving a connected user MCP alias', async () => { + const capabilityRouting = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: ['/feishu-delegate:message-feishu-coworkers'], + replacement: { kind: 'cindy-plugin', id: 'xd-feishu' }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + const userServerId = 'plugin_feishu-delegate_feishu-delegate'; + const remote = await startRemoteSession(() => 'prompt', { + attachResolver: () => ({ kind: 'permission', behavior: 'allow' }), + capabilityRouting, + mcpServerNames: [userServerId], + }); + + expect(remote.remoteStartParams?.toolGuards).toHaveLength(1); + await expect( + remote.onApprovalRequest({ + requestId: 'r-user-mcp-collision', + kind: 'permission', + toolName: `mcp__${userServerId}__read_messages`, + input: {}, + }), + ).resolves.toMatchObject({ behavior: 'allow' }); + expect(permissionRequests(remote.seen)).toHaveLength(1); + await remote.handle.close(); + }); + + it('uses the remote SDK init registry to preserve a colliding settings MCP', async () => { + const capabilityRouting = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: ['/feishu-delegate:message-feishu-coworkers'], + replacement: { kind: 'cindy-plugin', id: 'xd-feishu' }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + const userServerId = 'plugin_feishu-delegate_feishu-delegate'; + const remote = await startRemoteSession(() => 'prompt', { + attachResolver: () => ({ kind: 'permission', behavior: 'allow' }), + capabilityRouting, + mcpServerNames: [userServerId], + initMcpServerNames: [ + 'plugin:feishu-delegate:feishu-delegate', + userServerId, + ], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + await expect( + remote.onApprovalRequest({ + requestId: 'r-settings-user-mcp-collision', + kind: 'permission', + toolName: `mcp__${userServerId}__read_messages`, + input: {}, + metadata: { capabilityRoutingChecked: true }, + }), + ).resolves.toMatchObject({ behavior: 'allow' }); + expect(permissionRequests(remote.seen)).toHaveLength(1); + await remote.handle.close(); + + const exact = await startRemoteSession(() => 'prompt', { + attachResolver: () => ({ kind: 'permission', behavior: 'allow' }), + capabilityRouting, + mcpServerNames: [userServerId], + initMcpServerNames: ['plugin:feishu-delegate:feishu-delegate'], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await expect( + exact.onApprovalRequest({ + requestId: 'r-exact-settings-mcp-collision', + kind: 'permission', + toolName: + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + input: {}, + metadata: { capabilityRoutingChecked: true }, + }), + ).resolves.toMatchObject({ behavior: 'allow' }); + expect(permissionRequests(exact.seen)).toHaveLength(1); + await exact.handle.close(); + + const failed = await startRemoteSession(() => 'prompt', { + attachResolver: () => ({ kind: 'permission', behavior: 'allow' }), + capabilityRouting, + mcpServerNames: [userServerId], + initMcpServerNames: [], + failedInitMcpServerNames: [userServerId], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(failed.remoteStartParams?.toolGuards).toHaveLength(1); + await expect( + failed.onApprovalRequest({ + requestId: 'r-failed-settings-mcp-collision', + kind: 'permission', + toolName: `mcp__${userServerId}__read_messages`, + input: {}, + }), + ).resolves.toMatchObject({ behavior: 'deny' }); + expect(permissionRequests(failed.seen)).toHaveLength(0); + await failed.handle.close(); + }); }); diff --git a/packages/maker-core/src/agents/claude-code/__tests__/plan-mode.test.ts b/packages/maker-core/src/agents/claude-code/__tests__/plan-mode.test.ts index 58a0b97fc6d..bd465b8385f 100644 --- a/packages/maker-core/src/agents/claude-code/__tests__/plan-mode.test.ts +++ b/packages/maker-core/src/agents/claude-code/__tests__/plan-mode.test.ts @@ -17,6 +17,7 @@ import type { AgentDeps } from '../../base-agent.js'; import type { AuthAdapter } from '../../../interfaces/auth-adapter.js'; import type { AgentEvent, InteractionDecision, InteractionRequest } from '../../../types/events.js'; import type { Logger } from '../../../interfaces/logger.js'; +import type { CapabilityRoutingPolicy } from '../../../types/capability-routing.js'; const sdkMock = vi.hoisted(() => ({ forkSession: vi.fn(), @@ -469,6 +470,85 @@ describe('ClaudeCodeAgent plan mode', () => { await handle.close(); }); + it('merges user plan edits and feedback into capability routing', async () => { + const capabilityRouting = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: ['$feishu-delegate:message-feishu-coworkers'], + replacement: { kind: 'cindy-plugin', id: 'xd-feishu' }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + const cases: Array<{ + decision: InteractionDecision; + expectedBehavior: 'allow' | 'deny'; + }> = [ + { + decision: { + kind: 'plan_review', + behavior: 'allow', + editedPlan: + '1. 用 $feishu-delegate:message-feishu-coworkers 查询消息', + }, + expectedBehavior: 'allow', + }, + { + decision: { + kind: 'plan_review', + behavior: 'deny', + reason: + '请改用 $feishu-delegate:message-feishu-coworkers 并补充范围', + }, + expectedBehavior: 'allow', + }, + { + decision: { + kind: 'plan_review', + behavior: 'deny', + reason: 'system dismissed $feishu-delegate:message-feishu-coworkers', + dismissed: true, + }, + expectedBehavior: 'deny', + }, + ]; + + for (const [index, testCase] of cases.entries()) { + const { handle, queryOptions } = await startPlanSession(true, { + capabilityRouting, + getMcpToolApprovalPolicy: () => 'auto-approve', + }); + handle.setInteractionResolver(async (req): Promise => + req.kind === 'plan_review' + ? testCase.decision + : { kind: 'permission', behavior: 'allow' }, + ); + await handle.send({ type: 'user', content: '制定一个查询消息的计划' }); + const canUseTool = queryOptions.canUseTool; + if (!canUseTool) throw new Error('expected canUseTool'); + await canUseTool( + 'ExitPlanMode', + { plan: '1. 查询消息' }, + { toolUseID: `plan-${index}` }, + ); + await expect( + canUseTool( + 'mcp__plugin_feishu-delegate_feishu-delegate__read_messages', + {}, + { toolUseID: `mcp-${index}` }, + ), + ).resolves.toMatchObject({ behavior: testCase.expectedBehavior }); + await handle.close(); + } + }); + it('defers the SDK switch when armed mid-turn, and pushes plan at the next send boundary', async () => { const { handle, fakeQuery } = await startPlanSession(false); diff --git a/packages/maker-core/src/agents/claude-code/capability-routing.ts b/packages/maker-core/src/agents/claude-code/capability-routing.ts new file mode 100644 index 00000000000..77a80b8209e --- /dev/null +++ b/packages/maker-core/src/agents/claude-code/capability-routing.ts @@ -0,0 +1,177 @@ +import type { + HookCallback, + HookCallbackMatcher, + HookEvent, + PreToolUseHookInput, + Settings, +} from '@anthropic-ai/claude-agent-sdk'; + +import type { + CapabilityRouteOverride, + CapabilityRoutingPolicy, +} from '../../types/capability-routing.js'; +import { + claudeMcpToolPrefix, + findClaudeMcpCapabilityRoute, + isHarnessOwnedCapabilitySource, + isCapabilityRouteInvocationAllowed, +} from '../../types/capability-routing.js'; + +const CLAUDE_CODE_HARNESS_ID = 'claude-code'; + +function isClaudeSkillDirective(directive: CapabilityRouteOverride): boolean { + return ( + directive.source.surface === 'skill' && + directive.source.harness === CLAUDE_CODE_HARNESS_ID && + (directive.source.kind === 'harness-builtin' || + directive.source.kind === 'harness-plugin') + ); +} + +/** + * Translate host routing policy into Claude Code's native per-skill listing + * controls. `user-invocable-only` removes a skill from implicit model context + * while preserving exact `/name` invocation. User and project skills are never + * overridden here, even if a host policy accidentally names one. + */ +export function buildClaudeSkillOverrides( + policy: CapabilityRoutingPolicy | undefined, +): NonNullable { + const overrides: NonNullable = {}; + if (!policy) return overrides; + + for (const directive of policy.overrides) { + if (!isClaudeSkillDirective(directive)) continue; + switch (directive.invocation) { + case 'auto': + overrides[directive.source.id] = 'on'; + break; + case 'explicit-only': + overrides[directive.source.id] = 'user-invocable-only'; + break; + case 'disabled': + overrides[directive.source.id] = 'off'; + break; + } + } + return overrides; +} + +export interface ClaudeRemoteToolGuard { + toolNamePrefix: string; + sourceServerId: string; + invocation: 'auto' | 'explicit-only' | 'disabled'; + explicitSelectors?: string[]; + denialMessage?: string; +} + +/** + * Build the JSON-safe routing guards enforced by the remote cc-manager as + * daemon-side PreToolUse hooks. Local callback hooks cannot cross the RPC + * boundary, so leaving this to canUseTool would let remote settings allow + * rules or bypassPermissions short-circuit the host route. Pre-init MCP + * configuration cannot suppress a guard: the daemon resolves normalized-name + * collisions from the SDK's authoritative connected registry after init. + */ +export function buildClaudeRemoteToolGuards( + policy: CapabilityRoutingPolicy | undefined, +): ClaudeRemoteToolGuard[] { + if (!policy) return []; + return policy.overrides.flatMap((directive) => { + if ( + !isHarnessOwnedCapabilitySource(directive.source) || + directive.source.harness !== CLAUDE_CODE_HARNESS_ID || + directive.source.surface !== 'mcp' || + directive.invocation === 'auto' + ) { + return []; + } + return [ + { + toolNamePrefix: claudeMcpToolPrefix(directive.source.id), + sourceServerId: directive.source.id, + invocation: directive.invocation, + ...(directive.explicitSelectors + ? { explicitSelectors: [...directive.explicitSelectors] } + : {}), + denialMessage: directive.replacement + ? `This downstream source was not explicitly selected. Use Cindy capability ${directive.replacement.id}.` + : 'This downstream source was not explicitly selected.', + }, + ]; + }); +} + +/** + * Build the local in-process PreToolUse guard. The selection text comes from + * maker-core's accepted send/steer state, so failed sends and turn boundaries + * cannot leave stale explicit selectors behind. + */ +export function buildClaudeLocalToolGuardHooks( + policy: CapabilityRoutingPolicy | undefined, + getSelectionText: () => string, + onDeny?: (toolName: string, route: CapabilityRouteOverride) => void, + getNonHarnessServerIds: () => ReadonlySet = () => new Set(), +): Partial> { + if ( + !policy?.overrides.some( + (directive) => + isHarnessOwnedCapabilitySource(directive.source) && + directive.source.harness === CLAUDE_CODE_HARNESS_ID && + directive.source.surface === 'mcp' && + directive.invocation !== 'auto', + ) + ) { + return {}; + } + + const guardTool: HookCallback = async (input) => { + if (input.hook_event_name !== 'PreToolUse') return { continue: true }; + const pre = input as PreToolUseHookInput; + const route = findClaudeMcpCapabilityRoute( + policy, + pre.tool_name, + getNonHarnessServerIds(), + ); + if ( + !route || + isCapabilityRouteInvocationAllowed(route, getSelectionText()) + ) { + return { continue: true }; + } + + onDeny?.(pre.tool_name, route); + return { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: route.replacement + ? `This downstream source was not explicitly selected. Use Cindy capability ${route.replacement.id}.` + : 'This downstream source was not explicitly selected.', + }, + }; + }; + + return { + PreToolUse: [{ hooks: [guardTool] }], + }; +} + +export function mergeClaudeHookSets( + ...sets: Array< + Partial> | undefined + > +): Partial> { + const merged: Partial> = {}; + for (const set of sets) { + if (!set) continue; + for (const [event, matchers] of Object.entries(set) as Array< + [HookEvent, HookCallbackMatcher[] | undefined] + >) { + if (!matchers || matchers.length === 0) continue; + merged[event] = [...(merged[event] ?? []), ...matchers]; + } + } + return merged; +} diff --git a/packages/maker-core/src/agents/claude-code/flag-settings.ts b/packages/maker-core/src/agents/claude-code/flag-settings.ts index 206d1a44166..afd62dc1b58 100644 --- a/packages/maker-core/src/agents/claude-code/flag-settings.ts +++ b/packages/maker-core/src/agents/claude-code/flag-settings.ts @@ -29,6 +29,8 @@ */ import type { Settings } from '@anthropic-ai/claude-agent-sdk'; +import type { CapabilityRoutingPolicy } from '../../types/capability-routing.js'; +import { buildClaudeSkillOverrides } from './capability-routing.js'; export interface ClaudeFlagSettingsInput { /** reasoning summary 展示开关(displayReasoning === 'summarized')。 */ @@ -43,10 +45,13 @@ export interface ClaudeFlagSettingsInput { * 缺省时整字段不出现 → 与未升级行为逐字节一致,零缓存影响。 */ fastMode: boolean; + /** Host-owned policy for colliding downstream skills. */ + capabilityRouting?: CapabilityRoutingPolicy; } /** 装配 startSession 注入的 flag settings 对象。纯函数 —— 每次调用读最新输入值。 */ export function buildClaudeFlagSettings(input: ClaudeFlagSettingsInput): Settings { + const skillOverrides = buildClaudeSkillOverrides(input.capabilityRouting); return { showThinkingSummaries: input.showThinkingSummaries, // 屏蔽用户级 apiKeyHelper,防止它劫持 oauth-spawn 的订阅鉴权(见文件头注释)。 @@ -56,5 +61,6 @@ export function buildClaudeFlagSettings(input: ClaudeFlagSettingsInput): Setting autoDreamEnabled: input.memoryOverride, }), ...(input.fastMode && { fastMode: true }), + ...(Object.keys(skillOverrides).length > 0 && { skillOverrides }), }; } diff --git a/packages/maker-core/src/agents/claude-code/index.ts b/packages/maker-core/src/agents/claude-code/index.ts index fbe5b40cb44..6e95c425fac 100644 --- a/packages/maker-core/src/agents/claude-code/index.ts +++ b/packages/maker-core/src/agents/claude-code/index.ts @@ -78,6 +78,11 @@ import type { } from '../../types/events.js'; import { isTerminalAgentErrorEvent } from '../../types/events.js'; import type { UserMessage } from '../../types/common.js'; +import { + capabilitySelectionAddedByPlanEdit, + findClaudeMcpCapabilityRoute, + isCapabilityRouteInvocationAllowed, +} from '../../types/capability-routing.js'; import { createAsyncQueue, type AsyncQueue } from '../shared/async-queue.js'; import { AutoCompactController } from '../shared/auto-compact-controller.js'; import { scanClaudeAtResources, scanClaudeSlashCommands } from '../shared/palette-scanner.js'; @@ -93,6 +98,11 @@ import { REMOTE_ROUTE_OVERRIDE_ENV_KEYS, } from './env-builder.js'; import { buildClaudeFlagSettings } from './flag-settings.js'; +import { + buildClaudeLocalToolGuardHooks, + buildClaudeRemoteToolGuards, + mergeClaudeHookSets, +} from './capability-routing.js'; import { classifyBuiltinToolForAutoReview } from './auto-review-policy.js'; import { resolveAgentCredentialMode } from '../credential-mode.js'; import { repairForkedClaudeSessionJsonl, type RepairForkedClaudeJsonlResult } from './fork-jsonl-repair.js'; @@ -379,6 +389,17 @@ export async function toClaudeSdkContent( return text || prefix.trim(); } +function userMessageTextForCapabilityRouting(content: UserMessage['content']): string { + if (typeof content === 'string') return content; + return content + .flatMap((block) => (block.type === 'text' ? [block.text] : [])) + .join('\n'); +} + +function isUserSettingsMcpScope(scope: unknown): boolean { + return scope === 'user' || scope === 'project' || scope === 'local'; +} + /** * Anthropic Messages SDK 错误 → OneShotError 分类映射。 * 参考自 apps/desktop/src/main/skillReview/claudeSdkReviewer.ts:mapApiError, @@ -1063,9 +1084,81 @@ export class ClaudeCodeAgent extends BaseAgent { * 一律解析失败 → MCP 策略不参与判定, 维持原权限链。 */ let registeredMcpServerNames: ReadonlySet = new Set(); + let hostMcpServerNames: ReadonlySet = new Set(); + let nonHarnessMcpServerNames: ReadonlySet = new Set(); + const noteSdkInitMcpServerNames = (message: unknown): boolean => { + if (!message || typeof message !== 'object') return false; + const record = message as Record; + if ( + record.type !== 'system' || + record.subtype !== 'init' || + !Array.isArray(record.mcp_servers) + ) { + return false; + } + const finalNames = record.mcp_servers + .map((server) => { + if (!server || typeof server !== 'object') return undefined; + const serverRecord = server as Record; + if (serverRecord.status !== 'connected') return undefined; + const name = serverRecord.name; + return typeof name === 'string' && name.length > 0 ? name : undefined; + }) + .filter((name): name is string => name !== undefined); + // The init payload is the SDK's authoritative post-settings registry. + // Replace instead of unioning so a query rebuild cannot retain a server + // removed from user/project/local settings and disable a guard forever. + registeredMcpServerNames = new Set(finalNames); + return true; + }; + const refreshSdkMcpProvenance = async (currentQ: Query): Promise => { + const fallbackNames = new Set(hostMcpServerNames); + const queryWithStatus = currentQ as Query & { + mcpServerStatus?: () => Promise>; + }; + if (typeof queryWithStatus.mcpServerStatus !== 'function') { + if (currentQ === q) nonHarnessMcpServerNames = fallbackNames; + return; + } + try { + const statuses = await queryWithStatus.mcpServerStatus(); + const connectedNonHarnessNames = new Set(); + for (const server of statuses) { + if ( + server.status === 'connected' && + typeof server.name === 'string' && + server.name.length > 0 && + ( + hostMcpServerNames.has(server.name) || + isUserSettingsMcpScope(server.scope) + ) + ) { + connectedNonHarnessNames.add(server.name); + } + } + if (currentQ === q) nonHarnessMcpServerNames = connectedNonHarnessNames; + return; + } catch (error) { + // Init names have no provenance. On status failure, preserve only + // host-injected MCPs and keep settings/plugin routing fail-closed. + log.warn('failed to read scoped MCP status for capability routing', { + error: error instanceof Error ? error.message : String(error), + }); + } + if (currentQ === q) nonHarnessMcpServerNames = fallbackNames; + }; const buildMcpServers = (): Record | undefined => { const providers = mcpProviders; - if (providers.length === 0) return undefined; + if (providers.length === 0) { + hostMcpServerNames = new Set(); + registeredMcpServerNames = hostMcpServerNames; + nonHarnessMcpServerNames = hostMcpServerNames; + return undefined; + } const context: McpProviderContext = { agentKind: 'claude-code' as const, workingDir: opts.workingDir, @@ -1106,7 +1199,9 @@ export class ClaudeCodeAgent extends BaseAgent { } // canUseTool 只认这批真实注册过的 server 名, 不靠 `mcp__` 工具名切分猜归属 // (见 resolveMcpToolTarget: 自定义 server id 可以含 `__`, 盲切会被冒名顶替)。 - registeredMcpServerNames = new Set(Object.keys(out)); + hostMcpServerNames = new Set(Object.keys(out)); + registeredMcpServerNames = hostMcpServerNames; + nonHarnessMcpServerNames = hostMcpServerNames; // 交回普通对象: SDK / RPC 序列化路径按普通对象处理(有的实现会调 obj.hasOwnProperty)。 // spread 走 CreateDataProperty, 不触发 `__proto__` setter, 所以这一步是安全的。 return Object.keys(out).length > 0 ? { ...out } : undefined; @@ -1137,6 +1232,39 @@ export class ClaudeCodeAgent extends BaseAgent { // Keep the policy across Claude task_notification auto-continue turns, // which do not call handle.send again. The next explicit send replaces it. let activeTurnPermissionPolicy: TurnPermissionPolicy | null = null; + let activeCapabilitySelectionText = ''; + const appendActiveCapabilitySelectionText = (text: string | undefined): void => { + if (!text) return; + activeCapabilitySelectionText = [activeCapabilitySelectionText, text] + .filter(Boolean) + .join('\n'); + }; + const localClaudeHooks = mergeClaudeHookSets( + buildClaudeLocalToolGuardHooks( + this.deps.capabilityRouting, + () => activeCapabilitySelectionText, + (toolName, route) => { + log.warn('downstream MCP source denied by host PreToolUse route', { + toolName, + capabilityId: route.capabilityId, + replacement: route.replacement?.id, + }); + }, + () => nonHarnessMcpServerNames, + ), + this.deps.claudeHooks, + ); + const deniedCapabilityRoute = (toolName: string) => { + const route = findClaudeMcpCapabilityRoute( + this.deps.capabilityRouting, + toolName, + nonHarnessMcpServerNames, + ); + return route && + !isCapabilityRouteInvocationAllowed(route, activeCapabilitySelectionText) + ? route + : undefined; + }; const forceTurnConfirmation = (toolName: string, input: unknown): boolean => { const policy = activeTurnPermissionPolicy; if (!policy) return false; @@ -1373,8 +1501,19 @@ export class ClaudeCodeAgent extends BaseAgent { return { behavior: 'deny', message: 'resolver kind mismatch' }; } if (decision.behavior === 'deny') { + if (!decision.dismissed) { + appendActiveCapabilitySelectionText(decision.reason); + } return { behavior: 'deny', message: decision.reason ?? 'plan rejected by user' }; } + appendActiveCapabilitySelectionText( + capabilitySelectionAddedByPlanEdit( + this.deps.capabilityRouting, + 'claude-code', + plan, + decision.editedPlan, + ), + ); // 计划批准 → 本轮 plan 循环结束: SDK 切回底层权限档。武装态正常已在 send // 消耗(plan_mode_changed 已广播), 这里兜底处理"未经 send 直接批准"的路径。 // 不能在 canUseTool 里 await SDK 控制请求(SDK 正等本回调返回), @@ -1399,6 +1538,20 @@ export class ClaudeCodeAgent extends BaseAgent { } // ── 3. 其他工具 → permission kind ── + const capabilityRoute = deniedCapabilityRoute(toolName); + if (capabilityRoute) { + log.warn('downstream MCP source denied by host capability route', { + toolName, + capabilityId: capabilityRoute.capabilityId, + replacement: capabilityRoute.replacement?.id, + }); + return { + behavior: 'deny', + message: capabilityRoute.replacement + ? `This downstream source was not selected. Use Cindy capability ${capabilityRoute.replacement.id}.` + : 'This downstream source was not selected.', + }; + } // 没接 resolver → fail-closed(安全拦截逻辑不许 fail-open)。 // 正常流程里 Session 构造时**必定**注入 resolver(见 session.ts: // setInteractionResolver, 且 host 没接 listener 时该 resolver 自身返回 deny), @@ -1521,6 +1674,7 @@ export class ClaudeCodeAgent extends BaseAgent { // fast(否则二进制按 "Agent SDK 不可用" 拒绝)。是否 Opus/官方/firstParty 由二进制把关, // agent 层不重复硬判(规则 9:确定性逻辑就近,但 fast 的最终门槛是二进制 + 配置门控)。 fastMode: mutableFastMode, + capabilityRouting: this.deps.capabilityRouting, }); // file checkpointing 与 capability 强绑定 —— 声明 rewind 能力时必须开此开关, @@ -2005,7 +2159,9 @@ export class ClaudeCodeAgent extends BaseAgent { // startParams shape 跟 sdkQuery options 同源 (cwd / model / env / mcpServers / // permissionMode / systemPrompt / additionalDirectories), JSON 序列化时 // canUseTool / pathToClaudeCodeExecutable / stderr / hooks 等 callback/path - // 字段自动 strip; SDK 在 daemon 端用默认行为继续跑。 + // 字段不能序列化。权限回调由反向 RPC 承接;host capability route 则转成 + // 下方 JSON-safe toolGuards,由 daemon 重建 PreToolUse hook,避免远端 + // settings allow 规则或 bypassPermissions 绕过来源选择。 // // mcpServers: 远端 cc MVP 只支持 stdio / sse / http 三种 process-transport // server (plain JSON 可跨进程)。in-process SDK MCP (type='sdk' + 闭包 instance) @@ -2036,6 +2192,9 @@ export class ClaudeCodeAgent extends BaseAgent { // 计划模式开启时远端 SDK 同样跑 plan; 读 mutable 值让 rewind 重建也拿到当前档。 const remotePermissionMode = extra?.permissionMode ?? effectiveSdkPermissionMode(); sdkInPlanMode = remotePermissionMode === 'plan'; + const remoteToolGuards = buildClaudeRemoteToolGuards( + this.deps.capabilityRouting, + ); const startParams: Record = { cwd: opts.workingDir, @@ -2052,6 +2211,9 @@ export class ClaudeCodeAgent extends BaseAgent { // cc-manager 的 QueryStartParams 已原生支持 allowedTools; 传副本避免 RPC // 序列化前后任一侧原地改写 session 快照。 ...(claudeAllowedTools ? { allowedTools: [...claudeAllowedTools] } : {}), + ...(remoteToolGuards.length > 0 + ? { toolGuards: remoteToolGuards } + : {}), systemPrompt: (() => { const appendText = [ MAKER_SYSTEM_PROMPT_APPEND, @@ -2162,34 +2324,68 @@ export class ClaudeCodeAgent extends BaseAgent { } if (params.kind === 'plan_review') { const planInput = (params.input ?? {}) as { plan?: string; planFilePath?: string }; + const plan = params.plan ?? planInput.plan ?? ''; const decision = await dispatchWithTimeout({ kind: 'plan_review', requestId: params.requestId, - plan: params.plan ?? planInput.plan ?? '', + plan, planFilePath: params.planFilePath ?? planInput.planFilePath, }); if (decision.kind !== 'plan_review') { return { kind: 'plan_review', behavior: 'deny', reason: 'resolver kind mismatch' }; } + if (decision.behavior === 'allow') { + appendActiveCapabilitySelectionText( + capabilitySelectionAddedByPlanEdit( + this.deps.capabilityRouting, + 'claude-code', + plan, + decision.editedPlan, + ), + ); + } else if (!decision.dismissed) { + appendActiveCapabilitySelectionText(decision.reason); + } return { kind: 'plan_review', behavior: decision.behavior, editedPlan: decision.editedPlan, reason: decision.reason, + dismissed: decision.dismissed, }; } // permission kind + const remoteToolName = params.toolName ?? ''; + // Remote cc-manager checks the route with authoritative scoped MCP + // provenance before forwarding canUseTool. Old managers do not add + // this attestation, so retain the desktop-side fail-closed fallback. + const capabilityRoute = params.metadata?.capabilityRoutingChecked === true + ? undefined + : deniedCapabilityRoute(remoteToolName); + if (capabilityRoute) { + log.warn('cc remote: downstream MCP source denied by host capability route', { + toolName: remoteToolName, + capabilityId: capabilityRoute.capabilityId, + replacement: capabilityRoute.replacement?.id, + }); + return { + kind: 'permission', + behavior: 'deny', + reason: capabilityRoute.replacement + ? `This downstream source was not selected. Use Cindy capability ${capabilityRoute.replacement.id}.` + : 'This downstream source was not selected.', + }; + } // 没接 resolver → 与本地 canUseTool 同款 fail-closed: 只放行已知只读工具, // 其余(含未知工具与所有 MCP 工具)一律 deny。这里过去 return allow, 一个 // misconfigured / 裸 handle 的远端会话可以在无人在场时跑破坏性工具 —— // 本地那侧不允许的事, 远端没有理由更宽。 if (!interactionResolver) { - const remoteTool = params.toolName ?? ''; - if (isReadOnlyClaudeTool(remoteTool)) { + if (isReadOnlyClaudeTool(remoteToolName)) { return { kind: 'permission', behavior: 'allow' }; } log.warn('cc remote: approval without interactionResolver → fail-closed deny', { - tool: remoteTool || 'unknown', + tool: remoteToolName || 'unknown', }); return { kind: 'permission', @@ -2197,9 +2393,11 @@ export class ClaudeCodeAgent extends BaseAgent { reason: 'no interaction resolver attached; denying non-read-only tool (fail-closed)', }; } + if (mutablePermissionMode === 'bypassPermissions') { + return { kind: 'permission', behavior: 'allow' }; + } // 远端会话走同一份 host MCP 策略 —— 否则 SSH 会话里可信 server 又要逐次 // 弹窗, prompt-each-time 的"禁止持久化授权"保护也整套缺失。 - const remoteToolName = params.toolName ?? ''; const remoteTurnPolicyForcePrompt = forceTurnConfirmation( remoteToolName || 'unknown', params.input ?? {}, @@ -2274,9 +2472,11 @@ export class ClaudeCodeAgent extends BaseAgent { // cindy_orca / orca_worker_bridge, 见 maker-host remoteCcQueryFactory), // 审批归属快照必须按注入后的最终清单定稿, 否则 canUseTool 的 // resolveMcpToolTarget 认不出 orca server 名, 归属判定缺失。 - registeredMcpServerNames = new Set( + hostMcpServerNames = new Set( Object.keys((startParams as { mcpServers?: Record }).mcpServers ?? {}), ); + registeredMcpServerNames = hostMcpServerNames; + nonHarnessMcpServerNames = hostMcpServerNames; // 记入 closure: handle.close / U2 兜底需要 await remoteQuery.close()。 activeRemoteQuery = remoteQuery as unknown as { close: () => Promise; detach?: () => Promise }; @@ -2459,10 +2659,12 @@ export class ClaudeCodeAgent extends BaseAgent { } : {}), ...(mcpServers ? { mcpServers } : {}), - // hooks 是 host 注入的 SDK in-process hook 回调表 (PreToolUse / PostToolUse / ...). - // maker-core 不持有任何 hook 实现, 这里只透传 deps.claudeHooks; undefined 时 - // 跳过字段, 让 SDK 走默认 (= 无 hook). 详见 AgentDeps.claudeHooks 文档。 - ...(this.deps.claudeHooks ? { hooks: this.deps.claudeHooks } : {}), + // Host hooks keep their normal behavior, while the harness adapter + // prepends its narrow capability-route guard. Both run in-process + // before Claude's permission mode (including Full access). + ...(Object.keys(localClaudeHooks).length > 0 + ? { hooks: localClaudeHooks } + : {}), }, }); }; @@ -2772,6 +2974,9 @@ export class ClaudeCodeAgent extends BaseAgent { bridgeSuppressedDoneData = undefined; } const rawType = (rawMsg as { type?: string } | null)?.type; + if (noteSdkInitMcpServerNames(rawMsg)) { + await refreshSdkMcpProvenance(currentQ); + } const expectedResumeSessionId = resumeValidationPending ? configuredResumeSessionId : undefined; const inBandInvalidConversationId = expectedResumeSessionId ?? (freshSessionValidationPending ? sdkSessionId : undefined); @@ -3717,6 +3922,7 @@ export class ClaudeCodeAgent extends BaseAgent { throw new Error('Claude input queue is closed'); } userInputAccepted = true; + activeCapabilitySelectionText = userMessageTextForCapabilityRouting(message.content); replayableUserInput = sdkInput; // upstream-response-idle watchdog 起表 — 放在 inputQueue.push 之后, 避免把 // client 端的 toClaudeSdkContent (多模态 image-resizer 同步等几秒) 算进上游 @@ -3786,6 +3992,9 @@ export class ClaudeCodeAgent extends BaseAgent { // received it. throw new Error('No active Claude turn to steer: input queue is closed'); } + appendActiveCapabilitySelectionText( + userMessageTextForCapabilityRouting(message.content), + ); armUpstreamResponseIdle(); }, diff --git a/packages/maker-core/src/agents/codex/capability-routing.test.ts b/packages/maker-core/src/agents/codex/capability-routing.test.ts new file mode 100644 index 00000000000..56a6e554a65 --- /dev/null +++ b/packages/maker-core/src/agents/codex/capability-routing.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import type { CapabilityRoutingPolicy } from '../../types/capability-routing.js'; +import { buildCodexCapabilityConfigOverrides } from './capability-routing.js'; + +describe('buildCodexCapabilityConfigOverrides', () => { + it('disables the selected Codex plugin with a per-thread config override', () => { + const policy = { + overrides: [ + { + capabilityId: 'computer-use', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'plugin', + id: 'computer-use@openai-bundled', + }, + invocation: 'disabled', + replacement: { + kind: 'cindy-host', + id: 'cindy_computer', + }, + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + expect(buildCodexCapabilityConfigOverrides(policy)).toEqual({ + 'plugins."computer-use@openai-bundled".enabled': false, + }); + }); + + it('does not widen unsupported or unrelated directives', () => { + const policy = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'mcp', + id: 'cindy-routed-feishu-delegate', + artifactId: 'feishu-delegate', + containerId: 'feishu-delegate@personal', + }, + invocation: 'explicit-only', + }, + { + capabilityId: 'computer-use', + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'plugin', + id: 'computer-use', + }, + invocation: 'disabled', + }, + { + capabilityId: 'computer-use', + source: { + kind: 'harness-builtin', + harness: 'codex', + surface: 'tool', + id: 'computer', + }, + invocation: 'disabled', + }, + { + capabilityId: 'computer-use', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'plugin', + id: 'computer-use@openai-bundled', + }, + invocation: 'auto', + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + expect(buildCodexCapabilityConfigOverrides(policy)).toEqual({ + 'plugins."feishu-delegate@personal".mcp_servers."feishu-delegate".enabled': false, + 'plugins."feishu-delegate@personal".mcp_servers."cindy-routed-feishu-delegate".default_tools_approval_mode': + 'prompt', + }); + }); + + it('quotes plugin ids as safe TOML path segments', () => { + const policy = { + overrides: [ + { + capabilityId: 'example', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'plugin', + id: 'plugin\\"quoted', + }, + invocation: 'disabled', + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + expect(buildCodexCapabilityConfigOverrides(policy)).toEqual({ + 'plugins."plugin\\\\\\"quoted".enabled': false, + }); + }); + + it('fails closed for explicit-only plugins when the Codex home has no isolated overlay', () => { + const policy = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'skill', + id: 'feishu-delegate:message-feishu-coworkers', + artifactId: 'message-feishu-coworkers', + containerId: 'feishu-delegate@personal', + }, + invocation: 'explicit-only', + }, + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'mcp', + id: 'cindy-routed-feishu-delegate', + artifactId: 'feishu-delegate', + containerId: 'feishu-delegate@personal', + }, + invocation: 'explicit-only', + }, + { + capabilityId: 'computer-use', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'plugin', + id: 'computer-use@openai-bundled', + }, + invocation: 'disabled', + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + expect( + buildCodexCapabilityConfigOverrides(policy, { + isolatedPluginOverlays: false, + }), + ).toEqual({ + 'plugins."feishu-delegate@personal".enabled': false, + 'plugins."computer-use@openai-bundled".enabled': false, + }); + }); + + it('fails closed when a remote explicit-only route has no owning plugin id', () => { + const policy = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'skill', + id: 'feishu-delegate:message-feishu-coworkers', + }, + invocation: 'explicit-only', + }, + ], + } as const satisfies CapabilityRoutingPolicy; + + expect(() => + buildCodexCapabilityConfigOverrides(policy, { + isolatedPluginOverlays: false, + }), + ).toThrowError(/source\.containerId is required/); + }); +}); diff --git a/packages/maker-core/src/agents/codex/capability-routing.ts b/packages/maker-core/src/agents/codex/capability-routing.ts new file mode 100644 index 00000000000..8036ab85ef4 --- /dev/null +++ b/packages/maker-core/src/agents/codex/capability-routing.ts @@ -0,0 +1,105 @@ +import type { + CapabilityRouteOverride, + CapabilityRoutingPolicy, +} from '../../types/capability-routing.js'; + +const CODEX_HARNESS_ID = 'codex'; + +export interface CodexCapabilityRoutingOptions { + /** + * Whether the host prepared a provenance-preserving plugin overlay for this + * Codex home. + * + * Local Cindy sessions have one. Remote Codex runs from a separate isolated + * CODEX_HOME, so until the host synchronizes the overlay there we must disable + * an explicit-only downstream plugin as a whole instead of pretending its + * renamed MCP and non-implicit Skill are available. + */ + isolatedPluginOverlays?: boolean; +} + +/** + * Codex thread config accepts flattened TOML paths. Plugin config names contain + * `@`, so they must be rendered as quoted dotted-key segments. + */ +function quoteTomlKeySegment(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function isCodexHarnessPluginDirective( + directive: CapabilityRouteOverride, +): boolean { + return ( + directive.source.kind === 'harness-plugin' && + directive.source.harness === CODEX_HARNESS_ID + ); +} + +/** + * Convert host policy into per-thread Codex config overrides. + * + * Codex 0.145 exposes plugin-wide enablement but has no host-side equivalent of + * Claude's `user-invocable-only` skill override. Local explicit-only sources + * are narrowed by the provenance-preserving plugin overlay plus the approval + * guard. When that overlay is unavailable (currently remote Codex), the whole + * targeted plugin is disabled rather than silently widened. + */ +export function buildCodexCapabilityConfigOverrides( + policy: CapabilityRoutingPolicy | undefined, + opts: CodexCapabilityRoutingOptions = {}, +): Record { + const config: Record = Object.create(null); + if (!policy) return config; + + for (const directive of policy.overrides) { + if (!isCodexHarnessPluginDirective(directive)) continue; + if ( + opts.isolatedPluginOverlays === false && + directive.invocation === 'explicit-only' + ) { + const pluginId = + directive.source.surface === 'plugin' + ? directive.source.id + : directive.source.containerId; + if (!pluginId) { + const requiredField = + directive.source.surface === 'plugin' + ? 'source.id' + : 'source.containerId'; + throw new Error( + `Cannot enforce explicit-only Codex capability ${directive.capabilityId} without an isolated plugin overlay: ${requiredField} is required for ${directive.source.surface} source ${directive.source.id}`, + ); + } + config[`plugins.${quoteTomlKeySegment(pluginId)}.enabled`] = false; + continue; + } + if ( + directive.source.surface === 'plugin' && + directive.invocation === 'disabled' + ) { + config[`plugins.${quoteTomlKeySegment(directive.source.id)}.enabled`] = + false; + continue; + } + if ( + directive.source.surface === 'mcp' && + directive.invocation === 'explicit-only' && + directive.source.containerId + ) { + const artifactId = directive.source.artifactId; + if (artifactId && artifactId !== directive.source.id) { + // The isolated plugin overlay renames the downstream MCP server so the + // runtime approval request cannot collide with a user-owned MCP of the + // same name. Disabling the original name also makes overlay failures + // fail closed for the MCP surface. + config[ + `plugins.${quoteTomlKeySegment(directive.source.containerId)}.mcp_servers.${quoteTomlKeySegment(artifactId)}.enabled` + ] = false; + } + config[ + `plugins.${quoteTomlKeySegment(directive.source.containerId)}.mcp_servers.${quoteTomlKeySegment(directive.source.id)}.default_tools_approval_mode` + ] = 'prompt'; + } + } + return config; +} diff --git a/packages/maker-core/src/agents/codex/index.test.ts b/packages/maker-core/src/agents/codex/index.test.ts index 683eee87f84..a181d0b2336 100644 --- a/packages/maker-core/src/agents/codex/index.test.ts +++ b/packages/maker-core/src/agents/codex/index.test.ts @@ -521,6 +521,740 @@ describe('CodexAgent permissions', () => { }); }); +describe('CodexAgent capability routing', () => { + const capabilityRouting = { + overrides: [ + { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'mcp', + id: 'cindy-routed-feishu-delegate', + artifactId: 'feishu-delegate', + containerId: 'feishu-delegate@personal', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '$feishu-delegate:message-feishu-coworkers', + '/feishu-delegate:message-feishu-coworkers', + ], + replacement: { + kind: 'cindy-plugin', + id: 'xd-feishu', + }, + }, + { + capabilityId: 'computer-use', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'plugin', + id: 'computer-use@openai-bundled', + }, + invocation: 'disabled', + replacement: { + kind: 'cindy-host', + id: 'cindy_computer', + }, + }, + ], + } as const; + + it('applies host-owned plugin policy to new and resumed Codex 0.145 threads', async () => { + const startAgent = new CodexAgent(createDeps({}, { capabilityRouting })); + const startHost = installFakeHost(startAgent, undefined, { + userAgent: 'mock-codex/0.145.0', + }); + const startHandle = await startAgent.startSession({ + sessionId: 'session-capability-routing-start', + model: 'gpt-5.4', + workingDir: '/repo', + }); + const startParams = startHost.request.mock.calls.find( + ([method]) => method === Method.ThreadStart, + )?.[1] as { config?: Record }; + expect(startParams.config).toMatchObject({ + 'plugins."computer-use@openai-bundled".enabled': false, + 'plugins."feishu-delegate@personal".mcp_servers."feishu-delegate".enabled': false, + 'plugins."feishu-delegate@personal".mcp_servers."cindy-routed-feishu-delegate".default_tools_approval_mode': + 'prompt', + }); + + const resumeAgent = new CodexAgent(createDeps({}, { capabilityRouting })); + const resumeHost = installFakeHost(resumeAgent, undefined, { + userAgent: 'mock-codex/0.145.0', + }); + const resumeHandle = await resumeAgent.startSession({ + sessionId: 'session-capability-routing-resume', + model: 'gpt-5.4', + workingDir: '/repo', + resumeSessionId: '123e4567-e89b-12d3-a456-426614174000', + }); + const resumeParams = resumeHost.request.mock.calls.find( + ([method]) => method === Method.ThreadResume, + )?.[1] as { config?: Record }; + expect(resumeParams.config).toMatchObject({ + 'plugins."computer-use@openai-bundled".enabled': false, + 'plugins."feishu-delegate@personal".mcp_servers."feishu-delegate".enabled': false, + 'plugins."feishu-delegate@personal".mcp_servers."cindy-routed-feishu-delegate".default_tools_approval_mode': + 'prompt', + }); + + await startHandle.close(); + await resumeHandle.close(); + }); + + it('fails closed for older Codex daemons that cannot apply plugin overrides', async () => { + const agent = new CodexAgent(createDeps({}, { capabilityRouting })); + installFakeHost(agent, undefined, { + userAgent: 'mock-codex/0.144.6', + }); + await expect( + agent.startSession({ + sessionId: 'session-capability-routing-legacy', + model: 'gpt-5.4', + workingDir: '/repo', + remoteHostId: 'legacy-remote', + }), + ).rejects.toThrow('requires Codex app-server 0.145.0 or newer'); + }); + + it('disables an explicit-only plugin on remote Codex where the local overlay is unavailable', async () => { + const agent = new CodexAgent(createDeps({}, { capabilityRouting })); + const host = installFakeHost(agent, undefined, { + userAgent: 'mock-codex/0.145.0', + }); + const handle = await agent.startSession({ + sessionId: 'session-capability-routing-remote', + model: 'gpt-5.4', + workingDir: '/repo', + remoteHostId: 'remote-host', + }); + const params = host.request.mock.calls.find( + ([method]) => method === Method.ThreadStart, + )?.[1] as { config?: Record }; + + expect(params.config).toMatchObject({ + 'plugins."feishu-delegate@personal".enabled': false, + 'plugins."computer-use@openai-bundled".enabled': false, + }); + expect(params.config).not.toHaveProperty( + 'plugins."feishu-delegate@personal".mcp_servers."cindy-routed-feishu-delegate".default_tools_approval_mode', + ); + + await handle.close(); + }); + + it('declines an explicit-only downstream MCP unless the user chose that source', async () => { + const makeAgent = () => + new CodexAgent( + createDeps( + {}, + { + capabilityRouting, + getMcpToolApprovalPolicy: () => 'auto-approve', + }, + ), + ); + const install = (agent: CodexAgent, turnId: string) => + installFakeHost( + agent, + (method) => { + if (method === Method.TurnStart) return { turn: { id: turnId } }; + return undefined; + }, + { userAgent: 'mock-codex/0.145.0' }, + ); + const request = (turnId: string) => ({ + threadId: 'start-thread-id', + turnId, + serverName: 'cindy-routed-feishu-delegate', + mode: 'form' as const, + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'feishu_read_messages', + }, + message: 'Allow tool call', + requestedSchema: {}, + }); + + const implicitAgent = makeAgent(); + const implicitHost = install(implicitAgent, 'turn-implicit-feishu'); + const implicitHandle = await implicitAgent.startSession({ + sessionId: 'session-implicit-feishu', + model: 'gpt-5.4', + workingDir: '/repo', + }); + await implicitHandle.send({ + type: 'user', + content: '查一下我和康康的飞书消息', + }); + const implicitHandlers = implicitHost.getThreadHandlers(); + if (!implicitHandlers?.mcpServerElicitation) { + throw new Error('expected mcpServerElicitation handler'); + } + await expect( + implicitHandlers.mcpServerElicitation(request('turn-implicit-feishu')), + ).resolves.toEqual({ action: 'decline', content: null, _meta: null }); + implicitHandlers.itemStarted?.({ + threadId: 'start-thread-id', + turnId: 'turn-implicit-feishu', + item: { + id: 'user-owned-colliding-mcp', + type: 'mcpToolCall', + server: 'cindy-routed-feishu-delegate', + tool: 'feishu_read_messages', + pluginId: null, + }, + }); + await expect( + implicitHandlers.mcpServerElicitation(request('turn-implicit-feishu')), + ).resolves.toEqual({ action: 'accept', content: null, _meta: null }); + await expect( + implicitHandlers.mcpServerElicitation({ + ...request('turn-implicit-feishu'), + serverName: 'feishu-delegate', + }), + ).resolves.toEqual({ action: 'accept', content: null, _meta: null }); + + const explicitAgent = makeAgent(); + const explicitHost = install(explicitAgent, 'turn-explicit-feishu'); + const explicitHandle = await explicitAgent.startSession({ + sessionId: 'session-explicit-feishu', + model: 'gpt-5.4', + workingDir: '/repo', + }); + await explicitHandle.send({ + type: 'user', + content: '请用 $feishu-delegate:message-feishu-coworkers 查一下康康', + }); + const explicitHandlers = explicitHost.getThreadHandlers(); + if (!explicitHandlers?.mcpServerElicitation) { + throw new Error('expected mcpServerElicitation handler'); + } + // The routed runtime name alone is not enough: without app-server + // provenance we cannot distinguish the plugin overlay from another source. + await expect( + explicitHandlers.mcpServerElicitation(request('turn-explicit-feishu')), + ).resolves.toEqual({ action: 'decline', content: null, _meta: null }); + explicitHandlers.itemStarted?.({ + threadId: 'start-thread-id', + turnId: 'turn-explicit-feishu', + item: { + id: 'routed-feishu-call', + type: 'mcpToolCall', + server: 'cindy-routed-feishu-delegate', + tool: 'feishu_read_messages', + pluginId: 'feishu-delegate@personal', + }, + }); + await expect( + explicitHandlers.mcpServerElicitation(request('turn-explicit-feishu')), + ).resolves.toEqual({ action: 'accept', content: null, _meta: null }); + explicitHandlers.itemStarted?.({ + threadId: 'start-thread-id', + turnId: 'turn-explicit-feishu', + item: { + id: 'colliding-user-feishu-call', + type: 'mcpToolCall', + server: 'cindy-routed-feishu-delegate', + tool: 'other_tool', + pluginId: null, + }, + }); + await expect( + explicitHandlers.mcpServerElicitation({ + ...request('turn-explicit-feishu'), + _meta: { + codex_approval_kind: 'mcp_tool_call', + }, + }), + ).resolves.toEqual({ action: 'decline', content: null, _meta: null }); + + await implicitHandle.close(); + await explicitHandle.close(); + }); + + it('merges inherited and newly added capability selectors into plan follow-up turns', async () => { + const cases = [ + { + label: 'inherited-implementation', + initialPrompt: '用 $feishu-delegate:message-feishu-coworkers 制定查询计划', + plan: '1. 查询消息', + decision: { kind: 'plan_review', behavior: 'allow' } as const, + }, + { + label: 'inherited-revision', + initialPrompt: '用 $feishu-delegate:message-feishu-coworkers 制定查询计划', + plan: '1. 查询消息', + decision: { + kind: 'plan_review', + behavior: 'deny', + reason: '再补充消息范围', + } as const, + }, + { + label: 'edited-implementation', + initialPrompt: '制定一个查询消息的计划', + plan: '1. 查询消息', + decision: { + kind: 'plan_review', + behavior: 'allow', + editedPlan: + '1. 用 $feishu-delegate:message-feishu-coworkers 查询消息', + } as const, + }, + { + label: 'feedback-revision', + initialPrompt: '制定一个查询消息的计划', + plan: '1. 查询消息', + decision: { + kind: 'plan_review', + behavior: 'deny', + reason: + '请改用 $feishu-delegate:message-feishu-coworkers 并补充消息范围', + } as const, + }, + ]; + + for (const testCase of cases) { + let turnSeq = 0; + const agent = new CodexAgent( + createDeps( + {}, + { + capabilityRouting, + getMcpToolApprovalPolicy: () => 'auto-approve', + }, + ), + ); + const host = installFakeHost( + agent, + (method) => { + if (method === Method.TurnStart) { + turnSeq += 1; + return { turn: { id: `${testCase.label}-turn-${turnSeq}` } }; + } + return undefined; + }, + { userAgent: 'mock-codex/0.145.0' }, + ); + const handle = await agent.startSession({ + sessionId: `session-plan-${testCase.label}-capability-routing`, + model: 'gpt-5.4', + workingDir: '/repo', + planMode: true, + }); + handle.setInteractionResolver(async () => testCase.decision); + await handle.send({ + type: 'user', + content: testCase.initialPrompt, + }); + + const handlers = host.getThreadHandlers(); + if (!handlers?.mcpServerElicitation) { + throw new Error('expected mcpServerElicitation handler'); + } + const planTurnId = `${testCase.label}-turn-1`; + handlers.turnStarted?.({ threadId: 'start-thread-id', turn: { id: planTurnId } }); + handlers.itemCompleted?.({ + threadId: 'start-thread-id', + turnId: planTurnId, + item: { type: 'plan', id: `${planTurnId}-plan`, text: testCase.plan }, + } as never); + handlers.turnCompleted?.({ + threadId: 'start-thread-id', + turn: { id: planTurnId, status: 'completed' }, + }); + + await vi.waitFor(() => { + expect( + host.request.mock.calls.filter(([method]) => method === Method.TurnStart), + ).toHaveLength(2); + }); + const followUpTurnId = `${testCase.label}-turn-2`; + handlers.itemStarted?.({ + threadId: 'start-thread-id', + turnId: followUpTurnId, + item: { + id: `${testCase.label}-routed-feishu-call`, + type: 'mcpToolCall', + server: 'cindy-routed-feishu-delegate', + tool: 'feishu_read_messages', + pluginId: 'feishu-delegate@personal', + }, + }); + await expect( + handlers.mcpServerElicitation({ + threadId: 'start-thread-id', + turnId: followUpTurnId, + serverName: 'cindy-routed-feishu-delegate', + mode: 'form', + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'feishu_read_messages', + }, + message: 'Allow tool call', + requestedSchema: {}, + }), + ).resolves.toEqual({ action: 'accept', content: null, _meta: null }); + + await handle.close(); + } + }); + + it('binds explicit capability selection before a pending turn/start response', async () => { + const pendingTurnStart = deferred<{ turn: { id: string } }>(); + const agent = new CodexAgent( + createDeps( + {}, + { + capabilityRouting, + getMcpToolApprovalPolicy: () => 'auto-approve', + }, + ), + ); + const host = installFakeHost( + agent, + (method) => { + if (method === Method.TurnStart) return pendingTurnStart.promise; + return undefined; + }, + { userAgent: 'mock-codex/0.145.0' }, + ); + const handle = await agent.startSession({ + sessionId: 'session-started-before-response-capability-routing', + model: 'gpt-5.4', + workingDir: '/repo', + }); + const sendPromise = handle.send({ + type: 'user', + content: '请用 $feishu-delegate:message-feishu-coworkers 查消息', + }); + await vi.waitFor(() => { + expect( + host.request.mock.calls.filter(([method]) => method === Method.TurnStart), + ).toHaveLength(1); + }); + + const handlers = host.getThreadHandlers(); + if (!handlers?.mcpServerElicitation) { + throw new Error('expected mcpServerElicitation handler'); + } + handlers.turnStarted?.({ + threadId: 'start-thread-id', + turn: { id: 'early-capability-turn' }, + }); + handlers.itemStarted?.({ + threadId: 'start-thread-id', + turnId: 'early-capability-turn', + item: { + id: 'early-routed-feishu-call', + type: 'mcpToolCall', + server: 'cindy-routed-feishu-delegate', + tool: 'feishu_read_messages', + pluginId: 'feishu-delegate@personal', + }, + }); + await expect( + handlers.mcpServerElicitation({ + threadId: 'start-thread-id', + turnId: 'early-capability-turn', + serverName: 'cindy-routed-feishu-delegate', + mode: 'form', + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'feishu_read_messages', + }, + message: 'Allow tool call', + requestedSchema: {}, + }), + ).resolves.toEqual({ action: 'accept', content: null, _meta: null }); + + pendingTurnStart.resolve({ turn: { id: 'early-capability-turn' } }); + await sendPromise; + await handle.close(); + }); + + it('does not unlock a downstream MCP when an explicit steering message is rejected', async () => { + const steerAck = deferred<{ turnId: string }>(); + const agent = new CodexAgent( + createDeps( + {}, + { + capabilityRouting, + getMcpToolApprovalPolicy: () => 'auto-approve', + }, + ), + ); + const host = installFakeHost( + agent, + (method) => { + if (method === Method.TurnStart) { + return { turn: { id: 'turn-rejected-capability-steer' } }; + } + if (method === Method.TurnSteer) { + return steerAck.promise; + } + return undefined; + }, + { userAgent: 'mock-codex/0.145.0' }, + ); + const handle = await agent.startSession({ + sessionId: 'session-rejected-capability-steer', + model: 'gpt-5.4', + workingDir: '/repo', + }); + await handle.send({ + type: 'user', + content: '查一下我和康康的飞书消息', + }); + const handlers = host.getThreadHandlers(); + if (!handlers?.mcpServerElicitation) { + throw new Error('expected mcpServerElicitation handler'); + } + handlers.itemStarted?.({ + threadId: 'start-thread-id', + turnId: 'turn-rejected-capability-steer', + item: { + id: 'rejected-steer-routed-feishu-call', + type: 'mcpToolCall', + server: 'cindy-routed-feishu-delegate', + tool: 'feishu_read_messages', + pluginId: 'feishu-delegate@personal', + }, + }); + const steerPromise = handle.steer({ + type: 'user', + content: '/feishu-delegate:message-feishu-coworkers 查一下康康', + }); + await vi.waitFor(() => { + expect( + host.request.mock.calls.filter(([method]) => method === Method.TurnSteer), + ).toHaveLength(1); + }); + let elicitationSettled = false; + const elicitationPromise = handlers.mcpServerElicitation({ + threadId: 'start-thread-id', + turnId: 'turn-rejected-capability-steer', + serverName: 'cindy-routed-feishu-delegate', + mode: 'form', + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'feishu_read_messages', + }, + message: 'Allow tool call', + requestedSchema: {}, + }).then((result) => { + elicitationSettled = true; + return result; + }); + await Promise.resolve(); + expect(elicitationSettled).toBe(false); + + const serverError = Object.assign( + new Error( + 'expected active turn id turn-rejected-capability-steer but found another-turn', + ), + { code: -32600 }, + ); + const steerAssertion = expect(steerPromise).rejects.toThrow( + 'No active Codex turn to steer', + ); + steerAck.reject(serverError); + await steerAssertion; + await expect(elicitationPromise).resolves.toEqual({ + action: 'decline', + content: null, + _meta: null, + }); + + await handle.close(); + }); + + it('waits for a steer acknowledgement before applying its explicit capability selection', async () => { + const steerAck = deferred<{ turnId: string }>(); + const agent = new CodexAgent( + createDeps( + {}, + { + capabilityRouting, + getMcpToolApprovalPolicy: () => 'auto-approve', + }, + ), + ); + const host = installFakeHost( + agent, + (method) => { + if (method === Method.TurnStart) { + return { turn: { id: 'turn-pending-capability-steer' } }; + } + if (method === Method.TurnSteer) return steerAck.promise; + return undefined; + }, + { userAgent: 'mock-codex/0.145.0' }, + ); + const handle = await agent.startSession({ + sessionId: 'session-pending-capability-steer', + model: 'gpt-5.4', + workingDir: '/repo', + }); + await handle.send({ + type: 'user', + content: '查一下我和康康的飞书消息', + }); + const handlers = host.getThreadHandlers(); + if (!handlers?.mcpServerElicitation) { + throw new Error('expected mcpServerElicitation handler'); + } + handlers.itemStarted?.({ + threadId: 'start-thread-id', + turnId: 'turn-pending-capability-steer', + item: { + id: 'pending-steer-routed-feishu-call', + type: 'mcpToolCall', + server: 'cindy-routed-feishu-delegate', + tool: 'feishu_read_messages', + pluginId: 'feishu-delegate@personal', + }, + }); + const steerPromise = handle.steer({ + type: 'user', + content: '/feishu-delegate:message-feishu-coworkers 查一下康康', + }); + await vi.waitFor(() => { + expect( + host.request.mock.calls.filter(([method]) => method === Method.TurnSteer), + ).toHaveLength(1); + }); + let elicitationSettled = false; + const elicitationPromise = handlers.mcpServerElicitation({ + threadId: 'start-thread-id', + turnId: 'turn-pending-capability-steer', + serverName: 'cindy-routed-feishu-delegate', + mode: 'form', + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'feishu_read_messages', + }, + message: 'Allow tool call', + requestedSchema: {}, + }).then((result) => { + elicitationSettled = true; + return result; + }); + await Promise.resolve(); + expect(elicitationSettled).toBe(false); + + steerAck.resolve({ turnId: 'turn-pending-capability-steer' }); + await steerPromise; + await expect(elicitationPromise).resolves.toEqual({ + action: 'accept', + content: null, + _meta: null, + }); + + await handle.close(); + }); + + it('records explicit capability selection after a late steer acknowledgement', async () => { + const steerAck = deferred<{ turnId: string }>(); + const agent = new CodexAgent( + createDeps( + {}, + { + capabilityRouting, + getMcpToolApprovalPolicy: () => 'auto-approve', + }, + ), + ); + const host = installFakeHost( + agent, + (method) => { + if (method === Method.TurnStart) { + return { turn: { id: 'turn-late-capability-steer' } }; + } + if (method === Method.TurnSteer) return steerAck.promise; + return undefined; + }, + { userAgent: 'mock-codex/0.145.0' }, + ); + const handle = await agent.startSession({ + sessionId: 'session-late-capability-steer', + model: 'gpt-5.4', + workingDir: '/repo', + }); + await handle.send({ + type: 'user', + content: '查一下飞书消息', + }); + const handlers = host.getThreadHandlers(); + if (!handlers?.mcpServerElicitation) { + throw new Error('expected mcpServerElicitation handler'); + } + const invokeRoutedTool = async (itemId: string) => { + handlers.itemStarted?.({ + threadId: 'start-thread-id', + turnId: 'turn-late-capability-steer', + item: { + id: itemId, + type: 'mcpToolCall', + server: 'cindy-routed-feishu-delegate', + tool: 'feishu_read_messages', + pluginId: 'feishu-delegate@personal', + }, + }); + return handlers.mcpServerElicitation!({ + threadId: 'start-thread-id', + turnId: 'turn-late-capability-steer', + serverName: 'cindy-routed-feishu-delegate', + mode: 'form', + _meta: { + codex_approval_kind: 'mcp_tool_call', + tool_name: 'feishu_read_messages', + }, + message: 'Allow tool call', + requestedSchema: {}, + }); + }; + + vi.useFakeTimers(); + try { + const steerPromise = handle.steer({ + type: 'user', + content: '/feishu-delegate:message-feishu-coworkers 查一下康康', + }); + for (let i = 0; i < 5; i += 1) { + if (host.request.mock.calls.some(([method]) => method === Method.TurnSteer)) break; + await Promise.resolve(); + } + const timeoutAssertion = expect(steerPromise).rejects.toThrow( + /did not acknowledge/i, + ); + await vi.advanceTimersByTimeAsync(10_000); + await timeoutAssertion; + + await expect(invokeRoutedTool('before-late-ack')).resolves.toEqual({ + action: 'decline', + content: null, + _meta: null, + }); + steerAck.resolve({ turnId: 'turn-late-capability-steer' }); + await Promise.resolve(); + await Promise.resolve(); + await expect(invokeRoutedTool('after-late-ack')).resolves.toEqual({ + action: 'accept', + content: null, + _meta: null, + }); + } finally { + vi.useRealTimers(); + } + await handle.close(); + }); +}); + describe('CodexAgent reference directories', () => { const profileName = 'cindy-readonly-references'; diff --git a/packages/maker-core/src/agents/codex/index.ts b/packages/maker-core/src/agents/codex/index.ts index 44cafabeadd..0be6dc5288d 100644 --- a/packages/maker-core/src/agents/codex/index.ts +++ b/packages/maker-core/src/agents/codex/index.ts @@ -72,6 +72,11 @@ import type { ConsumeAccountRateLimitResetCreditParams, ConsumeAccountRateLimitResetCreditResponse, } from '../../types/account-rate-limits.js'; +import { + capabilitySelectionAddedByPlanEdit, + findCapabilityRouteOverride, + isCapabilityRouteInvocationAllowed, +} from '../../types/capability-routing.js'; import { createAsyncQueue, type AsyncQueue } from '../shared/async-queue.js'; import { reviewAction, type ReviewableAction } from '../shared/auto-review.js'; import { UsageTracker } from '../shared/usage-tracker.js'; @@ -83,6 +88,7 @@ import { parseOverloadError, } from '../shared/overload-error.js'; import { buildCodexEnv } from './env-builder.js'; +import { buildCodexCapabilityConfigOverrides } from './capability-routing.js'; import { scanCodexCustomizations } from './customization-scanner.js'; import { commandExecutionDisplayInput } from './command-display.js'; import { @@ -387,6 +393,15 @@ function supportsCodexReadonlyReferenceDirs(userAgent: string | undefined): bool return supportsCodexApprovalsReviewerProtocol(userAgent); } +/** + * Per-thread plugin config for capability arbitration was verified against + * Codex 0.145.0. If the host policy needs those overrides, an older daemon must + * be rejected rather than silently starting with the downstream plugins active. + */ +function supportsCodexCapabilityRoutingProtocol(userAgent: string | undefined): boolean { + return codexUserAgentAtLeast(userAgent, [0, 145, 0]); +} + /** * `excludeTurns` was introduced in Codex 0.125.0 and later marked experimental. * Older remote daemons can outlive desktop upgrades, so omit the unknown field @@ -496,6 +511,13 @@ interface ActiveToolContext { type: 'mcpToolCall' | 'dynamicToolCall'; turnId?: string | null; server?: string | null; + /** + * Codex attaches the owning plugin id to mcpToolCall items. `null` means the + * configured server is user-owned (including a user server shadowing a + * plugin server); `undefined` means this app-server did not provide + * provenance, so routing must fail closed. + */ + pluginId?: string | null; namespace?: string | null; tool?: string | null; } @@ -829,6 +851,10 @@ function codexPermissionStrictnessRank(mode: PermissionMode): number { // (codex-rs/tui/src/chatwidget/plan_implementation.rs 的 // PLAN_IMPLEMENTATION_CODING_MESSAGE), 模型对这句有训练分布上的既有理解。 const PLAN_IMPLEMENTATION_MESSAGE = 'Implement the plan.'; +const CODEX_INHERITED_CAPABILITY_SELECTION = Symbol('codexInheritedCapabilitySelection'); +type CodexInternalSendOptions = SendOptions & { + [CODEX_INHERITED_CAPABILITY_SELECTION]?: string; +}; const SYSTEM_PLAN_REVIEW_DISMISSAL_REASONS = new Set([ 'no_listener_attached', 'no_interaction_resolver', @@ -1006,6 +1032,14 @@ function formatReferencedPathsForCodex(refs: ReferencedPath[], requestText: stri return lines.join('\n'); } +function userMessageText(content: UserMessage['content']): string { + if (typeof content === 'string') return content; + return content + .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text') + .map((block) => block.text) + .join('\n'); +} + /** * 把 UserMessage content 转成 codex app-server 的 UserInput 数组。 * @@ -2427,6 +2461,7 @@ export class CodexAgent extends BaseAgent { const inFlightStarts = new Map(); /** 每次 turn/start RPC 的自增序号, 作为登记表的键。 */ @@ -2442,11 +2477,15 @@ export class CodexAgent extends BaseAgent { let sendGeneration = 0; /** 登记一次即将发出的 turn/start, 返回它的序号。 */ - const beginTurnStart = (ownerSendGen: number): number => { + const beginTurnStart = ( + ownerSendGen: number, + capabilitySelectionText: string, + ): number => { const seq = ++turnStartSeq; inFlightStarts.set(seq, { quarantined: false, terminalSettled: false, + capabilitySelectionText, sendGen: ownerSendGen, }); isTurnStartPending = true; @@ -2553,6 +2592,95 @@ export class CodexAgent extends BaseAgent { // Kept across the internal plan implementation/revision turns. A later // explicit Session.send replaces it before turn/start. let activeTurnPermissionPolicy: TurnPermissionPolicy | null = null; + // Capability source choice belongs to the server-accepted turn that + // carried it. A global "last send text" can be poisoned by a turn/start + // that later fails, and can then unlock an unrelated surviving turn. + const capabilitySelectionTextByTurnId = new Map(); + type PendingCapabilitySteer = { + completion: Promise; + resolve: () => void; + }; + const pendingCapabilitySteersByTurnId = new Map>(); + + const appendCapabilitySelectionText = (turnId: string, selectionText: string): void => { + if (!selectionText) return; + capabilitySelectionTextByTurnId.set( + turnId, + [capabilitySelectionTextByTurnId.get(turnId) ?? '', selectionText] + .filter(Boolean) + .join('\n'), + ); + }; + + const recordAcceptedCapabilitySteer = ( + turnId: string, + selectionText: string, + ): void => { + if ( + closed || + !isTurnInFlight || + currentTurnId !== turnId || + completedTurnIds.has(turnId) || + terminalErroredTurnIds.has(turnId) + ) { + return; + } + appendCapabilitySelectionText(turnId, selectionText); + }; + + const registerPendingCapabilitySteer = ( + turnId: string, + selectionText: string, + ): ((accepted: boolean) => void) => { + let resolve!: () => void; + const entry: PendingCapabilitySteer = { + completion: new Promise((done) => { + resolve = done; + }), + resolve: () => resolve(), + }; + const entries = pendingCapabilitySteersByTurnId.get(turnId) ?? new Set(); + entries.add(entry); + pendingCapabilitySteersByTurnId.set(turnId, entries); + let settled = false; + return (accepted) => { + if (settled) return; + settled = true; + if (accepted) recordAcceptedCapabilitySteer(turnId, selectionText); + entries.delete(entry); + if (entries.size === 0) pendingCapabilitySteersByTurnId.delete(turnId); + entry.resolve(); + }; + }; + + const waitForPendingCapabilitySteers = async (turnId: string): Promise => { + // More than one direct caller can steer the same turn concurrently. A + // controlled MCP request cannot be attributed to one steer RPC, so wait + // until every steer that was already in flight has an authoritative ACK. + while (true) { + const entries = pendingCapabilitySteersByTurnId.get(turnId); + if (!entries || entries.size === 0) break; + await Promise.all([...entries].map((entry) => entry.completion)); + } + return ( + !closed && + !completedTurnIds.has(turnId) && + !terminalErroredTurnIds.has(turnId) + ); + }; + + const abandonPendingCapabilitySteersForTurn = (turnId: string): void => { + const entries = pendingCapabilitySteersByTurnId.get(turnId); + if (!entries) return; + pendingCapabilitySteersByTurnId.delete(turnId); + for (const entry of entries) entry.resolve(); + }; + + const abandonPendingCapabilitySteers = (): void => { + for (const turnId of pendingCapabilitySteersByTurnId.keys()) { + abandonPendingCapabilitySteersForTurn(turnId); + } + }; const forceTurnConfirmation = (toolName: string, input: unknown): boolean => { const policy = activeTurnPermissionPolicy; if (!policy) return false; @@ -2726,6 +2854,27 @@ export class CodexAgent extends BaseAgent { : credentialMode ?? this.hostEffectiveCredentialModes.get(currentHostKey); const approvalsReviewerProtocolSupported = supportsCodexApprovalsReviewerProtocol(initResp.userAgent); + const capabilityRoutingPolicy = this.deps.capabilityRouting; + const capabilityRoutingConfig = buildCodexCapabilityConfigOverrides( + capabilityRoutingPolicy, + { + // Remote Codex uses its own isolated CODEX_HOME. Cindy currently + // prepares provenance-preserving plugin overlays only in the local + // home, so remote explicit-only harness plugins must fail closed. + isolatedPluginOverlays: !opts.remoteHostId, + }, + ); + const capabilityRoutingProtocolSupported = + supportsCodexCapabilityRoutingProtocol(initResp.userAgent); + if ( + Object.keys(capabilityRoutingConfig).length > 0 && + !capabilityRoutingProtocolSupported + ) { + releaseHostBindingLeaseIfNeeded(); + throw new Error( + `Cindy capability routing requires Codex app-server 0.145.0 or newer (current: ${initResp.userAgent ?? 'unknown'})`, + ); + } // OpenAI OAuth can use Codex's hidden reviewer model directly. Local proxy // routes may opt in after registering a parent-thread → session → main-model // context; until that synchronous registration succeeds they stay on the @@ -2885,15 +3034,19 @@ export class CodexAgent extends BaseAgent { | 'config' > { const { approvalPolicy, approvalsReviewer, sandbox } = currentApprovalConfig(); + const config = { + ...capabilityRoutingConfig, + ...(readonlyReferenceDirsSupported ? readonlyReferencesConfig : {}), + }; const shared = { approvalPolicy, ...(approvalsReviewer ? { approvalsReviewer } : {}), ...(readonlyReferenceDirsSupported ? { runtimeWorkspaceRoots: runtimeWorkspaceRoots(), - config: readonlyReferencesConfig, } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), }; if (shouldUseReadonlyReferencesProfile()) { return { @@ -3676,9 +3829,26 @@ export class CodexAgent extends BaseAgent { * - 取消 / 无反馈的 deny (会话关闭 / 交互被 dismiss): 结束本轮循环, 下一条 * 消息回到常规模式(想再规划需重新勾选), 不发起任何新 turn。 */ - async function runPlanReviewFlow(plan: string, turnId: string): Promise { + async function runPlanReviewFlow( + plan: string, + turnId: string, + inheritedCapabilitySelectionText: string, + ): Promise { planReviewSeq += 1; const requestId = `codex-plan-review:${turnId}:${planReviewSeq}`; + const planFollowUpSendOptions = ( + additionalSelectionText = '', + ): CodexInternalSendOptions => ({ + ...(activeTurnPermissionPolicy + ? { turnPermissionPolicy: activeTurnPermissionPolicy } + : {}), + [CODEX_INHERITED_CAPABILITY_SELECTION]: [ + inheritedCapabilitySelectionText, + additionalSelectionText, + ] + .filter(Boolean) + .join('\n'), + }); const emitPlanFollowUpStartFailure = (kind: 'implementation' | 'revision', error: unknown): void => { log.warn(`plan ${kind} turn failed to start`, { error: String(error) }); // If handle.send throws before it can emit its own terminal event (for @@ -3714,13 +3884,17 @@ export class CodexAgent extends BaseAgent { const message = edited && edited !== plan.trim() ? `${PLAN_IMPLEMENTATION_MESSAGE} Follow this revised plan:\n\n${edited}` : PLAN_IMPLEMENTATION_MESSAGE; + const addedCapabilitySelection = capabilitySelectionAddedByPlanEdit( + capabilityRoutingPolicy, + 'codex', + plan, + decision.editedPlan, + ); log.debug('plan review ◀ approved — starting implementation turn', { turnId, edited: Boolean(edited && edited !== plan.trim()) }); try { await handle.send( { type: 'user', content: message }, - activeTurnPermissionPolicy - ? { turnPermissionPolicy: activeTurnPermissionPolicy } - : undefined, + planFollowUpSendOptions(addedCapabilitySelection), ); } catch (e) { emitPlanFollowUpStartFailure('implementation', e); @@ -3745,9 +3919,7 @@ export class CodexAgent extends BaseAgent { try { await handle.send( { type: 'user', content: feedback }, - activeTurnPermissionPolicy - ? { turnPermissionPolicy: activeTurnPermissionPolicy } - : undefined, + planFollowUpSendOptions(feedback), ); } catch (e) { planCycleActive = false; @@ -4382,6 +4554,31 @@ export class CodexAgent extends BaseAgent { return stringFromMeta(recordFromUnknown(context.toolParams), 'name'); } + function mcpToolPluginId( + params: McpServerElicitationRequestParams, + ): string | null | undefined { + const toolName = stringFromMeta(mcpElicitationMeta(params), 'tool_name'); + const matches: ActiveToolContext[] = []; + for (const context of activeToolContexts.values()) { + if ( + context.type !== 'mcpToolCall' || + context.turnId !== params.turnId || + context.server !== params.serverName || + (toolName && context.tool !== toolName) + ) { + continue; + } + matches.push(context); + } + if (matches.length === 0 || (!toolName && matches.length !== 1)) { + return undefined; + } + const pluginId = matches[0]?.pluginId; + return matches.every((context) => context.pluginId === pluginId) + ? pluginId + : undefined; + } + const mcpToolApprovalPolicy = (params: McpServerElicitationRequestParams) => { const classifier = this.deps.getMcpToolApprovalPolicy; if (!classifier) return 'prompt' as const; @@ -4420,6 +4617,64 @@ export class CodexAgent extends BaseAgent { return { action: 'decline', content: null, _meta: null }; } + const capabilityRoute = findCapabilityRouteOverride( + this.deps.capabilityRouting, + { + harness: 'codex', + surface: 'mcp', + id: params.serverName, + }, + ); + if ( + capabilityRoute && + params.turnId && + !(await waitForPendingCapabilitySteers(params.turnId)) + ) { + log.warn('Codex MCP invocation blocked while its steer turn became inactive', { + serverName: params.serverName, + turnId: params.turnId, + capabilityId: capabilityRoute.capabilityId, + }); + return { action: 'decline', content: null, _meta: null }; + } + const activePluginId = capabilityRoute + ? mcpToolPluginId(params) + : undefined; + if (capabilityRoute && activePluginId === undefined) { + log.warn('Codex MCP invocation blocked because plugin provenance is unavailable', { + serverName: params.serverName, + capabilityId: capabilityRoute.capabilityId, + }); + return { action: 'decline', content: null, _meta: null }; + } + const isRoutedSource = + capabilityRoute != null && + activePluginId === capabilityRoute.source.containerId; + if ( + capabilityRoute && + isRoutedSource && + !isCapabilityRouteInvocationAllowed( + capabilityRoute, + params.turnId + ? capabilitySelectionTextByTurnId.get(params.turnId) ?? '' + : '', + ) + ) { + log.warn('Codex MCP invocation blocked by host capability routing', { + serverName: params.serverName, + capabilityId: capabilityRoute.capabilityId, + invocation: capabilityRoute.invocation, + }); + return { action: 'decline', content: null, _meta: null }; + } + if (capabilityRoute && !isRoutedSource) { + log.debug('Codex MCP routing skipped for a non-target source', { + serverName: params.serverName, + capabilityId: capabilityRoute.capabilityId, + activePluginId, + }); + } + // Host policy 可在 outer call_tool 的 metadata 中识别渐进式 server 的 // inner action。查询继续静默,高风险 action 逐次确认且不得持久化授权。 const approvalPolicy = mcpToolApprovalPolicy(params); @@ -4524,6 +4779,12 @@ export class CodexAgent extends BaseAgent { type: 'mcpToolCall', turnId, server: typeof rec.server === 'string' ? rec.server : null, + pluginId: + typeof rec.pluginId === 'string' + ? rec.pluginId + : rec.pluginId === null + ? null + : undefined, tool: typeof rec.tool === 'string' ? rec.tool : null, }, }; @@ -5518,6 +5779,13 @@ export class CodexAgent extends BaseAgent { // 同一个墓碑也负责拦截该 turn 随后迟到的 item / reasoning / started 事件。 if (completedTurnIds.has(turn.id)) return; completedTurnIds.add(turn.id); + // A controlled MCP request may already be waiting for a steer ACK. The + // completed tombstone is authoritative, so release it immediately to + // decline instead of waiting for the local ACK timeout. + abandonPendingCapabilitySteersForTurn(turn.id); + const completedCapabilitySelectionText = + capabilitySelectionTextByTurnId.get(turn.id) ?? ''; + capabilitySelectionTextByTurnId.delete(turn.id); const suppressTerminalUi = terminalErroredTurnIds.has(turn.id); deferredTerminalTurnCompletions.delete(turn.id); if (currentTurnId === turn.id || currentTurnId === null) { @@ -5692,7 +5960,11 @@ export class CodexAgent extends BaseAgent { proposedPlanText = null; if (completedTurnWasPlanMode && planCycleActive) { if (planForReview) { - void runPlanReviewFlow(planForReview, turn.id); + void runPlanReviewFlow( + planForReview, + turn.id, + completedCapabilitySelectionText, + ); } else { log.debug('plan turn produced no proposed plan — plan cycle ends', { turnId: turn.id }); planCycleActive = false; @@ -6465,11 +6737,30 @@ export class CodexAgent extends BaseAgent { const wasSameTurn = currentTurnId === params.turn.id; // started 先于响应到达时归属方只能推断: 只有一个 start 在飞 → 就是它; 多个 → 认不出, // 不登记(读取方按"归属不明"从严处理)。 - const startedOwnerSeqs = [...inFlightStarts.keys()]; + const startedOwnerEntries = [...inFlightStarts.entries()]; + const startedOwner = startedOwnerEntries.length === 1 + ? startedOwnerEntries[0] + : undefined; turnOriginByTurnId.set(params.turn.id, { - startSeq: startedOwnerSeqs.length === 1 ? (startedOwnerSeqs[0] as number) : null, - sendGen: sendGeneration, + startSeq: startedOwner?.[0] ?? null, + sendGen: startedOwner?.[1].sendGen ?? sendGeneration, }); + // Notifications may arrive before the turn/start RPC response. Bind the + // selector at the same unique-owner boundary as turnOrigin so an early + // MCP elicitation sees the accepted send's capability choice. Multiple + // starts, quarantined starts, and already-settled cancellations remain + // unbound and therefore fail closed. + if ( + !wasSameTurn && + startedOwner && + !startedOwner[1].quarantined && + !startedOwner[1].terminalSettled + ) { + capabilitySelectionTextByTurnId.set( + params.turn.id, + startedOwner[1].capabilitySelectionText, + ); + } currentTurnId = params.turn.id; isTurnInFlight = true; turnStartGeneration += 1; // 见声明处:延迟善后靠它判断"期间起过新 turn" @@ -6976,6 +7267,10 @@ export class CodexAgent extends BaseAgent { } if (sendOpts) handle.validateSendOptions?.(sendOpts); activeTurnPermissionPolicy = sendOpts?.turnPermissionPolicy ?? null; + const capabilitySelectionText = + (sendOpts as CodexInternalSendOptions | undefined)?.[ + CODEX_INHERITED_CAPABILITY_SELECTION + ] ?? userMessageText(message.content); assertCurrentHost('turn/start'); resubscribeAfterTransportErrorIfNeeded(); // 新 turn 总是携带当前 (可能已收紧的) 策略, 上一轮残留的延迟中断标记 @@ -7231,6 +7526,13 @@ export class CodexAgent extends BaseAgent { // turnCompleted(interrupted) 先回), 不得重新置活, 否则会话卡 running。 const alreadyCompleted = turnsCompletedBeforeStartResp.has(resp.turn.id); if (!alreadyCompleted && !terminalErroredTurnIds.has(resp.turn.id)) { + // The app-server has now acknowledged which turn owns this + // input. Bind explicit source selection before buffered tool + // requests are released, and never on a pre-accept failure. + capabilitySelectionTextByTurnId.set( + resp.turn.id, + capabilitySelectionText, + ); // 权威归属: 这个 turn 由本次 start 生出, 属于本轮 send。 turnOriginByTurnId.set(resp.turn.id, { startSeq: ownerSeq, sendGen: mySendGen }); currentTurnId = resp.turn.id; @@ -7311,7 +7613,10 @@ export class CodexAgent extends BaseAgent { // RPC 在途也算忙(见 isTurnRunning 注释):计时器已清、turn 未激活的 // 这段窗口若报 idle,并发 send 会把原消息挤掉。 state.inFlight = true; - const retryStartSeq = beginTurnStart(state.sendGen); + const retryStartSeq = beginTurnStart( + state.sendGen, + capabilitySelectionText, + ); // RPC 是否走完了成功路径。补排延后的容量失败**只能**在成功路径上做: // finally 先于外层 state.retry().catch 执行, 若 RPC 已经 reject 却在这里 // 排上新计时器, 紧随其后的 catch 会推终态 error + Done 收口 UI, 而那个 @@ -7414,7 +7719,10 @@ export class CodexAgent extends BaseAgent { let finalErr: unknown = null; // 初始 RPC 在飞期间到达的空 id 容量拒绝只会被"延后"(不排计时器), 由下面的 // finally 在响应处理完之后补排 —— 保证任一时刻只有一个 turn/start 在飞。 - const initialStartSeq = beginTurnStart(mySendGen); + const initialStartSeq = beginTurnStart( + mySendGen, + capabilitySelectionText, + ); let initialStartSettledOk = false; /** 本次请求是否已被 Stop / 撤单收口过(条目会在 finally 里删掉, 所以先取出来)。 */ let initialStartSettledByCancel = false; @@ -7673,12 +7981,24 @@ export class CodexAgent extends BaseAgent { // turn 结束后队列再发一遍"的重复消费;这里同时给在飞 RPC 挂 // late-resolution 观察,迟到结果留日志现场。 assertCurrentHost('turn/steer'); - const steerRpc = host.request(Method.TurnSteer, { - threadId, - input, - expectedTurnId: steeredTurnId, - }); + const capabilitySelectionText = userMessageText(message.content); + const settleCapabilitySteer = registerPendingCapabilitySteer( + steeredTurnId, + capabilitySelectionText, + ); + let steerRpc: Promise; + try { + steerRpc = host.request(Method.TurnSteer, { + threadId, + input, + expectedTurnId: steeredTurnId, + }); + } catch (error) { + settleCapabilitySteer(false); + throw error; + } let ackSettled = false; + let capabilitySteerAccepted = false; try { await new Promise((resolve, reject) => { const onAbort = () => { @@ -7719,6 +8039,7 @@ export class CodexAgent extends BaseAgent { ); }); ackSettled = true; + capabilitySteerAccepted = true; } catch (error) { if (isExpectedTurnIdMismatchError(error)) { // app-server 已明确拒绝该 stale expectedTurnId,消息没有注入其它 turn。 @@ -7728,12 +8049,23 @@ export class CodexAgent extends BaseAgent { } throw error; } finally { + // Only an authoritative ACK may extend this turn's explicit source + // selection. Requests emitted before that ACK wait on this entry; + // rejection/timeout/abort releases them against the previous state. + settleCapabilitySteer(capabilitySteerAccepted); if (!ackSettled) { // 超时 / abort 后请求仍在飞:迟到成功说明消息已注入但上层已按失败 // 处理(队列行被暂停保留),留 warn 现场供排查;迟到失败静默吞掉, // 防 unhandled rejection。 steerRpc.then( () => { + // The timeout/abort already released waiting MCP requests + // against the previous selection. A later authoritative ACK + // must still update subsequent requests while this turn lives. + recordAcceptedCapabilitySteer( + steeredTurnId, + capabilitySelectionText, + ); log.warn('turn/steer acknowledged after local timeout/abort; message may already be injected', { threadId, turnId: steeredTurnId, @@ -7825,6 +8157,7 @@ export class CodexAgent extends BaseAgent { // 统一按拒绝释放, 否则 handler 永远悬挂, dispatchServerRequest // 永不返回, server 侧请求卡死。 abandonBufferedTurns('session closed'); + abandonPendingCapabilitySteers(); // 把挂起的 approval 强制 deny + emit interaction_dismissed (UI 关 dialog), // 否则 server 那边没回 response 会卡; UI 上的 PermissionPrompt 也会留尸 try { dismissAllPending('session_closed', 'deny'); } catch (e) { log.warn('dismissAllPending threw', { error: String(e) }); } diff --git a/packages/maker-core/src/types/capability-routing.test.ts b/packages/maker-core/src/types/capability-routing.test.ts new file mode 100644 index 00000000000..2d5d5495282 --- /dev/null +++ b/packages/maker-core/src/types/capability-routing.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; + +import { + capabilitySelectionAddedByPlanEdit, + findCapabilityRouteOverride, + findClaudeMcpCapabilityRoute, + isCapabilitySourceExplicitlySelected, + type CapabilityRouteOverride, + type CapabilityRoutingPolicy, + type CapabilitySourceSelector, +} from './capability-routing.js'; + +const route: CapabilityRouteOverride = { + capabilityId: 'feishu', + source: { + kind: 'harness-plugin', + harness: 'codex', + surface: 'mcp', + id: 'feishu-delegate', + }, + invocation: 'explicit-only', + explicitSelectors: [ + '$feishu-delegate:message-feishu-coworkers', + '/feishu-delegate:message-feishu-coworkers', + ], +}; + +describe('capability route resolution', () => { + it('requires harness provenance only for harness-owned sources', () => { + // @ts-expect-error Harness-owned selectors must identify their adapter. + const missingHarness: CapabilitySourceSelector = { + kind: 'harness-plugin', + surface: 'mcp', + id: 'feishu-delegate', + }; + // @ts-expect-error User-owned selectors cannot masquerade as harness sources. + const userSourceWithHarness: CapabilitySourceSelector = { + kind: 'user-skill', + harness: 'codex', + surface: 'skill', + id: 'user-skill', + }; + + expect(missingHarness.kind).toBe('harness-plugin'); + expect(userSourceWithHarness.kind).toBe('user-skill'); + }); + + it('recognizes exact namespaced skill selectors without unlocking on a display name', () => { + expect( + isCapabilitySourceExplicitlySelected( + route, + '请用 $feishu-delegate:message-feishu-coworkers 查一下康康', + ), + ).toBe(true); + expect( + isCapabilitySourceExplicitlySelected( + route, + '/feishu-delegate:message-feishu-coworkers 查一下康康', + ), + ).toBe(true); + expect( + isCapabilitySourceExplicitlySelected( + route, + '请用$feishu-delegate:message-feishu-coworkers查一下康康', + ), + ).toBe(true); + expect( + isCapabilitySourceExplicitlySelected( + route, + 'prefix$feishu-delegate:message-feishu-coworkers', + ), + ).toBe(false); + expect( + isCapabilitySourceExplicitlySelected(route, '查一下我和康康的飞书消息'), + ).toBe(false); + expect( + isCapabilitySourceExplicitlySelected( + route, + '请用 /message-feishu-coworkers 查一下康康', + ), + ).toBe(false); + expect( + isCapabilitySourceExplicitlySelected( + route, + '不要使用 Feishu Delegate,改用 Cindy', + ), + ).toBe(false); + expect( + isCapabilitySourceExplicitlySelected( + { ...route, explicitSelectors: ['Feishu Delegate'] }, + '请使用 Feishu Delegate', + ), + ).toBe(false); + }); + + it('matches MCP routes without conflating harnesses or surfaces', () => { + const userOwnedLookalike = { + ...route, + source: { + kind: 'project-skill' as const, + surface: route.source.surface, + id: route.source.id, + }, + }; + const policy = { + overrides: [ + userOwnedLookalike, + route, + { + ...route, + source: { + kind: 'harness-plugin', + harness: 'claude-code', + surface: 'mcp', + id: 'plugin:feishu-delegate:feishu-delegate', + }, + }, + ], + } satisfies CapabilityRoutingPolicy; + + expect( + findCapabilityRouteOverride(policy, { + harness: 'codex', + surface: 'mcp', + id: 'feishu-delegate', + }), + ).toBe(route); + expect( + findClaudeMcpCapabilityRoute( + policy, + 'mcp__plugin_feishu-delegate_feishu-delegate__feishu_read_messages', + )?.source.harness, + ).toBe('claude-code'); + expect( + findClaudeMcpCapabilityRoute( + policy, + 'mcp__feishu-delegate__feishu_read_messages', + ), + ).toBeUndefined(); + expect( + findClaudeMcpCapabilityRoute( + policy, + 'mcp__plugin_feishu-delegate_feishu-delegate__feishu_read_messages', + new Set(['plugin_feishu-delegate_feishu-delegate']), + ), + ).toBeUndefined(); + expect( + findClaudeMcpCapabilityRoute( + policy, + 'mcp__plugin_feishu-delegate_feishu-delegate__feishu_read_messages', + new Set(['plugin:feishu-delegate:feishu-delegate']), + ), + ).toBeUndefined(); + expect( + findClaudeMcpCapabilityRoute(policy, 'mcp__other__read'), + ).toBeUndefined(); + }); + + it('accepts only selectors newly introduced by a user plan edit', () => { + const policy = { overrides: [route] } satisfies CapabilityRoutingPolicy; + expect( + capabilitySelectionAddedByPlanEdit( + policy, + 'codex', + '1. 查询消息', + '1. 用 $feishu-delegate:message-feishu-coworkers 查询消息', + ), + ).toBe('$feishu-delegate:message-feishu-coworkers'); + expect( + capabilitySelectionAddedByPlanEdit( + policy, + 'codex', + '1. 用 $feishu-delegate:message-feishu-coworkers 查询消息', + '1. 用 $feishu-delegate:message-feishu-coworkers 查询最近消息', + ), + ).toBe(''); + expect( + capabilitySelectionAddedByPlanEdit( + policy, + 'codex', + '1. 用 $feishu-delegate:message-feishu-coworkers 查询消息', + '1. 改用 /feishu-delegate:message-feishu-coworkers 查询消息', + ), + ).toBe('/feishu-delegate:message-feishu-coworkers'); + }); +}); diff --git a/packages/maker-core/src/types/capability-routing.ts b/packages/maker-core/src/types/capability-routing.ts new file mode 100644 index 00000000000..e53b896da7d --- /dev/null +++ b/packages/maker-core/src/types/capability-routing.ts @@ -0,0 +1,258 @@ +/** + * Host-owned routing policy for capabilities that may also be supplied by an + * agent harness. + * + * The model-facing capability id is intentionally separate from a harness + * implementation id. A future harness can map the same stable capability id to + * its own plugin/tool name without changing Cindy's product policy. + */ + +export type CapabilityInvocationPolicy = 'auto' | 'explicit-only' | 'disabled'; + +export type CapabilitySourceKind = + | 'cindy-host' + | 'cindy-plugin' + | 'user-skill' + | 'project-skill' + | 'harness-builtin' + | 'harness-plugin'; + +export type CapabilitySurface = 'skill' | 'plugin' | 'mcp' | 'app' | 'tool'; + +interface CapabilitySourceSelectorBase { + surface: CapabilitySurface; + /** + * Harness-native stable id. + * + * Plugin skills must use the name exposed by the harness, including its + * namespace (for example `feishu-delegate:message-feishu-coworkers`). + * This keeps a plugin skill distinct from a same-named user or project skill. + */ + id: string; + /** + * Optional on-disk artifact id when it differs from the harness-native id. + * + * For example, a namespaced plugin skill may be exposed as + * `feishu-delegate:message-feishu-coworkers` while its directory remains + * `skills/message-feishu-coworkers`. + */ + artifactId?: string; + /** + * Optional owner id for a nested surface. + * + * Example: a Codex plugin skill uses + * `id: feishu-delegate:message-feishu-coworkers` and + * `containerId: feishu-delegate@personal`. + */ + containerId?: string; +} + +interface HarnessOwnedCapabilitySourceSelector + extends CapabilitySourceSelectorBase { + kind: Extract< + CapabilitySourceKind, + 'harness-builtin' | 'harness-plugin' + >; + /** + * Adapter id such as `codex` or `claude-code`. + * + * This remains a string instead of AgentKind so adding a third harness does + * not require widening the shared routing contract first. + */ + harness: string; +} + +interface NonHarnessCapabilitySourceSelector + extends CapabilitySourceSelectorBase { + kind: Exclude< + CapabilitySourceKind, + HarnessOwnedCapabilitySourceSelector['kind'] + >; + /** Prevent host/user/project selectors from carrying harness provenance. */ + harness?: never; +} + +export type CapabilitySourceSelector = + | HarnessOwnedCapabilitySourceSelector + | NonHarnessCapabilitySourceSelector; + +export interface CapabilityReplacement { + kind: 'cindy-host' | 'cindy-plugin'; + id: string; +} + +export interface CapabilityRouteOverride { + /** Product-level identity shared across implementations. */ + capabilityId: string; + source: CapabilitySourceSelector; + invocation: CapabilityInvocationPolicy; + /** + * User-visible selectors that explicitly choose this source for the current + * turn, for example + * `$feishu-delegate:message-feishu-coworkers` or + * `/feishu-delegate:message-feishu-coworkers`. + * + * Only unambiguous command tokens are accepted. A plain display name must + * never unlock a source in a sentence such as "do not use Feishu Delegate". + */ + explicitSelectors?: readonly string[]; + replacement?: CapabilityReplacement; + reason?: string; +} + +export interface CapabilityRoutingPolicy { + overrides: readonly CapabilityRouteOverride[]; +} + +export interface CapabilityRouteTarget { + harness: string; + surface: CapabilitySurface; + id: string; +} + +export function isHarnessOwnedCapabilitySource( + source: CapabilitySourceSelector, +): source is HarnessOwnedCapabilitySourceSelector { + return ( + source.kind === 'harness-builtin' || + source.kind === 'harness-plugin' + ); +} + +export function findCapabilityRouteOverride( + policy: CapabilityRoutingPolicy | undefined, + target: CapabilityRouteTarget, +): CapabilityRouteOverride | undefined { + return policy?.overrides.find( + (directive) => + isHarnessOwnedCapabilitySource(directive.source) && + directive.source.harness === target.harness && + directive.source.surface === target.surface && + directive.source.id === target.id, + ); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matchesExplicitSelector(text: string, selector: string): boolean { + const normalizedSelector = selector.trim(); + if (!normalizedSelector) return false; + if (normalizedSelector.startsWith('$')) { + return new RegExp( + `(^|[^A-Za-z0-9_:-])${escapeRegExp(normalizedSelector)}(?![A-Za-z0-9_:-])`, + 'iu', + ).test(text); + } + if (normalizedSelector.startsWith('/')) { + return new RegExp( + `(^|\\s)${escapeRegExp(normalizedSelector)}(?=$|\\s|[.,!?;:,。!?;:])`, + 'iu', + ).test(text); + } + return false; +} + +export function isCapabilitySourceExplicitlySelected( + directive: CapabilityRouteOverride, + userText: string, +): boolean { + return ( + directive.explicitSelectors?.some((selector) => + matchesExplicitSelector(userText, selector), + ) ?? false + ); +} + +export function isCapabilityRouteInvocationAllowed( + directive: CapabilityRouteOverride, + userText: string, +): boolean { + switch (directive.invocation) { + case 'auto': + return true; + case 'disabled': + return false; + case 'explicit-only': + return isCapabilitySourceExplicitlySelected(directive, userText); + } +} + +/** + * Return only selectors that a user newly introduced while editing a + * model-authored plan. Copying the full edited plan into selection state would + * let a selector already written by the model become an explicit user choice + * when the user merely approves or edits an unrelated line. + */ +export function capabilitySelectionAddedByPlanEdit( + policy: CapabilityRoutingPolicy | undefined, + harness: string, + originalPlan: string, + editedPlan: string | undefined, +): string { + if (editedPlan === undefined || editedPlan === originalPlan) return ''; + const added = new Set(); + for (const directive of policy?.overrides ?? []) { + if ( + directive.invocation !== 'explicit-only' || + directive.source.harness !== harness + ) { + continue; + } + for (const selector of directive.explicitSelectors ?? []) { + if ( + !matchesExplicitSelector(originalPlan, selector) && + matchesExplicitSelector(editedPlan, selector) + ) { + added.add(selector.trim()); + } + } + } + return [...added].filter(Boolean).join('\n'); +} + +export function findClaudeMcpCapabilityRoute( + policy: CapabilityRoutingPolicy | undefined, + toolName: string, + nonHarnessServerIds: ReadonlySet = new Set(), +): CapabilityRouteOverride | undefined { + return policy?.overrides.find( + (directive) => + isHarnessOwnedCapabilitySource(directive.source) && + directive.source.harness === 'claude-code' && + directive.source.surface === 'mcp' && + !hasClaudeMcpPrefixCollision( + directive.source.id, + nonHarnessServerIds, + ) && + toolName.startsWith(claudeMcpToolPrefix(directive.source.id)), + ); +} + +export function claudeMcpToolPrefix(serverId: string): string { + return `mcp__${serverId.replace(/[^a-zA-Z0-9_-]/g, '_')}__`; +} + +/** + * Claude flattens punctuation in MCP server ids before exposing tool names. + * A harness plugin id such as `plugin:foo:bar` therefore aliases a perfectly + * valid user MCP id such as `plugin_foo_bar`. Once flattened, PreToolUse only + * receives the tool name and cannot recover which server produced it. + * The same ambiguity exists when a user registration uses the exact harness + * id: the SDK registry reports only the id, not its settings/plugin origin. + * + * Prefer the known non-harness registration in that ambiguous case. This may + * narrow a harness guard for the session, but it never silently disables a + * user/host MCP merely because its valid id collides after normalization. + */ +export function hasClaudeMcpPrefixCollision( + harnessServerId: string, + nonHarnessServerIds: ReadonlySet, +): boolean { + const harnessPrefix = claudeMcpToolPrefix(harnessServerId); + for (const serverId of nonHarnessServerIds) { + if (claudeMcpToolPrefix(serverId) === harnessPrefix) return true; + } + return false; +} diff --git a/packages/maker-core/src/types/index.ts b/packages/maker-core/src/types/index.ts index 10477e7cbde..b8cac8839d9 100644 --- a/packages/maker-core/src/types/index.ts +++ b/packages/maker-core/src/types/index.ts @@ -1,5 +1,6 @@ export * from './common.js'; export * from './capabilities.js'; +export * from './capability-routing.js'; export * from './events.js'; export * from './permissions.js'; export * from './palette.js';