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

@@ -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();
},