diff --git a/apps/dashboard/src/RuntimeInventorySettings.css b/apps/dashboard/src/RuntimeInventorySettings.css
index c67465e..c40e826 100644
--- a/apps/dashboard/src/RuntimeInventorySettings.css
+++ b/apps/dashboard/src/RuntimeInventorySettings.css
@@ -171,6 +171,43 @@
font-size: 0.86rem;
}
+.runtime-room-skill-toggles {
+ display: grid;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.runtime-room-skill-toggle {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 10px;
+ min-height: 44px;
+ border-radius: 12px;
+ background: rgba(15, 23, 42, 0.38);
+ padding: 8px 10px;
+}
+
+.runtime-room-skill-toggle input {
+ width: 18px;
+ height: 18px;
+}
+
+.runtime-room-skill-toggle > span {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.runtime-room-skill-toggle strong {
+ color: var(--text);
+ font-size: 0.84rem;
+}
+
+.runtime-room-skill-toggle small {
+ color: var(--muted);
+}
+
@media (max-width: 980px) {
.runtime-summary-card,
.runtime-skill-grid,
diff --git a/apps/dashboard/src/RuntimeInventorySettings.tsx b/apps/dashboard/src/RuntimeInventorySettings.tsx
index 7753f94..af7a1d3 100644
--- a/apps/dashboard/src/RuntimeInventorySettings.tsx
+++ b/apps/dashboard/src/RuntimeInventorySettings.tsx
@@ -3,11 +3,13 @@ import { useEffect, useState } from 'react';
import {
fetchRoomSkillSettings,
fetchRuntimeInventory,
+ updateRoomSkillSetting,
type RuntimeAgentInventory,
type RuntimeInventorySnapshot,
type RuntimePathSnapshot,
type RuntimeSkillDirSnapshot,
type RoomSkillCatalogItem,
+ type RoomSkillSettingUpdateInput,
type RoomSkillSettingsSnapshot,
} from './api';
import { SettingsSectionHeading } from './SettingsPanelChrome';
@@ -106,8 +108,12 @@ function agentLabel(agentType: 'claude-code' | 'codex') {
}
function RoomSkillPolicyCard({
+ onToggle,
+ savingKey,
snapshot,
}: {
+ onToggle: (input: RoomSkillSettingUpdateInput) => void;
+ savingKey: string | null;
snapshot: RoomSkillSettingsSnapshot | null;
}) {
if (!snapshot) {
@@ -159,6 +165,7 @@ function RoomSkillPolicyCard({
const disabledNames = agent.disabledSkillIds
.map((id) => catalogById.get(id)?.displayName ?? id)
.slice(0, 3);
+ const enabledIds = new Set(agent.effectiveEnabledSkillIds);
return (
0 ? (
OFF: {disabledNames.join(', ')}
) : null}
+
+ {agent.availableSkillIds.map((skillId) => {
+ const skill = catalogById.get(skillId);
+ const key = `${room.jid}:${agent.agentType}:${skillId}`;
+ return (
+
+ );
+ })}
+
);
})}
@@ -201,6 +241,10 @@ export function RuntimeInventorySettings() {
const [roomSkillSnapshot, setRoomSkillSnapshot] =
useState(null);
const [error, setError] = useState(null);
+ const [roomSkillError, setRoomSkillError] = useState(null);
+ const [savingRoomSkillKey, setSavingRoomSkillKey] = useState(
+ null,
+ );
useEffect(() => {
let cancelled = false;
@@ -220,6 +264,20 @@ export function RuntimeInventorySettings() {
};
}, []);
+ async function handleRoomSkillToggle(input: RoomSkillSettingUpdateInput) {
+ const key = `${input.roomJid}:${input.agentType}:${input.skillId}`;
+ setSavingRoomSkillKey(key);
+ setRoomSkillError(null);
+ try {
+ const next = await updateRoomSkillSetting(input);
+ setRoomSkillSnapshot(next);
+ } catch (err) {
+ setRoomSkillError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSavingRoomSkillKey(null);
+ }
+ }
+
return (
-
+ {roomSkillError ? (
+ {roomSkillError}
+ ) : null}
+ {
+ void handleRoomSkillToggle(input);
+ }}
+ savingKey={savingRoomSkillKey}
+ snapshot={roomSkillSnapshot}
+ />
diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts
index ce1dbe6..02ffd57 100644
--- a/apps/dashboard/src/api.ts
+++ b/apps/dashboard/src/api.ts
@@ -473,6 +473,13 @@ export interface RoomSkillSettingsSnapshot {
rooms: RoomSkillPolicyRoom[];
}
+export interface RoomSkillSettingUpdateInput {
+ roomJid: string;
+ agentType: 'claude-code' | 'codex';
+ skillId: string;
+ enabled: boolean;
+}
+
export interface MoaReferenceStatus {
model: string;
checkedAt: string;
@@ -635,6 +642,30 @@ export async function fetchRoomSkillSettings(): Promise {
+ const response = await fetch('/api/settings/room-skills', {
+ method: 'PATCH',
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(input),
+ });
+ if (!response.ok) {
+ let msg = `update room skill failed: ${response.status}`;
+ try {
+ const payload = (await response.json()) as { error?: string };
+ if (payload.error) msg = payload.error;
+ } catch {
+ /* ignore */
+ }
+ throw new Error(msg);
+ }
+ return (await response.json()) as RoomSkillSettingsSnapshot;
+}
+
export async function updateCodexFeatures(
input: Partial,
): Promise {
diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts
index 28071bc..2f6b0ea 100644
--- a/scripts/dashboard-ux.ts
+++ b/scripts/dashboard-ux.ts
@@ -11,6 +11,8 @@ interface MockApiState {
codexFeatureUpdates: number;
ciWatcherFailures: number;
restartRequests: number;
+ roomSkillUpdates: number;
+ roomSkillsDisableCodexBrowser: boolean;
}
const seriousImpacts = new Set(['serious', 'critical']);
@@ -27,7 +29,7 @@ async function main() {
'settings nav keeps hash route stable',
browser,
baseUrl,
- async (page) => {
+ async (page, state) => {
await openSettings(page, baseUrl);
const originalUrl = page.url();
@@ -52,6 +54,17 @@ async function main() {
await assertVisible(page.locator('#settings-runtime'));
await page.getByText('Runtime inventory').waitFor();
await page.getByText('EJClaw bridge').waitFor();
+ const codexPolicy = page
+ .locator('.runtime-room-agent-policy')
+ .filter({ hasText: 'Codex' })
+ .first();
+ const codexBrowserToggle = codexPolicy
+ .locator('input[type="checkbox"]')
+ .first();
+ assert.equal(await codexBrowserToggle.isChecked(), true);
+ await codexBrowserToggle.click();
+ await codexPolicy.getByText('1개 OFF').waitFor();
+ assert.equal(state.roomSkillUpdates, 1);
await page
.locator(
@@ -225,6 +238,8 @@ function createMockApiState(): MockApiState {
codexFeatureUpdates: 0,
ciWatcherFailures: 0,
restartRequests: 0,
+ roomSkillUpdates: 0,
+ roomSkillsDisableCodexBrowser: false,
};
}
@@ -319,8 +334,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
return;
}
- if (method === 'GET' && url.pathname === '/api/settings/room-skills') {
- await fulfillJson(route, roomSkillSettingsFixture());
+ if (await handleMockRoomSkillRoute(route, state, url, method)) {
return;
}
@@ -419,6 +433,33 @@ async function handleMockApi(route: Route, state: MockApiState) {
);
}
+async function handleMockRoomSkillRoute(
+ route: Route,
+ state: MockApiState,
+ url: URL,
+ method: string,
+): Promise {
+ if (url.pathname !== '/api/settings/room-skills') return false;
+ if (method === 'GET') {
+ await fulfillJson(route, roomSkillSettingsFixture(state));
+ return true;
+ }
+ if (method !== 'PATCH' && method !== 'PUT') return false;
+
+ const body = parseJsonBody(route.request().postData());
+ if (
+ body.roomJid === 'room@example' &&
+ body.agentType === 'codex' &&
+ body.skillId === 'codex-user:agent-browser' &&
+ body.enabled === false
+ ) {
+ state.roomSkillUpdates += 1;
+ state.roomSkillsDisableCodexBrowser = true;
+ }
+ await fulfillJson(route, roomSkillSettingsFixture(state));
+ return true;
+}
+
function mockOverview(state: MockApiState) {
return {
generatedAt: MOCK_TIME,
@@ -588,7 +629,8 @@ function runtimeInventoryFixture() {
};
}
-function roomSkillSettingsFixture() {
+function roomSkillSettingsFixture(state?: MockApiState) {
+ const codexDisabled = state?.roomSkillsDisableCodexBrowser === true;
return {
generatedAt: '2026-05-04T00:00:00.000Z',
catalog: [
@@ -629,17 +671,16 @@ function roomSkillSettingsFixture() {
agents: [
{
agentType: 'codex',
- mode: 'all-enabled',
+ mode: codexDisabled ? 'custom' : 'all-enabled',
availableSkillIds: [
'codex-user:agent-browser',
'runner:agent-browser',
],
- disabledSkillIds: [],
+ disabledSkillIds: codexDisabled ? ['codex-user:agent-browser'] : [],
explicitEnabledSkillIds: [],
- effectiveEnabledSkillIds: [
- 'codex-user:agent-browser',
- 'runner:agent-browser',
- ],
+ effectiveEnabledSkillIds: codexDisabled
+ ? ['runner:agent-browser']
+ : ['codex-user:agent-browser', 'runner:agent-browser'],
},
{
agentType: 'claude-code',
diff --git a/src/db.ts b/src/db.ts
index 34b2704..90f346c 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -43,6 +43,7 @@ export {
createTask,
deleteAllSessionsForGroup,
deleteSession,
+ deleteStoredRoomSkillOverride,
deleteTask,
findDuplicateCiWatcher,
getAllRoomBindings,
@@ -72,6 +73,7 @@ export {
setRouterStateForService,
setSession,
setSessionForAgentType,
+ upsertStoredRoomSkillOverride,
updateRegisteredGroupName,
updateTask,
updateTaskAfterRun,
diff --git a/src/db/rooms.ts b/src/db/rooms.ts
index f9d9640..940f12b 100644
--- a/src/db/rooms.ts
+++ b/src/db/rooms.ts
@@ -38,6 +38,14 @@ export interface StoredRoomSkillOverride {
updatedAt: string;
}
+export interface StoredRoomSkillOverrideInput {
+ chatJid: string;
+ agentType: AgentType;
+ skillScope: string;
+ skillName: string;
+ enabled: boolean;
+}
+
export interface AssignRoomInput {
name: string;
roomMode?: RoomMode;
@@ -373,6 +381,56 @@ export function getStoredRoomSkillOverridesFromDatabase(
.filter((row): row is StoredRoomSkillOverride => Boolean(row));
}
+export function upsertStoredRoomSkillOverrideInDatabase(
+ database: Database,
+ input: StoredRoomSkillOverrideInput,
+): void {
+ const agentType = normalizeStoredAgentType(input.agentType);
+ if (!agentType) {
+ throw new Error(`Unsupported agent type: ${input.agentType}`);
+ }
+ const now = new Date().toISOString();
+ database
+ .prepare(
+ `INSERT INTO room_skill_overrides (
+ chat_jid, agent_type, skill_scope, skill_name, enabled,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(chat_jid, agent_type, skill_scope, skill_name)
+ DO UPDATE SET
+ enabled = excluded.enabled,
+ updated_at = excluded.updated_at`,
+ )
+ .run(
+ input.chatJid,
+ agentType,
+ input.skillScope,
+ input.skillName,
+ input.enabled ? 1 : 0,
+ now,
+ now,
+ );
+}
+
+export function deleteStoredRoomSkillOverrideFromDatabase(
+ database: Database,
+ input: Omit,
+): void {
+ const agentType = normalizeStoredAgentType(input.agentType);
+ if (!agentType) {
+ throw new Error(`Unsupported agent type: ${input.agentType}`);
+ }
+ database
+ .prepare(
+ `DELETE FROM room_skill_overrides
+ WHERE chat_jid = ?
+ AND agent_type = ?
+ AND skill_scope = ?
+ AND skill_name = ?`,
+ )
+ .run(input.chatJid, agentType, input.skillScope, input.skillName);
+}
+
function getStoredRoomModeRowFromDatabase(
database: Database,
chatJid: string,
diff --git a/src/db/runtime-tasks-rooms.ts b/src/db/runtime-tasks-rooms.ts
index a1a8b7e..45ccca6 100644
--- a/src/db/runtime-tasks-rooms.ts
+++ b/src/db/runtime-tasks-rooms.ts
@@ -28,6 +28,7 @@ import {
assignRoomInDatabase,
clearExplicitRoomModeInDatabase,
deleteStoredRoomSettingsForTestsInDatabase,
+ deleteStoredRoomSkillOverrideFromDatabase,
getAllRoomBindingsFromDatabase,
getEffectiveRoomModeFromDatabase,
getEffectiveRuntimeRoomModeFromDatabase,
@@ -41,7 +42,9 @@ import {
setRegisteredGroupForTestsInDatabase,
setStoredRoomOwnerAgentTypeForTestsInDatabase,
type StoredRoomSkillOverride,
+ type StoredRoomSkillOverrideInput,
updateRegisteredGroupNameInDatabase,
+ upsertStoredRoomSkillOverrideInDatabase,
} from './rooms.js';
import { type StoredRoomSettings } from './room-registration.js';
import {
@@ -373,6 +376,18 @@ export function getStoredRoomSkillOverrides(
return getStoredRoomSkillOverridesFromDatabase(db, chatJid);
}
+export function upsertStoredRoomSkillOverride(
+ input: StoredRoomSkillOverrideInput,
+): void {
+ upsertStoredRoomSkillOverrideInDatabase(requireDatabase(), input);
+}
+
+export function deleteStoredRoomSkillOverride(
+ input: Omit,
+): void {
+ deleteStoredRoomSkillOverrideFromDatabase(requireDatabase(), input);
+}
+
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
}
diff --git a/src/room-skill-settings.test.ts b/src/room-skill-settings.test.ts
index 813b873..88cc6b5 100644
--- a/src/room-skill-settings.test.ts
+++ b/src/room-skill-settings.test.ts
@@ -3,7 +3,11 @@ import { Database } from 'bun:sqlite';
import { describe, expect, it } from 'vitest';
import { initializeDatabaseSchema } from './db/bootstrap.js';
-import { getStoredRoomSkillOverridesFromDatabase } from './db/rooms.js';
+import {
+ deleteStoredRoomSkillOverrideFromDatabase,
+ getStoredRoomSkillOverridesFromDatabase,
+ upsertStoredRoomSkillOverrideInDatabase,
+} from './db/rooms.js';
import {
buildRoomSkillSettingsSnapshot,
type RoomSkillSettingsBuildInput,
@@ -146,6 +150,39 @@ describe('room skill settings', () => {
});
});
+ it('keeps default mode when explicit enable rows do not change effective skills', () => {
+ const snapshot = buildRoomSkillSettingsSnapshot({
+ generatedAt: '2026-05-04T00:00:00.000Z',
+ inventory: inventoryFixture(),
+ roomBindings: {
+ 'room-1': {
+ name: 'Room One',
+ folder: 'room-one',
+ added_at: '2026-05-04T00:00:00.000Z',
+ agentType: 'codex',
+ },
+ },
+ registeredAgentTypesByJid: new Map([['room-1', ['codex']]]),
+ overrides: [
+ {
+ chatJid: 'room-1',
+ agentType: 'codex',
+ skillScope: 'runner',
+ skillName: 'runner-browser',
+ enabled: true,
+ createdAt: '2026-05-04T00:00:00.000Z',
+ updatedAt: '2026-05-04T00:00:00.000Z',
+ },
+ ],
+ });
+
+ expect(snapshot.rooms[0]?.agents[0]).toMatchObject({
+ mode: 'all-enabled',
+ explicitEnabledSkillIds: ['runner:runner-browser'],
+ effectiveEnabledSkillIds: ['codex-user:browser', 'runner:runner-browser'],
+ });
+ });
+
it('reads normalized room skill overrides from the database', () => {
const database = new Database(':memory:');
try {
@@ -192,4 +229,58 @@ describe('room skill settings', () => {
database.close();
}
});
+
+ it('upserts and deletes room skill overrides in the database', () => {
+ const database = new Database(':memory:');
+ try {
+ initializeDatabaseSchema(database);
+ database
+ .prepare(
+ `INSERT INTO room_settings (
+ chat_jid, room_mode, mode_source, name, folder, updated_at
+ ) VALUES (?, 'single', 'explicit', ?, ?, ?)`,
+ )
+ .run('room-1', 'Room One', 'room-one', '2026-05-04T00:00:00.000Z');
+
+ upsertStoredRoomSkillOverrideInDatabase(database, {
+ chatJid: 'room-1',
+ agentType: 'codex',
+ skillScope: 'runner',
+ skillName: 'agent-browser',
+ enabled: false,
+ });
+ expect(getStoredRoomSkillOverridesFromDatabase(database)).toMatchObject([
+ {
+ chatJid: 'room-1',
+ agentType: 'codex',
+ skillScope: 'runner',
+ skillName: 'agent-browser',
+ enabled: false,
+ },
+ ]);
+
+ upsertStoredRoomSkillOverrideInDatabase(database, {
+ chatJid: 'room-1',
+ agentType: 'codex',
+ skillScope: 'runner',
+ skillName: 'agent-browser',
+ enabled: true,
+ });
+ expect(
+ getStoredRoomSkillOverridesFromDatabase(database)[0],
+ ).toMatchObject({
+ enabled: true,
+ });
+
+ deleteStoredRoomSkillOverrideFromDatabase(database, {
+ chatJid: 'room-1',
+ agentType: 'codex',
+ skillScope: 'runner',
+ skillName: 'agent-browser',
+ });
+ expect(getStoredRoomSkillOverridesFromDatabase(database)).toEqual([]);
+ } finally {
+ database.close();
+ }
+ });
});
diff --git a/src/room-skill-settings.ts b/src/room-skill-settings.ts
index 448a064..ab073d7 100644
--- a/src/room-skill-settings.ts
+++ b/src/room-skill-settings.ts
@@ -1,9 +1,11 @@
import path from 'node:path';
import {
+ deleteStoredRoomSkillOverride,
getAllRoomBindings,
getRegisteredAgentTypesForJid,
getStoredRoomSkillOverrides,
+ upsertStoredRoomSkillOverride,
} from './db.js';
import {
getRuntimeInventory,
@@ -57,6 +59,23 @@ export interface RoomSkillSettingsBuildInput {
overrides?: StoredRoomSkillOverride[];
}
+export interface RoomSkillSettingUpdateInput {
+ roomJid: string;
+ agentType: string;
+ skillId: string;
+ enabled: boolean;
+}
+
+export class RoomSkillSettingsError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ ) {
+ super(message);
+ this.name = 'RoomSkillSettingsError';
+ }
+}
+
function skillId(scope: RoomSkillScope, name: string): string {
return `${scope}:${name}`;
}
@@ -134,6 +153,26 @@ function overrideKey(
return `${jid}\u0000${agentType}\u0000${scope}\u0000${name}`;
}
+function parseSkillId(id: string): { scope: RoomSkillScope; name: string } {
+ const separator = id.indexOf(':');
+ if (separator <= 0 || separator === id.length - 1) {
+ throw new RoomSkillSettingsError('skillId must be ":"', 400);
+ }
+ const scope = id.slice(0, separator);
+ const name = id.slice(separator + 1);
+ if (scope !== 'codex-user' && scope !== 'claude-user' && scope !== 'runner') {
+ throw new RoomSkillSettingsError(`Unsupported skill scope: ${scope}`, 400);
+ }
+ return { scope, name };
+}
+
+function normalizeRoomSkillAgentType(agentType: string): AgentType {
+ if (agentType === 'codex' || agentType === 'claude-code') {
+ return agentType;
+ }
+ throw new RoomSkillSettingsError(`Unsupported agent type: ${agentType}`, 400);
+}
+
function resolveRoomAgentTypes(
jid: string,
group: RegisteredGroup,
@@ -202,7 +241,8 @@ export function buildRoomSkillSettingsSnapshot({
return {
agentType,
mode:
- disabledSkillIds.length > 0 || explicitEnabledSkillIds.length > 0
+ disabledSkillIds.length > 0 ||
+ effectiveEnabledSkillIds.length !== available.length
? 'custom'
: 'all-enabled',
availableSkillIds: available.map((skill) => skill.id),
@@ -237,3 +277,53 @@ export function getRoomSkillSettings(): RoomSkillSettingsSnapshot {
overrides: getStoredRoomSkillOverrides(),
});
}
+
+export function updateRoomSkillSetting(
+ input: RoomSkillSettingUpdateInput,
+): RoomSkillSettingsSnapshot {
+ const roomJid = input.roomJid.trim();
+ const agentType = normalizeRoomSkillAgentType(input.agentType);
+ const { scope, name } = parseSkillId(input.skillId.trim());
+ const before = getRoomSkillSettings();
+ const room = before.rooms.find((candidate) => candidate.jid === roomJid);
+ if (!room) {
+ throw new RoomSkillSettingsError('Room not found', 404);
+ }
+ const agent = room.agents.find(
+ (candidate) => candidate.agentType === agentType,
+ );
+ if (!agent) {
+ throw new RoomSkillSettingsError(
+ 'Agent is not registered for this room',
+ 400,
+ );
+ }
+ const skill = before.catalog.find(
+ (candidate) => candidate.scope === scope && candidate.name === name,
+ );
+ if (!skill || !agent.availableSkillIds.includes(skill.id)) {
+ throw new RoomSkillSettingsError(
+ 'Skill is not available for this room agent',
+ 400,
+ );
+ }
+
+ if (input.enabled) {
+ deleteStoredRoomSkillOverride({
+ chatJid: roomJid,
+ agentType,
+ skillScope: scope,
+ skillName: name,
+ });
+ } else {
+ upsertStoredRoomSkillOverride({
+ chatJid: roomJid,
+ agentType,
+ skillScope: scope,
+ skillName: name,
+ enabled: false,
+ });
+ }
+
+ return getRoomSkillSettings();
+}
diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts
index f3ca628..f2b99dc 100644
--- a/src/web-dashboard-settings-routes.test.ts
+++ b/src/web-dashboard-settings-routes.test.ts
@@ -241,6 +241,7 @@ function makeDeps(
updateFastMode: () => fastMode,
updateModelConfig: () => modelConfig,
updateMoaSettings: () => moaSettings,
+ updateRoomSkillSetting: () => roomSkillSettings,
...overrides,
};
}
@@ -336,6 +337,25 @@ describe('web dashboard settings routes', () => {
calls.push(`moa:${JSON.stringify(input)}`);
return moaSettings;
},
+ updateRoomSkillSetting: (input) => {
+ calls.push(`room-skill:${JSON.stringify(input)}`);
+ return {
+ ...roomSkillSettings,
+ rooms: [
+ {
+ ...roomSkillSettings.rooms[0]!,
+ agents: [
+ {
+ ...roomSkillSettings.rooms[0]!.agents[0]!,
+ mode: 'custom',
+ disabledSkillIds: [input.skillId],
+ effectiveEnabledSkillIds: [],
+ },
+ ],
+ },
+ ],
+ };
+ },
});
const modelUpdate = await route(
@@ -404,6 +424,31 @@ describe('web dashboard settings routes', () => {
status: { model: 'glm', ok: true },
});
+ const roomSkill = await route(
+ '/api/settings/room-skills',
+ 'PATCH',
+ {
+ roomJid: 'room@example',
+ agentType: 'codex',
+ skillId: 'runner:agent-browser',
+ enabled: false,
+ },
+ deps,
+ );
+ expect(roomSkill?.status).toBe(200);
+ await expect(roomSkill?.json()).resolves.toMatchObject({
+ rooms: [
+ {
+ agents: [
+ {
+ mode: 'custom',
+ disabledSkillIds: ['runner:agent-browser'],
+ },
+ ],
+ },
+ ],
+ });
+
expect(calls).toEqual([
'models:{"owner":{"model":"gpt-5.5"}}',
'add:claude-token',
@@ -411,6 +456,42 @@ describe('web dashboard settings routes', () => {
'current:4',
'codex-features:{"goals":true}',
'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}',
+ 'room-skill:{"roomJid":"room@example","agentType":"codex","skillId":"runner:agent-browser","enabled":false}',
]);
});
+
+ it('rejects unsupported room skill methods with 405', async () => {
+ const response = await route('/api/settings/room-skills', 'DELETE');
+ expect(response?.status).toBe(405);
+ expect(response?.headers.get('allow')).toBe('GET, HEAD, PATCH, PUT');
+ await expect(response?.json()).resolves.toEqual({
+ error: 'Method not allowed',
+ });
+ });
+
+ it('validates room skill mutation payloads', async () => {
+ const missingEnabled = await route('/api/settings/room-skills', 'PATCH', {
+ roomJid: 'room@example',
+ agentType: 'codex',
+ skillId: 'runner:agent-browser',
+ });
+ expect(missingEnabled?.status).toBe(400);
+
+ const invalid = await route(
+ '/api/settings/room-skills',
+ 'PATCH',
+ {
+ roomJid: 'room@example',
+ agentType: 'codex',
+ skillId: 'runner:agent-browser',
+ enabled: false,
+ },
+ makeDeps({
+ updateRoomSkillSetting: () => {
+ throw new Error('boom');
+ },
+ }),
+ );
+ expect(invalid?.status).toBe(500);
+ });
});
diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts
index 0468153..2d40652 100644
--- a/src/web-dashboard-settings-routes.ts
+++ b/src/web-dashboard-settings-routes.ts
@@ -25,7 +25,10 @@ import {
} from './runtime-inventory.js';
import {
getRoomSkillSettings,
+ RoomSkillSettingsError,
+ updateRoomSkillSetting,
type RoomSkillSettingsSnapshot,
+ type RoomSkillSettingUpdateInput,
} from './room-skill-settings.js';
import {
checkMoaModel,
@@ -60,6 +63,7 @@ export interface SettingsRouteDependencies {
updateFastMode: typeof updateFastMode;
updateModelConfig: typeof updateModelConfig;
updateMoaSettings: typeof updateMoaSettings;
+ updateRoomSkillSetting: typeof updateRoomSkillSetting;
}
interface SettingsRouteContext {
@@ -89,6 +93,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
updateFastMode,
updateModelConfig,
updateMoaSettings,
+ updateRoomSkillSetting,
};
function readMethod(method: string): boolean {
@@ -118,6 +123,19 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+function methodNotAllowed(
+ jsonResponse: JsonResponse,
+ allowed: string[],
+): Response {
+ return jsonResponse(
+ { error: 'Method not allowed' },
+ {
+ status: 405,
+ headers: { Allow: allowed.join(', ') },
+ },
+ );
+}
+
async function handleModelSettingsRoute(
request: Request,
jsonResponse: JsonResponse,
@@ -228,13 +246,42 @@ function handleRuntimeInventoryRoute(
return jsonResponse(deps.getRuntimeInventory());
}
-function handleRoomSkillsRoute(
+async function handleRoomSkillsRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
-): Response | null {
- if (!readMethod(request.method)) return null;
- return jsonResponse(deps.getRoomSkillSettings());
+): Promise {
+ if (readMethod(request.method)) {
+ return jsonResponse(deps.getRoomSkillSettings());
+ }
+ if (request.method !== 'PATCH' && request.method !== 'PUT') {
+ return methodNotAllowed(jsonResponse, ['GET', 'HEAD', 'PATCH', 'PUT']);
+ }
+
+ const body = await readJsonObject(request, jsonResponse);
+ if (body instanceof Response) return body;
+
+ const input: RoomSkillSettingUpdateInput = {
+ roomJid: typeof body.roomJid === 'string' ? body.roomJid : '',
+ agentType: typeof body.agentType === 'string' ? body.agentType : '',
+ skillId: typeof body.skillId === 'string' ? body.skillId : '',
+ enabled: body.enabled === true,
+ };
+ if (typeof body.enabled !== 'boolean') {
+ return jsonResponse(
+ { error: 'enabled must be a boolean' },
+ { status: 400 },
+ );
+ }
+
+ try {
+ return jsonResponse(deps.updateRoomSkillSetting(input));
+ } catch (err) {
+ if (err instanceof RoomSkillSettingsError) {
+ return jsonResponse({ error: err.message }, { status: err.status });
+ }
+ return jsonResponse({ error: errorMessage(err) }, { status: 500 });
+ }
}
async function handleClaudeAccountAddRoute(