Files
PaperClipAI/packages/adapters/hermes/src/index.test.ts
T
DottaandPaperclip a9d0927fe8 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>
2026-08-26 09:45:24 -05:00

157 lines
6.3 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import {
createHermesGatewayServerAdapter,
createHermesLocalServerAdapter,
createServerAdapter,
hermesGatewayType,
} from "./index.js";
import { createServerAdapter as createGatewayServerAdapterFromSubpath } from "./gateway/index.js";
test("root package export exposes Paperclip external adapter entrypoint", () => {
const adapter = createServerAdapter();
expect(adapter.type).toBe("hermes_local");
expect(typeof adapter.execute).toBe("function");
expect(typeof adapter.testEnvironment).toBe("function");
expect(typeof adapter.sessionCodec?.deserialize).toBe("function");
expect(adapter.sessionManagement?.nativeContextManagement).toBe("confirmed");
expect(adapter.supportsLocalAgentJwt).toBe(true);
expect(adapter.supportsInstructionsBundle).toBe(true);
expect(adapter.instructionsPathKey).toBe("instructionsFilePath");
expect(adapter.getRuntimeCommandSpec?.({ command: "hermes-dev" })).toMatchObject({
command: "hermes-dev",
detectCommand: "hermes-dev",
installCommand: null,
});
expect(typeof adapter.detectModel).toBe("function");
expect(typeof adapter.getConfigSchema).toBe("function");
});
test("root package export keeps explicit local and gateway adapter factories", () => {
const localAdapter = createHermesLocalServerAdapter();
const gatewayAdapter = createHermesGatewayServerAdapter();
expect(localAdapter.type).toBe("hermes_local");
expect(gatewayAdapter.type).toBe("hermes_gateway");
expect(hermesGatewayType).toBe("hermes_gateway");
expect(gatewayAdapter.supportsLocalAgentJwt).toBe(false);
expect(gatewayAdapter.supportsInstructionsBundle).toBe(false);
});
test("gateway subpath export exposes the Hermes Gateway adapter entrypoint", () => {
const adapter = createGatewayServerAdapterFromSubpath();
expect(adapter.type).toBe("hermes_gateway");
expect(typeof adapter.execute).toBe("function");
expect(typeof adapter.testEnvironment).toBe("function");
expect(typeof adapter.sessionCodec?.deserialize).toBe("function");
expect(adapter.sessionManagement?.nativeContextManagement).toBe("confirmed");
expect(typeof adapter.getConfigSchema).toBe("function");
});
test("Hermes adapter exposes bundled Paperclip task bridge skill", async () => {
const adapter = createServerAdapter();
const snapshot = await adapter.listSkills?.({
adapterType: "hermes_local",
agentId: "11111111-1111-4111-8111-111111111111",
companyId: "22222222-2222-4222-8222-222222222222",
config: {},
});
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 });
}
});