diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index f972a02..4e8557d 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -108,6 +108,15 @@ const group: RegisteredGroup = { agentType: 'codex', }; +function writeSkill(dir: string, name: string): void { + const skillDir = path.join(dir, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `---\nname: ${name}\ndescription: ${name} skill\n---\n`, + ); +} + describe('prepareGroupEnvironment codex auth handling', () => { let tempRoot: string; let previousCwd: string; @@ -370,6 +379,133 @@ describe('prepareGroupEnvironment codex auth handling', () => { }); }); +describe('prepareGroupEnvironment room skill overrides', () => { + let tempRoot: string; + let previousCwd: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-skills-')); + previousCwd = process.cwd(); + process.chdir(tempRoot); + process.env.EJ_TEST_ROOT = tempRoot; + process.env.EJ_TEST_HOME = path.join(tempRoot, 'home'); + fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), { + recursive: true, + }); + mockReadEnvFile.mockReset(); + mockGetActiveCodexAuthPath.mockReset(); + }); + + afterEach(() => { + process.chdir(previousCwd); + delete process.env.EJ_TEST_ROOT; + delete process.env.EJ_TEST_HOME; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('filters Claude session skills with room overrides at spawn time', () => { + mockReadEnvFile.mockReturnValue({}); + const homeSkills = path.join( + process.env.EJ_TEST_HOME!, + '.claude', + 'skills', + ); + const workDir = path.join(tempRoot, 'workdir'); + const runnerSkills = path.join(tempRoot, 'runners', 'skills'); + writeSkill(homeSkills, 'claude-keep'); + writeSkill(path.join(workDir, '.claude', 'skills'), 'workdir-keep'); + writeSkill(runnerSkills, 'runner-keep'); + writeSkill(runnerSkills, 'runner-off'); + + prepareGroupEnvironment( + { ...group, agentType: 'claude-code', workDir }, + false, + 'dc:test', + { + skillOverrides: [ + { + chatJid: 'dc:test', + agentType: 'claude-code', + skillScope: 'runner', + skillName: 'runner-off', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + ], + }, + ); + + const sessionSkills = path.join( + tempRoot, + 'sessions', + group.folder, + 'services', + 'codex-main', + '.claude', + 'skills', + ); + expect(fs.existsSync(path.join(sessionSkills, 'claude-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'workdir-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false); + }); + + it('uses a session-scoped Codex home when room overrides disable skills', () => { + mockReadEnvFile.mockReturnValue({}); + const codexSkills = path.join( + process.env.EJ_TEST_HOME!, + '.agents', + 'skills', + ); + const runnerSkills = path.join(tempRoot, 'runners', 'skills'); + writeSkill(codexSkills, 'codex-keep'); + writeSkill(codexSkills, 'codex-off'); + writeSkill(runnerSkills, 'runner-keep'); + writeSkill(runnerSkills, 'runner-off'); + + const prepared = prepareGroupEnvironment(group, false, 'dc:test', { + skillOverrides: [ + { + chatJid: 'dc:test', + agentType: 'codex', + skillScope: 'codex-user', + skillName: 'codex-off', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + { + chatJid: 'dc:test', + agentType: 'codex', + skillScope: 'runner', + skillName: 'runner-off', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + ], + }); + + const sessionRoot = path.join( + tempRoot, + 'sessions', + group.folder, + 'services', + 'codex-main', + ); + const sessionHome = path.join(sessionRoot, 'home'); + const sessionSkills = path.join(sessionHome, '.agents', 'skills'); + expect(prepared.env.HOME).toBe(sessionHome); + expect(prepared.env.CODEX_HOME).toBe(path.join(sessionRoot, '.codex')); + expect(fs.existsSync(path.join(sessionSkills, 'codex-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'codex-off'))).toBe(false); + expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false); + expect(fs.existsSync(path.join(codexSkills, 'codex-off'))).toBe(true); + }); +}); + describe('prepareGroupEnvironment Codex goals handling', () => { let tempRoot: string; let previousCwd: string; diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 433b62f..1d779be 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -33,6 +33,7 @@ import { hasReviewerLease, } from './service-routing.js'; import type { AgentType, RegisteredGroup } from './types.js'; +import type { StoredRoomSkillOverride } from './db/rooms.js'; // writeCodexApiKeyAuth removed — Codex uses OAuth only. // API key auth caused unintended billing. @@ -53,6 +54,72 @@ function syncDirectoryEntries(sources: string[], destination: string): void { } } +type SkillSyncScope = 'codex-user' | 'claude-user' | 'runner' | 'workdir'; + +interface SkillSyncSource { + dir: string; + scope: SkillSyncScope; +} + +function getDisabledSkillNamesByScope( + overrides: StoredRoomSkillOverride[] | undefined, + agentType: AgentType, +): Map> { + const disabled = new Map>(); + for (const override of overrides ?? []) { + if (override.agentType !== agentType || override.enabled !== false) + continue; + if ( + override.skillScope !== 'codex-user' && + override.skillScope !== 'claude-user' && + override.skillScope !== 'runner' + ) { + continue; + } + const names = disabled.get(override.skillScope) ?? new Set(); + names.add(override.skillName); + disabled.set(override.skillScope, names); + } + return disabled; +} + +function hasDisabledSkillOverrides( + overrides: StoredRoomSkillOverride[] | undefined, + agentType: AgentType, +): boolean { + return (overrides ?? []).some( + (override) => + override.agentType === agentType && override.enabled === false, + ); +} + +function syncRoomSkillDirectories(args: { + sources: SkillSyncSource[]; + destination: string; + agentType: AgentType; + overrides?: StoredRoomSkillOverride[]; +}): void { + const disabledNamesByScope = getDisabledSkillNamesByScope( + args.overrides, + args.agentType, + ); + + fs.rmSync(args.destination, { recursive: true, force: true }); + fs.mkdirSync(args.destination, { recursive: true }); + + for (const source of args.sources) { + if (!fs.existsSync(source.dir)) continue; + for (const entry of fs.readdirSync(source.dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (disabledNamesByScope.get(source.scope)?.has(entry.name)) continue; + + const srcPath = path.join(source.dir, entry.name); + const dstPath = path.join(args.destination, entry.name); + fs.cpSync(srcPath, dstPath, { recursive: true }); + } + } +} + function readOptionalPromptFile( projectRoot: string, filename: string, @@ -301,6 +368,7 @@ function prepareCodexSessionEnvironment(args: { isPairedRoom: boolean; useFailoverPromptPack: boolean; memoryBriefing?: string; + skillOverrides?: StoredRoomSkillOverride[]; }): void { // API key auth intentionally removed — Codex uses OAuth only. // Never pass any API key to Codex child process to prevent API billing. @@ -385,15 +453,39 @@ function prepareCodexSessionEnvironment(args: { // Codex reads skills from ~/.agents/skills/ (user-level) and // {workDir}/.agents/skills/ (project-level), NOT from .codex/skills/. - // Sync to the user-level path so all Codex sessions can discover them. - const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills'); - syncDirectoryEntries( - [ - path.join(os.homedir(), '.claude', 'skills'), - path.join(args.projectRoot, 'runners', 'skills'), - ], - codexSkillsDir, - ); + if (hasDisabledSkillOverrides(args.skillOverrides, 'codex')) { + const sessionHomeDir = path.join(args.sessionRootDir, 'home'); + args.env.HOME = sessionHomeDir; + syncRoomSkillDirectories({ + sources: [ + { + dir: path.join(os.homedir(), '.claude', 'skills'), + scope: 'codex-user', + }, + { + dir: path.join(os.homedir(), '.agents', 'skills'), + scope: 'codex-user', + }, + { + dir: path.join(args.projectRoot, 'runners', 'skills'), + scope: 'runner', + }, + ], + destination: path.join(sessionHomeDir, '.agents', 'skills'), + agentType: 'codex', + overrides: args.skillOverrides, + }); + } else { + // Preserve the historical global sync path when no room override exists. + const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills'); + syncDirectoryEntries( + [ + path.join(os.homedir(), '.claude', 'skills'), + path.join(args.projectRoot, 'runners', 'skills'), + ], + codexSkillsDir, + ); + } const mcpServerPath = path.join( args.projectRoot, @@ -411,7 +503,7 @@ function prepareCodexSessionEnvironment(args: { chatJid: args.chatJid, groupFolder: args.group.folder, isMain: args.isMain, - agentType: args.group.agentType || 'claude-code', + agentType: 'codex', workDir: args.env.EJCLAW_WORK_DIR || args.group.workDir || args.projectRoot, }); @@ -430,6 +522,10 @@ export interface PreparedGroupEnvironment { runnerDir: string; } +export interface PreparedReadonlySessionEnvironment { + codexHomeDir?: string; +} + export function prepareGroupEnvironment( group: RegisteredGroup, isMain: boolean, @@ -438,6 +534,7 @@ export function prepareGroupEnvironment( memoryBriefing?: string; runtimeTaskId?: string; useTaskScopedSession?: boolean; + skillOverrides?: StoredRoomSkillOverride[]; }, ): PreparedGroupEnvironment { const projectRoot = process.cwd(); @@ -463,12 +560,26 @@ export function prepareGroupEnvironment( const workDirClaude = group.workDir ? path.join(group.workDir, '.claude') : null; - const skillSources = [ - path.join(os.homedir(), '.claude', 'skills'), - ...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []), - path.join(projectRoot, 'runners', 'skills'), - ]; - syncDirectoryEntries(skillSources, path.join(groupSessionsDir, 'skills')); + syncRoomSkillDirectories({ + sources: [ + { + dir: path.join(os.homedir(), '.claude', 'skills'), + scope: 'claude-user', + }, + ...(workDirClaude + ? [ + { + dir: path.join(workDirClaude, 'skills'), + scope: 'workdir' as const, + }, + ] + : []), + { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' }, + ], + destination: path.join(groupSessionsDir, 'skills'), + agentType: 'claude-code', + overrides: options?.skillOverrides, + }); const groupIpcDir = runtimeTaskId ? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId) @@ -566,6 +677,7 @@ export function prepareGroupEnvironment( isPairedRoom, useFailoverPromptPack: useCodexReviewFailoverPromptPack, memoryBriefing: options?.memoryBriefing, + skillOverrides: options?.skillOverrides, }); } else { prepareClaudeEnvironment({ env, envVars, group }); @@ -594,7 +706,8 @@ export function prepareReadonlySessionEnvironment(args: { ipcDir?: string; hostIpcDir?: string; workDir?: string; -}): void { + skillOverrides?: StoredRoomSkillOverride[]; +}): PreparedReadonlySessionEnvironment { const { sessionDir, chatJid, @@ -606,6 +719,7 @@ export function prepareReadonlySessionEnvironment(args: { ipcDir = '/workspace/ipc', hostIpcDir = ipcDir, workDir = '/workspace/project', + skillOverrides, } = args; const projectRoot = process.cwd(); @@ -614,11 +728,40 @@ export function prepareReadonlySessionEnvironment(args: { ensureClaudeGlobalSettingsFile(sessionDir); // Sync skills from host - const skillSources = [ - path.join(os.homedir(), '.claude', 'skills'), - path.join(projectRoot, 'runners', 'skills'), - ]; - syncDirectoryEntries(skillSources, path.join(sessionDir, 'skills')); + syncRoomSkillDirectories({ + sources: [ + { + dir: path.join(os.homedir(), '.claude', 'skills'), + scope: agentType === 'codex' ? 'codex-user' : 'claude-user', + }, + { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' }, + ], + destination: path.join(sessionDir, 'skills'), + agentType, + overrides: skillOverrides, + }); + const codexHomeDir = + agentType === 'codex' && hasDisabledSkillOverrides(skillOverrides, 'codex') + ? sessionDir + : undefined; + if (codexHomeDir) { + syncRoomSkillDirectories({ + sources: [ + { + dir: path.join(os.homedir(), '.claude', 'skills'), + scope: 'codex-user', + }, + { + dir: path.join(os.homedir(), '.agents', 'skills'), + scope: 'codex-user', + }, + { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' }, + ], + destination: path.join(codexHomeDir, '.agents', 'skills'), + agentType, + overrides: skillOverrides, + }); + } // Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter) const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot); @@ -688,4 +831,5 @@ export function prepareReadonlySessionEnvironment(args: { } else if (fs.existsSync(sessionClaudeMdPath)) { fs.unlinkSync(sessionClaudeMdPath); } + return { codexHomeDir }; } diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 962c85e..8428462 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -71,6 +71,10 @@ vi.mock('./service-routing.js', () => ({ hasReviewerLease: vi.fn(() => true), })); +vi.mock('./db.js', () => ({ + getStoredRoomSkillOverrides: vi.fn(() => []), +})); + // Create a controllable fake ChildProcess function createFakeProcess() { const proc = new EventEmitter() as EventEmitter & { @@ -108,6 +112,7 @@ vi.mock('child_process', async () => { import { runAgentProcess, AgentOutput } from './agent-runner.js'; import * as agentRunnerEnvironment from './agent-runner-environment.js'; +import { getStoredRoomSkillOverrides } from './db.js'; import type { RegisteredGroup } from './types.js'; const testGroup: RegisteredGroup = { @@ -681,10 +686,61 @@ OUROBOROS_LLM_BACKEND = "codex" }); }); +describe('agent-runner room skill overrides', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([]); + fakeProc = createFakeProcess(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes room skill overrides into runner environment setup', async () => { + const skillOverride = { + chatJid: testInput.chatJid, + agentType: 'claude-code' as const, + skillScope: 'runner', + skillName: 'runner-off', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }; + vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([skillOverride]); + const prepareGroupEnvironmentSpy = vi.spyOn( + agentRunnerEnvironment, + 'prepareGroupEnvironment', + ); + + const resultPromise = runAgentProcess( + testGroup, + testInput, + () => {}, + async () => {}, + ); + + fakeProc.emit('close', 0); + const result = await resultPromise; + expect(result.status).toBe('success'); + expect(getStoredRoomSkillOverrides).toHaveBeenCalledWith(testInput.chatJid); + expect(prepareGroupEnvironmentSpy).toHaveBeenCalledWith( + testGroup, + testInput.isMain, + testInput.chatJid, + expect.objectContaining({ + skillOverrides: [skillOverride], + }), + ); + }); +}); + describe('agent-runner Codex goals', () => { beforeEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([]); fakeProc = createFakeProcess(); vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { const str = String(p); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index e64c239..f1b9a84 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -13,6 +13,7 @@ import { prepareGroupEnvironment, } from './agent-runner-environment.js'; import { runSpawnedAgentProcess } from './agent-runner-process.js'; +import { getStoredRoomSkillOverrides } from './db.js'; export { type AvailableGroup, writeGroupsSnapshot, @@ -20,6 +21,7 @@ export { } from './agent-runner-snapshot.js'; import { logger } from './logger.js'; import { RegisteredGroup, RoomRoleContext } from './types.js'; +import type { StoredRoomSkillOverride } from './db/rooms.js'; export interface AgentInput { prompt: string; @@ -50,6 +52,23 @@ export interface AgentOutput { error?: string; } +function readRoomSkillOverridesForRunner( + chatJid: string, +): StoredRoomSkillOverride[] { + try { + return getStoredRoomSkillOverrides(chatJid); + } catch (err) { + logger.warn( + { + err, + chatJid, + }, + 'Failed to read room skill overrides; falling back to default skills', + ); + return []; + } +} + export async function runAgentProcess( group: RegisteredGroup, input: AgentInput, @@ -65,6 +84,7 @@ export async function runAgentProcess( // ── Host process mode (owner) ─────────────────────────────────── const startTime = Date.now(); + const skillOverrides = readRoomSkillOverridesForRunner(input.chatJid); const { env, groupDir, runnerDir } = prepareGroupEnvironment( group, input.isMain, @@ -73,6 +93,7 @@ export async function runAgentProcess( memoryBriefing: input.memoryBriefing, runtimeTaskId: input.runtimeTaskId, useTaskScopedSession: input.useTaskScopedSession, + skillOverrides, }, ); @@ -88,7 +109,7 @@ export async function runAgentProcess( (input.roomRoleContext?.role === 'reviewer' || input.roomRoleContext?.role === 'arbiter') ) { - prepareReadonlySessionEnvironment({ + const readonlySession = prepareReadonlySessionEnvironment({ sessionDir: envOverrides.CLAUDE_CONFIG_DIR, chatJid: input.chatJid, isMain: input.isMain, @@ -99,9 +120,13 @@ export async function runAgentProcess( ipcDir: env.EJCLAW_IPC_DIR, hostIpcDir: env.EJCLAW_HOST_IPC_DIR, workDir: envOverrides.EJCLAW_WORK_DIR || env.EJCLAW_WORK_DIR, + skillOverrides, }); if ((group.agentType || 'claude-code') === 'codex') { env.CODEX_HOME = path.join(envOverrides.CLAUDE_CONFIG_DIR, '.codex'); + if (readonlySession.codexHomeDir) { + env.HOME = readonlySession.codexHomeDir; + } } } if (input.runId) {