remove skills bs

This commit is contained in:
George Pickett
2026-03-05 15:22:47 -08:00
parent 34fc9f1cae
commit 7355d24396
34 changed files with 44 additions and 6189 deletions
@@ -1,260 +0,0 @@
import { NextResponse } from "next/server";
import {
ensureDomainIntentRuntime,
parseIntentBody,
} from "@/lib/controlplane/intent-route";
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime";
type AgentSkillsAccessMode = "all" | "none" | "allowlist";
type ConfigAgentEntry = Record<string, unknown> & { id: string };
type GatewayConfigSnapshot = {
config?: unknown;
hash?: string;
exists?: boolean;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const readConfigAgentList = (
config: Record<string, unknown> | undefined
): ConfigAgentEntry[] => {
if (!config) return [];
const agents = isRecord(config.agents) ? config.agents : null;
const list = Array.isArray(agents?.list) ? agents.list : [];
return list.filter((entry): entry is ConfigAgentEntry => {
if (!isRecord(entry)) return false;
if (typeof entry.id !== "string") return false;
return entry.id.trim().length > 0;
});
};
const writeConfigAgentList = (
config: Record<string, unknown>,
list: ConfigAgentEntry[]
): Record<string, unknown> => {
const agents = isRecord(config.agents) ? { ...config.agents } : {};
return { ...config, agents: { ...agents, list } };
};
const upsertConfigAgentEntry = (
list: ConfigAgentEntry[],
agentId: string,
updater: (entry: ConfigAgentEntry) => ConfigAgentEntry
): { list: ConfigAgentEntry[]; entry: ConfigAgentEntry } => {
let updatedEntry: ConfigAgentEntry | null = null;
const nextList = list.map((entry) => {
if (entry.id !== agentId) return entry;
const next = updater({ ...entry, id: agentId });
updatedEntry = next;
return next;
});
if (!updatedEntry) {
updatedEntry = updater({ id: agentId });
nextList.push(updatedEntry);
}
return { list: nextList, entry: updatedEntry };
};
const normalizeSkillAllowlistInput = (values: unknown): string[] => {
if (!Array.isArray(values)) return [];
const next = values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(next)).sort((a, b) => a.localeCompare(b));
};
const areStringArraysEqual = (a: readonly string[], b: readonly string[]): boolean => {
if (a.length !== b.length) return false;
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) return false;
}
return true;
};
const buildAgentSkillsConfig = (params: {
baseConfig: Record<string, unknown>;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
}): Record<string, unknown> => {
const list = readConfigAgentList(params.baseConfig);
const currentEntry = list.find((entry) => entry.id === params.agentId);
const hasEntry = Boolean(currentEntry);
const currentRawSkills = currentEntry?.skills;
if (params.mode === "all") {
if (!hasEntry) {
return params.baseConfig;
}
if (!Object.prototype.hasOwnProperty.call(currentEntry, "skills")) {
return params.baseConfig;
}
}
if (params.mode === "none" && Array.isArray(currentRawSkills) && currentRawSkills.length === 0) {
return params.baseConfig;
}
if (params.mode === "allowlist") {
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
const normalizedNext = normalizeSkillAllowlistInput(rawSkills);
if (Array.isArray(currentRawSkills)) {
const normalizedCurrent = normalizeSkillAllowlistInput(currentRawSkills);
if (areStringArraysEqual(normalizedCurrent, normalizedNext)) {
return params.baseConfig;
}
}
}
const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => {
const next: ConfigAgentEntry = { ...entry, id: params.agentId };
if (params.mode === "all") {
if ("skills" in next) {
delete next.skills;
}
return next;
}
if (params.mode === "none") {
next.skills = [];
return next;
}
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
next.skills = normalizeSkillAllowlistInput(rawSkills);
return next;
});
return writeConfigAgentList(params.baseConfig, nextList);
};
const isConfigConflict = (err: unknown): boolean => {
if (!(err instanceof ControlPlaneGatewayError)) return false;
if (err.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
const message = err.message.toLowerCase();
return (
message.includes("basehash") ||
message.includes("base hash") ||
message.includes("changed since last load") ||
message.includes("re-run config.get")
);
};
const mapIntentError = (error: unknown): NextResponse => {
if (error instanceof ControlPlaneGatewayError) {
if (error.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") {
return NextResponse.json(
{
error: error.message,
code: "GATEWAY_UNAVAILABLE",
reason: "gateway_unavailable",
},
{ status: 503 }
);
}
return NextResponse.json(
{
error: error.message,
code: error.code,
details: error.details,
},
{ status: 400 }
);
}
const message = error instanceof Error ? error.message : "intent_failed";
return NextResponse.json({ error: message }, { status: 500 });
};
const applySkillsMode = async (params: {
runtime: ControlPlaneRuntime;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
attempt?: number;
}): Promise<void> => {
const attempt = params.attempt ?? 0;
const snapshot = await params.runtime.callGateway<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildAgentSkillsConfig({
baseConfig,
agentId: params.agentId,
mode: params.mode,
skillNames: params.skillNames,
});
if (nextConfig === baseConfig) {
return;
}
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
const requiresBaseHash = snapshot.exists !== false;
const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
if (baseHash) {
payload.baseHash = baseHash;
}
try {
await params.runtime.callGateway("config.set", payload);
} catch (error) {
if (attempt >= 1 || !isConfigConflict(error)) {
throw error;
}
await applySkillsMode({ ...params, attempt: attempt + 1 });
}
};
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
const modeRaw = typeof bodyOrError.mode === "string" ? bodyOrError.mode.trim() : "";
if (!agentId) {
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
}
if (modeRaw !== "all" && modeRaw !== "none" && modeRaw !== "allowlist") {
return NextResponse.json({ error: "mode must be one of: all, none, allowlist." }, { status: 400 });
}
const mode = modeRaw as AgentSkillsAccessMode;
const skillNames = normalizeSkillAllowlistInput(bodyOrError.skillNames);
if (mode === "allowlist" && skillNames.length === 0) {
return NextResponse.json(
{ error: "skillNames must contain at least one value when mode is allowlist." },
{ status: 400 }
);
}
const runtimeOrError = await ensureDomainIntentRuntime();
if (runtimeOrError instanceof Response) {
return runtimeOrError as NextResponse;
}
try {
await applySkillsMode({
runtime: runtimeOrError,
agentId,
mode,
...(mode === "allowlist" ? { skillNames } : {}),
});
return NextResponse.json({ ok: true, payload: { updated: true } });
} catch (error) {
return mapIntentError(error);
}
}
@@ -1,30 +0,0 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : "";
const installId =
typeof bodyOrError.installId === "string" ? bodyOrError.installId.trim() : "";
if (!name || !installId) {
return NextResponse.json({ error: "name and installId are required." }, { status: 400 });
}
const timeoutMs =
typeof bodyOrError.timeoutMs === "number" && Number.isFinite(bodyOrError.timeoutMs)
? Math.max(1, Math.floor(bodyOrError.timeoutMs))
: undefined;
return await executeGatewayIntent("skills.install", {
name,
installId,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
});
}
@@ -1,88 +0,0 @@
import { NextResponse } from "next/server";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import { removeSkillLocally } from "@/lib/skills/remove-local";
import type { RemovableSkillSource, SkillRemoveRequest } from "@/lib/skills/types";
import {
resolveConfiguredSshTarget,
resolveGatewaySshTargetFromGatewayUrl,
} from "@/lib/ssh/gateway-host";
import { removeSkillOverSsh } from "@/lib/ssh/skills-remove";
import { loadStudioSettings } from "@/lib/studio/settings-store";
export const runtime = "nodejs";
const REMOVABLE_SOURCES = new Set<RemovableSkillSource>([
"openclaw-managed",
"openclaw-workspace",
]);
const normalizeRequired = (value: unknown, field: string): string => {
if (typeof value !== "string") {
throw new Error(`${field} is required.`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${field} is required.`);
}
return trimmed;
};
const resolveSkillRemovalSshTarget = (): string | null => {
const configured = resolveConfiguredSshTarget(process.env);
if (configured) return configured;
const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? "";
if (isLocalGatewayUrl(gatewayUrl)) return null;
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
};
const normalizeRemoveRequest = (body: unknown): SkillRemoveRequest => {
if (!body || typeof body !== "object") {
throw new Error("Invalid request payload.");
}
const record = body as Partial<Record<keyof SkillRemoveRequest, unknown>>;
const sourceRaw = normalizeRequired(record.source, "source");
if (!REMOVABLE_SOURCES.has(sourceRaw as RemovableSkillSource)) {
throw new Error(`Unsupported skill source for removal: ${sourceRaw}`);
}
return {
skillKey: normalizeRequired(record.skillKey, "skillKey"),
source: sourceRaw as RemovableSkillSource,
baseDir: normalizeRequired(record.baseDir, "baseDir"),
workspaceDir: normalizeRequired(record.workspaceDir, "workspaceDir"),
managedSkillsDir: normalizeRequired(record.managedSkillsDir, "managedSkillsDir"),
};
};
export async function POST(request: Request) {
try {
const body = (await request.json()) as unknown;
const removeRequest = normalizeRemoveRequest(body);
const sshTarget = resolveSkillRemovalSshTarget();
const result = sshTarget
? removeSkillOverSsh({ sshTarget, request: removeRequest })
: removeSkillLocally(removeRequest);
return NextResponse.json({ result });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to remove skill.";
const status =
message.includes("required") ||
message.includes("Invalid request payload") ||
message.includes("Unsupported skill source") ||
message.includes("Refusing to remove") ||
message.includes("not a directory") ||
message.includes("Gateway URL is missing") ||
message.includes("Invalid gateway URL")
? 400
: 500;
if (status >= 500) {
console.error(message);
}
return NextResponse.json({ error: message }, { status });
}
}
@@ -1,32 +0,0 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
const hasOwn = (value: Record<string, unknown>, key: string) =>
Object.prototype.hasOwnProperty.call(value, key);
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const skillKey = typeof bodyOrError.skillKey === "string" ? bodyOrError.skillKey.trim() : "";
if (!skillKey) {
return NextResponse.json({ error: "skillKey is required." }, { status: 400 });
}
const includeEnabled = hasOwn(bodyOrError, "enabled");
const includeApiKey = hasOwn(bodyOrError, "apiKey");
if (!includeEnabled && !includeApiKey) {
return NextResponse.json({ error: "enabled or apiKey is required." }, { status: 400 });
}
return await executeGatewayIntent("skills.update", {
skillKey,
...(includeEnabled ? { enabled: bodyOrError.enabled } : {}),
...(includeApiKey ? { apiKey: bodyOrError.apiKey } : {}),
});
}
@@ -1,14 +0,0 @@
import { NextResponse } from "next/server";
import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route";
export const runtime = "nodejs";
export async function GET(request: Request) {
const url = new URL(request.url);
const agentId = (url.searchParams.get("agentId") ?? "").trim();
if (!agentId) {
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
}
return await executeRuntimeGatewayRead("skills.status", { agentId });
}
+2 -119
View File
@@ -40,7 +40,6 @@ import {
} from "@/lib/cron/types";
import {
readConfigAgentList,
resolveDefaultConfigAgentId,
slugifyAgentName,
} from "@/lib/gateway/agentConfig";
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
@@ -120,7 +119,6 @@ import { useRuntimeEventStream } from "@/features/agents/state/useRuntimeEventSt
const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500;
type MobilePane = "fleet" | "chat";
type SettingsSidebarItem = SettingsRouteTab;
const RESERVED_MAIN_AGENT_ID = "main";
@@ -271,9 +269,7 @@ const AgentStudioPage = () => {
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(null);
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null);
const [systemInitialSkillKey, setSystemInitialSkillKey] = useState<string | null>(null);
const [personalityHasUnsavedChanges, setPersonalityHasUnsavedChanges] = useState(false);
const [settingsSidebarItem, setSettingsSidebarItem] = useState<SettingsSidebarItem>("personality");
const [createAgentBlock, setCreateAgentBlock] = useState<CreateAgentBlockState | null>(null);
const [pendingExecApprovalsByAgentId, setPendingExecApprovalsByAgentId] = useState<
Record<string, PendingExecApproval[]>
@@ -332,21 +328,10 @@ const AgentStudioPage = () => {
const inspectSidebarAgentId = inspectSidebar?.agentId ?? null;
const inspectSidebarTab = inspectSidebar?.tab ?? null;
const effectiveSettingsTab: SettingsRouteTab = inspectSidebarTab ?? "personality";
useEffect(() => {
setSettingsSidebarItem(effectiveSettingsTab);
}, [effectiveSettingsTab]);
const inspectSidebarAgent = useMemo(() => {
if (!inspectSidebarAgentId) return null;
return agents.find((entry) => entry.agentId === inspectSidebarAgentId) ?? null;
}, [agents, inspectSidebarAgentId]);
useEffect(() => {
setSystemInitialSkillKey(null);
}, [inspectSidebarAgentId]);
useEffect(() => {
if (effectiveSettingsTab !== "system") {
setSystemInitialSkillKey(null);
}
}, [effectiveSettingsTab]);
const settingsAgentPermissionsDraft = useMemo(() => {
if (!inspectSidebarAgent) return null;
const baseConfig =
@@ -370,39 +355,6 @@ const AgentStudioPage = () => {
existingTools: tools,
});
}, [gatewayConfigSnapshot, inspectSidebarAgent]);
const settingsAgentSkillsAllowlist = useMemo(() => {
if (!inspectSidebarAgent) return undefined;
const baseConfig =
gatewayConfigSnapshot?.config &&
typeof gatewayConfigSnapshot.config === "object" &&
!Array.isArray(gatewayConfigSnapshot.config)
? (gatewayConfigSnapshot.config as Record<string, unknown>)
: undefined;
const list = readConfigAgentList(baseConfig);
const configEntry = list.find((entry) => entry.id === inspectSidebarAgent.agentId) ?? null;
const raw = configEntry?.skills;
if (!Array.isArray(raw)) return undefined;
return raw
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length > 0);
}, [gatewayConfigSnapshot, inspectSidebarAgent]);
const settingsDefaultAgentId = useMemo(() => {
const baseConfig =
gatewayConfigSnapshot?.config &&
typeof gatewayConfigSnapshot.config === "object" &&
!Array.isArray(gatewayConfigSnapshot.config)
? (gatewayConfigSnapshot.config as Record<string, unknown>)
: undefined;
return resolveDefaultConfigAgentId(baseConfig);
}, [gatewayConfigSnapshot]);
const settingsSkillScopeWarning = useMemo(() => {
if (!inspectSidebarAgent) return null;
if (inspectSidebarAgent.agentId === settingsDefaultAgentId) {
return "Setup actions are shared across agents. Installs run in this shared workspace.";
}
return `Setup actions are shared across agents. Installs currently run in ${settingsDefaultAgentId} (shared workspace), not ${inspectSidebarAgent.agentId}.`;
}, [inspectSidebarAgent, settingsDefaultAgentId]);
const focusedPendingExecApprovals = useMemo(() => {
if (!focusedAgentId) return unscopedPendingExecApprovals;
const scoped = pendingExecApprovalsByAgentId[focusedAgentId] ?? [];
@@ -442,7 +394,6 @@ const AgentStudioPage = () => {
const settingsHeaderThinkingRaw = (inspectSidebarAgent?.thinkingLevel ?? "").trim() || "low";
const settingsHeaderThinking =
settingsHeaderThinkingRaw.charAt(0).toUpperCase() + settingsHeaderThinkingRaw.slice(1);
const activeSettingsSidebarItem: SettingsSidebarItem = settingsSidebarItem;
useEffect(() => {
const selector = 'link[data-agent-favicon="true"]';
@@ -952,16 +903,6 @@ const AgentStudioPage = () => {
replace: router.replace,
confirmDiscard: () => window.confirm("Discard changes?"),
});
const handleOpenSystemSkillSetup = useCallback(
(skillKey?: string) => {
const normalized = skillKey?.trim() ?? "";
setSystemInitialSkillKey(normalized.length > 0 ? normalized : null);
setSettingsSidebarItem("system");
handleSettingsRouteTabChange("system");
},
[handleSettingsRouteTabChange]
);
const handleOpenCreateAgentModal = useCallback(() => {
if (createAgentBusy) return;
if (createAgentBlock) return;
@@ -1545,13 +1486,11 @@ const AgentStudioPage = () => {
[
{ id: "personality", label: "Behavior" },
{ id: "capabilities", label: "Capabilities" },
{ id: "skills", label: "Skills" },
{ id: "system", label: "System setup" },
{ id: "automations", label: "Automations" },
{ id: "advanced", label: "Advanced" },
] as const
).map((entry) => {
const active = activeSettingsSidebarItem === entry.id;
const active = effectiveSettingsTab === entry.id;
return (
<button
key={entry.id}
@@ -1562,7 +1501,6 @@ const AgentStudioPage = () => {
: "font-normal text-muted-foreground hover:bg-surface-2/35 hover:text-foreground"
}`}
onClick={() => {
setSettingsSidebarItem(entry.id);
handleSettingsRouteTabChange(entry.id);
}}
>
@@ -1611,11 +1549,7 @@ const AgentStudioPage = () => {
mode={
effectiveSettingsTab === "automations"
? "automations"
: effectiveSettingsTab === "skills"
? "skills"
: effectiveSettingsTab === "system"
? "system"
: effectiveSettingsTab === "advanced"
: effectiveSettingsTab === "advanced"
? "advanced"
: "capabilities"
}
@@ -1633,57 +1567,6 @@ const AgentStudioPage = () => {
settingsMutationController.handleDeleteAgent(inspectSidebarAgent.agentId)
}
canDelete={inspectSidebarAgent.agentId !== RESERVED_MAIN_AGENT_ID}
skillsReport={settingsMutationController.settingsSkillsReport}
skillsLoading={settingsMutationController.settingsSkillsLoading}
skillsError={settingsMutationController.settingsSkillsError}
skillsBusy={settingsMutationController.settingsSkillsBusy}
skillsBusyKey={settingsMutationController.settingsSkillsBusyKey}
skillMessages={settingsMutationController.settingsSkillMessages}
skillApiKeyDrafts={settingsMutationController.settingsSkillApiKeyDrafts}
defaultAgentScopeWarning={settingsSkillScopeWarning}
systemInitialSkillKey={systemInitialSkillKey}
onSystemInitialSkillHandled={() => {
setSystemInitialSkillKey(null);
}}
skillsAllowlist={settingsAgentSkillsAllowlist}
onSetSkillEnabled={(skillName, enabled) =>
settingsMutationController.handleSetSkillEnabled(
inspectSidebarAgent.agentId,
skillName,
enabled
)
}
onOpenSystemSetup={handleOpenSystemSkillSetup}
onInstallSkill={(skillKey, name, installId) =>
settingsMutationController.handleInstallSkill(
inspectSidebarAgent.agentId,
skillKey,
name,
installId
)
}
onRemoveSkill={(skill) =>
settingsMutationController.handleRemoveSkill(
inspectSidebarAgent.agentId,
skill
)
}
onSkillApiKeyChange={(skillKey, value) =>
settingsMutationController.handleSkillApiKeyDraftChange(skillKey, value)
}
onSaveSkillApiKey={(skillKey) =>
settingsMutationController.handleSaveSkillApiKey(
inspectSidebarAgent.agentId,
skillKey
)
}
onSetSkillGlobalEnabled={(skillKey, enabled) =>
settingsMutationController.handleSetSkillGlobalEnabled(
inspectSidebarAgent.agentId,
skillKey,
enabled
)
}
cronJobs={settingsMutationController.settingsCronJobs}
cronLoading={settingsMutationController.settingsCronLoading}
cronError={settingsMutationController.settingsCronError}
@@ -18,15 +18,12 @@ import type { AgentState } from "@/features/agents/state/store";
import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPayloadBuilder";
import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import type { SkillStatusReport } from "@/lib/skills/types";
import { readDomainAgentFile, writeDomainAgentFile } from "@/lib/controlplane/domain-runtime-client";
import {
resolveExecutionRoleFromAgent,
resolvePresetDefaultsForRole,
type AgentPermissionsDraft,
} from "@/features/agents/operations/agentPermissionsOperation";
import { AgentSkillsPanel } from "@/features/agents/components/AgentSkillsPanel";
import { SystemSkillsPanel } from "@/features/agents/components/SystemSkillsPanel";
import {
AGENT_FILE_NAMES,
type AgentFileName,
@@ -91,7 +88,7 @@ const AgentInspectHeader = ({
type AgentSettingsPanelProps = {
agent: AgentState;
mode?: "capabilities" | "skills" | "system" | "automations" | "advanced";
mode?: "capabilities" | "automations" | "advanced";
showHeader?: boolean;
onClose: () => void;
permissionsDraft?: AgentPermissionsDraft;
@@ -108,26 +105,6 @@ type AgentSettingsPanelProps = {
cronCreateBusy?: boolean;
onCreateCronJob?: (draft: CronCreateDraft) => Promise<void> | void;
controlUiUrl?: string | null;
skillsReport?: SkillStatusReport | null;
skillsLoading?: boolean;
skillsError?: string | null;
skillsBusy?: boolean;
skillsBusyKey?: string | null;
skillMessages?: Record<string, { kind: "success" | "error"; message: string }>;
skillApiKeyDrafts?: Record<string, string>;
defaultAgentScopeWarning?: string | null;
systemInitialSkillKey?: string | null;
onSystemInitialSkillHandled?: () => void;
skillsAllowlist?: string[] | undefined;
onSetSkillEnabled?: (skillName: string, enabled: boolean) => Promise<void> | void;
onOpenSystemSetup?: (skillKey?: string) => void;
onSetSkillGlobalEnabled?: (skillKey: string, enabled: boolean) => Promise<void> | void;
onInstallSkill?: (skillKey: string, name: string, installId: string) => Promise<void> | void;
onRemoveSkill?: (
skill: { skillKey: string; source: string; baseDir: string }
) => Promise<void> | void;
onSkillApiKeyChange?: (skillKey: string, value: string) => Promise<void> | void;
onSaveSkillApiKey?: (skillKey: string) => Promise<void> | void;
};
const formatCronStateLine = (job: CronJobSummary): string | null => {
@@ -309,24 +286,6 @@ export const AgentSettingsPanel = ({
cronCreateBusy = false,
onCreateCronJob = () => {},
controlUiUrl = null,
skillsReport = null,
skillsLoading = false,
skillsError = null,
skillsBusy = false,
skillsBusyKey = null,
skillMessages = {},
skillApiKeyDrafts = {},
defaultAgentScopeWarning = null,
systemInitialSkillKey = null,
onSystemInitialSkillHandled = () => {},
skillsAllowlist,
onSetSkillEnabled = () => {},
onOpenSystemSetup = () => {},
onSetSkillGlobalEnabled = () => {},
onInstallSkill = () => {},
onRemoveSkill = () => {},
onSkillApiKeyChange = () => {},
onSaveSkillApiKey = () => {},
}: AgentSettingsPanelProps) => {
const initialPermissionsDraft =
permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent));
@@ -503,11 +462,7 @@ export const AgentSettingsPanel = ({
const panelLabel =
mode === "advanced"
? "Advanced"
: mode === "skills"
? "Skills"
: mode === "system"
? "System setup"
: "";
: "";
const canOpenControlUi = typeof controlUiUrl === "string" && controlUiUrl.trim().length > 0;
const timedAutomationStepMeta =
TIMED_AUTOMATION_STEP_META[cronCreateStep] ??
@@ -673,39 +628,6 @@ export const AgentSettingsPanel = ({
</>
) : null}
{mode === "skills" ? (
<AgentSkillsPanel
skillsReport={skillsReport}
skillsLoading={skillsLoading}
skillsError={skillsError}
skillsBusy={skillsBusy}
skillsBusyKey={skillsBusyKey}
skillsAllowlist={skillsAllowlist}
onSetSkillEnabled={onSetSkillEnabled}
onOpenSystemSetup={onOpenSystemSetup}
/>
) : null}
{mode === "system" ? (
<SystemSkillsPanel
skillsReport={skillsReport}
skillsLoading={skillsLoading}
skillsError={skillsError}
skillsBusy={skillsBusy}
skillsBusyKey={skillsBusyKey}
skillMessages={skillMessages}
skillApiKeyDrafts={skillApiKeyDrafts}
defaultAgentScopeWarning={defaultAgentScopeWarning}
initialSkillKey={systemInitialSkillKey}
onInitialSkillKeyHandled={onSystemInitialSkillHandled}
onSetSkillGlobalEnabled={onSetSkillGlobalEnabled}
onInstallSkill={onInstallSkill}
onRemoveSkill={onRemoveSkill}
onSkillApiKeyChange={onSkillApiKeyChange}
onSaveSkillApiKey={onSaveSkillApiKey}
/>
) : null}
{mode === "automations" ? (
<section
className="sidebar-section"
@@ -1,255 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import type { SkillStatusReport } from "@/lib/skills/types";
import {
buildAgentSkillsAllowlistSet,
buildSkillMissingDetails,
deriveAgentSkillDisplayState,
deriveAgentSkillsAccessMode,
deriveSkillReadinessState,
type AgentSkillDisplayState,
} from "@/lib/skills/presentation";
type SkillRowFilter = "all" | AgentSkillDisplayState;
type AgentSkillsPanelProps = {
skillsReport?: SkillStatusReport | null;
skillsLoading?: boolean;
skillsError?: string | null;
skillsBusy?: boolean;
skillsBusyKey?: string | null;
skillsAllowlist?: string[] | undefined;
onSetSkillEnabled: (skillName: string, enabled: boolean) => Promise<void> | void;
onOpenSystemSetup: (skillKey?: string) => void;
};
const FILTERS: Array<{ id: SkillRowFilter; label: string }> = [
{ id: "all", label: "All" },
{ id: "ready", label: "Ready" },
{ id: "setup-required", label: "Setup required" },
{ id: "not-supported", label: "Not supported" },
];
const DISPLAY_LABELS: Record<AgentSkillDisplayState, string> = {
ready: "Ready",
"setup-required": "Setup required",
"not-supported": "Not supported",
};
const DISPLAY_CLASSES: Record<AgentSkillDisplayState, string> = {
ready: "ui-badge-status-running",
"setup-required": "ui-badge-status-error",
"not-supported": "ui-badge-status-error",
};
const resolveHint = (
skill: SkillStatusReport["skills"][number],
displayState: AgentSkillDisplayState
): string | null => {
if (displayState === "ready") {
return null;
}
if (displayState === "not-supported") {
if (skill.blockedByAllowlist) {
return "Blocked by bundled skills policy.";
}
return buildSkillMissingDetails(skill).find((line) => line.startsWith("Requires OS:")) ?? "Not supported.";
}
const readiness = deriveSkillReadinessState(skill);
if (readiness === "disabled-globally") {
return "Disabled globally. Enable it in System setup.";
}
return buildSkillMissingDetails(skill)[0] ?? "Requires setup in System setup.";
};
export const AgentSkillsPanel = ({
skillsReport = null,
skillsLoading = false,
skillsError = null,
skillsBusy = false,
skillsBusyKey = null,
skillsAllowlist,
onSetSkillEnabled,
onOpenSystemSetup,
}: AgentSkillsPanelProps) => {
const [skillsFilter, setSkillsFilter] = useState("");
const [rowFilter, setRowFilter] = useState<SkillRowFilter>("all");
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
const accessMode = deriveAgentSkillsAccessMode(skillsAllowlist);
const allowlistSet = useMemo(() => buildAgentSkillsAllowlistSet(skillsAllowlist), [skillsAllowlist]);
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
const rows = useMemo(() => {
return skillEntries.map((skill) => {
const normalizedName = skill.name.trim();
const allowed =
accessMode === "all" ? true : accessMode === "none" ? false : allowlistSet.has(normalizedName);
const readiness = deriveSkillReadinessState(skill);
return {
skill,
allowed,
displayState: deriveAgentSkillDisplayState(readiness),
};
});
}, [accessMode, allowlistSet, skillEntries]);
const searchedRows = useMemo(() => {
const query = skillsFilter.trim().toLowerCase();
if (!query) {
return rows;
}
return rows.filter((entry) =>
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
.join(" ")
.toLowerCase()
.includes(query)
);
}, [rows, skillsFilter]);
const filteredRows = useMemo(() => {
if (rowFilter === "all") {
return searchedRows;
}
return searchedRows.filter((entry) => entry.displayState === rowFilter);
}, [rowFilter, searchedRows]);
const filterCounts = useMemo(
() =>
searchedRows.reduce(
(counts, entry) => {
counts.all += 1;
counts[entry.displayState] += 1;
return counts;
},
{
all: 0,
ready: 0,
"setup-required": 0,
"not-supported": 0,
} satisfies Record<SkillRowFilter, number>
),
[searchedRows]
);
const enabledCount = useMemo(
() => rows.reduce((count, entry) => count + (entry.allowed ? 1 : 0), 0),
[rows]
);
return (
<section className="sidebar-section" data-testid="agent-settings-skills">
<div className="flex items-center justify-between gap-3">
<h3 className="sidebar-section-title">Skills</h3>
<div className="font-mono text-[10px] text-muted-foreground">
{enabledCount}/{skillEntries.length}
</div>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">Skill access controls apply to this agent.</div>
{accessMode === "selected" ? (
<div className="mt-2 text-[10px] text-muted-foreground/80">
This agent is using selected skills only.
</div>
) : null}
<div className="mt-3">
<input
value={skillsFilter}
onChange={(event) => setSkillsFilter(event.target.value)}
placeholder="Search skills"
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
aria-label="Search skills"
/>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{FILTERS.map((filter) => {
const selected = rowFilter === filter.id;
return (
<button
key={filter.id}
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
data-active={selected ? "true" : "false"}
disabled={skillsLoading}
onClick={() => {
setRowFilter(filter.id);
}}
>
{filter.label} ({filterCounts[filter.id]})
</button>
);
})}
</div>
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
{!skillsLoading && skillsError ? (
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{filteredRows.map((entry) => {
const statusLabel = DISPLAY_LABELS[entry.displayState];
const statusClassName = DISPLAY_CLASSES[entry.displayState];
const canConfigureInSystem = entry.displayState === "setup-required";
const switchDisabled = anySkillBusy || entry.displayState === "not-supported";
return (
<div
key={`${entry.skill.source}:${entry.skill.skillKey}`}
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
{entry.skill.source}
</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${statusClassName}`}
>
{statusLabel}
</span>
</div>
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
{entry.displayState !== "ready" ? (
<div className="mt-1 text-[10px] text-muted-foreground/80">
{resolveHint(entry.skill, entry.displayState)}
</div>
) : null}
</div>
<div className="flex w-full items-center justify-between gap-2 sm:w-[240px] sm:justify-end">
<button
type="button"
role="switch"
aria-label={`Skill ${entry.skill.name}`}
aria-checked={entry.allowed}
className={`ui-switch self-start ${entry.allowed ? "ui-switch--on" : ""}`}
disabled={switchDisabled}
onClick={() => {
void onSetSkillEnabled(entry.skill.name, !entry.allowed);
}}
>
<span className="ui-switch-thumb" />
</button>
{canConfigureInSystem ? (
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold"
onClick={() => {
onOpenSystemSetup(entry.skill.skillKey);
}}
>
Open System Setup
</button>
) : null}
</div>
</div>
);
})}
</div>
) : null}
</section>
);
};
@@ -1,235 +0,0 @@
"use client";
import { useEffect } from "react";
import type { SkillStatusEntry } from "@/lib/skills/types";
import {
buildSkillMissingDetails,
canRemoveSkill,
deriveSkillReadinessState,
resolvePreferredInstallOption,
} from "@/lib/skills/presentation";
type SkillSetupMessage = { kind: "success" | "error"; message: string };
type AgentSkillsSetupModalProps = {
skill: SkillStatusEntry | null;
skillsBusy: boolean;
skillsBusyKey: string | null;
skillMessage: SkillSetupMessage | null;
apiKeyDraft: string;
defaultAgentScopeWarning?: string | null;
onClose: () => void;
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
onRemoveSkill: (
skill: { skillKey: string; source: string; baseDir: string }
) => Promise<void> | void;
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
};
const READINESS_LABELS = {
ready: "Ready",
"needs-setup": "Needs setup",
unavailable: "Unavailable",
"disabled-globally": "Disabled globally",
} as const;
const READINESS_CLASSES = {
ready: "ui-badge-status-running",
"needs-setup": "ui-badge-status-error",
unavailable: "ui-badge-status-error",
"disabled-globally": "ui-badge-status-error",
} as const;
export const AgentSkillsSetupModal = ({
skill,
skillsBusy,
skillsBusyKey,
skillMessage,
apiKeyDraft,
defaultAgentScopeWarning = null,
onClose,
onInstallSkill,
onSetSkillGlobalEnabled,
onRemoveSkill,
onSkillApiKeyChange,
onSaveSkillApiKey,
}: AgentSkillsSetupModalProps) => {
useEffect(() => {
if (!skill) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, skill]);
if (!skill) {
return null;
}
const readiness = deriveSkillReadinessState(skill);
const readinessLabel = READINESS_LABELS[readiness];
const readinessClassName = READINESS_CLASSES[readiness];
const missingDetails = buildSkillMissingDetails(skill);
const installOption = resolvePreferredInstallOption(skill);
const canDeleteSkill = canRemoveSkill(skill);
const busyForSkill = skillsBusyKey === skill.skillKey;
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
const trimmedApiKey = apiKeyDraft.trim();
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4"
role="dialog"
aria-modal="true"
aria-label={`Setup ${skill.name}`}
onClick={onClose}
>
<div
className="ui-panel w-full max-w-2xl bg-card shadow-xs"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 px-6 py-5">
<div className="min-w-0">
<div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80">
System setup
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="text-base font-semibold text-foreground">{skill.name}</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
>
{readinessLabel}
</span>
</div>
<div className="mt-2 text-[10px] text-muted-foreground/80">
Changes affect all agents on this gateway.
</div>
</div>
<button
type="button"
className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]"
onClick={onClose}
>
Close
</button>
</div>
<div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground">
{defaultAgentScopeWarning ? (
<div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/80">
{defaultAgentScopeWarning}
</div>
) : null}
<div>{skill.description}</div>
{skill.blockedByAllowlist ? (
<div className="text-[10px] text-muted-foreground/80">
Blocked by bundled skills policy (`skills.allowBundled`).
</div>
) : null}
{missingDetails.map((line) => (
<div key={`${skill.skillKey}:${line}`} className="text-[10px] text-muted-foreground/80">
{line}
</div>
))}
{skillMessage ? (
<div
className={`text-[10px] ${skillMessage.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
>
{skillMessage.message}
</div>
) : null}
<div className="space-y-2 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
{installOption ? (
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
void onInstallSkill(skill.skillKey, skill.name, installOption.id);
}}
>
{busyForSkill ? "Working..." : installOption.label}
</button>
) : null}
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
void onSetSkillGlobalEnabled(skill.skillKey, skill.disabled);
}}
>
{busyForSkill
? "Working..."
: skill.disabled
? "Enable globally"
: "Disable globally"}
</button>
{skill.primaryEnv ? (
<>
<input
type="password"
value={apiKeyDraft}
onChange={(event) => {
void onSkillApiKeyChange(skill.skillKey, event.target.value);
}}
disabled={anySkillBusy}
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border"
placeholder={`Set ${skill.primaryEnv}`}
aria-label={`API key for ${skill.name}`}
/>
<button
type="button"
className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy || trimmedApiKey.length === 0}
onClick={() => {
if (trimmedApiKey.length === 0) {
return;
}
void onSaveSkillApiKey(skill.skillKey);
}}
>
{busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`}
</button>
</>
) : null}
{canDeleteSkill ? (
<button
type="button"
className="ui-btn-secondary ui-btn-danger w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
const approved = window.confirm(
`Remove ${skill.name} from the gateway? This affects all agents.`
);
if (!approved) {
return;
}
void onRemoveSkill({
skillKey: skill.skillKey,
source: skill.source,
baseDir: skill.baseDir,
});
onClose();
}}
>
Remove skill from gateway
</button>
) : null}
</div>
</div>
</div>
</div>
);
};
@@ -1,319 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { AgentSkillsSetupModal } from "@/features/agents/components/AgentSkillsSetupModal";
import {
buildSkillMissingDetails,
deriveSkillReadinessState,
type SkillReadinessState,
} from "@/lib/skills/presentation";
import type { SkillStatusReport } from "@/lib/skills/types";
type SkillSetupMessage = { kind: "success" | "error"; message: string };
type ReadinessFilter = "all" | SkillReadinessState;
type SystemSkillsPanelProps = {
skillsReport?: SkillStatusReport | null;
skillsLoading?: boolean;
skillsError?: string | null;
skillsBusy?: boolean;
skillsBusyKey?: string | null;
skillMessages?: Record<string, SkillSetupMessage>;
skillApiKeyDrafts?: Record<string, string>;
defaultAgentScopeWarning?: string | null;
initialSkillKey?: string | null;
onInitialSkillKeyHandled?: () => void;
onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void;
onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void;
onRemoveSkill: (
skill: { skillKey: string; source: string; baseDir: string }
) => Promise<void> | void;
onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void;
onSaveSkillApiKey: (skillKey: string) => Promise<void> | void;
};
const READINESS_FILTERS: Array<{ id: ReadinessFilter; label: string }> = [
{ id: "all", label: "All" },
{ id: "ready", label: "Ready" },
{ id: "needs-setup", label: "Needs setup" },
{ id: "unavailable", label: "Unavailable" },
{ id: "disabled-globally", label: "Disabled globally" },
];
const READINESS_LABELS = {
ready: "Ready",
"needs-setup": "Needs setup",
unavailable: "Unavailable",
"disabled-globally": "Disabled globally",
} as const;
const READINESS_CLASSES = {
ready: "ui-badge-status-running",
"needs-setup": "ui-badge-status-error",
unavailable: "ui-badge-status-error",
"disabled-globally": "ui-badge-status-error",
} as const;
const resolveReadinessHint = (
skill: SkillStatusReport["skills"][number],
readiness: SkillReadinessState
): string | null => {
if (readiness === "ready") {
return null;
}
if (readiness === "disabled-globally") {
return "Disabled globally for all agents.";
}
if (readiness === "unavailable") {
if (skill.blockedByAllowlist) {
return "Blocked by bundled skills policy.";
}
return buildSkillMissingDetails(skill)[0] ?? "Unavailable on this system.";
}
return buildSkillMissingDetails(skill)[0] ?? "Requires setup.";
};
export const SystemSkillsPanel = ({
skillsReport = null,
skillsLoading = false,
skillsError = null,
skillsBusy = false,
skillsBusyKey = null,
skillMessages = {},
skillApiKeyDrafts = {},
defaultAgentScopeWarning = null,
initialSkillKey = null,
onInitialSkillKeyHandled,
onSetSkillGlobalEnabled,
onInstallSkill,
onRemoveSkill,
onSkillApiKeyChange,
onSaveSkillApiKey,
}: SystemSkillsPanelProps) => {
const [skillsFilter, setSkillsFilter] = useState("");
const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>("all");
const [setupSkillKey, setSetupSkillKey] = useState<string | null>(null);
const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]);
const anySkillBusy = skillsBusy || Boolean(skillsBusyKey);
const requestedInitialSkillKey = useMemo(() => {
const candidate = initialSkillKey?.trim() ?? "";
if (!candidate) {
return null;
}
return skillEntries.some((entry) => entry.skillKey === candidate) ? candidate : null;
}, [initialSkillKey, skillEntries]);
const rows = useMemo(
() =>
skillEntries.map((skill) => ({
skill,
readiness: deriveSkillReadinessState(skill),
})),
[skillEntries]
);
const searchedRows = useMemo(() => {
const query = skillsFilter.trim().toLowerCase();
if (!query) {
return rows;
}
return rows.filter((entry) =>
[entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey]
.join(" ")
.toLowerCase()
.includes(query)
);
}, [rows, skillsFilter]);
const filteredRows = useMemo(() => {
if (readinessFilter === "all") {
return searchedRows;
}
return searchedRows.filter((entry) => entry.readiness === readinessFilter);
}, [readinessFilter, searchedRows]);
const readinessCounts = useMemo(
() =>
searchedRows.reduce(
(counts, entry) => {
counts.all += 1;
counts[entry.readiness] += 1;
return counts;
},
{
all: 0,
ready: 0,
"needs-setup": 0,
unavailable: 0,
"disabled-globally": 0,
} satisfies Record<ReadinessFilter, number>
),
[searchedRows]
);
const setupQueue = useMemo(
() =>
rows.filter(
(entry) => entry.readiness === "needs-setup" || entry.readiness === "disabled-globally"
),
[rows]
);
const selectedSkillKey = setupSkillKey ?? requestedInitialSkillKey;
const selectedSetupSkill = selectedSkillKey
? skillEntries.find((entry) => entry.skillKey === selectedSkillKey) ?? null
: null;
return (
<section className="sidebar-section" data-testid="agent-settings-system-skills">
<div className="flex items-center justify-between gap-3">
<h3 className="sidebar-section-title">System skill setup</h3>
<div className="font-mono text-[10px] text-muted-foreground">{skillEntries.length}</div>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">
Changes here affect all agents on this gateway.
</div>
{defaultAgentScopeWarning ? (
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/82">
{defaultAgentScopeWarning}
</div>
) : null}
{setupQueue.length > 0 ? (
<div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3">
<div className="text-[10px] font-semibold text-foreground/85">Needs setup ({setupQueue.length})</div>
<div className="mt-2 flex flex-col gap-2">
{setupQueue.slice(0, 5).map((entry) => (
<div
key={`setup-queue:${entry.skill.skillKey}`}
className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground/85"
>
<span className="truncate">{entry.skill.name}</span>
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(entry.skill.skillKey);
}}
>
Set up
</button>
</div>
))}
</div>
</div>
) : null}
<div className="mt-3">
<input
value={skillsFilter}
onChange={(event) => setSkillsFilter(event.target.value)}
placeholder="Search skills"
className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border"
aria-label="Search skills"
/>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{READINESS_FILTERS.map((filter) => {
const selected = readinessFilter === filter.id;
return (
<button
key={filter.id}
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
data-active={selected ? "true" : "false"}
disabled={skillsLoading}
onClick={() => {
setReadinessFilter(filter.id);
}}
>
{filter.label} ({readinessCounts[filter.id]})
</button>
);
})}
</div>
{skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null}
{!skillsLoading && skillsError ? (
<div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length === 0 ? (
<div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div>
) : null}
{!skillsLoading && !skillsError && filteredRows.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{filteredRows.map((entry) => {
const readinessLabel = READINESS_LABELS[entry.readiness];
const readinessClassName = READINESS_CLASSES[entry.readiness];
const message = skillMessages[entry.skill.skillKey] ?? null;
return (
<div
key={`${entry.skill.source}:${entry.skill.skillKey}`}
className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span>
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground">
{entry.skill.source}
</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`}
>
{readinessLabel}
</span>
</div>
<div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div>
{entry.readiness !== "ready" ? (
<div className="mt-1 text-[10px] text-muted-foreground/80">
{resolveReadinessHint(entry.skill, entry.readiness)}
</div>
) : null}
{message ? (
<div
className={`mt-1 text-[10px] ${message.kind === "error" ? "ui-text-danger" : "ui-text-success"}`}
>
{message.message}
</div>
) : null}
</div>
<div className="flex w-full items-center justify-end gap-2 sm:w-[210px]">
<button
type="button"
className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65"
disabled={anySkillBusy}
onClick={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(entry.skill.skillKey);
}}
>
Configure
</button>
</div>
</div>
);
})}
</div>
) : null}
<AgentSkillsSetupModal
skill={selectedSetupSkill}
skillsBusy={skillsBusy}
skillsBusyKey={skillsBusyKey}
skillMessage={selectedSetupSkill ? skillMessages[selectedSetupSkill.skillKey] ?? null : null}
apiKeyDraft={selectedSetupSkill ? skillApiKeyDrafts[selectedSetupSkill.skillKey] ?? "" : ""}
defaultAgentScopeWarning={defaultAgentScopeWarning}
onClose={() => {
onInitialSkillKeyHandled?.();
setSetupSkillKey(null);
}}
onInstallSkill={onInstallSkill}
onSetSkillGlobalEnabled={onSetSkillGlobalEnabled}
onRemoveSkill={onRemoveSkill}
onSkillApiKeyChange={onSkillApiKeyChange}
onSaveSkillApiKey={onSaveSkillApiKey}
/>
</section>
);
};
@@ -8,19 +8,11 @@ const RESERVED_MAIN_AGENT_ID = "main";
type GuardedActionKind =
| "delete-agent"
| "rename-agent"
| "update-agent-permissions"
| "use-all-skills"
| "disable-all-skills"
| "set-skills-allowlist"
| "set-skill-enabled"
| "set-skill-global-enabled"
| "install-skill"
| "remove-skill"
| "save-skill-api-key";
| "update-agent-permissions";
type CronActionKind = "run-cron-job" | "delete-cron-job";
type AgentSettingsMutationRequest =
| { kind: GuardedActionKind; agentId: string; skillName?: string; skillKey?: string }
| { kind: GuardedActionKind; agentId: string }
| { kind: "create-cron-job"; agentId: string }
| { kind: CronActionKind; agentId: string; jobId: string };
@@ -39,9 +31,7 @@ type AgentSettingsMutationDenyReason =
| "reserved-main-delete"
| "cron-action-busy"
| "missing-agent-id"
| "missing-job-id"
| "missing-skill-name"
| "missing-skill-key";
| "missing-job-id";
type AgentSettingsMutationDecision =
| {
@@ -63,15 +53,7 @@ const isGuardedAction = (
): kind is GuardedActionKind =>
kind === "delete-agent" ||
kind === "rename-agent" ||
kind === "update-agent-permissions" ||
kind === "use-all-skills" ||
kind === "disable-all-skills" ||
kind === "set-skills-allowlist" ||
kind === "set-skill-enabled" ||
kind === "set-skill-global-enabled" ||
kind === "install-skill" ||
kind === "remove-skill" ||
kind === "save-skill-api-key";
kind === "update-agent-permissions";
const isCronActionBusy = (context: AgentSettingsMutationContext) =>
context.cronCreateBusy ||
@@ -141,33 +123,6 @@ export const planAgentSettingsMutation = (
};
}
if (request.kind === "set-skill-enabled") {
const normalizedSkillName = normalizeId(request.skillName ?? "");
if (!normalizedSkillName) {
return {
kind: "deny",
reason: "missing-skill-name",
message: null,
};
}
}
if (
request.kind === "set-skill-global-enabled" ||
request.kind === "install-skill" ||
request.kind === "remove-skill" ||
request.kind === "save-skill-api-key"
) {
const normalizedSkillKey = normalizeId(request.skillKey ?? "");
if (!normalizedSkillKey) {
return {
kind: "deny",
reason: "missing-skill-key",
message: null,
};
}
}
return {
kind: "allow",
normalizedAgentId,
@@ -1,8 +1,6 @@
export type SettingsRouteTab =
| "personality"
| "capabilities"
| "skills"
| "system"
| "automations"
| "advanced";
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation";
import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
@@ -32,34 +32,15 @@ import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import { isGatewayDisconnectLikeError } from "@/lib/gateway/gateway-disconnect";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import {
readGatewayAgentSkillsAllowlist,
updateGatewayAgentSkillsAllowlist,
} from "@/lib/gateway/agentConfig";
import { fetchJson } from "@/lib/http";
import { canRemoveSkillSource, filterOsCompatibleSkills } from "@/lib/skills/presentation";
import { removeSkillFromGateway } from "@/lib/skills/remove";
import {
installSkill,
loadAgentSkillStatus,
updateSkill,
type SkillStatusEntry,
type SkillStatusReport,
} from "@/lib/skills/types";
import {
createDomainCronJob,
installDomainSkill,
listDomainCronJobs,
loadDomainSkillStatus,
removeDomainCronJob,
setDomainAgentSkillsAllowlist,
runDomainCronJobNow,
updateDomainSkill,
} from "@/lib/controlplane/domain-runtime-client";
type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind };
type SkillSetupMessage = { kind: "success" | "error"; message: string };
type SkillSetupMessageMap = Record<string, SkillSetupMessage>;
type AgentForSettingsMutation = Pick<AgentState, "agentId" | "name" | "sessionKey">;
@@ -90,48 +71,7 @@ type UseAgentSettingsMutationControllerParams = {
useDomainIntents: boolean;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const readAgentSkillsAllowlistFromSnapshot = (
snapshot: GatewayModelPolicySnapshot | null,
agentId: string
): string[] | undefined => {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) return undefined;
const configRaw = snapshot?.config;
const config = isRecord(configRaw) ? configRaw : null;
const agentsRaw = config && isRecord(config.agents) ? config.agents : null;
const list = Array.isArray(agentsRaw?.list) ? agentsRaw.list : [];
const entry = list.find((candidate) => {
if (!isRecord(candidate)) return false;
return candidate.id === normalizedAgentId;
});
if (!entry || !isRecord(entry)) {
return undefined;
}
const rawSkills = (entry as Record<string, unknown>).skills;
if (!Array.isArray(rawSkills)) {
return undefined;
}
const values = rawSkills
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(values));
};
export function useAgentSettingsMutationController(params: UseAgentSettingsMutationControllerParams) {
const skillsLoadRequestIdRef = useRef(0);
const [settingsSkillsReport, setSettingsSkillsReport] = useState<SkillStatusReport | null>(null);
const [settingsSkillsLoading, setSettingsSkillsLoading] = useState(false);
const [settingsSkillsError, setSettingsSkillsError] = useState<string | null>(null);
const [settingsSkillsBusy, setSettingsSkillsBusy] = useState(false);
const [settingsSkillsBusyKey, setSettingsSkillsBusyKey] = useState<string | null>(null);
const [settingsSkillMessages, setSettingsSkillMessages] = useState<SkillSetupMessageMap>({});
const [settingsSkillApiKeyDrafts, setSettingsSkillApiKeyDrafts] = useState<Record<string, string>>(
{}
);
const [settingsCronJobs, setSettingsCronJobs] = useState<CronJobSummary[]>([]);
const [settingsCronLoading, setSettingsCronLoading] = useState(false);
const [settingsCronError, setSettingsCronError] = useState<string | null>(null);
@@ -141,7 +81,6 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
const [restartingMutationBlock, setRestartingMutationBlock] =
useState<RestartingMutationBlockState | null>(null);
const REMOTE_MUTATION_EXEC_TIMEOUT_MS = 45_000;
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent";
const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent";
@@ -170,97 +109,6 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
]
);
const setSkillMessage = useCallback((skillKey: string, message?: SkillSetupMessage) => {
const normalizedSkillKey = skillKey.trim();
if (!normalizedSkillKey) {
return;
}
setSettingsSkillMessages((current) => {
const next = { ...current };
if (!message) {
delete next[normalizedSkillKey];
} else {
next[normalizedSkillKey] = message;
}
return next;
});
}, []);
const loadSkillsForSettingsAgent = useCallback(
async (agentId: string) => {
const requestId = skillsLoadRequestIdRef.current + 1;
skillsLoadRequestIdRef.current = requestId;
const resolvedAgentId = agentId.trim();
if (!resolvedAgentId) {
if (requestId === skillsLoadRequestIdRef.current) {
setSettingsSkillsReport(null);
setSettingsSkillsError("Failed to load skills: missing agent id.");
}
return;
}
setSettingsSkillsLoading(true);
setSettingsSkillsError(null);
try {
const report = params.useDomainIntents
? await loadDomainSkillStatus(resolvedAgentId)
: await loadAgentSkillStatus(params.client, resolvedAgentId);
if (requestId !== skillsLoadRequestIdRef.current) {
return;
}
setSettingsSkillsReport(report);
} catch (err) {
if (requestId !== skillsLoadRequestIdRef.current) {
return;
}
const message = err instanceof Error ? err.message : "Failed to load skills.";
setSettingsSkillsReport(null);
setSettingsSkillsError(message);
if (!isGatewayDisconnectLikeError(err)) {
console.error(message);
}
} finally {
if (requestId === skillsLoadRequestIdRef.current) {
setSettingsSkillsLoading(false);
}
}
},
[params.client, params.useDomainIntents]
);
useEffect(() => {
const skillsTabActive =
params.inspectSidebarTab === "skills" || params.inspectSidebarTab === "system";
if (
!params.settingsRouteActive ||
!params.inspectSidebarAgentId ||
params.status !== "connected" ||
!skillsTabActive
) {
skillsLoadRequestIdRef.current += 1;
setSettingsSkillsReport(null);
setSettingsSkillsLoading(false);
setSettingsSkillsError(null);
setSettingsSkillsBusy(false);
setSettingsSkillsBusyKey(null);
setSettingsSkillMessages({});
setSettingsSkillApiKeyDrafts({});
return;
}
void loadSkillsForSettingsAgent(params.inspectSidebarAgentId);
}, [
loadSkillsForSettingsAgent,
params.inspectSidebarAgentId,
params.inspectSidebarTab,
params.settingsRouteActive,
params.status,
]);
useEffect(() => {
setSettingsSkillsBusyKey(null);
setSettingsSkillMessages({});
setSettingsSkillApiKeyDrafts({});
}, [params.inspectSidebarAgentId]);
const loadCronJobsForSettingsAgent = useCallback(
async (agentId: string) => {
const resolvedAgentId = agentId.trim();
@@ -712,467 +560,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
[mutationContext, params]
);
const reloadSkillsIfVisible = useCallback(
async (agentId: string) => {
const skillsTabActive =
params.inspectSidebarTab === "skills" || params.inspectSidebarTab === "system";
if (
params.settingsRouteActive &&
skillsTabActive &&
params.inspectSidebarAgentId === agentId &&
params.status === "connected"
) {
await loadSkillsForSettingsAgent(agentId);
}
},
[
loadSkillsForSettingsAgent,
params.inspectSidebarAgentId,
params.inspectSidebarTab,
params.settingsRouteActive,
params.status,
]
);
const runSkillsMutation = useCallback(
async (input: {
agentId: string;
decisionKind:
| "use-all-skills"
| "disable-all-skills"
| "set-skills-allowlist"
| "set-skill-enabled";
skillName?: string;
run: (normalizedAgentId: string) => Promise<void>;
}) => {
const decision = planAgentSettingsMutation(
{
kind: input.decisionKind,
agentId: input.agentId,
...(input.skillName ? { skillName: input.skillName } : {}),
},
mutationContext
);
if (decision.kind === "deny") {
if (decision.message) {
setSettingsSkillsError(decision.message);
}
return;
}
const agent =
params.agents.find((entry) => entry.agentId === decision.normalizedAgentId) ?? null;
setSettingsSkillsBusy(true);
setSettingsSkillsError(null);
try {
await params.enqueueConfigMutation({
kind: "update-agent-skills",
label: `Update skills for ${agent?.name ?? decision.normalizedAgentId}`,
run: async () => {
await input.run(decision.normalizedAgentId);
await params.loadAgents();
await params.refreshGatewayConfigSnapshot();
await reloadSkillsIfVisible(decision.normalizedAgentId);
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update skills.";
setSettingsSkillsError(message);
if (!isGatewayDisconnectLikeError(err)) {
console.error(message);
}
} finally {
setSettingsSkillsBusy(false);
}
},
[mutationContext, params, reloadSkillsIfVisible]
);
const handleUseAllSkills = useCallback(
async (agentId: string) => {
await runSkillsMutation({
agentId,
decisionKind: "use-all-skills",
run: async (normalizedAgentId) => {
if (params.useDomainIntents) {
await setDomainAgentSkillsAllowlist({
agentId: normalizedAgentId,
mode: "all",
});
} else {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "all",
});
}
},
});
},
[params.client, params.useDomainIntents, runSkillsMutation]
);
const handleDisableAllSkills = useCallback(
async (agentId: string) => {
await runSkillsMutation({
agentId,
decisionKind: "disable-all-skills",
run: async (normalizedAgentId) => {
if (params.useDomainIntents) {
await setDomainAgentSkillsAllowlist({
agentId: normalizedAgentId,
mode: "none",
});
} else {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "none",
});
}
},
});
},
[params.client, params.useDomainIntents, runSkillsMutation]
);
const handleSetSkillEnabled = useCallback(
async (agentId: string, skillName: string, enabled: boolean) => {
await runSkillsMutation({
agentId,
decisionKind: "set-skill-enabled",
skillName,
run: async (normalizedAgentId) => {
const resolvedSkillName = skillName.trim();
const visibleSkillNames = Array.from(
new Set(
filterOsCompatibleSkills(settingsSkillsReport?.skills ?? [])
.map((entry) => entry.name.trim())
.filter((name) => name.length > 0)
)
);
if (visibleSkillNames.length === 0) {
throw new Error("Cannot update skill access: no skills available for this agent.");
}
const existingAllowlist = params.useDomainIntents
? readAgentSkillsAllowlistFromSnapshot(
params.gatewayConfigSnapshot,
normalizedAgentId
)
: await readGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
});
const baseline = existingAllowlist ?? visibleSkillNames;
const next = new Set(
baseline.map((value) => value.trim()).filter((value) => value.length > 0)
);
if (enabled) {
next.add(resolvedSkillName);
} else {
next.delete(resolvedSkillName);
}
const nextAllowlist = [...next];
if (params.useDomainIntents) {
await setDomainAgentSkillsAllowlist({
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: nextAllowlist,
});
} else {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: nextAllowlist,
});
}
},
});
},
[params.client, params.gatewayConfigSnapshot, params.useDomainIntents, runSkillsMutation, settingsSkillsReport]
);
const handleSetSkillsAllowlist = useCallback(
async (agentId: string, skillNames: string[]) => {
await runSkillsMutation({
agentId,
decisionKind: "set-skills-allowlist",
run: async (normalizedAgentId) => {
const normalizedSkillNames = Array.from(
new Set(
skillNames
.map((value) => value.trim())
.filter((value) => value.length > 0)
)
);
if (normalizedSkillNames.length === 0) {
throw new Error("Cannot set selected skills mode: choose at least one skill.");
}
if (params.useDomainIntents) {
await setDomainAgentSkillsAllowlist({
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: normalizedSkillNames,
});
} else {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: normalizedSkillNames,
});
}
},
});
},
[params.client, params.useDomainIntents, runSkillsMutation]
);
const handleSkillApiKeyDraftChange = useCallback((skillKey: string, value: string) => {
const normalizedSkillKey = skillKey.trim();
if (!normalizedSkillKey) {
return;
}
setSettingsSkillApiKeyDrafts((current) => ({
...current,
[normalizedSkillKey]: value,
}));
}, []);
const runSkillSetupMutation = useCallback(
async (input: {
agentId: string;
decisionKind:
| "install-skill"
| "remove-skill"
| "save-skill-api-key"
| "set-skill-global-enabled";
skillKey: string;
label: string;
run: () => Promise<{ successMessage: string }>;
refreshConfigSnapshot?: boolean;
}) => {
const normalizedSkillKey = input.skillKey.trim();
const decision = planAgentSettingsMutation(
{
kind: input.decisionKind,
agentId: input.agentId,
skillKey: normalizedSkillKey,
},
mutationContext
);
if (decision.kind === "deny") {
if (decision.message) {
setSettingsSkillsError(decision.message);
}
return;
}
setSettingsSkillsError(null);
setSettingsSkillsBusyKey(normalizedSkillKey);
setSkillMessage(normalizedSkillKey);
try {
await params.enqueueConfigMutation({
kind: "update-skill-setup",
label: input.label,
run: async () => {
const result = await input.run();
if (input.refreshConfigSnapshot) {
await params.refreshGatewayConfigSnapshot();
}
await reloadSkillsIfVisible(decision.normalizedAgentId);
setSkillMessage(normalizedSkillKey, {
kind: "success",
message: result.successMessage,
});
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update skill setup.";
setSettingsSkillsError(message);
setSkillMessage(normalizedSkillKey, {
kind: "error",
message,
});
if (!isGatewayDisconnectLikeError(err)) {
console.error(message);
}
} finally {
setSettingsSkillsBusyKey((current) => (current === normalizedSkillKey ? null : current));
}
},
[mutationContext, params, reloadSkillsIfVisible, setSkillMessage]
);
const handleInstallSkill = useCallback(
async (agentId: string, skillKey: string, name: string, installId: string) => {
await runSkillSetupMutation({
agentId,
decisionKind: "install-skill",
skillKey,
label: `Install dependencies for ${name.trim() || skillKey.trim()}`,
run: async () => {
const result = params.useDomainIntents
? await installDomainSkill({
name,
installId,
timeoutMs: SKILL_INSTALL_TIMEOUT_MS,
})
: await installSkill(params.client, {
name,
installId,
timeoutMs: SKILL_INSTALL_TIMEOUT_MS,
});
return {
successMessage: result.message || "Installed",
};
},
});
},
[SKILL_INSTALL_TIMEOUT_MS, params.client, params.useDomainIntents, runSkillSetupMutation]
);
const handleRemoveSkill = useCallback(
async (
agentId: string,
skill: Pick<SkillStatusEntry, "skillKey" | "source" | "baseDir">
) => {
const report = settingsSkillsReport;
const normalizedSkillKey = skill.skillKey.trim();
if (!normalizedSkillKey) {
const message = "Skill key is required to remove the skill.";
setSettingsSkillsError(message);
return;
}
if (!report) {
const message = "Cannot remove skill: skills are not loaded.";
setSettingsSkillsError(message);
setSkillMessage(normalizedSkillKey, {
kind: "error",
message,
});
return;
}
const normalizedSource = skill.source.trim();
if (!canRemoveSkillSource(normalizedSource)) {
const message = `Skill source cannot be removed from Studio: ${normalizedSource || "unknown"}.`;
setSettingsSkillsError(message);
setSkillMessage(normalizedSkillKey, {
kind: "error",
message,
});
return;
}
await runSkillSetupMutation({
agentId,
decisionKind: "remove-skill",
skillKey: normalizedSkillKey,
label: `Remove ${normalizedSkillKey}`,
run: async () => {
const result = await removeSkillFromGateway({
skillKey: normalizedSkillKey,
source: normalizedSource,
baseDir: skill.baseDir,
workspaceDir: report.workspaceDir,
managedSkillsDir: report.managedSkillsDir,
});
return {
successMessage: result.removed
? "Skill removed from gateway files"
: "Skill files were already removed",
};
},
});
},
[runSkillSetupMutation, setSkillMessage, settingsSkillsReport]
);
const handleSaveSkillApiKey = useCallback(
async (agentId: string, skillKey: string) => {
const normalizedSkillKey = skillKey.trim();
const apiKey = (settingsSkillApiKeyDrafts[normalizedSkillKey] ?? "").trim();
if (!apiKey) {
const message = "API key cannot be empty.";
setSettingsSkillsError(message);
setSkillMessage(normalizedSkillKey, {
kind: "error",
message,
});
return;
}
await runSkillSetupMutation({
agentId,
decisionKind: "save-skill-api-key",
skillKey: normalizedSkillKey,
label: `Save API key for ${normalizedSkillKey}`,
refreshConfigSnapshot: true,
run: async () => {
if (params.useDomainIntents) {
await updateDomainSkill({
skillKey: normalizedSkillKey,
apiKey,
});
} else {
await updateSkill(params.client, {
skillKey: normalizedSkillKey,
apiKey,
});
}
return {
successMessage: "API key saved",
};
},
});
},
[
params.client,
params.useDomainIntents,
runSkillSetupMutation,
setSkillMessage,
settingsSkillApiKeyDrafts,
]
);
const handleSetSkillGlobalEnabled = useCallback(
async (agentId: string, skillKey: string, enabled: boolean) => {
const normalizedSkillKey = skillKey.trim();
await runSkillSetupMutation({
agentId,
decisionKind: "set-skill-global-enabled",
skillKey: normalizedSkillKey,
label: `${enabled ? "Enable" : "Disable"} ${normalizedSkillKey}`,
refreshConfigSnapshot: true,
run: async () => {
if (params.useDomainIntents) {
await updateDomainSkill({
skillKey: normalizedSkillKey,
enabled,
});
} else {
await updateSkill(params.client, {
skillKey: normalizedSkillKey,
enabled,
});
}
return {
successMessage: enabled ? "Skill enabled globally" : "Skill disabled globally",
};
},
});
},
[params.client, params.useDomainIntents, runSkillSetupMutation]
);
return {
settingsSkillsReport,
settingsSkillsLoading,
settingsSkillsError,
settingsSkillsBusy,
settingsSkillsBusyKey,
settingsSkillMessages,
settingsSkillApiKeyDrafts,
settingsCronJobs,
settingsCronLoading,
settingsCronError,
@@ -1189,14 +577,5 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
handleDeleteCronJob,
handleRenameAgent,
handleUpdateAgentPermissions,
handleUseAllSkills,
handleDisableAllSkills,
handleSetSkillsAllowlist,
handleSetSkillEnabled,
handleInstallSkill,
handleRemoveSkill,
handleSkillApiKeyDraftChange,
handleSaveSkillApiKey,
handleSetSkillGlobalEnabled,
};
}
@@ -10,8 +10,6 @@ export type ConfigMutationKind =
| "delete-agent"
| "update-agent-execution-role"
| "update-agent-permissions"
| "update-agent-skills"
| "update-skill-setup"
| "repair-sandbox-tool-allowlist";
type QueuedConfigMutation = {
@@ -6,7 +6,6 @@ import type {
CronJobSummary,
CronRunResult,
} from "@/lib/cron/types";
import type { SkillStatusReport } from "@/lib/skills/types";
type Envelope<T> = {
ok?: boolean;
@@ -71,60 +70,6 @@ export const loadDomainModels = async (): Promise<GatewayModelChoice[]> => {
return Array.isArray(payload.models) ? payload.models : [];
};
export const loadDomainSkillStatus = async (agentId: string): Promise<SkillStatusReport> => {
const encodedAgentId = encodeURIComponent(agentId.trim());
const result = await fetchJson<Envelope<SkillStatusReport>>(
`/api/runtime/skills/status?agentId=${encodedAgentId}`,
{ cache: "no-store" }
);
return unwrapPayload(result);
};
export const installDomainSkill = async (params: {
name: string;
installId: string;
timeoutMs?: number;
}): Promise<{ ok: boolean; message: string; stdout: string; stderr: string; code: number | null; warnings?: string[] }> => {
const result = await fetchJson<Envelope<{ ok: boolean; message: string; stdout: string; stderr: string; code: number | null; warnings?: string[] }>>(
"/api/intents/skills-install",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
}
);
return unwrapPayload(result);
};
export const updateDomainSkill = async (params: {
skillKey: string;
enabled?: boolean;
apiKey?: string;
}): Promise<{ ok: boolean; skillKey: string; config: Record<string, unknown> }> => {
const result = await fetchJson<Envelope<{ ok: boolean; skillKey: string; config: Record<string, unknown> }>>(
"/api/intents/skills-update",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
}
);
return unwrapPayload(result);
};
export const setDomainAgentSkillsAllowlist = async (params: {
agentId: string;
mode: "all" | "none" | "allowlist";
skillNames?: string[];
}): Promise<void> => {
const result = await fetchJson<Envelope<{ updated: boolean }>>("/api/intents/agent-skills-allowlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
unwrapPayload(result);
};
export const listDomainCronJobs = async (params: {
includeDisabled?: boolean;
} = {}): Promise<{ jobs: CronJobSummary[] }> => {
+16 -5
View File
@@ -46,9 +46,6 @@ const DEFAULT_METHOD_ALLOWLIST = new Set<string>([
"config.get",
"config.set",
"models.list",
"skills.status",
"skills.install",
"skills.update",
"exec.approval.resolve",
"exec.approvals.get",
"exec.approvals.set",
@@ -87,6 +84,20 @@ const resolveOriginForUpstream = (upstreamUrl: string): string => {
return `${proto}//${host}`;
};
const resolveConnectFailureMessage = (error: unknown, upstreamUrl: string): string => {
if (!(error instanceof Error)) {
return "Control-plane gateway connection failed.";
}
const details = error.message.trim();
if (!details) {
return "Control-plane gateway connection failed.";
}
if (details.includes("Unexpected server response: 502")) {
return `Control-plane gateway connection failed: upstream ${upstreamUrl} returned HTTP 502 during websocket upgrade.`;
}
return `Control-plane gateway connection failed: ${details}`;
};
const loadGatewaySettings = (): ControlPlaneGatewaySettings => {
const settings = loadStudioSettings();
const gateway = settings.gateway;
@@ -300,10 +311,10 @@ export class OpenClawGatewayAdapter {
this.scheduleReconnect();
});
ws.on("error", () => {
ws.on("error", (error) => {
if (this.stopping) return;
if (!settled) {
settle(() => reject(new Error("Control-plane gateway connection failed.")));
settle(() => reject(new Error(resolveConnectFailureMessage(error, settings.url))));
}
});
}).catch((err) => {
-156
View File
@@ -506,162 +506,6 @@ export const removeGatewayHeartbeatOverride = async (params: {
return resolveHeartbeatSettings(nextConfig, params.agentId);
};
type AgentSkillsAccessMode = "all" | "none" | "allowlist";
const resolveRequiredAgentId = (agentId: string): string => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required.");
}
return trimmed;
};
const normalizeSkillAllowlistInput = (values: ReadonlyArray<unknown>): string[] => {
const next = values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(next)).sort((a, b) => a.localeCompare(b));
};
const normalizeSkillAllowlist = (values: string[]): string[] => {
return normalizeSkillAllowlistInput(values);
};
const areStringArraysEqual = (a: readonly string[], b: readonly string[]): boolean => {
if (a.length !== b.length) return false;
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) return false;
}
return true;
};
const buildAgentSkillsConfig = (params: {
baseConfig: Record<string, unknown>;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
}): Record<string, unknown> => {
const list = readConfigAgentList(params.baseConfig);
const currentEntry = list.find((entry) => entry.id === params.agentId);
const hasEntry = Boolean(currentEntry);
const currentRawSkills = currentEntry?.skills;
if (params.mode === "all") {
if (!hasEntry) {
return params.baseConfig;
}
if (!Object.prototype.hasOwnProperty.call(currentEntry, "skills")) {
return params.baseConfig;
}
}
if (params.mode === "none" && Array.isArray(currentRawSkills) && currentRawSkills.length === 0) {
return params.baseConfig;
}
if (params.mode === "allowlist") {
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
const normalizedNext = normalizeSkillAllowlist(rawSkills);
if (Array.isArray(currentRawSkills)) {
const normalizedCurrent = normalizeSkillAllowlistInput(currentRawSkills);
if (areStringArraysEqual(normalizedCurrent, normalizedNext)) {
return params.baseConfig;
}
}
}
const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => {
const next: ConfigAgentEntry = { ...entry, id: params.agentId };
if (params.mode === "all") {
if ("skills" in next) {
delete next.skills;
}
return next;
}
if (params.mode === "none") {
next.skills = [];
return next;
}
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
next.skills = normalizeSkillAllowlist(rawSkills);
return next;
});
return writeConfigAgentList(params.baseConfig, nextList);
};
export const readGatewayAgentSkillsAllowlist = async (params: {
client: GatewayClient;
agentId: string;
}): Promise<string[] | undefined> => {
const agentId = resolveRequiredAgentId(params.agentId);
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const entry = list.find((item) => item.id === agentId);
if (!entry) {
return undefined;
}
const raw = entry.skills;
if (!Array.isArray(raw)) {
return undefined;
}
return normalizeSkillAllowlistInput(raw);
};
export const updateGatewayAgentSkillsAllowlist = async (params: {
client: GatewayClient;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
}): Promise<void> => {
const agentId = resolveRequiredAgentId(params.agentId);
if (params.mode === "allowlist" && !params.skillNames) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildAgentSkillsConfig({
baseConfig,
agentId,
mode: params.mode,
skillNames: params.skillNames,
});
if (nextConfig === baseConfig) {
return;
}
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
const requiresBaseHash = snapshot.exists !== false;
const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
if (baseHash) {
payload.baseHash = baseHash;
}
try {
await callGateway(params.client, "config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
return attemptWrite(attempt + 1);
}
throw err;
}
};
await attemptWrite(0);
};
const normalizeToolList = (values: string[] | undefined): string[] | undefined => {
if (!values) return undefined;
const next = values
-280
View File
@@ -1,280 +0,0 @@
import type {
RemovableSkillSource,
SkillInstallOption,
SkillStatusEntry,
} from "@/lib/skills/types";
type SkillSourceGroupId = "workspace" | "built-in" | "installed" | "extra" | "other";
type SkillSourceGroup = {
id: SkillSourceGroupId;
label: string;
skills: SkillStatusEntry[];
};
export type SkillReadinessState =
| "ready"
| "needs-setup"
| "unavailable"
| "disabled-globally";
export type AgentSkillDisplayState = "ready" | "setup-required" | "not-supported";
type AgentSkillsAccessMode = "all" | "none" | "selected";
const GROUP_DEFINITIONS: Array<{ id: Exclude<SkillSourceGroupId, "other">; label: string }> = [
{ id: "workspace", label: "Workspace Skills" },
{ id: "built-in", label: "Built-in Skills" },
{ id: "installed", label: "Installed Skills" },
{ id: "extra", label: "Extra Skills" },
];
const WORKSPACE_SOURCES = new Set(["openclaw-workspace", "agents-skills-personal", "agents-skills-project"]);
const REMOVABLE_SOURCES = new Set<RemovableSkillSource>([
"openclaw-managed",
"openclaw-workspace",
]);
const trimNonEmpty = (value: string): string | null => {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const OS_LABELS: Record<string, string> = {
darwin: "macOS",
linux: "Linux",
win32: "Windows",
windows: "Windows",
};
const toOsLabel = (value: string): string => {
const normalized = value.trim().toLowerCase();
return OS_LABELS[normalized] ?? value.trim();
};
const normalizeStringList = (values: string[] | undefined): string[] => {
if (!Array.isArray(values)) {
return [];
}
const normalized: string[] = [];
for (const value of values) {
const trimmed = trimNonEmpty(value);
if (trimmed) {
normalized.push(trimmed);
}
}
return normalized;
};
export const normalizeAgentSkillsAllowlist = (values: string[] | undefined): string[] => {
const normalized = normalizeStringList(values);
return Array.from(new Set(normalized));
};
export const deriveAgentSkillsAccessMode = (
values: string[] | undefined
): AgentSkillsAccessMode => {
if (!Array.isArray(values)) {
return "all";
}
return normalizeAgentSkillsAllowlist(values).length === 0 ? "none" : "selected";
};
export const buildAgentSkillsAllowlistSet = (values: string[] | undefined): Set<string> =>
new Set(normalizeAgentSkillsAllowlist(values));
const resolveGroupId = (skill: SkillStatusEntry): SkillSourceGroupId => {
const source = trimNonEmpty(skill.source) ?? "";
const bundled = skill.bundled || source === "openclaw-bundled";
if (bundled) return "built-in";
if (WORKSPACE_SOURCES.has(source)) return "workspace";
if (source === "openclaw-managed") return "installed";
if (source === "openclaw-extra") return "extra";
return "other";
};
export const groupSkillsBySource = (skills: SkillStatusEntry[]): SkillSourceGroup[] => {
const grouped = new Map<SkillSourceGroupId, SkillSourceGroup>();
for (const def of GROUP_DEFINITIONS) {
grouped.set(def.id, { id: def.id, label: def.label, skills: [] });
}
grouped.set("other", { id: "other", label: "Other Skills", skills: [] });
for (const skill of skills) {
const groupId = resolveGroupId(skill);
grouped.get(groupId)?.skills.push(skill);
}
const ordered: SkillSourceGroup[] = [];
for (const def of GROUP_DEFINITIONS) {
const group = grouped.get(def.id);
if (group && group.skills.length > 0) {
ordered.push(group);
}
}
const other = grouped.get("other");
if (other && other.skills.length > 0) {
ordered.push(other);
}
return ordered;
};
export const canRemoveSkillSource = (source: string): source is RemovableSkillSource => {
const trimmed = trimNonEmpty(source);
if (!trimmed) {
return false;
}
return REMOVABLE_SOURCES.has(trimmed as RemovableSkillSource);
};
export const canRemoveSkill = (skill: SkillStatusEntry): boolean => {
return canRemoveSkillSource(skill.source);
};
export const buildSkillMissingDetails = (skill: SkillStatusEntry): string[] => {
const details: string[] = [];
const bins = normalizeStringList(skill.missing.bins);
if (bins.length > 0) {
details.push(`Missing tools: ${bins.join(", ")}`);
}
const anyBins = normalizeStringList(skill.missing.anyBins);
if (anyBins.length > 0) {
details.push(`Missing one-of tools (install any): ${anyBins.join(" | ")}`);
}
const env = normalizeStringList(skill.missing.env);
if (env.length > 0) {
details.push(`Missing env vars (set in gateway env): ${env.join(", ")}`);
}
const config = normalizeStringList(skill.missing.config);
if (config.length > 0) {
details.push(`Missing config values (set in openclaw.json): ${config.join(", ")}`);
}
const os = normalizeStringList(skill.missing.os);
if (os.length > 0) {
details.push(`Requires OS: ${os.map((value) => toOsLabel(value)).join(", ")}`);
}
return details;
};
export const buildSkillReasons = (skill: SkillStatusEntry): string[] => {
const reasons: string[] = [];
if (skill.disabled) {
reasons.push("disabled");
}
if (skill.blockedByAllowlist) {
reasons.push("blocked by allowlist");
}
if (normalizeStringList(skill.missing.bins).length > 0) {
reasons.push("missing tools");
}
if (normalizeStringList(skill.missing.anyBins).length > 0) {
reasons.push("missing one-of tools");
}
if (normalizeStringList(skill.missing.env).length > 0) {
reasons.push("missing env vars");
}
if (normalizeStringList(skill.missing.config).length > 0) {
reasons.push("missing config values");
}
if (normalizeStringList(skill.missing.os).length > 0) {
reasons.push("unsupported OS");
}
return reasons;
};
export const isSkillOsIncompatible = (skill: SkillStatusEntry): boolean => {
return normalizeStringList(skill.missing.os).length > 0;
};
export const filterOsCompatibleSkills = (skills: SkillStatusEntry[]): SkillStatusEntry[] => {
return skills.filter((skill) => !isSkillOsIncompatible(skill));
};
export const deriveSkillReadinessState = (skill: SkillStatusEntry): SkillReadinessState => {
if (skill.disabled) {
return "disabled-globally";
}
if (isSkillOsIncompatible(skill) || skill.blockedByAllowlist) {
return "unavailable";
}
if (skill.eligible) {
return "ready";
}
return "needs-setup";
};
export const deriveAgentSkillDisplayState = (
readiness: SkillReadinessState
): AgentSkillDisplayState => {
if (readiness === "ready") {
return "ready";
}
if (readiness === "unavailable") {
return "not-supported";
}
return "setup-required";
};
export const isBundledBlockedSkill = (skill: SkillStatusEntry): boolean => {
const source = trimNonEmpty(skill.source) ?? "";
return (skill.bundled || source === "openclaw-bundled") && !skill.eligible;
};
export const hasInstallableMissingBinary = (skill: SkillStatusEntry): boolean => {
const installOptions = Array.isArray(skill.install) ? skill.install : [];
if (installOptions.length === 0) {
return false;
}
const missingBinarySet = new Set([
...normalizeStringList(skill.missing.bins),
...normalizeStringList(skill.missing.anyBins),
]);
if (missingBinarySet.size === 0) {
return false;
}
for (const option of installOptions) {
const bins = normalizeStringList(option.bins);
if (bins.length === 0) {
return true;
}
for (const bin of bins) {
if (missingBinarySet.has(bin)) {
return true;
}
}
}
return false;
};
export const resolvePreferredInstallOption = (
skill: SkillStatusEntry
): SkillInstallOption | null => {
if (!hasInstallableMissingBinary(skill)) {
return null;
}
const missingBinarySet = new Set([
...normalizeStringList(skill.missing.bins),
...normalizeStringList(skill.missing.anyBins),
]);
for (const option of skill.install) {
const bins = normalizeStringList(option.bins);
if (bins.length === 0) {
return option;
}
for (const bin of bins) {
if (missingBinarySet.has(bin)) {
return option;
}
}
}
return skill.install[0] ?? null;
};
-91
View File
@@ -1,91 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveUserPath } from "@/lib/clawdbot/paths";
import type { RemovableSkillSource, SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
const resolveComparablePath = (input: string): string => {
const resolved = path.resolve(input);
if (!fs.existsSync(resolved)) {
return resolved;
}
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
};
const isPathInside = (root: string, candidate: string): boolean => {
const resolvedRoot = resolveComparablePath(root);
const resolvedCandidate = resolveComparablePath(candidate);
if (resolvedCandidate === resolvedRoot) {
return true;
}
const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
return resolvedCandidate.startsWith(rootPrefix);
};
const normalizeRequiredPath = (value: string, field: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${field} is required.`);
}
return resolveUserPath(trimmed, os.homedir);
};
const resolveAllowedRoot = (params: {
source: RemovableSkillSource;
workspaceDir: string;
managedSkillsDir: string;
}): string => {
if (params.source === "openclaw-managed") {
return params.managedSkillsDir;
}
return path.join(params.workspaceDir, "skills");
};
export const removeSkillLocally = (params: SkillRemoveRequest): SkillRemoveResult => {
const skillKey = params.skillKey.trim();
if (!skillKey) {
throw new Error("skillKey is required.");
}
const source = params.source;
const baseDir = normalizeRequiredPath(params.baseDir, "baseDir");
const workspaceDir = normalizeRequiredPath(params.workspaceDir, "workspaceDir");
const managedSkillsDir = normalizeRequiredPath(params.managedSkillsDir, "managedSkillsDir");
const allowedRoot = resolveAllowedRoot({
source,
workspaceDir,
managedSkillsDir,
});
if (!isPathInside(allowedRoot, baseDir)) {
throw new Error(`Refusing to remove skill outside allowed root: ${baseDir}`);
}
if (resolveComparablePath(allowedRoot) === resolveComparablePath(baseDir)) {
throw new Error(`Refusing to remove the skills root directory: ${baseDir}`);
}
const exists = fs.existsSync(baseDir);
if (exists) {
const stats = fs.statSync(baseDir);
if (!stats.isDirectory()) {
throw new Error(`Skill path is not a directory: ${baseDir}`);
}
const skillDocPath = path.join(baseDir, "SKILL.md");
if (!fs.existsSync(skillDocPath) || !fs.statSync(skillDocPath).isFile()) {
throw new Error(`Refusing to remove non-skill directory: ${baseDir}`);
}
fs.rmSync(baseDir, { recursive: true, force: false });
}
return {
removed: exists,
removedPath: baseDir,
source,
};
};
-29
View File
@@ -1,29 +0,0 @@
import { fetchJson } from "@/lib/http";
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
const normalizeRequired = (value: string, field: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${field} is required.`);
}
return trimmed;
};
export const removeSkillFromGateway = async (
request: SkillRemoveRequest
): Promise<SkillRemoveResult> => {
const payload: SkillRemoveRequest = {
skillKey: normalizeRequired(request.skillKey, "skillKey"),
source: request.source,
baseDir: normalizeRequired(request.baseDir, "baseDir"),
workspaceDir: normalizeRequired(request.workspaceDir, "workspaceDir"),
managedSkillsDir: normalizeRequired(request.managedSkillsDir, "managedSkillsDir"),
};
const response = await fetchJson<{ result: SkillRemoveResult }>("/api/intents/skills-remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
return response.result;
};
-141
View File
@@ -1,141 +0,0 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
type SkillStatusConfigCheck = {
path: string;
satisfied: boolean;
};
type SkillRequirementSet = {
bins: string[];
anyBins: string[];
env: string[];
config: string[];
os: string[];
};
export type SkillInstallOption = {
id: string;
kind: "brew" | "node" | "go" | "uv" | "download";
label: string;
bins: string[];
};
export type RemovableSkillSource = "openclaw-managed" | "openclaw-workspace";
export type SkillStatusEntry = {
name: string;
description: string;
source: string;
bundled: boolean;
filePath: string;
baseDir: string;
skillKey: string;
primaryEnv?: string;
emoji?: string;
homepage?: string;
always: boolean;
disabled: boolean;
blockedByAllowlist: boolean;
eligible: boolean;
requirements: SkillRequirementSet;
missing: SkillRequirementSet;
configChecks: SkillStatusConfigCheck[];
install: SkillInstallOption[];
};
export type SkillStatusReport = {
workspaceDir: string;
managedSkillsDir: string;
skills: SkillStatusEntry[];
};
type SkillInstallRequest = {
name: string;
installId: string;
timeoutMs?: number;
};
type SkillInstallResult = {
ok: boolean;
message: string;
stdout: string;
stderr: string;
code: number | null;
warnings?: string[];
};
type SkillUpdateRequest = {
skillKey: string;
enabled?: boolean;
apiKey?: string;
};
type SkillUpdateResult = {
ok: boolean;
skillKey: string;
config: Record<string, unknown>;
};
export type SkillRemoveRequest = {
skillKey: string;
source: RemovableSkillSource;
baseDir: string;
workspaceDir: string;
managedSkillsDir: string;
};
export type SkillRemoveResult = {
removed: boolean;
removedPath: string;
source: RemovableSkillSource;
};
const resolveAgentId = (agentId: string): string => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required to load skill status.");
}
return trimmed;
};
const resolveRequiredValue = (value: string, message: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(message);
}
return trimmed;
};
export const loadAgentSkillStatus = async (
client: GatewayClient,
agentId: string
): Promise<SkillStatusReport> => {
return client.call<SkillStatusReport>("skills.status", {
agentId: resolveAgentId(agentId),
});
};
export const installSkill = async (
client: GatewayClient,
params: SkillInstallRequest
): Promise<SkillInstallResult> => {
return client.call<SkillInstallResult>("skills.install", {
name: resolveRequiredValue(params.name, "Skill name is required to install dependencies."),
installId: resolveRequiredValue(
params.installId,
"Install option id is required to install dependencies."
),
...(typeof params.timeoutMs === "number" ? { timeoutMs: params.timeoutMs } : {}),
});
};
export const updateSkill = async (
client: GatewayClient,
params: SkillUpdateRequest
): Promise<SkillUpdateResult> => {
return client.call<SkillUpdateResult>("skills.update", {
skillKey: resolveRequiredValue(params.skillKey, "Skill key is required to update skill setup."),
...(typeof params.enabled === "boolean" ? { enabled: params.enabled } : {}),
...(typeof params.apiKey === "string" ? { apiKey: params.apiKey } : {}),
});
};
-88
View File
@@ -1,88 +0,0 @@
import { runSshJson } from "@/lib/ssh/gateway-host";
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
const REMOVE_SKILL_SCRIPT = `
set -euo pipefail
python3 - "$1" "$2" "$3" "$4" "$5" <<'PY'
import json
import pathlib
import shutil
import sys
skill_key = sys.argv[1].strip()
source = sys.argv[2].strip()
base_dir_raw = sys.argv[3].strip()
workspace_dir_raw = sys.argv[4].strip()
managed_skills_dir_raw = sys.argv[5].strip()
if not skill_key:
raise SystemExit("skillKey is required.")
if not source:
raise SystemExit("source is required.")
if not base_dir_raw:
raise SystemExit("baseDir is required.")
if not workspace_dir_raw:
raise SystemExit("workspaceDir is required.")
if not managed_skills_dir_raw:
raise SystemExit("managedSkillsDir is required.")
allowed_sources = {
"openclaw-managed",
"openclaw-workspace",
}
if source not in allowed_sources:
raise SystemExit(f"Unsupported skill source for removal: {source}")
base_dir = pathlib.Path(base_dir_raw).expanduser().resolve(strict=False)
workspace_dir = pathlib.Path(workspace_dir_raw).expanduser().resolve(strict=False)
managed_skills_dir = pathlib.Path(managed_skills_dir_raw).expanduser().resolve(strict=False)
if source == "openclaw-managed":
allowed_root = managed_skills_dir
else:
allowed_root = (workspace_dir / "skills").resolve(strict=False)
try:
base_dir.relative_to(allowed_root)
except ValueError:
raise SystemExit(f"Refusing to remove skill outside allowed root: {base_dir}")
if base_dir == allowed_root:
raise SystemExit(f"Refusing to remove the skills root directory: {base_dir}")
removed = False
if base_dir.exists():
if not base_dir.is_dir():
raise SystemExit(f"Skill path is not a directory: {base_dir}")
skill_doc = base_dir / "SKILL.md"
if not skill_doc.exists() or not skill_doc.is_file():
raise SystemExit(f"Refusing to remove non-skill directory: {base_dir}")
shutil.rmtree(base_dir)
removed = True
print(json.dumps({"removed": removed, "removedPath": str(base_dir), "source": source}))
PY
`;
export const removeSkillOverSsh = (params: {
sshTarget: string;
request: SkillRemoveRequest;
}): SkillRemoveResult => {
const result = runSshJson({
sshTarget: params.sshTarget,
argv: [
"bash",
"-s",
"--",
params.request.skillKey,
params.request.source,
params.request.baseDir,
params.request.workspaceDir,
params.request.managedSkillsDir,
],
input: REMOVE_SKILL_SCRIPT,
label: `remove skill (${params.request.skillKey})`,
});
return result as SkillRemoveResult;
};
@@ -19,31 +19,11 @@ const createContext = (
});
describe("agentSettingsMutationWorkflow", () => {
it("denies_guarded_actions_when_not_connected", () => {
it("denies guarded actions when not connected", () => {
const renameResult = planAgentSettingsMutation(
{ kind: "rename-agent", agentId: "agent-1" },
createContext({ status: "disconnected" })
);
const skillsResult = planAgentSettingsMutation(
{ kind: "use-all-skills", agentId: "agent-1" },
createContext({ status: "disconnected" })
);
const installResult = planAgentSettingsMutation(
{ kind: "install-skill", agentId: "agent-1", skillKey: "browser" },
createContext({ status: "disconnected" })
);
const allowlistResult = planAgentSettingsMutation(
{ kind: "set-skills-allowlist", agentId: "agent-1" },
createContext({ status: "disconnected" })
);
const globalToggleResult = planAgentSettingsMutation(
{ kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: "browser" },
createContext({ status: "disconnected" })
);
const removeResult = planAgentSettingsMutation(
{ kind: "remove-skill", agentId: "agent-1", skillKey: "browser" },
createContext({ status: "disconnected" })
);
expect(renameResult).toEqual({
kind: "deny",
@@ -51,39 +31,9 @@ describe("agentSettingsMutationWorkflow", () => {
message: null,
guardReason: "not-connected",
});
expect(skillsResult).toEqual({
kind: "deny",
reason: "start-guard-deny",
message: null,
guardReason: "not-connected",
});
expect(installResult).toEqual({
kind: "deny",
reason: "start-guard-deny",
message: null,
guardReason: "not-connected",
});
expect(allowlistResult).toEqual({
kind: "deny",
reason: "start-guard-deny",
message: null,
guardReason: "not-connected",
});
expect(globalToggleResult).toEqual({
kind: "deny",
reason: "start-guard-deny",
message: null,
guardReason: "not-connected",
});
expect(removeResult).toEqual({
kind: "deny",
reason: "start-guard-deny",
message: null,
guardReason: "not-connected",
});
});
it("denies_delete_for_reserved_main_agent_with_actionable_message", () => {
it("denies delete for reserved main agent", () => {
const result = planAgentSettingsMutation(
{ kind: "delete-agent", agentId: " main " },
createContext()
@@ -96,21 +46,7 @@ describe("agentSettingsMutationWorkflow", () => {
});
});
it("denies_guarded_actions_when_mutation_block_is_active", () => {
const result = planAgentSettingsMutation(
{ kind: "update-agent-permissions", agentId: "agent-1" },
createContext({ hasCreateBlock: true })
);
expect(result).toEqual({
kind: "deny",
reason: "start-guard-deny",
message: null,
guardReason: "create-block-active",
});
});
it("denies_cron_run_delete_when_other_cron_action_is_busy", () => {
it("denies cron mutations when another cron action is busy", () => {
const result = planAgentSettingsMutation(
{ kind: "run-cron-job", agentId: "agent-1", jobId: "job-1" },
createContext({ cronDeleteBusyJobId: "job-2" })
@@ -123,7 +59,7 @@ describe("agentSettingsMutationWorkflow", () => {
});
});
it("allows_with_normalized_agent_and_job_ids", () => {
it("allows with normalized agent and job ids", () => {
const runResult = planAgentSettingsMutation(
{ kind: "run-cron-job", agentId: " agent-1 ", jobId: " job-1 " },
createContext()
@@ -143,69 +79,4 @@ describe("agentSettingsMutationWorkflow", () => {
normalizedAgentId: "agent-2",
});
});
it("denies_skill_toggle_when_skill_name_is_missing", () => {
const result = planAgentSettingsMutation(
{ kind: "set-skill-enabled", agentId: "agent-1", skillName: " " },
createContext()
);
expect(result).toEqual({
kind: "deny",
reason: "missing-skill-name",
message: null,
});
});
it("denies_skill_setup_when_skill_key_is_missing", () => {
const installResult = planAgentSettingsMutation(
{ kind: "install-skill", agentId: "agent-1", skillKey: " " },
createContext()
);
const saveResult = planAgentSettingsMutation(
{ kind: "save-skill-api-key", agentId: "agent-1", skillKey: " " },
createContext()
);
const globalToggleResult = planAgentSettingsMutation(
{ kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: " " },
createContext()
);
const removeResult = planAgentSettingsMutation(
{ kind: "remove-skill", agentId: "agent-1", skillKey: " " },
createContext()
);
expect(installResult).toEqual({
kind: "deny",
reason: "missing-skill-key",
message: null,
});
expect(saveResult).toEqual({
kind: "deny",
reason: "missing-skill-key",
message: null,
});
expect(globalToggleResult).toEqual({
kind: "deny",
reason: "missing-skill-key",
message: null,
});
expect(removeResult).toEqual({
kind: "deny",
reason: "missing-skill-key",
message: null,
});
});
it("allows_setting_skills_allowlist_with_normalized_agent_id", () => {
const result = planAgentSettingsMutation(
{ kind: "set-skills-allowlist", agentId: " agent-1 " },
createContext()
);
expect(result).toEqual({
kind: "allow",
normalizedAgentId: "agent-1",
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,284 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { GatewayResponseError } from "@/lib/gateway/GatewayClient";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import {
readGatewayAgentSkillsAllowlist,
updateGatewayAgentSkillsAllowlist,
} from "@/lib/gateway/agentConfig";
describe("gateway agent skills allowlist", () => {
it("reads and normalizes existing skills allowlist", async () => {
const client = {
call: vi.fn(async (method: string) => {
if (method === "config.get") {
return {
exists: true,
hash: "cfg-read-1",
config: {
agents: {
list: [{ id: "agent-1", skills: [" github ", "slack", "github"] }],
},
},
};
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as GatewayClient;
await expect(
readGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
})
).resolves.toEqual(["github", "slack"]);
});
it("writes mode all by removing the skills key", async () => {
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
exists: true,
hash: "cfg-all-1",
config: {
agents: {
list: [{ id: "agent-1", skills: ["github", "slack"] }],
},
},
};
}
if (method === "config.set") {
const payload = params as { raw?: string; baseHash?: string };
const parsed = JSON.parse(payload.raw ?? "") as {
agents?: { list?: Array<{ id?: string; skills?: string[] }> };
};
expect(payload.baseHash).toBe("cfg-all-1");
const entry = parsed.agents?.list?.find((item) => item.id === "agent-1");
expect(entry).toEqual({ id: "agent-1" });
return { ok: true };
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as GatewayClient;
await updateGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
mode: "all",
});
});
it("writes mode none and mode allowlist with normalized names", async () => {
const calls: Array<{ method: string; params?: unknown }> = [];
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
calls.push({ method, params });
if (method === "config.get") {
return {
exists: true,
hash: `cfg-${calls.length}`,
config: {
agents: {
list: [{ id: "agent-1" }],
},
},
};
}
if (method === "config.set") {
return { ok: true };
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as GatewayClient;
await updateGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
mode: "none",
});
await updateGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
mode: "allowlist",
skillNames: [" slack ", "github", "slack"],
});
const nonePayload = calls.find(
(entry) => entry.method === "config.set"
)?.params as { raw?: string };
const setCalls = calls.filter((entry) => entry.method === "config.set");
const allowPayload = setCalls[1]?.params as { raw?: string };
expect(
(JSON.parse(nonePayload.raw ?? "") as { agents?: { list?: Array<{ id?: string; skills?: string[] }> } })
.agents?.list?.find((entry) => entry.id === "agent-1")
).toEqual({ id: "agent-1", skills: [] });
expect(
(JSON.parse(allowPayload.raw ?? "") as {
agents?: { list?: Array<{ id?: string; skills?: string[] }> };
}).agents?.list?.find((entry) => entry.id === "agent-1")
).toEqual({ id: "agent-1", skills: ["github", "slack"] });
});
it("retries once after stale hash and preserves concurrent config changes", async () => {
let getCount = 0;
let setCount = 0;
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
getCount += 1;
if (getCount === 1) {
return {
exists: true,
hash: "cfg-retry-1",
config: {
gateway: { reload: { mode: "hybrid" } },
agents: { list: [{ id: "agent-1" }] },
},
};
}
return {
exists: true,
hash: "cfg-retry-2",
config: {
gateway: { reload: { mode: "off" } },
agents: { list: [{ id: "agent-1" }] },
},
};
}
if (method === "config.set") {
setCount += 1;
const payload = params as { raw?: string; baseHash?: string };
const parsed = JSON.parse(payload.raw ?? "") as {
gateway?: { reload?: { mode?: string } };
agents?: { list?: Array<{ id?: string; skills?: string[] }> };
};
if (setCount === 1) {
expect(payload.baseHash).toBe("cfg-retry-1");
expect(parsed.gateway?.reload?.mode).toBe("hybrid");
throw new GatewayResponseError({
code: "INVALID_REQUEST",
message: "config changed since last load; re-run config.get and retry",
});
}
expect(payload.baseHash).toBe("cfg-retry-2");
expect(parsed.gateway?.reload?.mode).toBe("off");
expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")).toEqual({
id: "agent-1",
skills: ["github"],
});
return { ok: true };
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as GatewayClient;
await updateGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
mode: "allowlist",
skillNames: ["github"],
});
expect(getCount).toBe(2);
expect(setCount).toBe(2);
});
it("fails fast when mode allowlist omits skill names", async () => {
const client = {
call: vi.fn(),
} as unknown as GatewayClient;
await expect(
updateGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
mode: "allowlist",
})
).rejects.toThrow("Skills allowlist is required when mode is allowlist.");
expect(client.call).not.toHaveBeenCalled();
});
it("skips config.set when mode all is already implied", async () => {
const client = {
call: vi.fn(async (method: string) => {
if (method === "config.get") {
return {
exists: true,
hash: "cfg-skip-write",
config: { agents: { list: [{ id: "other-agent" }] } },
};
}
if (method === "config.set") {
throw new Error("config.set should not be called for no-op mode all");
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as GatewayClient;
await updateGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
mode: "all",
});
expect(client.call).toHaveBeenCalledTimes(1);
expect(client.call).toHaveBeenCalledWith("config.get", {});
});
it("skips config.set when mode all has no explicit skills on existing agent entry", async () => {
const client = {
call: vi.fn(async (method: string) => {
if (method === "config.get") {
return {
exists: true,
hash: "cfg-skip-write-existing",
config: { agents: { list: [{ id: "agent-1", name: "Agent One" }] } },
};
}
if (method === "config.set") {
throw new Error("config.set should not be called for no-op mode all");
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as GatewayClient;
await updateGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
mode: "all",
});
expect(client.call).toHaveBeenCalledTimes(1);
expect(client.call).toHaveBeenCalledWith("config.get", {});
});
it("skips config.set when allowlist is unchanged after normalization", async () => {
const client = {
call: vi.fn(async (method: string) => {
if (method === "config.get") {
return {
exists: true,
hash: "cfg-skip-write-allowlist",
config: { agents: { list: [{ id: "agent-1", skills: [" github ", "slack"] }] } },
};
}
if (method === "config.set") {
throw new Error("config.set should not be called for unchanged allowlist");
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as GatewayClient;
await updateGatewayAgentSkillsAllowlist({
client,
agentId: "agent-1",
mode: "allowlist",
skillNames: ["slack", "github", "github"],
});
expect(client.call).toHaveBeenCalledTimes(1);
expect(client.call).toHaveBeenCalledWith("config.get", {});
});
});
+7 -7
View File
@@ -102,10 +102,10 @@ describe("settingsRouteWorkflow", () => {
]);
});
it("changes from capabilities to skills without discard confirmation", () => {
it("changes from capabilities to automations without discard confirmation", () => {
expect(
planSettingsTabChangeCommands({
nextTab: "skills",
nextTab: "automations",
currentInspectSidebar: { agentId: "agent-1", tab: "capabilities" },
settingsRouteAgentId: "agent-1",
settingsRouteActive: true,
@@ -115,16 +115,16 @@ describe("settingsRouteWorkflow", () => {
).toEqual([
{
kind: "set-inspect-sidebar",
value: { agentId: "agent-1", tab: "skills" },
value: { agentId: "agent-1", tab: "automations" },
},
]);
});
it("changes from skills to system without discard confirmation", () => {
it("changes from automations to advanced without discard confirmation", () => {
expect(
planSettingsTabChangeCommands({
nextTab: "system",
currentInspectSidebar: { agentId: "agent-1", tab: "skills" },
nextTab: "advanced",
currentInspectSidebar: { agentId: "agent-1", tab: "automations" },
settingsRouteAgentId: "agent-1",
settingsRouteActive: true,
personalityHasUnsavedChanges: true,
@@ -133,7 +133,7 @@ describe("settingsRouteWorkflow", () => {
).toEqual([
{
kind: "set-inspect-sidebar",
value: { agentId: "agent-1", tab: "system" },
value: { agentId: "agent-1", tab: "advanced" },
},
]);
});
-127
View File
@@ -1,127 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { installSkill, loadAgentSkillStatus, updateSkill } from "@/lib/skills/types";
describe("skills gateway client", () => {
it("loads skills status for the selected agent", async () => {
const report = {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
};
const client = {
call: vi.fn(async () => report),
} as unknown as GatewayClient;
const result = await loadAgentSkillStatus(client, " agent-1 ");
expect(client.call).toHaveBeenCalledWith("skills.status", { agentId: "agent-1" });
expect(result).toBe(report);
});
it("fails fast when agent id is empty", async () => {
const client = {
call: vi.fn(),
} as unknown as GatewayClient;
await expect(loadAgentSkillStatus(client, " ")).rejects.toThrow(
"Agent id is required to load skill status."
);
expect(client.call).not.toHaveBeenCalled();
});
it("installs skill dependencies with normalized params", async () => {
const response = {
ok: true,
message: "Installed",
stdout: "",
stderr: "",
code: 0,
};
const client = {
call: vi.fn(async () => response),
} as unknown as GatewayClient;
const result = await installSkill(client, {
name: " browser ",
installId: " install-browser ",
timeoutMs: 120_000,
});
expect(client.call).toHaveBeenCalledWith("skills.install", {
name: "browser",
installId: "install-browser",
timeoutMs: 120_000,
});
expect(result).toBe(response);
});
it("fails fast when install inputs are empty", async () => {
const client = {
call: vi.fn(),
} as unknown as GatewayClient;
await expect(installSkill(client, { name: " ", installId: "id" })).rejects.toThrow(
"Skill name is required to install dependencies."
);
await expect(installSkill(client, { name: "browser", installId: " " })).rejects.toThrow(
"Install option id is required to install dependencies."
);
expect(client.call).not.toHaveBeenCalled();
});
it("updates skill setup with normalized skill key", async () => {
const response = {
ok: true,
skillKey: "browser",
config: {},
};
const client = {
call: vi.fn(async () => response),
} as unknown as GatewayClient;
const result = await updateSkill(client, {
skillKey: " browser ",
apiKey: "secret-token",
});
expect(client.call).toHaveBeenCalledWith("skills.update", {
skillKey: "browser",
apiKey: "secret-token",
});
expect(result).toBe(response);
});
it("updates global enabled state through skills.update", async () => {
const response = {
ok: true,
skillKey: "browser",
config: {},
};
const client = {
call: vi.fn(async () => response),
} as unknown as GatewayClient;
await updateSkill(client, {
skillKey: " browser ",
enabled: false,
});
expect(client.call).toHaveBeenCalledWith("skills.update", {
skillKey: "browser",
enabled: false,
});
});
it("fails fast when skill key is empty for updates", async () => {
const client = {
call: vi.fn(),
} as unknown as GatewayClient;
await expect(updateSkill(client, { skillKey: " ", apiKey: "token" })).rejects.toThrow(
"Skill key is required to update skill setup."
);
expect(client.call).not.toHaveBeenCalled();
});
});
-274
View File
@@ -1,274 +0,0 @@
import { describe, expect, it } from "vitest";
import type { SkillStatusEntry } from "@/lib/skills/types";
import {
deriveAgentSkillDisplayState,
buildAgentSkillsAllowlistSet,
buildSkillMissingDetails,
buildSkillReasons,
canRemoveSkill,
deriveAgentSkillsAccessMode,
deriveSkillReadinessState,
filterOsCompatibleSkills,
groupSkillsBySource,
hasInstallableMissingBinary,
isBundledBlockedSkill,
isSkillOsIncompatible,
normalizeAgentSkillsAllowlist,
resolvePreferredInstallOption,
} from "@/lib/skills/presentation";
const createSkill = (overrides: Partial<SkillStatusEntry>): SkillStatusEntry => ({
name: "skill",
description: "",
source: "openclaw-workspace",
bundled: false,
filePath: "/tmp/workspace/skill/SKILL.md",
baseDir: "/tmp/workspace/skill",
skillKey: "skill",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [],
...overrides,
});
describe("skills presentation helpers", () => {
it("groups skills by source with stable ordering", () => {
const groups = groupSkillsBySource([
createSkill({ name: "other", source: "custom-source" }),
createSkill({ name: "installed", source: "openclaw-managed" }),
createSkill({ name: "workspace", source: "openclaw-workspace" }),
createSkill({ name: "bundled", source: "openclaw-bundled", bundled: true }),
createSkill({ name: "extra", source: "openclaw-extra" }),
]);
expect(groups.map((group) => group.id)).toEqual([
"workspace",
"built-in",
"installed",
"extra",
"other",
]);
expect(groups[0]?.skills.map((skill) => skill.name)).toEqual(["workspace"]);
expect(groups[1]?.skills.map((skill) => skill.name)).toEqual(["bundled"]);
});
it("builds explicit missing detail lines", () => {
const details = buildSkillMissingDetails(
createSkill({
eligible: false,
missing: {
bins: ["playwright"],
anyBins: ["chromium", "chrome"],
env: ["GITHUB_TOKEN"],
config: ["browser.enabled"],
os: ["linux"],
},
})
);
expect(details).toEqual([
"Missing tools: playwright",
"Missing one-of tools (install any): chromium | chrome",
"Missing env vars (set in gateway env): GITHUB_TOKEN",
"Missing config values (set in openclaw.json): browser.enabled",
"Requires OS: Linux",
]);
});
it("builds reasons from policy and missing requirements", () => {
const reasons = buildSkillReasons(
createSkill({
eligible: false,
disabled: true,
blockedByAllowlist: true,
missing: {
bins: ["playwright"],
anyBins: [],
env: [],
config: [],
os: [],
},
})
);
expect(reasons).toEqual(["disabled", "blocked by allowlist", "missing tools"]);
});
it("detects_os_incompatibility_from_missing_os_requirements", () => {
expect(
isSkillOsIncompatible(
createSkill({
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: ["darwin"],
},
})
)
).toBe(true);
expect(
isSkillOsIncompatible(
createSkill({
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [" "],
},
})
)
).toBe(false);
});
it("filters_out_os_incompatible_skills_while_preserving_order", () => {
const filtered = filterOsCompatibleSkills([
createSkill({ name: "github", missing: { bins: [], anyBins: [], env: [], config: [], os: [] } }),
createSkill({
name: "apple-notes",
missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] },
}),
createSkill({ name: "slack", missing: { bins: [], anyBins: [], env: [], config: [], os: [] } }),
]);
expect(filtered.map((skill) => skill.name)).toEqual(["github", "slack"]);
});
it("detects bundled blocked skills", () => {
expect(
isBundledBlockedSkill(
createSkill({
source: "openclaw-bundled",
bundled: true,
eligible: false,
})
)
).toBe(true);
expect(isBundledBlockedSkill(createSkill({ bundled: true, eligible: true }))).toBe(false);
});
it("detects installable missing binaries including anyBins overlap", () => {
const skill = createSkill({
eligible: false,
missing: {
bins: [],
anyBins: ["chromium", "chrome"],
env: [],
config: [],
os: [],
},
install: [
{
id: "install-chromium",
kind: "download",
label: "Install chromium",
bins: ["chromium"],
},
],
});
expect(hasInstallableMissingBinary(skill)).toBe(true);
expect(resolvePreferredInstallOption(skill)?.id).toBe("install-chromium");
});
it("selects_install_option_that_matches_missing_bins", () => {
const skill = createSkill({
eligible: false,
missing: {
bins: ["gh"],
anyBins: [],
env: [],
config: [],
os: [],
},
install: [
{
id: "install-other",
kind: "download",
label: "Install other tool",
bins: ["other"],
},
{
id: "install-gh",
kind: "brew",
label: "Install gh",
bins: ["gh"],
},
],
});
expect(resolvePreferredInstallOption(skill)?.id).toBe("install-gh");
});
it("marks only gateway-managed and workspace skill sources as removable", () => {
expect(canRemoveSkill(createSkill({ source: "openclaw-managed" }))).toBe(true);
expect(canRemoveSkill(createSkill({ source: "openclaw-workspace" }))).toBe(true);
expect(canRemoveSkill(createSkill({ source: "agents-skills-personal" }))).toBe(false);
expect(canRemoveSkill(createSkill({ source: "agents-skills-project" }))).toBe(false);
expect(canRemoveSkill(createSkill({ source: "openclaw-bundled", bundled: true }))).toBe(
false
);
expect(canRemoveSkill(createSkill({ source: "openclaw-extra" }))).toBe(false);
});
it("derives agent access mode from allowlist shape", () => {
expect(deriveAgentSkillsAccessMode(undefined)).toBe("all");
expect(deriveAgentSkillsAccessMode([])).toBe("none");
expect(deriveAgentSkillsAccessMode([" ", "github"])).toBe("selected");
});
it("normalizes allowlist values and creates a lookup set", () => {
expect(normalizeAgentSkillsAllowlist([" github ", "github", "slack", " "])).toEqual([
"github",
"slack",
]);
expect(buildAgentSkillsAllowlistSet([" github ", "slack"]).has("github")).toBe(true);
expect(buildAgentSkillsAllowlistSet([" github ", "slack"]).has("browser")).toBe(false);
});
it("classifies readiness with disabled and unavailable precedence", () => {
expect(deriveSkillReadinessState(createSkill({ disabled: true, eligible: false }))).toBe(
"disabled-globally"
);
expect(
deriveSkillReadinessState(
createSkill({
eligible: false,
missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] },
})
)
).toBe("unavailable");
expect(
deriveSkillReadinessState(
createSkill({
eligible: false,
blockedByAllowlist: true,
})
)
).toBe("unavailable");
expect(
deriveSkillReadinessState(
createSkill({
eligible: false,
missing: { bins: ["gh"], anyBins: [], env: [], config: [], os: [] },
})
)
).toBe("needs-setup");
expect(deriveSkillReadinessState(createSkill({ eligible: true }))).toBe("ready");
});
it("maps readiness into agent display states", () => {
expect(deriveAgentSkillDisplayState("ready")).toBe("ready");
expect(deriveAgentSkillDisplayState("needs-setup")).toBe("setup-required");
expect(deriveAgentSkillDisplayState("disabled-globally")).toBe("setup-required");
expect(deriveAgentSkillDisplayState("unavailable")).toBe("not-supported");
});
});
-72
View File
@@ -1,72 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { removeSkillFromGateway } from "@/lib/skills/remove";
describe("skills remove client", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("posts skill removal payload to the Studio API route", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
text: async () =>
JSON.stringify({
result: {
removed: true,
removedPath: "/tmp/workspace/skills/github",
source: "openclaw-workspace",
},
}),
}));
vi.stubGlobal("fetch", fetchMock);
const result = await removeSkillFromGateway({
skillKey: " github ",
source: "openclaw-workspace",
baseDir: " /tmp/workspace/skills/github ",
workspaceDir: " /tmp/workspace ",
managedSkillsDir: " /tmp/managed ",
});
expect(fetchMock).toHaveBeenCalledWith("/api/intents/skills-remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
skillKey: "github",
source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/github",
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/managed",
}),
});
expect(result).toEqual({
removed: true,
removedPath: "/tmp/workspace/skills/github",
source: "openclaw-workspace",
});
});
it("fails fast when required payload fields are missing", async () => {
await expect(
removeSkillFromGateway({
skillKey: " ",
source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/github",
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/managed",
})
).rejects.toThrow("skillKey is required.");
await expect(
removeSkillFromGateway({
skillKey: "github",
source: "openclaw-workspace",
baseDir: " ",
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/managed",
})
).rejects.toThrow("baseDir is required.");
});
});
-58
View File
@@ -1,58 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runSshJson } from "@/lib/ssh/gateway-host";
import { removeSkillOverSsh } from "@/lib/ssh/skills-remove";
vi.mock("@/lib/ssh/gateway-host", () => ({
runSshJson: vi.fn(),
}));
describe("skills remove ssh executor", () => {
const mockedRunSshJson = vi.mocked(runSshJson);
beforeEach(() => {
mockedRunSshJson.mockReset();
});
it("removes skill files via ssh", () => {
mockedRunSshJson.mockReturnValueOnce({
removed: true,
removedPath: "/home/ubuntu/.openclaw/skills/github",
source: "openclaw-managed",
});
const result = removeSkillOverSsh({
sshTarget: "me@host",
request: {
skillKey: "github",
source: "openclaw-managed",
baseDir: "/home/ubuntu/.openclaw/skills/github",
workspaceDir: "/home/ubuntu/.openclaw/workspace-main",
managedSkillsDir: "/home/ubuntu/.openclaw/skills",
},
});
expect(result).toEqual({
removed: true,
removedPath: "/home/ubuntu/.openclaw/skills/github",
source: "openclaw-managed",
});
expect(runSshJson).toHaveBeenCalledWith(
expect.objectContaining({
sshTarget: "me@host",
argv: [
"bash",
"-s",
"--",
"github",
"openclaw-managed",
"/home/ubuntu/.openclaw/skills/github",
"/home/ubuntu/.openclaw/workspace-main",
"/home/ubuntu/.openclaw/skills",
],
label: "remove skill (github)",
input: expect.stringContaining('python3 - "$1" "$2" "$3" "$4" "$5"'),
})
);
});
});
-85
View File
@@ -1,85 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { removeSkillLocally } from "@/lib/skills/remove-local";
const mkTmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-studio-skill-remove-"));
describe("skills remove local", () => {
it("removes a workspace skill directory", () => {
const workspaceDir = mkTmpDir();
const managedSkillsDir = mkTmpDir();
const skillDir = path.join(workspaceDir, "skills", "github");
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "# skill", "utf8");
const result = removeSkillLocally({
skillKey: "github",
source: "openclaw-workspace",
baseDir: skillDir,
workspaceDir,
managedSkillsDir,
});
expect(result).toEqual({
removed: true,
removedPath: skillDir,
source: "openclaw-workspace",
});
expect(fs.existsSync(skillDir)).toBe(false);
});
it("rejects removal outside the source root", () => {
const workspaceDir = mkTmpDir();
const managedSkillsDir = mkTmpDir();
const outsideDir = mkTmpDir();
expect(() =>
removeSkillLocally({
skillKey: "github",
source: "openclaw-workspace",
baseDir: outsideDir,
workspaceDir,
managedSkillsDir,
})
).toThrow("Refusing to remove skill outside allowed root");
});
it("refuses removing the root skills directory itself", () => {
const workspaceDir = mkTmpDir();
const managedSkillsDir = mkTmpDir();
const workspaceSkillsRoot = path.join(workspaceDir, "skills");
fs.mkdirSync(workspaceSkillsRoot, { recursive: true });
expect(() =>
removeSkillLocally({
skillKey: "github",
source: "openclaw-workspace",
baseDir: workspaceSkillsRoot,
workspaceDir,
managedSkillsDir,
})
).toThrow("Refusing to remove the skills root directory");
});
it("refuses removing directories that are not skills", () => {
const workspaceDir = mkTmpDir();
const managedSkillsDir = mkTmpDir();
const nonSkillDir = path.join(workspaceDir, "skills", "tmp");
fs.mkdirSync(nonSkillDir, { recursive: true });
expect(() =>
removeSkillLocally({
skillKey: "tmp",
source: "openclaw-workspace",
baseDir: nonSkillDir,
workspaceDir,
managedSkillsDir,
})
).toThrow("Refusing to remove non-skill directory");
});
});
-150
View File
@@ -1,150 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { POST } from "@/app/api/intents/skills-remove/route";
const ORIGINAL_ENV = { ...process.env };
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>(
"node:child_process"
);
return {
default: actual,
...actual,
spawnSync: vi.fn(),
};
});
const mockedSpawnSync = vi.mocked(spawnSync);
const writeStudioSettings = (gatewayUrl: string) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "studio-state-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
const settingsDir = path.join(stateDir, "openclaw-studio");
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(
path.join(settingsDir, "settings.json"),
JSON.stringify(
{
version: 1,
gateway: { url: gatewayUrl, token: "token-123" },
focused: {},
},
null,
2
),
"utf8"
);
};
describe("skills remove route", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.OPENCLAW_GATEWAY_SSH_TARGET;
delete process.env.OPENCLAW_GATEWAY_SSH_USER;
delete process.env.OPENCLAW_STATE_DIR;
mockedSpawnSync.mockReset();
});
it("rejects invalid payload", async () => {
const response = await POST(
new Request("http://localhost/api/intents/skills-remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
})
);
expect(response.status).toBe(400);
});
it("removes skills via ssh for remote gateways", async () => {
writeStudioSettings("ws://example.test:18789");
mockedSpawnSync.mockReturnValueOnce({
status: 0,
stdout: JSON.stringify({
removed: true,
removedPath: "/home/ubuntu/.openclaw/skills/github",
source: "openclaw-managed",
}),
stderr: "",
error: undefined,
} as never);
const response = await POST(
new Request("http://localhost/api/intents/skills-remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
skillKey: "github",
source: "openclaw-managed",
baseDir: "/home/ubuntu/.openclaw/skills/github",
workspaceDir: "/home/ubuntu/.openclaw/workspace-main",
managedSkillsDir: "/home/ubuntu/.openclaw/skills",
}),
})
);
expect(response.status).toBe(200);
expect(mockedSpawnSync).toHaveBeenCalledTimes(1);
const [cmd, args] = mockedSpawnSync.mock.calls[0] as [string, string[]];
expect(cmd).toBe("ssh");
expect(args).toEqual(
expect.arrayContaining([
"-o",
"BatchMode=yes",
"ubuntu@example.test",
"bash",
"-s",
"--",
"github",
"openclaw-managed",
])
);
});
it("removes local workspace skills without ssh", async () => {
writeStudioSettings("ws://localhost:18789");
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-"));
const managedSkillsDir = fs.mkdtempSync(path.join(os.tmpdir(), "managed-"));
const skillDir = path.join(workspaceDir, "skills", "github");
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "# skill", "utf8");
const response = await POST(
new Request("http://localhost/api/intents/skills-remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
skillKey: "github",
source: "openclaw-workspace",
baseDir: skillDir,
workspaceDir,
managedSkillsDir,
}),
})
);
expect(response.status).toBe(200);
expect(mockedSpawnSync).not.toHaveBeenCalled();
const body = (await response.json()) as {
result: { removed: boolean; removedPath: string; source: string };
};
expect(body.result).toEqual({
removed: true,
removedPath: skillDir,
source: "openclaw-workspace",
});
expect(fs.existsSync(skillDir)).toBe(false);
});
});
@@ -1,945 +0,0 @@
import { createElement, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, waitFor } from "@testing-library/react";
import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation";
import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder";
import type { CronRunResult } from "@/lib/cron/types";
import type { MutationBlockState } from "@/features/agents/operations/mutationLifecycleWorkflow";
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation";
import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation";
import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow";
import { createRuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport";
import { runCronJobNow, removeCronJob } from "@/lib/cron/types";
import { shouldAwaitDisconnectRestartForRemoteMutation } from "@/lib/gateway/gatewayReloadMode";
import {
readGatewayAgentSkillsAllowlist,
updateGatewayAgentSkillsAllowlist,
} from "@/lib/gateway/agentConfig";
import { removeSkillFromGateway } from "@/lib/skills/remove";
import { installSkill, loadAgentSkillStatus, updateSkill } from "@/lib/skills/types";
let restartBlockHookParams:
| {
block: MutationBlockState | null;
onTimeout: () => void;
onRestartComplete: (
block: MutationBlockState,
ctx: { isCancelled: () => boolean }
) => void | Promise<void>;
}
| null = null;
vi.mock("@/features/agents/operations/useGatewayRestartBlock", () => ({
useGatewayRestartBlock: (params: {
block: MutationBlockState | null;
onTimeout: () => void;
onRestartComplete: (
block: MutationBlockState,
ctx: { isCancelled: () => boolean }
) => void | Promise<void>;
}) => {
restartBlockHookParams = {
block: params.block,
onTimeout: params.onTimeout,
onRestartComplete: params.onRestartComplete,
};
},
}));
vi.mock("@/features/agents/operations/deleteAgentOperation", () => ({
deleteAgentViaStudio: vi.fn(),
}));
vi.mock("@/features/agents/operations/cronCreateOperation", () => ({
performCronCreateFlow: vi.fn(),
}));
vi.mock("@/features/agents/operations/agentPermissionsOperation", async () => {
const actual = await vi.importActual<
typeof import("@/features/agents/operations/agentPermissionsOperation")
>("@/features/agents/operations/agentPermissionsOperation");
return {
...actual,
updateAgentPermissionsViaStudio: vi.fn(),
};
});
vi.mock("@/features/agents/operations/mutationLifecycleWorkflow", async () => {
const actual = await vi.importActual<
typeof import("@/features/agents/operations/mutationLifecycleWorkflow")
>("@/features/agents/operations/mutationLifecycleWorkflow");
return {
...actual,
runAgentConfigMutationLifecycle: vi.fn(),
};
});
vi.mock("@/lib/cron/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/cron/types")>("@/lib/cron/types");
return {
...actual,
runCronJobNow: vi.fn(),
removeCronJob: vi.fn(),
listCronJobs: vi.fn(async () => ({ jobs: [] })),
};
});
vi.mock("@/lib/gateway/gatewayReloadMode", () => ({
shouldAwaitDisconnectRestartForRemoteMutation: vi.fn(async () => false),
}));
vi.mock("@/lib/gateway/agentConfig", async () => {
const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>(
"@/lib/gateway/agentConfig"
);
return {
...actual,
readGatewayAgentSkillsAllowlist: vi.fn(async () => undefined),
updateGatewayAgentSkillsAllowlist: vi.fn(async () => undefined),
};
});
vi.mock("@/lib/skills/types", () => ({
loadAgentSkillStatus: vi.fn(async () => ({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
})),
installSkill: vi.fn(async () => ({
ok: true,
message: "Installed",
stdout: "",
stderr: "",
code: 0,
})),
updateSkill: vi.fn(async () => ({
ok: true,
skillKey: "browser",
config: {},
})),
}));
vi.mock("@/lib/skills/remove", () => ({
removeSkillFromGateway: vi.fn(async () => ({
removed: true,
removedPath: "/tmp/workspace/skills/browser",
source: "openclaw-workspace",
})),
}));
type ControllerValue = ReturnType<typeof useAgentSettingsMutationController>;
const draft: AgentPermissionsDraft = {
commandMode: "ask",
webAccess: true,
fileTools: false,
};
const createCronDraft = (): CronCreateDraft => ({
templateId: "custom",
name: "Nightly sync",
taskText: "Sync project status.",
scheduleKind: "every",
everyAmount: 30,
everyUnit: "minutes",
deliveryMode: "announce",
deliveryChannel: "last",
});
const renderController = (overrides?: Partial<Parameters<typeof useAgentSettingsMutationController>[0]>) => {
const setError = vi.fn();
const clearInspectSidebar = vi.fn();
const setInspectSidebarCapabilities = vi.fn();
const dispatchUpdateAgent = vi.fn();
const setMobilePaneChat = vi.fn();
const loadAgents = vi.fn(async () => undefined);
const refreshGatewayConfigSnapshot = vi.fn(async () => null);
const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => {
await run();
});
const client = {
call: vi.fn(async () => ({})),
};
const runtimeWriteTransport = createRuntimeWriteTransport({
client: client as never,
useDomainIntents: overrides?.useDomainIntents ?? false,
});
const paramsBase: Omit<
Parameters<typeof useAgentSettingsMutationController>[0],
"runtimeWriteTransport" | "useDomainIntents"
> = {
client: client as never,
status: "connected",
isLocalGateway: false,
agents: [{ agentId: "agent-1", name: "Agent One", sessionKey: "session-1" }] as never,
hasCreateBlock: false,
enqueueConfigMutation,
gatewayConfigSnapshot: null,
settingsRouteActive: false,
inspectSidebarAgentId: null,
inspectSidebarTab: null,
loadAgents,
refreshGatewayConfigSnapshot,
clearInspectSidebar,
setInspectSidebarCapabilities,
dispatchUpdateAgent,
setMobilePaneChat,
setError,
...(overrides ?? {}),
};
const params: Parameters<typeof useAgentSettingsMutationController>[0] = {
...paramsBase,
runtimeWriteTransport,
useDomainIntents: overrides?.useDomainIntents ?? false,
};
const valueRef: { current: ControllerValue | null } = { current: null };
const Probe = ({ onValue }: { onValue: (next: ControllerValue) => void }) => {
const value = useAgentSettingsMutationController(params);
useEffect(() => {
onValue(value);
}, [onValue, value]);
return createElement("div", { "data-testid": "probe" }, "ok");
};
render(
createElement(Probe, {
onValue: (next) => {
valueRef.current = next;
},
})
);
return {
getValue: () => {
if (!valueRef.current) throw new Error("hook value unavailable");
return valueRef.current;
},
setError,
clearInspectSidebar,
setInspectSidebarCapabilities,
dispatchUpdateAgent,
setMobilePaneChat,
loadAgents,
refreshGatewayConfigSnapshot,
enqueueConfigMutation,
};
};
describe("useAgentSettingsMutationController", () => {
const mockedDeleteAgentViaStudio = vi.mocked(deleteAgentViaStudio);
const mockedPerformCronCreateFlow = vi.mocked(performCronCreateFlow);
const mockedRunCronJobNow = vi.mocked(runCronJobNow);
const mockedRemoveCronJob = vi.mocked(removeCronJob);
const mockedRunLifecycle = vi.mocked(runAgentConfigMutationLifecycle);
const mockedUpdateAgentPermissions = vi.mocked(updateAgentPermissionsViaStudio);
const mockedShouldAwaitRemoteRestart = vi.mocked(shouldAwaitDisconnectRestartForRemoteMutation);
const mockedReadGatewayAgentSkillsAllowlist = vi.mocked(readGatewayAgentSkillsAllowlist);
const mockedUpdateGatewayAgentSkillsAllowlist = vi.mocked(updateGatewayAgentSkillsAllowlist);
const mockedLoadAgentSkillStatus = vi.mocked(loadAgentSkillStatus);
const mockedInstallSkill = vi.mocked(installSkill);
const mockedRemoveSkillFromGateway = vi.mocked(removeSkillFromGateway);
const mockedUpdateSkill = vi.mocked(updateSkill);
beforeEach(() => {
restartBlockHookParams = null;
mockedDeleteAgentViaStudio.mockReset();
mockedPerformCronCreateFlow.mockReset();
mockedRunCronJobNow.mockReset();
mockedRemoveCronJob.mockReset();
mockedRunLifecycle.mockReset();
mockedUpdateAgentPermissions.mockReset();
mockedShouldAwaitRemoteRestart.mockReset();
mockedReadGatewayAgentSkillsAllowlist.mockReset();
mockedUpdateGatewayAgentSkillsAllowlist.mockReset();
mockedLoadAgentSkillStatus.mockReset();
mockedInstallSkill.mockReset();
mockedRemoveSkillFromGateway.mockReset();
mockedUpdateSkill.mockReset();
mockedShouldAwaitRemoteRestart.mockResolvedValue(false);
mockedReadGatewayAgentSkillsAllowlist.mockResolvedValue(undefined);
mockedUpdateGatewayAgentSkillsAllowlist.mockResolvedValue(undefined);
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
});
mockedInstallSkill.mockResolvedValue({
ok: true,
message: "Installed",
stdout: "",
stderr: "",
code: 0,
});
mockedRemoveSkillFromGateway.mockResolvedValue({
removed: true,
removedPath: "/tmp/workspace/skills/browser",
source: "openclaw-workspace",
});
mockedUpdateSkill.mockResolvedValue({
ok: true,
skillKey: "browser",
config: {},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("delete_denied_by_guard_does_not_run_delete_side_effect", async () => {
const ctx = renderController({ status: "disconnected" });
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
});
it("domain_mode_delete_is_blocked_when_gateway_is_disconnected", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.setMutatingBlock();
await deps.executeMutation();
deps.clearBlock();
return true;
});
mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null });
const ctx = renderController({ status: "disconnected", useDomainIntents: true });
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(mockedRunLifecycle).not.toHaveBeenCalled();
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
});
it("delete_cancelled_by_confirmation_does_not_run_delete_side_effect", async () => {
vi.spyOn(window, "confirm").mockReturnValue(false);
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
});
it("reserved_main_delete_sets_error_and_skips_enqueue", async () => {
const ctx = renderController({
agents: [{ agentId: "main", name: "Main", sessionKey: "main-session" }] as never,
});
await act(async () => {
await ctx.getValue().handleDeleteAgent("main");
});
expect(ctx.setError).toHaveBeenCalledWith("The main agent cannot be deleted.");
expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled();
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
});
it("cron_delete_is_denied_while_run_busy_without_changing_error_state", async () => {
mockedRunCronJobNow.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { ok: true, ran: true } satisfies CronRunResult;
});
const ctx = renderController();
await act(async () => {
void ctx.getValue().handleRunCronJob("agent-1", "job-running");
});
await waitFor(() => {
expect(ctx.getValue().cronRunBusyJobId).toBe("job-running");
});
await act(async () => {
await ctx.getValue().handleDeleteCronJob("agent-1", "job-delete");
});
expect(mockedRemoveCronJob).not.toHaveBeenCalled();
expect(ctx.getValue().settingsCronError).toBeNull();
});
it("allowed_rename_and_delete_delegate_to_lifecycle_runner", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.setMutatingBlock();
await deps.executeMutation();
deps.clearBlock();
return true;
});
mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null });
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed");
});
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(mockedRunLifecycle).toHaveBeenCalledTimes(2);
expect(mockedDeleteAgentViaStudio).toHaveBeenCalledTimes(1);
});
it("permissions_update_keeps_load_refresh_and_focus_side_effects", async () => {
mockedUpdateAgentPermissions.mockResolvedValue(undefined);
const callOrder: string[] = [];
const ctx = renderController({
loadAgents: vi.fn(async () => {
callOrder.push("loadAgents");
}),
refreshGatewayConfigSnapshot: vi.fn(async () => {
callOrder.push("refresh");
return null;
}),
});
await act(async () => {
await ctx.getValue().handleUpdateAgentPermissions("agent-1", draft);
});
expect(mockedUpdateAgentPermissions).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
sessionKey: "session-1",
draft,
})
);
expect(callOrder).toEqual(["loadAgents", "refresh"]);
expect(ctx.setInspectSidebarCapabilities).toHaveBeenCalledWith("agent-1");
expect(ctx.setMobilePaneChat).toHaveBeenCalled();
});
it("exposes_restart_block_state_and_timeout_completion_handlers", async () => {
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.patchBlockAwaitingRestart({ phase: "awaiting-restart", sawDisconnect: false });
return true;
});
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed");
});
await waitFor(() => {
expect(ctx.getValue().hasRenameMutationBlock).toBe(true);
expect(ctx.getValue().restartingMutationBlock?.phase).toBe("awaiting-restart");
expect(ctx.getValue().hasRestartBlockInProgress).toBe(true);
});
await waitFor(() => {
expect(restartBlockHookParams?.block).not.toBeNull();
});
await act(async () => {
restartBlockHookParams?.onTimeout();
});
expect(ctx.setError).toHaveBeenCalledWith("Gateway restart timed out after renaming the agent.");
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.patchBlockAwaitingRestart({ phase: "awaiting-restart", sawDisconnect: false });
return true;
});
await act(async () => {
await ctx.getValue().handleRenameAgent("agent-1", "Renamed Again");
});
await waitFor(() => {
expect(restartBlockHookParams?.block?.phase).toBe("awaiting-restart");
});
await act(async () => {
await restartBlockHookParams?.onRestartComplete(
restartBlockHookParams.block as MutationBlockState,
{ isCancelled: () => false }
);
});
await waitFor(() => {
expect(ctx.loadAgents).toHaveBeenCalled();
expect(ctx.setMobilePaneChat).toHaveBeenCalled();
});
await waitFor(() => {
expect(ctx.getValue().restartingMutationBlock).toBeNull();
});
});
it("create_cron_handler_delegates_to_create_operation", async () => {
mockedPerformCronCreateFlow.mockResolvedValue("created");
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleCreateCronJob("agent-1", createCronDraft());
});
expect(mockedPerformCronCreateFlow).toHaveBeenCalledTimes(1);
});
it("loads_skills_when_settings_skills_tab_is_active", async () => {
const report = {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
};
mockedLoadAgentSkillStatus.mockResolvedValue(report);
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledWith(expect.anything(), "agent-1");
expect(ctx.getValue().settingsSkillsReport).toEqual(report);
});
});
it("loads_skills_when_settings_system_tab_is_active", async () => {
const report = {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
};
mockedLoadAgentSkillStatus.mockResolvedValue(report);
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "system",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledWith(expect.anything(), "agent-1");
expect(ctx.getValue().settingsSkillsReport).toEqual(report);
});
});
it("use_all_and_disable_all_skills_write_via_config_queue", async () => {
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleUseAllSkills("agent-1");
await ctx.getValue().handleDisableAllSkills("agent-1");
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-agent-skills" })
);
expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ agentId: "agent-1", mode: "all" })
);
expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ agentId: "agent-1", mode: "none" })
);
expect(ctx.loadAgents).toHaveBeenCalledTimes(2);
expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(2);
expect(mockedLoadAgentSkillStatus).not.toHaveBeenCalled();
});
it("sets_selected_skills_allowlist_via_config_queue", async () => {
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleSetSkillsAllowlist("agent-1", [" github ", "slack", "github"]);
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-agent-skills" })
);
expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
mode: "allowlist",
skillNames: ["github", "slack"],
})
);
expect(ctx.loadAgents).toHaveBeenCalledTimes(1);
expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
});
it("rejects_empty_selected_skills_allowlist_before_gateway_call", async () => {
const ctx = renderController();
await act(async () => {
await ctx.getValue().handleSetSkillsAllowlist("agent-1", [" ", ""]);
});
expect(mockedUpdateGatewayAgentSkillsAllowlist).not.toHaveBeenCalled();
expect(ctx.getValue().settingsSkillsError).toBe(
"Cannot set selected skills mode: choose at least one skill."
);
});
it("installs_skill_dependencies_with_per_skill_busy_and_message_state", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
});
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
});
await act(async () => {
await ctx.getValue().handleInstallSkill("agent-1", "browser", "browser", "install-browser");
});
expect(mockedInstallSkill).toHaveBeenCalledWith(expect.anything(), {
name: "browser",
installId: "install-browser",
timeoutMs: 120000,
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-skill-setup" })
);
expect(ctx.getValue().settingsSkillsBusyKey).toBeNull();
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "success",
message: "Installed",
});
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(2);
});
it("refreshes_skills_after_system_setup_mutation_when_system_tab_is_active", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
});
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "system",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
});
await act(async () => {
await ctx.getValue().handleInstallSkill("agent-1", "browser", "browser", "install-browser");
});
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(2);
});
it("removes_skill_files_with_per_skill_busy_and_message_state", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [
{
name: "browser",
description: "",
source: "openclaw-workspace",
bundled: false,
filePath: "/tmp/workspace/skills/browser/SKILL.md",
baseDir: "/tmp/workspace/skills/browser",
skillKey: "browser",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [],
},
],
});
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(ctx.getValue().settingsSkillsReport?.workspaceDir).toBe("/tmp/workspace");
});
await act(async () => {
await ctx.getValue().handleRemoveSkill("agent-1", {
skillKey: "browser",
source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/browser",
});
});
expect(mockedRemoveSkillFromGateway).toHaveBeenCalledWith({
skillKey: "browser",
source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/browser",
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-skill-setup" })
);
expect(ctx.getValue().settingsSkillsBusyKey).toBeNull();
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "success",
message: "Skill removed from gateway files",
});
});
it("saves_skill_api_key_via_config_queue_and_refreshes_skills", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
});
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
});
await act(async () => {
ctx.getValue().handleSkillApiKeyDraftChange("browser", "token-123");
});
await act(async () => {
await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser");
});
expect(mockedUpdateSkill).toHaveBeenCalledWith(expect.anything(), {
skillKey: "browser",
apiKey: "token-123",
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-skill-setup" })
);
expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
expect(ctx.getValue().settingsSkillApiKeyDrafts.browser).toBe("token-123");
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "success",
message: "API key saved",
});
});
it("toggles_global_skill_enabled_via_skill_update", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
});
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
});
await act(async () => {
await ctx.getValue().handleSetSkillGlobalEnabled("agent-1", "browser", false);
});
expect(mockedUpdateSkill).toHaveBeenCalledWith(expect.anything(), {
skillKey: "browser",
enabled: false,
});
expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({ kind: "update-skill-setup" })
);
expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "success",
message: "Skill disabled globally",
});
});
it("preserves_api_key_draft_and_sets_error_message_when_save_fails", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
});
mockedUpdateSkill.mockRejectedValue(new Error("invalid key"));
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
});
await act(async () => {
ctx.getValue().handleSkillApiKeyDraftChange("browser", "token-123");
});
await act(async () => {
await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser");
});
expect(ctx.getValue().settingsSkillApiKeyDrafts.browser).toBe("token-123");
expect(ctx.getValue().settingsSkillsError).toBe("invalid key");
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "error",
message: "invalid key",
});
});
it("rejects_empty_api_key_before_gateway_call", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [],
});
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1);
});
await act(async () => {
ctx.getValue().handleSkillApiKeyDraftChange("browser", " ");
});
await act(async () => {
await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser");
});
expect(mockedUpdateSkill).not.toHaveBeenCalled();
expect(ctx.getValue().settingsSkillsError).toBe("API key cannot be empty.");
expect(ctx.getValue().settingsSkillMessages.browser).toEqual({
kind: "error",
message: "API key cannot be empty.",
});
});
it("disabling_one_skill_from_implicit_all_writes_explicit_allowlist", async () => {
mockedLoadAgentSkillStatus.mockResolvedValue({
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/skills",
skills: [
{
name: "github",
description: "",
source: "shared",
bundled: false,
filePath: "/tmp/skills/github/SKILL.md",
baseDir: "/tmp/skills/github",
skillKey: "github",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [],
},
{
name: "browser",
description: "",
source: "bundled",
bundled: true,
filePath: "/tmp/skills/browser/SKILL.md",
baseDir: "/tmp/skills/browser",
skillKey: "browser",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [],
},
{
name: "slack",
description: "",
source: "shared",
bundled: false,
filePath: "/tmp/skills/slack/SKILL.md",
baseDir: "/tmp/skills/slack",
skillKey: "slack",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [],
},
{
name: "apple-notes",
description: "",
source: "openclaw-managed",
bundled: false,
filePath: "/tmp/skills/apple-notes/SKILL.md",
baseDir: "/tmp/skills/apple-notes",
skillKey: "apple-notes",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: false,
requirements: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] },
missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] },
configChecks: [],
install: [],
},
],
});
const ctx = renderController({
settingsRouteActive: true,
inspectSidebarAgentId: "agent-1",
inspectSidebarTab: "skills",
});
await waitFor(() => {
expect(ctx.getValue().settingsSkillsReport?.skills.length).toBe(4);
});
await act(async () => {
await ctx.getValue().handleSetSkillEnabled("agent-1", "browser", false);
});
expect(mockedReadGatewayAgentSkillsAllowlist).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "agent-1" })
);
expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenLastCalledWith(
expect.objectContaining({
agentId: "agent-1",
mode: "allowlist",
skillNames: ["github", "slack"],
})
);
});
});
@@ -334,7 +334,7 @@ describe("useSettingsRouteController", () => {
});
});
it("switches to skills tab without discard prompt when leaving capabilities", () => {
it("switches to automations tab without discard prompt when leaving capabilities", () => {
const ctx = renderController({
settingsRouteActive: true,
settingsRouteAgentId: "agent-1",
@@ -344,33 +344,33 @@ describe("useSettingsRouteController", () => {
});
act(() => {
ctx.getValue().handleSettingsRouteTabChange("skills");
ctx.getValue().handleSettingsRouteTabChange("automations");
});
expect(ctx.confirmDiscard).not.toHaveBeenCalled();
expect(ctx.setInspectSidebar).toHaveBeenCalledWith({
agentId: "agent-1",
tab: "skills",
tab: "automations",
});
});
it("switches to system tab without discard prompt when leaving skills", () => {
it("switches to advanced tab without discard prompt when leaving automations", () => {
const ctx = renderController({
settingsRouteActive: true,
settingsRouteAgentId: "agent-1",
inspectSidebar: { agentId: "agent-1", tab: "skills" },
activeTab: "skills" satisfies SettingsRouteTab,
inspectSidebar: { agentId: "agent-1", tab: "automations" },
activeTab: "automations" satisfies SettingsRouteTab,
personalityHasUnsavedChanges: true,
});
act(() => {
ctx.getValue().handleSettingsRouteTabChange("system");
ctx.getValue().handleSettingsRouteTabChange("advanced");
});
expect(ctx.confirmDiscard).not.toHaveBeenCalled();
expect(ctx.setInspectSidebar).toHaveBeenCalledWith({
agentId: "agent-1",
tab: "system",
tab: "advanced",
});
});
});