Apply room skill overrides to runner spawn

This commit is contained in:
Eyejoker
2026-05-04 16:13:24 +09:00
committed by GitHub
parent 85da2b918c
commit 0a8decde8c
4 changed files with 384 additions and 23 deletions

View File

@@ -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;

View File

@@ -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<SkillSyncScope, Set<string>> {
const disabled = new Map<SkillSyncScope, Set<string>>();
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<string>();
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 };
}

View File

@@ -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);

View File

@@ -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) {