Files
PaperClipAI/server/src/__tests__/server-package-build-script.test.ts
T
DottaandPaperclip 6080daf262 fix(runner): start native OpenCode without postinstall (#15702)
## 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>
2026-10-09 11:46:15 -05:00

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"');
}
});
});