Manage orchestration skills per host (#3451)

* feat(skills): manage orchestration skills per host

Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once.

* fix(client): keep skill management internal

Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins.

* fix(e2e): declare skills fixture dependencies

Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite.

* test(skills): use native managed paths

Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
This commit is contained in:
Mohamed Boudra
2026-08-18 09:51:46 +02:00
committed by GitHub
parent eb3591cee8
commit 4108960709
71 changed files with 2225 additions and 2598 deletions
@@ -0,0 +1,215 @@
import { access } from "node:fs/promises";
import { expect, test as base } from "../support/fixtures";
import {
answerNextRemovalWarning,
cancelSkillSelection,
chooseAllSkills,
chooseCustomSkills,
expectInstalledSkills,
expectSavedSelection,
expectSkillFile,
openAgentSkillsSettings,
openSkillSelection,
saveSkillSelection,
startAgentSkillsSandbox,
toggleSkill,
type AgentSkillsSandbox,
} from "../support/helpers/agent-skills";
import { readVerticalGap, waitForSettledPosition } from "../support/helpers/sheet-layout";
const test = base.extend<{ skills: AgentSkillsSandbox }>({
// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring for fixture dependency discovery.
skills: async ({}, provide) => {
const sandbox = await startAgentSkillsSandbox();
try {
await provide(sandbox);
} finally {
await sandbox.close();
}
},
});
test.describe("Host agent skills", () => {
test.describe.configure({ timeout: 120_000 });
test("installs a custom selection, cancels a draft, then installs all skills", async ({
page,
skills,
}) => {
const custom = skills.available.slice(0, 2);
const cancelledAddition = skills.available[2]!;
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await chooseCustomSkills(page, custom);
await saveSkillSelection(page);
await expectInstalledSkills(skills, custom);
await expectSavedSelection(skills, { mode: "custom", skills: custom });
await openSkillSelection(page);
await toggleSkill(page, cancelledAddition);
await cancelSkillSelection(page);
await expectInstalledSkills(skills, custom);
await expectSavedSelection(skills, { mode: "custom", skills: custom });
await openSkillSelection(page);
await chooseAllSkills(page);
await saveSkillSelection(page);
await expectSavedSelection(skills, { mode: "all" });
await expectInstalledSkills(skills, skills.available);
});
test("dismisses and then accepts removal confirmation", async ({ page, skills }) => {
const retained = skills.available.slice(0, 2);
const removed = skills.available.slice(2);
await skills.install({ mode: "all" });
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await chooseCustomSkills(page, retained);
const dismissedWarning = answerNextRemovalWarning(page, "dismiss");
await saveSkillSelection(page);
const dismissedDialog = await dismissedWarning;
expect(dismissedDialog.message()).toContain("Remove deselected skills?");
for (const skill of removed) expect(dismissedDialog.message()).toContain(skill);
await expectInstalledSkills(skills, skills.available);
await expectSavedSelection(skills, { mode: "all" });
const acceptedWarning = answerNextRemovalWarning(page, "accept");
await saveSkillSelection(page);
expect((await acceptedWarning).message()).toContain(
"Anything you added inside those skill folders",
);
await expectInstalledSkills(skills, retained);
await expectSavedSelection(skills, { mode: "custom", skills: retained });
});
test("warns before removing a partial installation", async ({ page, skills }) => {
const partial = skills.available.at(-1)!;
const retained = skills.available.filter((name) => name !== partial);
await skills.install({ mode: "all" });
await skills.keepSkillOnlyIn(partial, "claude");
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await chooseCustomSkills(page, retained);
const warning = answerNextRemovalWarning(page, "dismiss");
await saveSkillSelection(page);
expect((await warning).message()).toContain(partial);
await expectSkillFile(skills, "claude", partial, "SKILL.md");
await expectSavedSelection(skills, { mode: "all" });
});
test("warns without deleting user files from an externally restored skill", async ({
page,
skills,
}) => {
const retained = skills.available.slice(0, 2);
const drift = skills.available[2]!;
await skills.install({ mode: "custom", skills: retained });
await skills.placeSkillOnDisk(drift, "claude", {
"SKILL.md": `# ${drift}\n`,
"notes/mine.md": "user notes",
});
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
const warning = answerNextRemovalWarning(page, "dismiss");
await saveSkillSelection(page);
expect((await warning).message()).toContain(drift);
await expectSkillFile(skills, "claude", drift, "notes/mine.md");
await expectSavedSelection(skills, { mode: "custom", skills: retained });
});
test("keeps the sheet open with an error when convergence fails", async ({ page, skills }) => {
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
await skills.blockAgentsDirectory();
await chooseCustomSkills(page, [skills.available[0]!]);
await saveSkillSelection(page);
await expect(
page.getByText("Could not save your skill selection.", { exact: true }),
).toBeVisible();
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
await skills.unblockAgentsDirectory();
await expectSavedSelection(skills, { mode: "all" });
});
test("serializes concurrent saves through the daemon command surface", async ({ skills }) => {
const firstSelection = { mode: "custom" as const, skills: [skills.available[0]!] };
const secondSelection = { mode: "custom" as const, skills: [skills.available[1]!] };
const first = skills.client.saveAgentSkillsSelection(firstSelection, skills.available);
const second = skills.client.saveAgentSkillsSelection(secondSelection, skills.available);
await expect(Promise.all([first, second])).resolves.toMatchObject([
{ confirmationRequired: null, selection: firstSelection },
{ confirmationRequired: null, selection: secondSelection },
]);
await expectInstalledSkills(skills, secondSelection.skills);
await expectSavedSelection(skills, secondSelection);
});
test("ignores agent config environment overrides", async ({ skills }) => {
await skills.install({ mode: "custom", skills: [skills.available[0]!] });
await expect(
access(`${skills.home}/../ignored-claude-config/skills`).then(
() => true,
() => false,
),
).resolves.toBe(false);
await expect(
access(`${skills.home}/../ignored-codex-home/skills`).then(
() => true,
() => false,
),
).resolves.toBe(false);
});
});
function allSkillsCard(page: Parameters<typeof openSkillSelection>[0]) {
return page.getByTestId("skill-selection-all-card");
}
function skillListCard(page: Parameters<typeof openSkillSelection>[0]) {
return page.getByTestId("skill-selection-list-card");
}
function bundledSkillsLabel(page: Parameters<typeof openSkillSelection>[0]) {
return page.getByText("Bundled skills", { exact: true });
}
test.describe("Agent skills sheet inset", () => {
test.describe.configure({ timeout: 120_000 });
test.describe("compact", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("keeps more space above the bundled skills label than below", async ({ page, skills }) => {
await openAgentSkillsSettings(page, skills, { compact: true });
await openSkillSelection(page);
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
test.describe("desktop", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test("keeps more space above the bundled skills label than below", async ({ page, skills }) => {
await openAgentSkillsSettings(page, skills);
await openSkillSelection(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
});
@@ -0,0 +1,244 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, type Dialog, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./app";
import { connectDaemonClient } from "./daemon-client-loader";
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./isolated-host-daemon";
import {
addDirectHostFromSettings,
goBackInSettings,
openCompactSettings,
openHostSection,
selectSettingsHost,
} from "./settings";
type AgentSkillsClient = Pick<
InternalDaemonClient,
"close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection"
>;
export type SkillTargetName = "agents" | "claude" | "codex";
export interface AgentSkillsSandbox {
available: string[];
client: AgentSkillsClient;
daemon: IsolatedHostDaemon;
home: string;
targets: Record<SkillTargetName, string>;
blockAgentsDirectory(): Promise<void>;
unblockAgentsDirectory(): Promise<void>;
close(): Promise<void>;
getStatus(): ReturnType<AgentSkillsClient["getAgentSkillsStatus"]>;
install(selection: AgentSkillSelection): Promise<void>;
keepSkillOnlyIn(skill: string, target: SkillTargetName): Promise<void>;
placeSkillOnDisk(
skill: string,
target: SkillTargetName,
files?: Record<string, string>,
): Promise<void>;
}
export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-skills-e2e-"));
const home = path.join(root, "home");
await mkdir(home, { recursive: true });
const daemon = await startIsolatedHostDaemon(`agent-skills-${randomUUID()}`, {
environment: {
HOME: home,
NODE_ENV: "development",
CLAUDE_CONFIG_DIR: path.join(root, "ignored-claude-config"),
CODEX_HOME: path.join(root, "ignored-codex-home"),
},
});
const client = await connectDaemonClient<AgentSkillsClient>({
clientIdPrefix: "app-e2e-agent-skills",
port: daemon.port,
});
const available = (await client.getAgentSkillsStatus()).available;
const targets = {
agents: path.join(home, ".agents", "skills"),
claude: path.join(home, ".claude", "skills"),
codex: path.join(home, ".codex", "skills"),
};
return {
available,
client,
daemon,
home,
targets,
blockAgentsDirectory: async () => {
await mkdir(path.dirname(targets.agents), { recursive: true });
await writeFile(targets.agents, "not a directory", "utf8");
},
unblockAgentsDirectory: async () => {
await rm(targets.agents, { force: true });
await mkdir(targets.agents, { recursive: true });
},
install: async (selection) => {
const result = await client.saveAgentSkillsSelection(selection, available);
if (result.confirmationRequired) {
throw new Error(`Unexpected removal confirmation: ${result.confirmationRequired.removals}`);
}
},
keepSkillOnlyIn: async (skill, target) => {
for (const [name, directory] of Object.entries(targets)) {
if (name === target) continue;
await rm(path.join(directory, skill), { recursive: true, force: true });
}
},
placeSkillOnDisk: async (skill, target, files = { "SKILL.md": `# ${skill}\n` }) => {
const directory = path.join(targets[target], skill);
for (const [relativePath, contents] of Object.entries(files)) {
const file = path.join(directory, relativePath);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents, "utf8");
}
},
close: async () => {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
},
getStatus: async () => {
try {
return await client.getAgentSkillsStatus();
} catch {
await client.connect();
return client.getAgentSkillsStatus();
}
},
};
}
export async function openAgentSkillsSettings(
page: Page,
sandbox: AgentSkillsSandbox,
options: { compact?: boolean } = {},
): Promise<void> {
await gotoAppShell(page);
if (options.compact) {
await openCompactSettings(page, buildOpenProjectRoute());
} else {
await openSettings(page);
}
await addDirectHostFromSettings(page, {
host: "127.0.0.1",
port: sandbox.daemon.port,
});
if (options.compact) await goBackInSettings(page);
await selectSettingsHost(page, sandbox.daemon.serverId);
await openHostSection(page, sandbox.daemon.serverId, "agents");
await expect(page.getByTestId("host-agent-skills-card")).toBeVisible();
}
export async function openSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Choose skills", exact: true }).click();
await expectSkillSelectionOpen(page);
}
export async function expectSkillSelectionOpen(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
}
export async function chooseCustomSkills(page: Page, selected: string[]): Promise<void> {
const all = page.getByRole("switch", { name: "All skills", exact: true });
await expect(all).toBeChecked();
await all.click();
const keep = new Set(selected);
for (const name of await listSkillChoices(page)) {
if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click();
}
await expectSkillChoices(page, selected);
}
export async function chooseAllSkills(page: Page): Promise<void> {
const all = page.getByRole("switch", { name: "All skills", exact: true });
await expect(all).not.toBeChecked();
await all.click();
await expect(all).toBeChecked();
}
export async function saveSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save", exact: true }).click();
}
export async function cancelSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
}
export function answerNextRemovalWarning(
page: Page,
answer: "accept" | "dismiss",
): Promise<Dialog> {
return new Promise((resolve) => {
page.once("dialog", (dialog) => {
resolve(dialog);
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
});
});
}
export async function expectInstalledSkills(
sandbox: AgentSkillsSandbox,
expected: string[],
): Promise<void> {
for (const directory of Object.values(sandbox.targets)) {
await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort());
}
}
export async function expectSavedSelection(
sandbox: AgentSkillsSandbox,
expected: AgentSkillSelection,
): Promise<void> {
await expect.poll(async () => (await sandbox.getStatus()).selection).toEqual(expected);
}
export async function expectSkillFile(
sandbox: AgentSkillsSandbox,
target: SkillTargetName,
skill: string,
relativePath: string,
): Promise<void> {
await expect
.poll(() => readFile(path.join(sandbox.targets[target], skill, relativePath), "utf8"))
.not.toBe("");
}
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
const expected = new Set(selected);
for (const name of await listSkillChoices(page)) {
await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({
checked: expected.has(name),
});
}
}
async function listSkillChoices(page: Page): Promise<string[]> {
return page
.getByRole("checkbox")
.evaluateAll((elements) =>
elements
.map((element) => element.getAttribute("aria-label") ?? "")
.filter((name) => name.length > 0),
);
}
async function readInstalledSkills(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}