From be1bc5d494d77f86e1eab1ca22631509d2c81693 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:30:49 -0500 Subject: [PATCH] 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 --- doc/DEVELOPING.md | 12 +++ .../native-session-executor.test.ts | 73 ++++++++++++++++++- .../native-runtime/native-session-executor.ts | 11 +-- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index e9d2c94b9fa..8eb659935dc 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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: diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 4280e1566c2..6ae27897a98 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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 | 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; + }; + 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(); }, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index b5e5f298359..0ce2526c65b 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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"],