Files
PaperClipAI/server/src/__tests__/pi-local-execute.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

211 lines
7.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execute } from "@paperclipai/adapter-pi-local/server";
async function writeFakePiCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
if (process.argv.includes("--list-models")) {
console.log("provider model");
console.log("google gemini-3-flash-preview");
process.exit(0);
}
console.log(JSON.stringify({ type: "agent_start" }));
console.log(JSON.stringify({ type: "turn_start" }));
console.log(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: "" }, toolResults: [] }));
console.log(JSON.stringify({ type: "agent_end", messages: [] }));
console.log(JSON.stringify({
type: "auto_retry_end",
success: false,
attempt: 3,
finalError: "Cloud Code Assist API error (429): RESOURCE_EXHAUSTED"
}));
process.exit(0);
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
async function writeEnvDumpPiCommand(commandPath: string, envDumpPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
if (process.argv.includes("--list-models")) {
console.log("provider model");
console.log("google gemini-3-flash-preview");
process.exit(0);
}
fs.writeFileSync(${JSON.stringify(envDumpPath)}, process.env.PATH || "");
console.log(JSON.stringify({ type: "agent_start" }));
console.log(JSON.stringify({ type: "turn_start" }));
console.log(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: "" }, toolResults: [] }));
console.log(JSON.stringify({ type: "agent_end", messages: [] }));
process.exit(0);
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
describe("pi_local execute", () => {
it("fails the run when Pi exhausts automatic retries despite exiting 0", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-execute-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "pi");
await fs.mkdir(workspace, { recursive: true });
await writeFakePiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-pi-quota-exhausted",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Pi Agent",
adapterType: "pi_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "google/gemini-3-flash-preview",
promptTemplate: "Keep working.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toContain("RESOURCE_EXHAUSTED");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("prepends installed skill bin/ dirs to the spawned Pi child PATH", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-path-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "pi");
const skillDir = path.join(root, "skills", "demo-skill");
const skillBinDir = path.join(skillDir, "bin");
const envDumpPath = path.join(root, "captured-path.txt");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(skillBinDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# demo-skill\n", "utf8");
await writeEnvDumpPiCommand(commandPath, envDumpPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
await execute({
runId: "run-pi-skill-path",
agent: {
id: "agent-skill-path",
companyId: "company-skill-path",
name: "Pi Agent",
adapterType: "pi_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "google/gemini-3-flash-preview",
promptTemplate: "Keep working.",
paperclipRuntimeSkills: [
{ key: "demo-skill", runtimeName: "demo-skill", source: skillDir },
],
paperclipSkillSync: {
desiredSkills: ["demo-skill"],
},
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
const capturedPath = await fs.readFile(envDumpPath, "utf8");
const entries = capturedPath.split(path.delimiter);
expect(entries[0]).toBe(skillBinDir);
expect(entries.filter((entry) => entry === skillBinDir)).toHaveLength(1);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not expose bin/ dirs from skills that are not injected", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-path-neg-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "pi");
const nonInjectedSkillDir = path.join(root, "skills", "not-injected");
const nonInjectedBinDir = path.join(nonInjectedSkillDir, "bin");
const envDumpPath = path.join(root, "captured-path.txt");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(nonInjectedBinDir, { recursive: true });
await fs.writeFile(path.join(nonInjectedSkillDir, "SKILL.md"), "# not-injected\n", "utf8");
await writeEnvDumpPiCommand(commandPath, envDumpPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
await execute({
runId: "run-pi-skill-path-neg",
agent: {
id: "agent-skill-path-neg",
companyId: "company-skill-path-neg",
name: "Pi Agent",
adapterType: "pi_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "google/gemini-3-flash-preview",
promptTemplate: "Keep working.",
// 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 },
],
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
const capturedPath = await fs.readFile(envDumpPath, "utf8");
expect(capturedPath.split(path.delimiter)).not.toContain(nonInjectedBinDir);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
});