mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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:
1 parent
7a58591721
commit
be1bc5d494
3 files changed
+87
-9
No files matched your search
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user