fix(adapters): restore Paperclip skill for legacy runners (#12225)

<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Legacy local adapters run agents that use the Paperclip skill for
the control-plane workflow.
> - PR #7029 removed the required-skill fallback and made runtime skill
selection depend only on stored preferences.
> - No migration or runtime fallback replaced that behavior for existing
agents or non-CEO agents.
> - PR #12138 added core skills to new CEOs, and PR #12147 added Claude
skill discovery. These changes did not mount the operational skill for
all legacy agents.
> - This pull request makes the operational skill a legacy adapter
runtime invariant. It keeps all other skills configurable.
> - The native runner stays unchanged because its protocol supplies the
control-plane contract.
> - The benefit is that new and existing legacy agents can always
operate through Paperclip.

## Linked Issues or Issue Description

Refs #7029

Refs #12138

Refs #12147

**What happened?**

A skill-capable legacy local agent could start without
`paperclipai/paperclip/paperclip`. This happened when the agent had no
stored skill preference. An explicit empty preference also removed the
skill. The agent then reported that the Paperclip skill was not
available.

**Expected behavior**

Every skill-capable legacy local adapter must mount the Paperclip
operational skill when the runtime inventory contains it. Optional
skills must remain configurable. The native runner must keep its current
protocol-based behavior.

**Steps to reproduce**

1. Create a non-CEO `codex_local` agent without `paperclipSkillSync`
preferences.
2. Start a legacy heartbeat.
3. Inspect the managed `CODEX_HOME/skills` directory.
4. Observe that the Paperclip skill is absent before this change.

**Paperclip version or commit**

The problem reproduces on `master` before this pull request. PR #7029
introduced the configured-only selection behavior.

**Deployment mode**

Local development and self-hosted legacy local adapters.

## What Changed

- Added a shared legacy skill resolver that always selects the canonical
Paperclip operational skill when it is available.
- Applied the resolver to direct adapter execution, ACPX execution,
skill snapshots, and persistent skill sync.
- Added Hermes skill materialization at sync and run boundaries.
- Aligned Cursor, Gemini, and OpenCode execution-time injection with the
configured child `HOME`.
- Made Hermes stop execution when another installation blocks the
required operational skill.
- Kept optional skills controlled by `paperclipSkillSync.desiredSkills`.
- Kept `paperclip_runner` on the configurable-only resolver.
- Added regression coverage for missing preferences, empty preferences,
each skill-capable legacy adapter, ACPX, Hermes, and native runner
isolation.
- Documented the legacy runtime invariant.

## Verification

- `pnpm -r typecheck` passed on the pushed commit.
- `pnpm build` passed on the pushed commit.
- The adapter utility regression suites passed: 236 tests.
- The changed server adapter suites passed: 48 tests across 12 files.
- The OpenCode adapter suite passed: 8 tests.
- The Hermes adapter suite passed: 7 tests.
- `git diff --check` passed.
- `pnpm test:run` is not clean on this macOS host. The command reported
failures in unchanged workspace and filesystem suites. An isolated rerun
of `company-skills.test.ts` and `company-skills-service.test.ts`
reproduced 11 failures because macOS resolved `/var/...` paths as
`/private/var/...`. The changed adapter suites pass independently.

## Risks

- This change deliberately makes the operational skill non-removable for
skill-capable legacy local adapters.
- Existing agents receive the skill on their next list, sync, or run
boundary. No database migration is required.
- The resolver does not create a skill when the runtime inventory does
not contain the canonical entry.
- Hermes aborts a run if another installation occupies the required
operational skill target.
- Hermes removes only an undesired Paperclip-owned symlink that still
points to the known Paperclip source.
- The native runner does not receive the legacy default.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex based on GPT-5. The exact serving model ID and context
window were not exposed. The agent used reasoning, tool use, and code
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-26 09:45:24 -05:00 committed by GitHub
parent 6524d2b67f
commit a9d0927fe8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 549 additions and 121 deletions

View File

@ -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

View File

@ -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");

View File

@ -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),
);

View File

@ -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);

View File

@ -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<string, unknown>,
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<string, unknown>,
desiredSkills: Array<string | PaperclipDesiredSkillEntry>,

View File

@ -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<string, unknown>) {
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
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<string, unknown>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
}

View File

@ -43,7 +43,6 @@ import {
isPaperclipSkillSourceMissing,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
resolvePaperclipDesiredSkillNames,
renderTemplate,
renderPaperclipWakePrompt,
isPaperclipRecoveryWakePayload,

View File

@ -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<string, unknown>,
adapterType: string,
): Promise<AdapterSkillSnapshot> {
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<AdapterSkillSnapshot> {
return buildCodexSkillSnapshot(ctx.config);
return buildCodexSkillSnapshot(ctx.config, ctx.adapterType);
}
export async function syncCodexSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
return buildCodexSkillSnapshot(ctx.config);
return buildCodexSkillSnapshot(ctx.config, ctx.adapterType);
}
export function resolveCodexDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
}

View File

@ -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, string>): string {
].join("\n");
}
function cursorSkillsHome(): string {
return path.join(os.homedir(), ".cursor", "skills");
}
async function buildCursorSkillsDir(config: Record<string, unknown>): Promise<string> {
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<AdapterExec
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
const desiredCursorSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, cursorSkillEntries);
if (!executionTargetIsRemote) {
await ensureCursorSkillsInjected(onLog, {
skillsEntries: cursorSkillEntries.filter(
(entry) => desiredCursorSkillNames.includes(entry.key) && !isPaperclipSkillSourceMissing(entry),
),
skillsHome: resolveCursorSkillsHome(config),
});
}

View File

@ -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<string, unknown>) {
export function resolveCursorSkillsHome(config: Record<string, unknown>) {
const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? (config.env as Record<string, unknown>)
@ -32,7 +32,7 @@ function resolveCursorSkillsHome(config: Record<string, unknown>) {
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
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<AdapterSkillSnapshot> {
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<string, unknown>,
availableEntries: Array<{ key: string }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
}

View File

@ -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, string>): 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<void> {
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<AdapterExec
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
const desiredGeminiSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, geminiSkillEntries);
if (!executionTargetIsRemote) {
await ensureGeminiSkillsInjected(
onLog,
geminiSkillEntries.filter((entry) => !isPaperclipSkillSourceMissing(entry)),
desiredGeminiSkillNames,
resolveGeminiSkillsHome(config),
);
}

View File

@ -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<string, unknown>) {
export function resolveGeminiSkillsHome(config: Record<string, unknown>) {
const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? (config.env as Record<string, unknown>)
@ -32,7 +32,7 @@ function resolveGeminiSkillsHome(config: Record<string, unknown>) {
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
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<AdapterSkillSnapshot> {
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<string, unknown>,
availableEntries: Array<{ key: string }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
}

View File

@ -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<AdapterExec
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries);
const desiredGrokSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, grokSkillEntries);
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const stagedAssets = await stageGrokProjectAssets({
cwd,

View File

@ -7,7 +7,7 @@ import type {
import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
resolveLegacyPaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -16,7 +16,7 @@ async function buildGrokSkillSnapshot(
config: Record<string, unknown>,
): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
return buildRuntimeMountedSkillSnapshot({
adapterType: "grok_local",
availableEntries,

View File

@ -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 });
}
});

View File

@ -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<string, unknown> | 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)

View File

@ -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<string, unknown>): 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<string, unknown>,
requestedDesiredSkills?: string[],
): Promise<string[]> {
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<AdapterSkillSnapshot> {
// 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<string, unknown>,
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
): string[] {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
}

View File

@ -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<AdapterExec
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const kimiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredKimiSkillNames = resolvePaperclipDesiredSkillNames(config, kimiSkillEntries);
const desiredKimiSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, kimiSkillEntries);
const envConfig = parseObject(config.env);
const hasExplicitApiKey =

View File

@ -11,7 +11,7 @@ import {
ensurePaperclipSkillSymlink,
readPaperclipRuntimeSkillEntries,
readInstalledSkillTargets,
resolvePaperclipDesiredSkillNames,
resolveLegacyPaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -38,7 +38,7 @@ function resolveKimiSkillsHome(config: Record<string, unknown>) {
async function buildKimiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
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<AdapterSkillSnapshot> {
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);

View File

@ -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<string, unknown>;
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<string> {
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<string, unknown>) {
return {
exitCode: 0,
@ -23,6 +33,78 @@ function probeResult(overrides: Record<string, unknown>) {
} 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;

View File

@ -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<string, unknown>): 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<AdapterExec
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
const desiredOpenCodeSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, openCodeSkillEntries);
if (!executionTargetIsRemote) {
await ensureOpenCodeSkillsInjected(
onLog,
openCodeSkillEntries,
desiredOpenCodeSkillNames,
resolveOpenCodeSkillsHome(config),
);
}

View File

@ -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 resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
export function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? (config.env as Record<string, unknown>)
@ -32,7 +32,7 @@ function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
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<AdapterSkillSnapshot> {
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<string, unknown>,
availableEntries: Array<{ key: string }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
}

View File

@ -40,7 +40,7 @@ import {
isPaperclipSkillSourceMissing,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
resolvePaperclipDesiredSkillNames,
resolveLegacyPaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
renderPaperclipWakePrompt,
@ -127,7 +127,7 @@ async function buildPiSkillsDir(config: Record<string, unknown>): Promise<string
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;
@ -261,7 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
const piSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredPiSkillNames = resolvePaperclipDesiredSkillNames(config, piSkillEntries);
const desiredPiSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, piSkillEntries);
if (!executionTargetIsRemote) {
await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames);
}

View File

@ -11,7 +11,7 @@ import {
ensurePaperclipSkillSymlink,
readPaperclipRuntimeSkillEntries,
readInstalledSkillTargets,
resolvePaperclipDesiredSkillNames,
resolveLegacyPaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -32,7 +32,7 @@ function resolvePiSkillsHome(config: Record<string, unknown>) {
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
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<AdapterSkillSnapshot> {
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<string, unknown>,
availableEntries: Array<{ key: string }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
}

View File

@ -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 () => {

View File

@ -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",

View File

@ -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);

View File

@ -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;

View File

@ -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-");

View File

@ -73,7 +73,72 @@ type CapturePayload = {
paperclipEnvKeys: string[];
};
async function createSkillDir(root: string, name: string): Promise<string> {
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");

View File

@ -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;

View File

@ -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");

View File

@ -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;

View File

@ -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;

View File

@ -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 },
],

View File

@ -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;

View File

@ -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,