fix(runner): select bundled Linux assets for remote OpenCode (#15721)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The native Runner can execute OpenCode in a remote sandbox.
> - Installed releases contain a Linux daemon and the identity of their
provider pack.
> - OpenCode did not select those assets by default. Cursor did.
> - The upload fallback also compared the verified Linux daemon with the
controller platform.
> - This pull request uses the packaged remote assets for OpenCode and
corrects that platform check.
> - Users of a matching Paperclip package and Daytona image can start
OpenCode without artifact path overrides.

## Linked Issues or Issue Description

Refs #15702. Refs #15422.

**What happened?**

Remote native OpenCode asked for
`PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` even when the installed
release contained the expected pack identity. The daemon upload fallback
could then reject a verified Linux daemon when the controller ran on
macOS.

**Expected behavior**

OpenCode should select the release's Linux daemon and provider-pack
identity. The controller must still verify the image assets before
launch.

**Steps to reproduce**

1. Run a complete installed Paperclip release on macOS.
2. Assign native OpenCode to a Daytona environment with the matching
Paperclip image.
3. Start a task without remote artifact path overrides.

## What Changed

- Select the bundled Linux daemon and provider-pack identity for remote
OpenCode.
- Skip the controller-platform comparison when the Linux daemon came
from the verified bundled selector.
- Add regression coverage for OpenCode and Cursor. Make the
missing-identity fixture independent of local build outputs.
- Document the matching package and image requirement, plus
source-development setup.

## Verification

- Red: both new regression cases failed before the fix.
- Green: the affected runtime suites passed all 686 tests on the rebased
head.
- `pnpm -r typecheck` and `pnpm build` passed before the rebase.
- Full local tests were attempted, then stopped after failures outside
this change. That run overlapped a rebase. The clean rerun of the AI
connection fixture passed all 111 tests. The affected runtime suites
also passed after rebase. All latest-head CI and security checks passed:
54 successful checks and two expected skips.
- A real OpenCode/OpenRouter DeepSeek task completed in an isolated
Daytona workspace on macOS. Durable bash receipts confirm `df -h` and
`uname -s; uname -m`. The result was posted, the task completed, and
sandbox cleanup succeeded.
- The live test used verified remote development assets from the
official image
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:506f22db7edd78f37c0c40bec1cc084af1850455026dbf467194bfbb8fcef141`.
No remote artifact path overrides were set. This was not a new complete
release assembly.
- The low-trust test policy kept output quarantine. The operator
permitted only this disposable test agent's OpenRouter credential
binding.

## Risks

- A second real external-origin retry executed successfully and saved
its answer, but its completion report used unsupported evidence
references. Its task stayed in progress. This PR fixes startup;
completion-evidence guidance requires a separate fix. External voice
playback was not verified.

- A plain Daytona sandbox image lacks the provider pack. It still needs
a verified full-pack override.
- Source-development launchers must provide the remote build assets.
This PR does not publish a new image or create a complete release
assembly.
- Explicit artifact overrides retain precedence. Manifest, executable,
package, bridge, and provider-asset checks remain in place.

## Model Used

OpenAI GPT-6 through Codex. The exact deployment model ID and context
window are not exposed in this session. Capabilities used: reasoning,
code editing, shell commands, and browser testing.

## 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 merging

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-09 14:30:49 -05:00
1 parent 7a58591721
commit be1bc5d494
3 files changed
+87 -9

No files matched your search

+12
View File
@@ -557,6 +557,18 @@ or dependency postinstall script is required. This also works when installation
scripts are disabled. The published server declares the same pinned dependency
as the source Runner package.
For native OpenCode and Cursor on Daytona, a complete installed Paperclip release
also selects its packaged Linux daemon and provider-pack identity automatically.
Use the matching Paperclip Daytona image: the controller verifies the image's
pack manifest, executable hashes, bridge tree, and pinned versions before launch.
The controller's operating system does not determine the remote daemon target.
A plain Daytona sandbox image does not contain this provider pack. Custom images
and source-development launchers must supply a verified Linux provider pack and
daemon through the existing remote artifact overrides; see
[the remote E2E setup](../tests/runner-e2e/README.md#match-the-local-controller-package-to-the-daytona-image).
An explicit override remains authoritative and fails closed if invalid.
## One-Command Local Run
For a first-time local install, you can bootstrap and run in one command:
@@ -47,7 +47,7 @@ import {
type NativeExecutionInput,
type PrpEvent,
} from "@paperclipai/paperclip-runner";
import { createHash, generateKeyPairSync } from "node:crypto";
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { nativeSha256 } from "./canonical.js";
import { agentDirectoryWorkingCopyService } from "../agent-directory-working-copies.js";
@@ -203,6 +203,8 @@ const state = vi.hoisted(() => ({
async (): Promise<Record<string, unknown> | null> => null,
),
assertCurrentWakeCommentsRead: vi.fn(async () => undefined),
bundledPackManifestPath: null as string | null,
bundledRunnerBinary: null as string | null,
resolveRunnerBinary: vi.fn(() => "/tmp/paperclip-runnerd"),
release: null as null | (() => void),
}));
@@ -231,6 +233,8 @@ vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => {
>();
return {
...original,
bundledRemoteProviderPackManifestPath: () => state.bundledPackManifestPath ?? original.bundledRemoteProviderPackManifestPath(),
bundledRemoteRunnerBinary: () => state.bundledRunnerBinary ?? original.bundledRemoteRunnerBinary(),
createNativeSessionBackend: state.createBackend,
createRunnerdCodexTransport: state.createTransport,
executeNativeSession: state.execute,
@@ -365,6 +369,8 @@ import {
} from "./native-session-executor.js";
beforeEach(() => {
state.bundledPackManifestPath = null;
state.bundledRunnerBinary = null;
state.createAssignedMcpTools.mockReset();
state.resolveRunnerBinary.mockReset().mockReturnValue("/tmp/paperclip-runnerd");
state.resolveCurrentWakeCommentsBinding.mockReset().mockResolvedValue(null);
@@ -1121,6 +1127,63 @@ describe("remote provider pack manifest", () => {
return JSON.stringify(value);
};
it.each(["opencode", "cursor"] as const)("uses the bundled Linux image authority for remote %s without controller overrides", async provider => {
const root = await mkdtemp(join(tmpdir(), "paperclip-bundled-image-"));
const digest = "sha256:" + "a".repeat(64);
const artifacts = Object.fromEntries(Object.entries({
grokLauncher: "dist/providers/grok/launcher.cjs", nodeCommand: "node_modules/node/bin/node",
productionLock: "pnpm-lock.yaml", opencodeCommand: "node_modules/.bin/opencode",
opencodeExecutable: "node_modules/opencode-ai/bin/opencode.exe",
opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs",
}).map(([key, path]) => [key, { path, sha256: digest }]));
const payload = {
target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "a".repeat(40),
pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion,
opencode: "1.18.34", acpx: "0.13.1", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion,
claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion },
acpxProfileDigests: Object.fromEntries(["grok", "claude", "codex"].map(agent =>
[agent, QUALIFIED_ACPX_PROFILES[agent as "grok" | "claude" | "codex"].commandDigest])),
distDigest: digest, artifacts,
bridgeDigest: "sha256:" + createHash("sha256").update(digest + "\n" + digest + "\n" + digest).digest("hex"),
};
const manifest = { schema: "paperclip-runner/remote-provider-pack/v1", payload,
digest: "sha256:" + createHash("sha256").update(canonical(payload)).digest("hex") };
state.bundledPackManifestPath = join(root, "provider-pack.json");
state.bundledRunnerBinary = join(root, "linux-paperclip-runnerd");
await writeFile(state.bundledPackManifestPath, JSON.stringify(manifest));
await writeFile(state.bundledRunnerBinary, "verified Linux daemon fixture");
const remoteExecution = { ...execution, provider: provider === "opencode"
? { kind: "opencode", model: null } : { kind: "acpx", agent: "cursor", model: null },
session: { ...execution.session, driverKind: provider === "opencode" ? "opencode_server" : "acpx_runtime" },
} as unknown as NativeExecutionInputV1;
const syncIn = vi.fn(async () => undefined);
const execute = vi.fn(async (command: { command: string; args?: string[] }) => {
if (command.args?.[0] === "--build-metadata") throw new Error("reached-staged-Linux-daemon-verification");
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
});
try {
await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution,
runnerInstanceId: "bundled-image-runner", runnerIngressAuthorized: true,
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
environmentId: "environment", leaseId: "lease", providerKey: "daytona",
effectiveCapabilities: { runnerWebSocketIngress: true }, runner: { execute, syncIn } } as never });
state.createTransport.mockClear();
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
};
expect(transport.runnerBinary).toBe(state.bundledRunnerBinary);
await expect(transport.controlPlaneRegistration({})).rejects.toThrow("reached-staged-Linux-daemon-verification");
expect(syncIn).toHaveBeenCalledWith([expect.objectContaining({ files: [expect.objectContaining({
sourcePath: state.bundledRunnerBinary, targetPath: "/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd",
})] })]);
expect(execute.mock.calls.some(([call]) => call.args?.[1] === "uname -s; uname -m")).toBe(false);
} finally {
state.bundledPackManifestPath = null; state.bundledRunnerBinary = null;
await rm(root, { recursive: true, force: true });
}
});
it("accepts a fully digested pack and rejects artifact tampering", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-provider-pack-"));
await mkdir(join(root, "dist", "cli"), { recursive: true });
@@ -12914,8 +12977,9 @@ describe("runnerd provider runtime wiring", () => {
["opencode", { kind: "opencode", model: null }, "opencode_server"],
["acpx", { kind: "acpx", agent: "codex", model: null }, "acpx_runtime"],
])(
"requires the build-owned provider pack before launching remote %s",
"requires a build-owned provider pack identity before launching remote %s",
async (providerKind, provider, driverKind) => {
state.bundledPackManifestPath = join(tmpdir(), randomUUID(), "provider-pack.json");
const remoteCwd = "/home/daytona/paperclip-workspace";
const remoteProviderExecution = {
...execution,
@@ -12953,8 +13017,9 @@ describe("runnerd provider runtime wiring", () => {
},
},
}),
).rejects.toThrow(
"runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH",
).rejects.toThrow(providerKind === "opencode"
? "runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable"
: "runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH",
);
expect(state.createBackend).not.toHaveBeenCalled();
},
@@ -11520,9 +11520,10 @@ async function createRunnerdBackendWithinSessionClaim(
const configuredProviderPackRoot =
input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || null;
let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
const useBundledCursorImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot &&
input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor";
if (useBundledCursorImageAssets) {
const useBundledRemoteImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot &&
(input.execution.provider.kind === "opencode" ||
(input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor"));
if (useBundledRemoteImageAssets) {
expectedProviderPackManifest = readBundledRemoteProviderPackManifest();
} else if (requiresRemoteProviderPack) {
if (
@@ -11551,7 +11552,7 @@ async function createRunnerdBackendWithinSessionClaim(
// When an explicit remote artifact is configured, prepareRemoteRunner stages
// these exact bytes at remoteBinary before launch.
const controllerRunnerBinary = remoteTarget
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
: resolvePaperclipRunnerBinary();
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
@@ -11930,7 +11931,7 @@ async function createRunnerdBackendWithinSessionClaim(
if (!existsSync(sourceBinary)) {
throw new Error("runner_remote_artifact_unavailable");
}
if (!explicitRemoteBinary && !remotePiCompanion) {
if (!explicitRemoteBinary && !remotePiCompanion && !useBundledRemoteImageAssets) {
const platform = await remoteCommandRunner.execute({
command: "sh",
args: ["-c", "uname -s; uname -m"],