diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 33c5666c70..d5bcfb0349 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -756,6 +756,15 @@ Server and CLI import the generated manifest; they do not crawl repository paths at request time. Root `skills/` remains reserved for Paperclip runtime skills and is not part of the catalog. +Skill-capable legacy local adapters always select the bundled +`paperclipai/paperclip/paperclip` operational skill when it is present in the +runtime inventory. This applies to existing agents without a stored skill +preference and to explicit empty optional-skill selections. The operational +skill supplies the control-plane workflow that those adapters need for +heartbeats. Other runtime skills remain controlled by +`paperclipSkillSync.desiredSkills`. The native `paperclip_runner` does not use +this legacy default because its protocol supplies the control-plane contract. + Validate the catalog without writing the manifest: ```sh diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 9ba7ea4dde..63c5fe4789 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -1182,6 +1182,26 @@ describe("shared ACPX engine runtime behavior", () => { expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName))).toBe(false); }); + it.skipIf(process.platform === "win32")("keeps the operational skill in an ACPX Codex home after an empty replacement", async () => { + const root = await makeTempRoot(); + const skillRoot = path.join(root, "skills"); + const codexHome = path.join(root, "codex-home"); + const operational = { + ...await createSkill(skillRoot, "paperclip"), + key: "paperclipai/paperclip/paperclip", + }; + + await runExecutor({ + agent: "codex", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: codexHome }, + paperclipRuntimeSkills: [operational], + paperclipSkillSync: { desiredSkills: [] }, + }); + + expect(await pathExists(path.join(codexHome, "skills", operational.runtimeName, "SKILL.md"))).toBe(true); + }); + it.skipIf(process.platform === "win32")("removes legacy ACPX Codex skill symlinks when a skill is no longer desired", async () => { const root = await makeTempRoot(); const skillRoot = path.join(root, "skills"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 4442e7f642..29261ea596 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -64,7 +64,7 @@ import { renderTemplate, resolvePaperclipInstanceRootForAdapter, selectPaperclipTaskMarkdown, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, rewriteWorkspaceCwdEnvVarsForExecution, shapePaperclipWorkspaceEnvForExecution, @@ -910,7 +910,7 @@ async function resolveSelectedRuntimeSkills( moduleDir: string, ): Promise<{ allSkills: PaperclipSkillEntry[]; selectedSkills: PaperclipSkillEntry[]; desiredSkillNames: string[] }> { const allSkills = await readPaperclipRuntimeSkillEntries(config, moduleDir); - const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkills); + const desiredSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, allSkills); const desiredSet = new Set(desiredSkillNames); return { allSkills, @@ -1852,7 +1852,7 @@ async function buildRuntime(input: { skillsIdentity = preparedSkills.identity; skillCommandNotes.push(...preparedSkills.commandNotes); } else { - const desired = resolvePaperclipDesiredSkillNames( + const desired = resolveLegacyPaperclipDesiredSkillNames( config, await readPaperclipRuntimeSkillEntries(config, input.engine.moduleDir), ); diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 3b497239e8..17063ef424 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -13,8 +13,11 @@ import { buildPaperclipEnv, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, materializePaperclipSkillCopy, + PAPERCLIP_OPERATIONAL_SKILL_KEY, refreshPaperclipWorkspaceEnvForExecution, renderPaperclipWakePrompt, + resolveLegacyPaperclipDesiredSkillNames, + resolvePaperclipDesiredSkillNames, selectPaperclipTaskMarkdown, runningProcesses, runChildProcess, @@ -28,6 +31,45 @@ import { WATCHDOG_DEFAULT_MANDATE, } from "./server-utils.js"; +describe("legacy adapter skill selection", () => { + const operationalEntry = { + key: PAPERCLIP_OPERATIONAL_SKILL_KEY, + runtimeName: "paperclip", + }; + const optionalEntry = { + key: "company/example/reviewer", + runtimeName: "reviewer", + }; + + it("keeps the operational skill selected without a stored preference", () => { + expect(resolveLegacyPaperclipDesiredSkillNames({}, [operationalEntry, optionalEntry])).toEqual([ + PAPERCLIP_OPERATIONAL_SKILL_KEY, + ]); + }); + + it("keeps the operational skill selected after an explicit empty replacement", () => { + expect(resolveLegacyPaperclipDesiredSkillNames( + { paperclipSkillSync: { desiredSkills: [] } }, + [operationalEntry, optionalEntry], + )).toEqual([PAPERCLIP_OPERATIONAL_SKILL_KEY]); + }); + + it("does not force optional skills or synthesize a missing operational entry", () => { + const config = { paperclipSkillSync: { desiredSkills: [optionalEntry.key] } }; + expect(resolveLegacyPaperclipDesiredSkillNames(config, [operationalEntry, optionalEntry])).toEqual([ + PAPERCLIP_OPERATIONAL_SKILL_KEY, + optionalEntry.key, + ]); + expect(resolveLegacyPaperclipDesiredSkillNames(config, [optionalEntry])).toEqual([ + optionalEntry.key, + ]); + }); + + it("leaves the configurable resolver available for native runners", () => { + expect(resolvePaperclipDesiredSkillNames({}, [operationalEntry])).toEqual([]); + }); +}); + function isPidAlive(pid: number) { try { process.kill(pid, 0); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 48d229fb47..d3e73b35ce 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -2989,6 +2989,33 @@ export function resolvePaperclipDesiredSkillNames( return Array.from(new Set(desiredSkills)); } +/** + * Legacy adapters call the Paperclip API through the operational skill. Keep + * that skill mounted even when an agent predates skill preferences or carries + * an explicit empty desired set. Native runners provide the same authority + * through their protocol and must continue to use the configurable-only + * resolver above. + */ +export const PAPERCLIP_OPERATIONAL_SKILL_KEY = "paperclipai/paperclip/paperclip"; + +export function resolveLegacyPaperclipDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; runtimeName?: string | null }>, +): string[] { + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const operationalEntry = availableEntries.find( + (entry) => entry.key.trim().toLowerCase() === PAPERCLIP_OPERATIONAL_SKILL_KEY, + ); + if (!operationalEntry) return desiredSkills; + + return [ + operationalEntry.key, + ...desiredSkills.filter( + (key) => key.trim().toLowerCase() !== PAPERCLIP_OPERATIONAL_SKILL_KEY, + ), + ]; +} + export function writePaperclipSkillSyncPreference( config: Record, desiredSkills: Array, diff --git a/packages/adapters/claude-local/src/server/skills.ts b/packages/adapters/claude-local/src/server/skills.ts index 75fb27ce33..724263605c 100644 --- a/packages/adapters/claude-local/src/server/skills.ts +++ b/packages/adapters/claude-local/src/server/skills.ts @@ -9,7 +9,7 @@ import { buildRuntimeMountedSkillSnapshot, readPaperclipRuntimeSkillEntries, readInstalledSkillTargets, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -30,7 +30,7 @@ function resolveClaudeSkillsHome(config: Record) { async function buildClaudeSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveClaudeSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildRuntimeMountedSkillSnapshot({ @@ -60,5 +60,5 @@ export function resolveClaudeDesiredSkillNames( config: Record, availableEntries: Array<{ key: string; required?: boolean }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index f2f0635ede..430a77bcc2 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -43,7 +43,6 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, renderTemplate, renderPaperclipWakePrompt, isPaperclipRecoveryWakePayload, diff --git a/packages/adapters/codex-local/src/server/skills.ts b/packages/adapters/codex-local/src/server/skills.ts index 6d52cb338b..bdf4879dc8 100644 --- a/packages/adapters/codex-local/src/server/skills.ts +++ b/packages/adapters/codex-local/src/server/skills.ts @@ -7,6 +7,7 @@ import type { import { buildRuntimeMountedSkillSnapshot, readPaperclipRuntimeSkillEntries, + resolveLegacyPaperclipDesiredSkillNames, resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; @@ -14,11 +15,14 @@ const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); async function buildCodexSkillSnapshot( config: Record, + adapterType: string, ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = adapterType === "paperclip_runner" + ? resolvePaperclipDesiredSkillNames(config, availableEntries) + : resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); return buildRuntimeMountedSkillSnapshot({ - adapterType: "codex_local", + adapterType, availableEntries, desiredSkills, configuredDetail: "Will be linked into the effective CODEX_HOME/skills/ directory on the next run.", @@ -26,19 +30,19 @@ async function buildCodexSkillSnapshot( } export async function listCodexSkills(ctx: AdapterSkillContext): Promise { - return buildCodexSkillSnapshot(ctx.config); + return buildCodexSkillSnapshot(ctx.config, ctx.adapterType); } export async function syncCodexSkills( ctx: AdapterSkillContext, _desiredSkills: string[], ): Promise { - return buildCodexSkillSnapshot(ctx.config); + return buildCodexSkillSnapshot(ctx.config, ctx.adapterType); } export function resolveCodexDesiredSkillNames( config: Record, availableEntries: Array<{ key: string; required?: boolean }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 30f23950e8..2a7edeebf0 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -39,7 +39,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, @@ -53,6 +53,7 @@ import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; import { prepareCursorSandboxCommand } from "./remote-command.js"; import { normalizeCursorStreamLine } from "../shared/stream.js"; import { hasCursorTrustBypassArg } from "../shared/trust.js"; +import { resolveCursorSkillsHome } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -117,16 +118,12 @@ function renderPaperclipEnvNote(env: Record): string { ].join("\n"); } -function cursorSkillsHome(): string { - return path.join(os.homedir(), ".cursor", "skills"); -} - async function buildCursorSkillsDir(config: Record): Promise { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-skills-")); const target = path.join(tmp, "skills"); await fs.mkdir(target, { recursive: true }); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries)); for (const entry of availableEntries) { if (!desiredNames.has(entry.key)) continue; if (isPaperclipSkillSourceMissing(entry)) continue; @@ -158,7 +155,7 @@ export async function ensureCursorSkillsInjected( : await readPaperclipRuntimeSkillEntries({}, __moduleDir)); if (skillsEntries.length === 0) return; - const skillsHome = options.skillsHome ?? cursorSkillsHome(); + const skillsHome = options.skillsHome ?? resolveCursorSkillsHome({}); try { await fs.mkdir(skillsHome, { recursive: true }); } catch (err) { @@ -233,12 +230,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise desiredCursorSkillNames.includes(entry.key) && !isPaperclipSkillSourceMissing(entry), ), + skillsHome: resolveCursorSkillsHome(config), }); } diff --git a/packages/adapters/cursor-local/src/server/skills.ts b/packages/adapters/cursor-local/src/server/skills.ts index 854fc133a6..a725df4378 100644 --- a/packages/adapters/cursor-local/src/server/skills.ts +++ b/packages/adapters/cursor-local/src/server/skills.ts @@ -11,7 +11,7 @@ import { ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, readInstalledSkillTargets, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -20,7 +20,7 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function resolveCursorSkillsHome(config: Record) { +export function resolveCursorSkillsHome(config: Record) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? (config.env as Record) @@ -32,7 +32,7 @@ function resolveCursorSkillsHome(config: Record) { async function buildCursorSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveCursorSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -57,7 +57,10 @@ export async function syncCursorSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolveCursorSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -84,5 +87,5 @@ export function resolveCursorDesiredSkillNames( config: Record, availableEntries: Array<{ key: string }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index ce01307b96..fb81512d79 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -41,7 +41,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, parseObject, renderTemplate, @@ -66,6 +66,7 @@ import { formatGeminiAcpFallbackMessage, resolveGeminiExecutionEngineForRun, } from "./acp.js"; +import { resolveGeminiSkillsHome } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const executeGeminiAcp = createGeminiAcpExecutor(); @@ -132,25 +133,21 @@ function renderApiAccessNote(env: Record): string { ].join("\n"); } -function geminiSkillsHome(): string { - return path.join(os.homedir(), ".gemini", "skills"); -} - /** - * Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks. + * Inject Paperclip skills directly into the effective Gemini skills home. * This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds - * both its auth credentials and the injected skills in the real home directory. + * both its auth credentials and the injected skills under the child HOME. */ async function ensureGeminiSkillsInjected( onLog: AdapterExecutionContext["onLog"], skillsEntries: Array<{ key: string; runtimeName: string; source: string }>, desiredSkillNames?: string[], + skillsHome = resolveGeminiSkillsHome({}), ): Promise { const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); if (selectedEntries.length === 0) return; - const skillsHome = geminiSkillsHome(); try { await fs.mkdir(skillsHome, { recursive: true }); } catch (err) { @@ -197,7 +194,7 @@ async function buildGeminiSkillsDir( const target = path.join(tmp, "skills"); await fs.mkdir(target, { recursive: true }); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries)); for (const entry of availableEntries) { if (!desiredNames.has(entry.key)) continue; if (isPaperclipSkillSourceMissing(entry)) continue; @@ -258,12 +255,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise !isPaperclipSkillSourceMissing(entry)), desiredGeminiSkillNames, + resolveGeminiSkillsHome(config), ); } diff --git a/packages/adapters/gemini-local/src/server/skills.ts b/packages/adapters/gemini-local/src/server/skills.ts index 246d6eb44b..ea2ead2298 100644 --- a/packages/adapters/gemini-local/src/server/skills.ts +++ b/packages/adapters/gemini-local/src/server/skills.ts @@ -11,7 +11,7 @@ import { ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, readInstalledSkillTargets, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -20,7 +20,7 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function resolveGeminiSkillsHome(config: Record) { +export function resolveGeminiSkillsHome(config: Record) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? (config.env as Record) @@ -32,7 +32,7 @@ function resolveGeminiSkillsHome(config: Record) { async function buildGeminiSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveGeminiSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -57,7 +57,10 @@ export async function syncGeminiSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolveGeminiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -84,5 +87,5 @@ export function resolveGeminiDesiredSkillNames( config: Record, availableEntries: Array<{ key: string }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts index 72ab25d6b0..ba2fef1a8b 100644 --- a/packages/adapters/grok-local/src/server/execute.ts +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -34,7 +34,7 @@ import { renderTemplate, renderPaperclipWakePrompt, isPaperclipRecoveryWakePayload, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, stringifyPaperclipWakePayload, refreshPaperclipWorkspaceEnvForExecution, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, @@ -233,7 +233,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise, ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); return buildRuntimeMountedSkillSnapshot({ adapterType: "grok_local", availableEntries, diff --git a/packages/adapters/hermes/src/index.test.ts b/packages/adapters/hermes/src/index.test.ts index 03e8809262..fec9fdf724 100644 --- a/packages/adapters/hermes/src/index.test.ts +++ b/packages/adapters/hermes/src/index.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { expect, test } from "vitest"; import { @@ -61,3 +64,93 @@ test("Hermes adapter exposes bundled Paperclip task bridge skill", async () => { expect(snapshot?.entries.some((entry) => entry.runtimeName === "paperclip-task-bridge")).toBe(true); }); + +test("Hermes keeps the operational Paperclip skill linked after an empty replacement", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-skill-")); + try { + const source = path.join(home, "runtime-skills", "paperclip"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8"); + const adapter = createServerAdapter(); + const snapshot = await adapter.syncSkills?.({ + adapterType: "hermes_local", + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + config: { + env: { HOME: home }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source, + }], + paperclipSkillSync: { desiredSkills: [] }, + }, + }, []); + + expect(snapshot?.desiredSkills).toContain("paperclipai/paperclip/paperclip"); + expect((await fs.lstat(path.join(home, ".hermes", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("Hermes rejects a conflicting operational skill target", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-conflict-")); + try { + const source = path.join(home, "runtime-skills", "paperclip"); + const target = path.join(home, ".hermes", "skills", "paperclip"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8"); + await fs.mkdir(target, { recursive: true }); + await fs.writeFile(path.join(target, "SKILL.md"), "# Conflicting skill\n", "utf8"); + const adapter = createServerAdapter(); + + await expect(adapter.syncSkills?.({ + adapterType: "hermes_local", + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + config: { + env: { HOME: home }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source, + }], + }, + }, [])).rejects.toThrow("occupied by another installation"); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("Hermes rejects a live symlink owned by another operational skill", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-link-conflict-")); + try { + const source = path.join(home, "runtime-skills", "paperclip"); + const conflictingSource = path.join(home, "external-skills", "paperclip"); + const target = path.join(home, ".hermes", "skills", "paperclip"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8"); + await fs.mkdir(conflictingSource, { recursive: true }); + await fs.writeFile(path.join(conflictingSource, "SKILL.md"), "# External skill\n", "utf8"); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.symlink(conflictingSource, target); + const adapter = createServerAdapter(); + + await expect(adapter.syncSkills?.({ + adapterType: "hermes_local", + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + config: { + env: { HOME: home }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source, + }], + }, + }, [])).rejects.toThrow("occupied by another installation"); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index 3b4c5d4bc4..98b6b518cf 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -52,6 +52,7 @@ import { detectModel, resolveProvider, } from "./detect-model.js"; +import { reconcileHermesPaperclipSkills } from "./skills.js"; // --------------------------------------------------------------------------- // Config helpers @@ -350,6 +351,25 @@ export async function execute( (ctx.runtime?.sessionParams as Record | null)?.sessionId, ); + // The server adds this runtime inventory at the run boundary. Requiring the + // marker avoids touching a developer's real Hermes home in direct unit or + // library calls that did not opt into Paperclip runtime skills. + if (Object.prototype.hasOwnProperty.call(config, "paperclipRuntimeSkills")) { + try { + const selectedSkills = await reconcileHermesPaperclipSkills(config); + if (selectedSkills.length > 0) { + await ctx.onLog( + "stdout", + `[hermes] Reconciled ${selectedSkills.length} Paperclip-managed skill(s) into the Hermes skills home.\n`, + ); + } + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await ctx.onLog("stderr", `[hermes] Cannot start without the required Paperclip-managed skills: ${reason}\n`); + throw err; + } + } + // ── Resolve provider (defense in depth) ──────────────────────────────── // Priority chain: // 1. Explicit provider in adapterConfig (user override) diff --git a/packages/adapters/hermes/src/server/skills.ts b/packages/adapters/hermes/src/server/skills.ts index 6a9ea3c0d7..44e9453c45 100644 --- a/packages/adapters/hermes/src/server/skills.ts +++ b/packages/adapters/hermes/src/server/skills.ts @@ -7,8 +7,11 @@ import type { AdapterSkillSnapshot, } from "@paperclipai/adapter-utils"; import { + ensurePaperclipSkillSymlink, + isPaperclipSkillSourceMissing, + readInstalledSkillTargets, readPaperclipRuntimeSkillEntries, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { fileURLToPath } from "node:url"; @@ -132,7 +135,7 @@ async function buildHermesSkillSnapshot(config: Record): Promis // 1. Scan Paperclip-managed skills (bundled with the adapter) const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, paperclipEntries); const desiredSet = new Set(desiredSkills); const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e])); @@ -209,12 +212,53 @@ export async function listHermesSkills( return buildHermesSkillSnapshot(ctx.config); } +export async function reconcileHermesPaperclipSkills( + config: Record, + requestedDesiredSkills?: string[], +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = requestedDesiredSkills + ? Array.from(new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...requestedDesiredSkills, + ])) + : resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const skillsHome = path.join(resolveHermesHome(config), ".hermes", "skills"); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); + + for (const entry of availableEntries) { + if (!desiredSet.has(entry.key) || isPaperclipSkillSourceMissing(entry)) continue; + const target = path.join(skillsHome, entry.runtimeName); + await ensurePaperclipSkillSymlink(entry.source, target); + const linkedSource = await fs.readlink(target).catch(() => null); + const resolvedSource = linkedSource + ? path.resolve(path.dirname(target), linkedSource) + : null; + if (resolvedSource !== path.resolve(entry.source)) { + throw new Error( + `Cannot reconcile Hermes skill "${entry.key}" because ${target} is occupied by another installation.`, + ); + } + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByRuntimeName.get(name); + if (!available || desiredSet.has(available.key)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return desiredSkills; +} + export async function syncHermesSkills( ctx: AdapterSkillContext, - _desiredSkills: string[], + desiredSkills: string[], ): Promise { - // Hermes manages its own skill loading — sync is a no-op. - // Return the current snapshot so the UI stays in sync. + await reconcileHermesPaperclipSkills(ctx.config, desiredSkills); return buildHermesSkillSnapshot(ctx.config); } @@ -222,5 +266,5 @@ export function resolveHermesDesiredSkillNames( config: Record, availableEntries: Array<{ key: string; runtimeName?: string | null }>, ): string[] { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/kimi-local/src/server/execute.ts b/packages/adapters/kimi-local/src/server/execute.ts index 4ec307bbca..5d16e37bde 100644 --- a/packages/adapters/kimi-local/src/server/execute.ts +++ b/packages/adapters/kimi-local/src/server/execute.ts @@ -34,7 +34,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, parseObject, renderTemplate, renderPaperclipWakePrompt, @@ -174,7 +174,7 @@ async function buildKimiSkillsDir( const target = path.join(tmp, "skills"); await fs.mkdir(target, { recursive: true }); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries)); for (const entry of availableEntries) { if (!desiredNames.has(entry.key)) continue; if (isPaperclipSkillSourceMissing(entry)) continue; @@ -232,7 +232,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise) { async function buildKimiSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveKimiSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -63,7 +63,10 @@ export async function syncKimiSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolveKimiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); diff --git a/packages/adapters/opencode-local/src/server/execute.test.ts b/packages/adapters/opencode-local/src/server/execute.test.ts index 136cd6ca8a..74ec4d560a 100644 --- a/packages/adapters/opencode-local/src/server/execute.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.test.ts @@ -1,15 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, runAdapterExecutionTargetProcess: vi.fn() }; }); -import { ensureRemoteOpenCodeModelConfiguredAndAvailable } from "./execute.js"; +import { ensureRemoteOpenCodeModelConfiguredAndAvailable, execute } from "./execute.js"; import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target"; const runProcessMock = vi.mocked(runAdapterExecutionTargetProcess); +async function createSkillDir(root: string, name: string): Promise { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `# ${name}\n`, "utf8"); + return skillDir; +} + function probeResult(overrides: Record) { return { exitCode: 0, @@ -23,6 +33,78 @@ function probeResult(overrides: Record) { } as never; } +describe("OpenCode local skill injection", () => { + it("injects runtime skills into the configured child HOME", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-configured-home-")); + const processHome = path.join(root, "process-home"); + const configuredHome = path.join(root, "configured-home"); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "opencode"); + const skillSource = await createSkillDir(path.join(root, "runtime-skills"), "paperclip"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(commandPath, 0o755); + + const previousHome = process.env.HOME; + process.env.HOME = processHome; + runProcessMock.mockReset(); + runProcessMock.mockResolvedValueOnce(probeResult({ + stdout: JSON.stringify({ + type: "text", + sessionID: "session-configured-home", + part: { text: "done" }, + }), + })); + + try { + const result = await execute({ + runId: "run-configured-home", + agent: { + id: "agent-1", + companyId: "company-1", + name: "OpenCode Coder", + adapterType: "opencode_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "openai/gpt-5", + env: { + HOME: configuredHome, + OPENCODE_ALLOW_ALL_MODELS: "1", + }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source: skillSource, + }], + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const installedSkill = path.join(configuredHome, ".claude", "skills", "paperclip"); + expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(installedSkill)).toBe(await fs.realpath(skillSource)); + await expect(fs.lstat(path.join(processHome, ".claude", "skills", "paperclip"))).rejects.toThrow(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); + describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => { afterEach(() => { delete process.env.OPENCODE_ALLOW_ALL_MODELS; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index df3467511b..555a1d6757 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -46,7 +46,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { @@ -58,6 +58,7 @@ import { import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; +import { resolveOpenCodeSkillsHome } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -165,16 +166,12 @@ export async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { } } -function claudeSkillsHome(): string { - return path.join(os.homedir(), ".claude", "skills"); -} - async function ensureOpenCodeSkillsInjected( onLog: AdapterExecutionContext["onLog"], skillsEntries: Array<{ key: string; runtimeName: string; source: string }>, desiredSkillNames?: string[], + skillsHome = resolveOpenCodeSkillsHome({}), ) { - const skillsHome = claudeSkillsHome(); await fs.mkdir(skillsHome, { recursive: true }); const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); @@ -212,7 +209,7 @@ async function buildOpenCodeSkillsDir(config: Record): Promise< const target = path.join(tmp, "skills"); await fs.mkdir(target, { recursive: true }); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries)); for (const entry of availableEntries) { if (!desiredNames.has(entry.key)) continue; if (isPaperclipSkillSourceMissing(entry)) continue; @@ -256,12 +253,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? value.trim() : null; } -function resolveOpenCodeSkillsHome(config: Record) { +export function resolveOpenCodeSkillsHome(config: Record) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? (config.env as Record) @@ -32,7 +32,7 @@ function resolveOpenCodeSkillsHome(config: Record) { async function buildOpenCodeSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveOpenCodeSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -61,7 +61,10 @@ export async function syncOpenCodeSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolveOpenCodeSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -88,5 +91,5 @@ export function resolveOpenCodeDesiredSkillNames( config: Record, availableEntries: Array<{ key: string }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 9d3e50a35b..b601982033 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -40,7 +40,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, @@ -127,7 +127,7 @@ async function buildPiSkillsDir(config: Record): Promise) { async function buildPiSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolvePiSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -57,7 +57,10 @@ export async function syncPiSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolvePiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -84,5 +87,5 @@ export function resolvePiDesiredSkillNames( config: Record, availableEntries: Array<{ key: string }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/server/src/__tests__/claude-local-skill-sync.test.ts b/server/src/__tests__/claude-local-skill-sync.test.ts index ce5b428470..eeec299135 100644 --- a/server/src/__tests__/claude-local-skill-sync.test.ts +++ b/server/src/__tests__/claude-local-skill-sync.test.ts @@ -28,7 +28,7 @@ describe("claude local skill sync", () => { cleanupDirs.clear(); }); - it("reports built-in Paperclip skills as available when no explicit selection exists", async () => { + it("keeps the operational Paperclip skill configured when no explicit selection exists", async () => { const snapshot = await listClaudeSkills({ agentId: "agent-1", companyId: "company-1", @@ -38,8 +38,9 @@ describe("claude local skill sync", () => { expect(snapshot.mode).toBe("ephemeral"); expect(snapshot.supported).toBe(true); - expect(snapshot.desiredSkills).toEqual([]); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available"); + expect(snapshot.desiredSkills).toEqual([paperclipKey]); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("available"); }); it("respects an explicit desired skill list without mutating a persistent home", async () => { diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index a907119970..841b7d6fa7 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -1322,7 +1322,7 @@ process.exit(1); await fs.rm(root, { recursive: true, force: true }); } }); - it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { + it("uses a worktree-isolated CODEX_HOME and mounts the operational skill by default", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); const commandPath = path.join(root, "codex"); @@ -1380,9 +1380,6 @@ process.exit(1); PAPERCLIP_TEST_CAPTURE_PATH: capturePath, }, promptTemplate: "Follow the paperclip heartbeat.", - paperclipSkillSync: { - desiredSkills: ["paperclip"], - }, }, context: {}, authToken: "run-jwt-token", diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts index 94862a0147..17750781b8 100644 --- a/server/src/__tests__/codex-local-skill-sync.test.ts +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("codex local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills for workspace injection on the next run", async () => { + it("defaults the operational Paperclip skill for workspace injection on the next run", async () => { const codexHome = await makeTempDir("paperclip-codex-skill-sync-"); cleanupDirs.add(codexHome); @@ -32,9 +32,6 @@ describe("codex local skill sync", () => { env: { CODEX_HOME: codexHome, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; @@ -45,6 +42,19 @@ describe("codex local skill sync", () => { expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/"); }); + it("does not apply the legacy operational skill default to the native runner", async () => { + const snapshot = await listCodexSkills({ + agentId: "agent-native", + companyId: "company-1", + adapterType: "paperclip_runner", + config: {}, + }); + + expect(snapshot.adapterType).toBe("paperclip_runner"); + expect(snapshot.desiredSkills).toEqual([]); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available"); + }); + it("does not persist Paperclip skills into CODEX_HOME during sync", async () => { const codexHome = await makeTempDir("paperclip-codex-skill-prune-"); cleanupDirs.add(codexHome); diff --git a/server/src/__tests__/cursor-local-execute.test.ts b/server/src/__tests__/cursor-local-execute.test.ts index e37f12b478..ac0c848061 100644 --- a/server/src/__tests__/cursor-local-execute.test.ts +++ b/server/src/__tests__/cursor-local-execute.test.ts @@ -252,6 +252,8 @@ describe("cursor execute", () => { it("injects company-library runtime skills into the Cursor skills home before execution", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-")); + const processHome = path.join(root, "process-home"); + const configuredHome = path.join(root, "configured-home"); const workspace = path.join(root, "workspace"); const commandPath = path.join(root, "agent"); const runtimeSkillsRoot = path.join(root, "runtime-skills"); @@ -262,7 +264,7 @@ describe("cursor execute", () => { const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart"); const previousHome = process.env.HOME; - process.env.HOME = root; + process.env.HOME = processHome; try { const result = await execute({ @@ -284,6 +286,7 @@ describe("cursor execute", () => { command: commandPath, cwd: workspace, model: "auto", + env: { HOME: configuredHome }, paperclipRuntimeSkills: [ { name: "paperclip", @@ -307,10 +310,12 @@ describe("cursor execute", () => { expect(result.exitCode).toBe(0); expect(result.errorMessage).toBeNull(); - expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true); - expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe( + const installedSkill = path.join(configuredHome, ".cursor", "skills", "ascii-heart"); + expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(installedSkill)).toBe( await fs.realpath(asciiHeartDir), ); + await expect(fs.lstat(path.join(processHome, ".cursor", "skills", "ascii-heart"))).rejects.toThrow(); } finally { if (previousHome === undefined) { delete process.env.HOME; diff --git a/server/src/__tests__/cursor-local-skill-sync.test.ts b/server/src/__tests__/cursor-local-skill-sync.test.ts index 3660f8b203..5f188fa022 100644 --- a/server/src/__tests__/cursor-local-skill-sync.test.ts +++ b/server/src/__tests__/cursor-local-skill-sync.test.ts @@ -27,7 +27,7 @@ describe("cursor local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the Cursor skills home", async () => { + it("defaults and installs the operational Paperclip skill in the Cursor skills home", async () => { const home = await makeTempDir("paperclip-cursor-skill-sync-"); cleanupDirs.add(home); @@ -39,9 +39,6 @@ describe("cursor local skill sync", () => { env: { HOME: home, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; @@ -55,6 +52,25 @@ describe("cursor local skill sync", () => { expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); + it("keeps the operational skill installed after an explicit empty replacement", async () => { + const home = await makeTempDir("paperclip-cursor-required-skill-"); + cleanupDirs.add(home); + + const snapshot = await syncCursorSkills({ + agentId: "agent-required", + companyId: "company-1", + adapterType: "cursor", + config: { + env: { HOME: home }, + paperclipSkillSync: { desiredSkills: [] }, + }, + }, []); + + expect(snapshot.desiredSkills).toEqual([paperclipKey]); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); + it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => { const home = await makeTempDir("paperclip-cursor-runtime-skills-home-"); const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-"); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 83924852a5..80fc5d8eb8 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -73,7 +73,72 @@ type CapturePayload = { paperclipEnvKeys: string[]; }; +async function createSkillDir(root: string, name: string): Promise { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `# ${name}\n`, "utf8"); + return skillDir; +} + describe("gemini execute", () => { + it("injects runtime skills into the configured child HOME", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-configured-home-")); + const processHome = path.join(root, "process-home"); + const configuredHome = path.join(root, "configured-home"); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "gemini"); + const skillSource = await createSkillDir(path.join(root, "runtime-skills"), "paperclip"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeGeminiCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = processHome; + + try { + const result = await execute({ + runId: "run-configured-home", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Coder", + adapterType: "gemini_local", + adapterConfig: { engine: "cli" }, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + engine: "cli", + command: commandPath, + cwd: workspace, + env: { HOME: configuredHome }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source: skillSource, + }], + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const installedSkill = path.join(configuredHome, ".gemini", "skills", "paperclip"); + expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(installedSkill)).toBe(await fs.realpath(skillSource)); + await expect(fs.lstat(path.join(processHome, ".gemini", "skills", "paperclip"))).rejects.toThrow(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("passes prompt via --prompt and injects paperclip env vars", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/gemini-local-skill-sync.test.ts b/server/src/__tests__/gemini-local-skill-sync.test.ts index 646ac45a16..c5a5ec2958 100644 --- a/server/src/__tests__/gemini-local-skill-sync.test.ts +++ b/server/src/__tests__/gemini-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("gemini local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the Gemini skills home", async () => { + it("defaults and installs the operational Paperclip skill in the Gemini skills home", async () => { const home = await makeTempDir("paperclip-gemini-skill-sync-"); cleanupDirs.add(home); @@ -32,9 +32,6 @@ describe("gemini local skill sync", () => { env: { HOME: home, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; diff --git a/server/src/__tests__/grok-local-skill-sync.test.ts b/server/src/__tests__/grok-local-skill-sync.test.ts index 64c0fe769d..8c2aa0e927 100644 --- a/server/src/__tests__/grok-local-skill-sync.test.ts +++ b/server/src/__tests__/grok-local-skill-sync.test.ts @@ -7,16 +7,12 @@ import { describe("grok local skill sync", () => { const paperclipKey = "paperclipai/paperclip/paperclip"; - it("reports Grok skills as ephemeral workspace-mounted state", async () => { + it("defaults the operational Paperclip skill as ephemeral workspace-mounted state", async () => { const snapshot = await listGrokSkills({ agentId: "agent-1", companyId: "company-1", adapterType: "grok_local", - config: { - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, - }, + config: {}, }); expect(snapshot.adapterType).toBe("grok_local"); diff --git a/server/src/__tests__/kimi-local-skill-sync.test.ts b/server/src/__tests__/kimi-local-skill-sync.test.ts index 7bb904252a..f88f144f74 100644 --- a/server/src/__tests__/kimi-local-skill-sync.test.ts +++ b/server/src/__tests__/kimi-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("kimi local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the Kimi skills home", async () => { + it("defaults and installs the operational Paperclip skill in the Kimi skills home", async () => { const kimiCodeHome = await makeTempDir("paperclip-kimi-skill-sync-"); cleanupDirs.add(kimiCodeHome); @@ -32,9 +32,6 @@ describe("kimi local skill sync", () => { env: { KIMI_CODE_HOME: kimiCodeHome, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; diff --git a/server/src/__tests__/opencode-local-skill-sync.test.ts b/server/src/__tests__/opencode-local-skill-sync.test.ts index 48d97523c9..c07c5940c5 100644 --- a/server/src/__tests__/opencode-local-skill-sync.test.ts +++ b/server/src/__tests__/opencode-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("opencode local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the shared Claude/OpenCode skills home", async () => { + it("defaults and installs the operational Paperclip skill in the shared Claude/OpenCode skills home", async () => { const home = await makeTempDir("paperclip-opencode-skill-sync-"); cleanupDirs.add(home); @@ -32,9 +32,6 @@ describe("opencode local skill sync", () => { env: { HOME: home, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; diff --git a/server/src/__tests__/pi-local-execute.test.ts b/server/src/__tests__/pi-local-execute.test.ts index f9fe2d3ff7..265a26800f 100644 --- a/server/src/__tests__/pi-local-execute.test.ts +++ b/server/src/__tests__/pi-local-execute.test.ts @@ -188,8 +188,8 @@ describe("pi_local execute", () => { cwd: workspace, model: "google/gemini-3-flash-preview", promptTemplate: "Keep working.", - // No explicit paperclipSkillSync preference → - // resolvePaperclipDesiredSkillNames returns [] → skill is not injected. + // The implicit legacy default applies only to the canonical Paperclip + // operational skill, so this unrelated skill remains unselected. paperclipRuntimeSkills: [ { key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir }, ], diff --git a/server/src/__tests__/pi-local-skill-sync.test.ts b/server/src/__tests__/pi-local-skill-sync.test.ts index a49b202e32..4019ae5de4 100644 --- a/server/src/__tests__/pi-local-skill-sync.test.ts +++ b/server/src/__tests__/pi-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("pi local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the Pi skills home", async () => { + it("defaults and installs the operational Paperclip skill in the Pi skills home", async () => { const home = await makeTempDir("paperclip-pi-skill-sync-"); cleanupDirs.add(home); @@ -32,9 +32,6 @@ describe("pi local skill sync", () => { env: { HOME: home, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index fbdc97b0ae..cb510ce4d3 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2227,8 +2227,9 @@ export function agentRoutes( // The default CEO instructions assume the core paperclip skills (board // coordination, planning, hiring, memory). Union them into every // skills-capable CEO hire/create so a fresh CEO never starts with an empty - // desired-skill set that contradicts its own instructions. Callers can still - // remove any of them afterwards via the per-agent skills sync. + // desired-skill set that contradicts its own instructions. Optional role + // skills remain removable afterwards. Legacy adapters separately guarantee + // the Paperclip operational skill as a runtime invariant. function defaultRoleSkillSelections( role: string | null | undefined, adapterType: string,