mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 20:09:05 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner starts a qualified OpenCode runtime for native tasks. > - The default launch path uses a file populated by OpenCode's postinstall script. > - Package managers can skip that script and leave a text placeholder. Native task startup then fails with `spawn ENOEXEC` before the model request. > - This pull request resolves the declared platform package directly and includes the pinned runtime in the published server dependency graph. > - Users can run native OpenCode tasks after a normal install without global commands or manual dependency repair. ## Linked Issues or Issue Description **What happened?** Native OpenCode tasks fail at session startup when dependency postinstall is skipped. The Runner selects `opencode-ai/bin/opencode.exe`, which contains a text placeholder. The published server also lacks the OpenCode runtime dependency used by its vendored Runner. **Expected behavior** Paperclip starts the qualified platform binary installed by the package manager. macOS uses a Mac binary. No manual postinstall command is needed. **Steps to reproduce** 1. Install dependencies with lifecycle scripts disabled. 2. Configure a native OpenCode agent with an OpenRouter model and valid credentials. 3. Assign a task. The previous default fails before any model request. Related work: #15682 fixes HTTP transport registration. #15422 includes compatible OpenCode resolution in a broader agent setup change. This PR isolates the runtime repair for current users. ## What Changed - Resolve pinned OpenCode platform packages through the module dependency graph in source, standalone, and vendored server layouts. - Check package identity, version, platform, executable containment, and execute permission. Preserve verified executable launch profiles. - Use the resolver for new and resumed native sessions. Reject missing remote pack executables before controller dependency lookup. - Declare `opencode-ai@1.18.34` as a published server dependency. Leave platform selection to the consumer's package manager. - Check remote paths for the selected provider only. An unused OpenCode path must not reject a Pi run on macOS. - Add skipped-postinstall and invalid artifact regressions. Document automatic runtime selection. ## Verification - Regression tests fail with the previous default path and pass with platform resolution. - Source, standalone, and vendored layout fixtures cover Apple Silicon, Intel Mac, and Linux x64 baseline packages. - A fresh npm install with `--ignore-scripts` resolves the macOS executable and reports `1.18.34`. The placeholder remains unchanged. - Live browser test on Apple Silicon: a native OpenCode/OpenRouter/DeepSeek task ran shell arithmetic, created and uploaded a verified 23-byte file, and completed. A follow-up ran shell arithmetic and completed using the same session. Both runs committed successfully while the wrapper remained broken. - `pnpm -r typecheck` and `pnpm build` passed. All 269 affected Runner tests and 8 server packaging tests passed. `pnpm test:run` encountered 15 initial database-heavy failures; all 15 passed on an isolated group rerun. The redundant local run was then stopped after the matching server and workspace CI suites passed. All final-head checks passed (56 passed, 2 skipped); its unrelated chat-retry browser assertion passed on one rerun. An additional local repeat could not start its temporary server within the startup timeout. Greptile reviewed the final head at 5/5 with no open findings. ## Risks - Native runtime resolution requires the pinned optional platform package. Installations that omit optional dependencies fail with an explicit runtime error. - Supported native targets remain Apple Silicon, Intel Mac, and Linux x64. This change does not qualify another platform. - Published server installations now install the OpenCode dependency. Remote provider packs retain their verified executable paths and digests. - No schema changes or agent configuration migration. - Repository policy assigns `pnpm-lock.yaml` to CI. PR CI refreshes dependency resolution before frozen installs, and the master workflow updates the tracked lockfile. This PR leaves the tracked lockfile unchanged. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The session does not expose the exact GPT-6 variant or context window. - Live verification used OpenRouter `deepseek/deepseek-v4-flash-0731`; it did not author this patch. ## 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>
139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const packageJsonPath = fileURLToPath(
|
|
new URL("../../package.json", import.meta.url),
|
|
);
|
|
const runnerShimPath = fileURLToPath(
|
|
new URL("../vendor/paperclip-runner/index.ts", import.meta.url),
|
|
);
|
|
const evidenceClassifierPath = fileURLToPath(
|
|
new URL("../services/native-runtime/evidence-classifier.ts", import.meta.url),
|
|
);
|
|
const workspaceDiffReprojectionPath = fileURLToPath(
|
|
new URL("../services/provider-trace-workspace-diff-reprojection.ts", import.meta.url),
|
|
);
|
|
|
|
describe("server package build script", () => {
|
|
it("builds the compiled package entry during prepack", () => {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
scripts?: Record<string, string>;
|
|
};
|
|
|
|
expect(packageJson.scripts?.prepack).toBe(
|
|
"pnpm run prepare:ui-dist && pnpm run build",
|
|
);
|
|
});
|
|
|
|
it("copies static runtime asset directories into dist", () => {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
scripts?: Record<string, string>;
|
|
};
|
|
const buildScript = packageJson.scripts?.build ?? "";
|
|
|
|
expect(buildScript).toContain(
|
|
"mkdir -p dist/onboarding-assets dist/built-ins",
|
|
);
|
|
expect(buildScript).toContain(
|
|
"cp -R src/onboarding-assets/. dist/onboarding-assets/",
|
|
);
|
|
expect(buildScript).toContain("cp -R src/built-ins/. dist/built-ins/");
|
|
});
|
|
|
|
it("vendors the private runner runtime without a production workspace dependency", () => {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
scripts?: Record<string, string>;
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
};
|
|
|
|
expect(
|
|
packageJson.dependencies?.["@paperclipai/paperclip-runner"],
|
|
).toBeUndefined();
|
|
expect(packageJson.devDependencies?.["@paperclipai/paperclip-runner"]).toBe(
|
|
"workspace:*",
|
|
);
|
|
expect(packageJson.scripts?.["prepare:runner-vendor"]).toBe(
|
|
"pnpm --filter @paperclipai/paperclip-runner build",
|
|
);
|
|
expect(packageJson.scripts?.build).toContain(
|
|
"cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/",
|
|
);
|
|
});
|
|
|
|
it("replaces an existing read-only image manifest with the build's copy step", () => {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
const step = packageJson.scripts.build.split(" && ").find((command: string) =>
|
|
command.includes("../packages/paperclip-runner/dist/."));
|
|
expect(step).toBeDefined();
|
|
const [command, ...args] = step.split(" ");
|
|
const root = mkdtempSync(path.join(tmpdir(), "runner-vendor-copy-"));
|
|
try {
|
|
const relative = "remote-provider-packs/linux-x64/provider-pack.json";
|
|
const source = path.join(root, "packages/paperclip-runner/dist", relative);
|
|
const destination = path.join(root, "server/dist/vendor/paperclip-runner", relative);
|
|
mkdirSync(path.dirname(source), { recursive: true });
|
|
mkdirSync(path.dirname(destination), { recursive: true });
|
|
writeFileSync(source, "qualified image identity", { mode: 0o444 });
|
|
writeFileSync(destination, "previous image identity", { mode: 0o444 });
|
|
execFileSync(command, args, { cwd: path.join(root, "server") });
|
|
expect(readFileSync(destination, "utf8")).toBe("qualified image identity");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("verifies vendored runner dependencies are mirrored before building", () => {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
scripts?: Record<string, string>;
|
|
};
|
|
|
|
// See scripts/verify-runner-vendor-dependencies.mjs: packages/paperclip-runner
|
|
// is vendored with a raw copy of its compiled dist/, so every runtime
|
|
// dependency it imports must also be a direct dependency of server. This
|
|
// check derives that requirement from an esbuild scan of the vendored
|
|
// entry points instead of relying on a human to have kept a hand-copied
|
|
// list in sync (the smol-toml incident in #13110/#13116).
|
|
expect(packageJson.scripts?.build).toContain(
|
|
"node scripts/verify-runner-vendor-dependencies.mjs",
|
|
);
|
|
});
|
|
|
|
it("ships the pinned OpenCode dependency resolved by the vendored Runner", () => {
|
|
const server = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
const runner = JSON.parse(readFileSync(new URL(
|
|
"../../../packages/paperclip-runner/package.json", import.meta.url,
|
|
), "utf8"));
|
|
// createRequire resolution is dynamic and is not covered by the import scan.
|
|
expect(server.dependencies["opencode-ai"]).toBe("1.18.34");
|
|
expect(server.dependencies["opencode-ai"]).toBe(runner.dependencies["opencode-ai"]);
|
|
});
|
|
|
|
it("loads runner source when the source server starts before workspace builds", () => {
|
|
const shim = readFileSync(runnerShimPath, "utf8");
|
|
|
|
expect(shim).toContain(
|
|
'"../../../../packages/paperclip-runner/src/index.ts"',
|
|
);
|
|
expect(shim).not.toContain(
|
|
'export * from "@paperclipai/paperclip-runner"',
|
|
);
|
|
});
|
|
|
|
it("routes source-mode runtime imports through the runner shim", () => {
|
|
for (const consumerPath of [
|
|
evidenceClassifierPath,
|
|
workspaceDiffReprojectionPath,
|
|
]) {
|
|
const consumer = readFileSync(consumerPath, "utf8");
|
|
|
|
expect(consumer).toContain('vendor/paperclip-runner/index.js"');
|
|
expect(consumer).not.toContain('from "@paperclipai/paperclip-runner"');
|
|
}
|
|
});
|
|
});
|