mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
remove skills bs
This commit is contained in:
@@ -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
@@ -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[] }> => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
};
|
||||
@@ -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", {});
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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"'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user