From f96195a7a66043643f387903cdbdcfba0930f3d8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 00:09:04 -0500 Subject: [PATCH] feat(runner): import verified Linux companions for normal Pi execution Add explicit public CLI setup and full async source/profile/byte admission for an operator-pinned Linux companion. Preserve existing remote integrity checks and explicit overrides. Co-Authored-By: Paperclip --- cli/src/__tests__/runtime.test.ts | 19 +- cli/src/commands/runtime.ts | 29 +++- doc/architecture/runner-pi-capabilities.md | 70 ++++++++ scripts/create-runner-remote-companion.mjs | 14 ++ .../native-runtime/native-session-executor.ts | 15 +- .../remote-pi-companion.test.ts | 163 ++++++++++++++++++ .../native-runtime/remote-pi-companion.ts | 163 ++++++++++++++++++ 7 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 scripts/create-runner-remote-companion.mjs create mode 100644 server/src/services/native-runtime/remote-pi-companion.test.ts create mode 100644 server/src/services/native-runtime/remote-pi-companion.ts diff --git a/cli/src/__tests__/runtime.test.ts b/cli/src/__tests__/runtime.test.ts index 8371b2c2d8..3644cf8ac2 100644 --- a/cli/src/__tests__/runtime.test.ts +++ b/cli/src/__tests__/runtime.test.ts @@ -1,14 +1,14 @@ -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { Command } from "commander"; import { afterEach, expect, it } from "vitest"; -import { registerRuntimeCommands, resolvePiProvisioner } from "../commands/runtime.js"; +import { registerRuntimeCommands, resolvePiProvisioner, resolveRemoteCompanionImporter } from "../commands/runtime.js"; const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); async function fixture() { - const root = await mkdtemp(join(tmpdir(), "paperclip-cli-runtime-")); roots.push(root); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-cli-runtime-"))); roots.push(root); const cli = join(root, "dist/vendor/paperclip-runner/cli"); await mkdir(cli, { recursive: true }); await writeFile(join(root, "package.json"), '{"name":"@paperclipai/server"}'); await writeFile(join(root, "dist/index.js"), "throw new Error('server must not start during setup resolution')"); @@ -30,3 +30,16 @@ it("provides an explicit Pi-only operator command and rejects other providers be const program = new Command(); registerRuntimeCommands(program); await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported explicit runtime setup"); }); + +it("resolves the companion importer only inside the actual public server tar layout", async () => { + const f = await fixture(); const path = join(f.root, "dist/services/native-runtime/remote-pi-companion.js"); + await mkdir(join(f.root, "dist/services/native-runtime"), { recursive: true }); + await writeFile(path, "throw new Error('resolution must not execute importer')"); + expect(await resolveRemoteCompanionImporter(f.url)).toBe(path); + const other = await fixture(); await rm(path); await symlink(join(other.cli, "provision-pi.cjs"), path); + await expect(resolveRemoteCompanionImporter(f.url)).rejects.toThrow("escapes"); +}); +it("requires an explicit digest for the companion import command", async () => { + const program = new Command(); program.exitOverride().configureOutput({ writeErr() {} }); registerRuntimeCommands(program); + await expect(program.parseAsync(["node", "paperclipai", "runtime", "import-remote", "/unused"])).rejects.toThrow("sha256"); +}); diff --git a/cli/src/commands/runtime.ts b/cli/src/commands/runtime.ts index d4b48980ff..1595c97898 100644 --- a/cli/src/commands/runtime.ts +++ b/cli/src/commands/runtime.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { lstat, readFile, realpath } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import type { Command } from "commander"; /** Resolve the public server dependency, without importing/starting the server. */ @@ -19,6 +19,14 @@ export async function resolvePiProvisioner(serverUrl: string): Promise { return provisioner; } +export async function resolveRemoteCompanionImporter(serverUrl: string): Promise { + const provisioner = await resolvePiProvisioner(serverUrl); + const serverRoot = resolve(dirname(provisioner), "../../../.."); + const modulePath = join(serverRoot, "dist/services/native-runtime/remote-pi-companion.js"); + if (await realpath(modulePath) !== modulePath || !(await lstat(modulePath)).isFile()) throw new Error("Remote companion importer escapes its installed server"); + return modulePath; +} + export async function setupPiRuntime(): Promise { const provisioner = await resolvePiProvisioner(import.meta.resolve("@paperclipai/server")); // Only the explicit setup command can download pinned public dependencies. @@ -42,8 +50,23 @@ export async function setupPiRuntime(): Promise { } export function registerRuntimeCommands(program: Command): void { - program.command("runtime").description("Manage explicitly installed agent runtimes") - .command("setup ") + const runtime = program.command("runtime").description("Manage explicitly installed agent runtimes"); + runtime.command("import-remote ") + .description("Import a verified Linux Pi companion into the installed server (no downloads)") + .requiredOption("--sha256 ", "SHA256 of companion.json from the trusted release") + .action(async (directory: string, options: { sha256: string }) => { + const modulePath = await resolveRemoteCompanionImporter(import.meta.resolve("@paperclipai/server")); + const importer = await import(pathToFileURL(modulePath).href) as { importRemotePiCompanion(input: { directory: string; sha256: string; checkCancelled: () => void }): Promise }; + let cancelled = false; + const cancel = () => { cancelled = true; }; + process.on("SIGINT", cancel); process.on("SIGTERM", cancel); + try { + const result = await importer.importRemotePiCompanion({ directory, sha256: options.sha256, + checkCancelled: () => { if (cancelled) throw new Error("Remote companion import cancelled"); } }); + console.log(JSON.stringify(result)); + } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); } + }); + runtime.command("setup ") .description("Install and verify the pinned Pi runtime for this host (public downloads; no model calls)") .action(async (provider: string) => { if (provider !== "pi") throw new Error("Supported explicit runtime setup: paperclipai runtime setup pi"); diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index c4e730ec27..9ca44422b3 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -75,6 +75,76 @@ Pi's published CLI mode union is `text | json | rpc`; no native ACP mode is present in this release. The reviewed `pi-acp` wrapper remains necessary. +## Explicit Linux companion setup for a Mac controller + +A Mac controller needs independently verified Linux runner bytes for Daytona. +Its host Pi installation is not a Linux provider pack. The operator imports a +release companion once, before launching agents: + +```sh +paperclipai runtime import-remote /path/to/release/linux-x64 --sha256 MANIFEST_SHA256 +``` + +Obtain `MANIFEST_SHA256` from the trusted release channel, separately from the +copied directory. The directory contains `companion.json`, +`bin/paperclip-runnerd`, and the complete `provider-pack/`. Import verifies the +installed server's exact build commit, qualified Pi profile, Linux x64 ELF, +manifest digest, and every file, mode, directory and contained symbolic link. +External hardlinks, escaping links, extra or modified files, and unsafe modes +are rejected. This command performs no network requests or model calls and does +not run the Linux executable on the Mac. + +The destination is the private `remote-companions/linux-x64` directory beneath +the actual installed `@paperclipai/server` package. It must be writable by the +operator, outside the task workspace, and protected with the same host access +controls as the server installation. It is not an agent workspace or a remote +image's self-reported authority. An existing different or damaged installation +is never replaced automatically. Stop all runs before an operator removes or +upgrades that cache. Verification and copying use asynchronous 1 MiB chunks, +yield between chunks, and enforce the same full inventory and ten-minute bound. +Directory entries must retain owner read/write/search permissions so failed imports +can drain their owned cleanup. SIGINT/SIGTERM are deferred through owned filesystem +phases and drain cleanup; +an uncatchable kill or host failure leaves the import unadmitted until the +operator resolves its retained lock or incomplete directory. Do not move the +cache into a task workspace to work around permissions. + +Normal remote Pi resolves this authority without binary or pack environment +overrides. The controller re-verifies its complete inventory and uses the exact +Linux daemon identity. Existing remote verification compares image bytes against +that local authority; a mismatch takes the existing verified upload path. +Controller restart and warm-session recovery retain the original Runner artifact +identity checks. Cursor, Copilot and explicit operator overrides keep their +existing admission rules. + +For release maintainers, assemble the Linux daemon and default provider pack +for the **same final commit** as the public server/CLI packages. A reused daemon +requires complete native source/config/protocol input equality and retains its +original compiler/build provenance; the manifest does not claim recompilation. In a fresh +release directory place those outputs at `bin/paperclip-runnerd` and +`provider-pack/`, preserving the pack's relative links and modes. Then run the +source-owned manifest generator after building the matching server: + +```sh +node scripts/create-runner-remote-companion.mjs /path/to/release/linux-x64 FINAL_SOURCE_SHA +``` + +Publish the complete companion directory (or distribute it through the normal +trusted release channel) together with the printed manifest SHA256 and the +source/profile/platform provenance. The import command accepts an already +extracted directory, not an archive or URL. The release companion must remain +available with that release; a short-lived qualification artifact is not a +release distribution. No release publication is implied by the tests here. + +Build/publish the Daytona image from those same daemon/pack outputs and configure +its immutable OCI digest through the ordinary Daytona environment `image` field. +The image digest and companion manifest are output metadata, not new source +constants, so publication does not require another source commit. The final live +proof must use the installed public CLI, this import command, and ordinary image +configuration without E2E remote binary/provider-pack overrides. That installed +and paid proof remains pending. + + Historical v10 qualification checkpoint (2026-09-30): **Pi profile v10 remains unqualified.** The capped-key/login prerequisite remains blocked. Native USD is unknown. The optional private budget helper is not integrated; Cursor's account-cycle cap does not establish Pi's provider spending bound. V10 keeps the native wrapper and closures unchanged, binds the shared ACPX patch under a new digest, and rejects v9 sessions. Fresh exact-runtime admission and final controller verification remain pending. Historical v9 checkpoint (2026-09-30): **Pi profile v9 remains unqualified**. Current runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`; controller/Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`. Runtime builds for macOS ARM64/x64 and Linux x64 are complete. No paid profile-v9 pass is claimed. A credential with a verifiable spend limit is still needed for the remaining paid qualification. The optional transport-budget candidate is frozen on a separate branch and is not integrated or a live spending guarantee. See the [comparative capability report](runner-rich-acp-capabilities.md) for current qualification gates and the field audit. The dated observations below retain their original profile identities. diff --git a/scripts/create-runner-remote-companion.mjs b/scripts/create-runner-remote-companion.mjs new file mode 100644 index 0000000000..c3cb74e0a3 --- /dev/null +++ b/scripts/create-runner-remote-companion.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +/** Run after the final server build and Linux daemon/provider-pack assembly. */ +import { createHash } from 'node:crypto'; +import { writeFile, realpath } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const [directory, sourceRevision, ...extra] = process.argv.slice(2); +if (!directory || !/^[a-f0-9]{40}$/.test(sourceRevision ?? '') || extra.length) throw new Error('Usage: node scripts/create-runner-remote-companion.mjs DIRECTORY SOURCE_SHA'); +const root = await realpath(directory); +const { createRemotePiCompanionManifest } = await import(pathToFileURL(resolve('server/dist/services/native-runtime/remote-pi-companion.js')).href); +const manifest = await createRemotePiCompanionManifest(root, sourceRevision); +const bytes = JSON.stringify(manifest, null, 2) + '\n'; +await writeFile(resolve(root, 'companion.json'), bytes, { flag: 'wx', mode: 0o644 }); +console.log(JSON.stringify({ directory: root, sourceRevision, target: manifest.target, manifestSha256: createHash('sha256').update(bytes).digest('hex'), providerPackDigest: manifest.providerPackDigest, daemonSha256: manifest.daemonSha256 })); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index d4f70ab8c2..7db03feeee 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -21,6 +21,7 @@ import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js"; import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js"; +import { selectRemotePiCompanion } from "./remote-pi-companion.js"; import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js"; import { remoteLeaseCleanupScope } from "../remote-execution-termination.js"; import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js"; @@ -10979,8 +10980,16 @@ async function createRunnerdBackendWithinSessionClaim( remoteTarget !== null && (input.execution.provider.kind === "opencode" || input.execution.provider.kind === "acpx"); + // Pi's explicit operator import supplies the target-platform authority. Never + // substitute a Mac controller daemon or trust an image's self-reported hashes. + // Existing explicit overrides and every other provider keep their old path. + const remotePiCompanion = await selectRemotePiCompanion({ + provider: input.execution.provider, remote: remoteTarget !== null, + binaryOverride: input.runnerRemoteBinaryPath, packOverride: input.runnerRemoteProviderPackPath, + workspaceRoot: input.execution.workspace.cwd, + }); const configuredProviderPackRoot = - input.runnerRemoteProviderPackPath?.trim() || null; + input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || null; let expectedProviderPackManifest: RemoteProviderPackManifest | null = null; if (requiresRemoteProviderPack) { if ( @@ -11009,7 +11018,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() || resolvePaperclipRunnerBinary() + ? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || resolvePaperclipRunnerBinary() : resolvePaperclipRunnerBinary(); const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; @@ -11386,7 +11395,7 @@ async function createRunnerdBackendWithinSessionClaim( if (!existsSync(sourceBinary)) { throw new Error("runner_remote_artifact_unavailable"); } - if (!explicitRemoteBinary) { + if (!explicitRemoteBinary && !remotePiCompanion) { const platform = await remoteCommandRunner.execute({ command: "sh", args: ["-c", "uname -s; uname -m"], diff --git a/server/src/services/native-runtime/remote-pi-companion.test.ts b/server/src/services/native-runtime/remote-pi-companion.test.ts new file mode 100644 index 0000000000..c2cdef5efc --- /dev/null +++ b/server/src/services/native-runtime/remote-pi-companion.test.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; +import { chmodSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +const hooks = vi.hoisted(() => ({ mkdir: undefined as undefined | ((path: string) => void), rename: undefined as undefined | ((source: string, destination: string) => void), open: undefined as undefined | ((path: string) => void), read: undefined as undefined | ((path: string, bytes: number) => void) })); +vi.mock("node:fs/promises", async (original) => { + const fs = await original(); + return { ...fs, + mkdir: async (...args: Parameters) => { hooks.mkdir?.(String(args[0])); return fs.mkdir(...args); }, + rename: async (...args: Parameters) => { hooks.rename?.(String(args[0]), String(args[1])); return fs.rename(...args); }, + open: async (...args: Parameters) => { hooks.open?.(String(args[0])); const file = await fs.open(...args); const read = file.read.bind(file); file.read = (async (...readArgs: any[]) => { const result = await (read as any)(...readArgs); hooks.read?.(String(args[0]), result.bytesRead); return result; }) as typeof file.read; return file; }, + }; +}); +vi.mock("../../vendor/paperclip-runner/index.js", async () => await import("../../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js")); +import { QUALIFIED_ACPX_PROFILES } from "../../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js"; +import { createRemotePiCompanionManifest, importRemotePiCompanion, inventoryRemoteCompanion, resolveRemotePiCompanion, selectRemotePiCompanion } from "./remote-pi-companion.js"; +const roots: string[] = []; +afterEach(() => { hooks.mkdir = undefined; hooks.rename = undefined; hooks.open = undefined; hooks.read = undefined; for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); vi.restoreAllMocks(); }); +const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex"); +const canonical = (v: any): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}` : JSON.stringify(v); +async function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-companion-"))); roots.push(root); + const source = "a".repeat(40); const serverRoot = join(root, "server"); const directory = join(root, "release"); + mkdirSync(join(serverRoot, "dist"), { recursive: true }); writeFileSync(join(serverRoot, "package.json"), '{"name":"@paperclipai/server"}'); writeFileSync(join(serverRoot, "dist/build-info.json"), JSON.stringify({ commit: source })); + mkdirSync(join(directory, "bin"), { recursive: true }); mkdirSync(join(directory, "provider-pack")); + // Synthetic ELF header; these tests never launch it or claim native qualification. + const elf = Buffer.alloc(128); Buffer.from([127, 69, 76, 70, 2, 1]).copy(elf); elf.writeUInt16LE(62, 18); writeFileSync(join(directory, "bin/paperclip-runnerd"), elf, { mode: 0o755 }); + const payload = { runnerSourceRevision: source, target: { platform: "linux", architecture: "x64" }, candidateProviders: { pi: { qualification: "qualified", profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest, path: "provider-assets/pi/linux-x64" } } }; + writeFileSync(join(directory, "provider-pack/provider-pack.json"), JSON.stringify({ schema: "paperclip-runner/remote-provider-pack/v1", digest: `sha256:${hash(canonical(payload))}`, payload })); + const manifest = await createRemotePiCompanionManifest(directory, source); const bytes = JSON.stringify(manifest); writeFileSync(join(directory, "companion.json"), bytes); return { root, directory, serverRoot, sha256: hash(bytes), manifest, source }; +} +it("imports the release-generated closure and resolves target bytes without environment overrides", async () => { + const f = await fixture(); const result = await importRemotePiCompanion(f); expect(result.status).toBe("imported_verified"); + expect(result.runnerBinary).toBe(join(f.serverRoot, "remote-companions/linux-x64/bin/paperclip-runnerd")); + expect(readFileSync(result.runnerBinary!)).toEqual(readFileSync(join(f.directory, "bin/paperclip-runnerd"))); + expect((await importRemotePiCompanion(f)).status).toBe("verified_existing"); expect((await resolveRemotePiCompanion({ serverRoot: f.serverRoot }))?.manifestSha256).toBe(f.sha256); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); +it.each(["sha", "source", "profile", "target", "daemon", "extra", "mode", "outside-link", "outside-hardlink", "pack"])("rejects %s before publishing", async kind => { + const f = await fixture(); + if (kind === "sha") f.sha256 = "b".repeat(64); + if (kind === "source") writeFileSync(join(f.serverRoot, "dist/build-info.json"), JSON.stringify({ commit: "b".repeat(40) })); + if (kind === "profile" || kind === "target") { Object.assign(f.manifest, kind === "profile" ? { profileDigest: "sha256:" + "b".repeat(64) } : { target: "darwin-arm64" }); const bytes = JSON.stringify(f.manifest); writeFileSync(join(f.directory, "companion.json"), bytes); f.sha256 = hash(bytes); } + if (kind === "daemon") writeFileSync(join(f.directory, "bin/paperclip-runnerd"), "changed"); + if (kind === "extra") writeFileSync(join(f.directory, "extra"), "unlisted"); + if (kind === "mode") chmodSync(join(f.directory, "bin/paperclip-runnerd"), 0o777); + if (kind === "outside-link") symlinkSync("../../server/package.json", join(f.directory, "provider-pack/escape")); + if (kind === "outside-hardlink") linkSync(join(f.directory, "bin/paperclip-runnerd"), join(f.root, "alias")); + if (kind === "pack") writeFileSync(join(f.directory, "provider-pack/provider-pack.json"), "{}"); + await expect(importRemotePiCompanion(f)).rejects.toThrow(); expect(() => readdirSync(join(f.serverRoot, "remote-companions/linux-x64"))).toThrow(); +}); +it("copies contained symlinks and breaks only fully owned internal hardlinks", async () => { + const f = await fixture(); linkSync(join(f.directory, "bin/paperclip-runnerd"), join(f.directory, "bin/alias")); symlinkSync("paperclip-runnerd", join(f.directory, "bin/link")); + f.manifest = await createRemotePiCompanionManifest(f.directory, f.source); const bytes = JSON.stringify(f.manifest); writeFileSync(join(f.directory, "companion.json"), bytes); f.sha256 = hash(bytes); + expect((await importRemotePiCompanion(f)).status).toBe("imported_verified"); +}); +it("fails closed on cache corruption, foreign authority and workspace-contained cache", async () => { + const f = await fixture(); const result = await importRemotePiCompanion(f); + await expect(resolveRemotePiCompanion({ serverRoot: f.serverRoot, workspaceRoot: f.root })).rejects.toThrow("workspace"); + await expect(resolveRemotePiCompanion({ serverRoot: f.serverRoot, workspaceRoot: join(result.root!, "provider-pack") })).rejects.toThrow("workspace"); + writeFileSync(result.runnerBinary!, "corrupted"); await expect(resolveRemotePiCompanion({ serverRoot: f.serverRoot })).rejects.toThrow("inventory"); + await expect(importRemotePiCompanion(f)).rejects.toThrow(); +}); +it("preserves an existing empty destination and releases only its import lock", async () => { + const f = await fixture(); mkdirSync(join(f.serverRoot, "remote-companions/linux-x64"), { recursive: true, mode: 0o700 }); chmodSync(join(f.serverRoot, "remote-companions"), 0o700); + await expect(importRemotePiCompanion(f)).rejects.toThrow(); expect(readdirSync(join(f.serverRoot, "remote-companions/linux-x64"))).toEqual([]); expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); +it("rejects an unsafe cache parent and leaves it intact", async () => { + const f = await fixture(); const outside = join(f.root, "outside"); mkdirSync(outside); symlinkSync(outside, join(f.serverRoot, "remote-companions")); + await expect(importRemotePiCompanion(f)).rejects.toThrow(); expect(readdirSync(outside)).toEqual([]); +}); +it("reports missing installation without manufacturing a companion", async () => { const f = await fixture(); expect(await resolveRemotePiCompanion({ serverRoot: f.serverRoot })).toBeNull(); }); + +it("selects only normal remote Pi, preserving explicit overrides and C/C admission", async () => { + const f = await fixture(); await importRemotePiCompanion(f); const workspaceRoot = join(f.root, "workspace"); mkdirSync(workspaceRoot); + const input = { serverRoot: f.serverRoot, workspaceRoot, remote: true, provider: { kind: "acpx", agent: "pi" } }; + expect((await selectRemotePiCompanion(input))?.target).toBe("linux-x64"); + for (const patch of [{ remote: false }, { provider: { kind: "acpx", agent: "cursor" } }, { provider: { kind: "acpx", agent: "copilot" } }, { provider: { kind: "codex" } }, { binaryOverride: "/operator/runnerd" }, { packOverride: "/operator/pack" }]) expect(await selectRemotePiCompanion({ ...input, ...patch })).toBeNull(); +}); + +it("rejects an appeared empty destination without replacing it", async () => { + const f = await fixture(); const output = join(f.serverRoot, "remote-companions/linux-x64"); + hooks.mkdir = path => { if (path !== output) return; hooks.mkdir = undefined; mkdirSync(output, { mode: 0o700 }); }; + await expect(importRemotePiCompanion(f)).rejects.toThrow(); expect(readdirSync(output)).toEqual([]); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); +it("drains owned output, staging and lock cleanup after publication failure", async () => { + const f = await fixture(); hooks.rename = () => { hooks.rename = undefined; throw new Error("injected publication failure"); }; + await expect(importRemotePiCompanion(f)).rejects.toThrow("injected publication failure"); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual([]); +}); +it("preserves a replaced staging root while draining the other owned cleanup", async () => { + const f = await fixture(); let replaced = ""; + hooks.rename = source => { + hooks.rename = undefined; replaced = source.slice(0, source.lastIndexOf("/")); + renameSync(replaced, replaced + "-original"); mkdirSync(replaced, { mode: 0o700 }); writeFileSync(join(replaced, "foreign"), "preserve"); + throw new Error("replaced staging"); + }; + await expect(importRemotePiCompanion(f)).rejects.toThrow("cleanup incomplete"); expect(readFileSync(join(replaced, "foreign"), "utf8")).toBe("preserve"); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).not.toContain(".import-linux-x64.lock"); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).not.toContain("linux-x64"); +}); +it.each(["new", "existing"])("defers actual SIGTERM on the %s import path and drains owned cleanup", async (mode) => { + const f = await fixture(); if (mode === "existing") await importRemotePiCompanion(f); + const { build } = await import("esbuild"); + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const bundle = join(f.root, "companion.mjs"); + await build({ entryPoints: [new URL("./remote-pi-companion.ts", import.meta.url).pathname], outfile: bundle, + platform: "node", format: "esm", target: "node24", bundle: true, logLevel: "silent", + plugins: [{ name: "source-owned-profile-only", setup(builder) { + builder.onResolve({ filter: /vendor\/paperclip-runner\/index\.js$/ }, () => ({ path: new URL("../../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts", import.meta.url).pathname })); + } }], + }); + const script = join(f.root, "cancel.mjs"); + writeFileSync(script, `import {importRemotePiCompanion} from './companion.mjs'; +import {readdirSync,existsSync} from 'node:fs'; +let cancelled=false, checkpoints=0,sent=false; const cancel=()=>{cancelled=true}; process.on('SIGTERM',cancel); +try { await importRemotePiCompanion({...JSON.parse(process.argv[2]),checkCancelled(){ checkpoints++; if(!sent&&existsSync(process.argv[3]+'/.import-linux-x64.lock')){sent=true;process.kill(process.pid,'SIGTERM');} if(cancelled)throw Error('owned cancellation'); }}); throw Error('unexpected success'); } +catch(error){ if(error.message!=='owned cancellation')throw error; console.log(JSON.stringify({cancelled,checkpoints,remaining:readdirSync(process.argv[3])})); } +finally {process.off('SIGTERM',cancel);} +`); + const { stdout } = await promisify(execFile)(process.execPath, [script, JSON.stringify({ directory: f.directory, sha256: f.sha256, serverRoot: f.serverRoot }), join(f.serverRoot, "remote-companions")], { env: { PATH: "/usr/bin:/bin", LANG: "C.UTF-8" }, timeout: 10_000, maxBuffer: 65536 }); + expect(JSON.parse(stdout)).toMatchObject({ cancelled: true, remaining: mode === "existing" ? ["linux-x64"] : [] }); +}); + +it("honors deadline expiry after re-verifying an existing cache without deleting it", async () => { + const f = await fixture(); await importRemotePiCompanion(f); let expired = false; + hooks.open = path => { if(path === join(f.serverRoot, "remote-companions/linux-x64/companion.json")) expired = true; }; + vi.spyOn(Date, "now").mockImplementation(() => expired ? 600_001 : 0); + await expect(importRemotePiCompanion(f)).rejects.toThrow("deadline exceeded"); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); + +async function largeFixture() { + const f = await fixture(); writeFileSync(join(f.directory, "provider-pack/large"), Buffer.alloc(8 * 1024 ** 2, 0x61)); + f.manifest = await createRemotePiCompanionManifest(f.directory, f.source); + const bytes = JSON.stringify(f.manifest); writeFileSync(join(f.directory, "companion.json"), bytes); f.sha256 = hash(bytes); return f; +} +it("yields to unrelated event-loop work between chunks of a real cache file", async () => { + const f = await largeFixture(); await importRemotePiCompanion(f); + const file = join(f.serverRoot, "remote-companions/linux-x64/provider-pack/large"); + let reads = 0; let serviced = false; let servicedBeforeNextRead = false; + hooks.read = (path, bytes) => { if(path !== file || !bytes) return; reads++; if(reads === 1) setImmediate(() => { serviced = true; }); else servicedBeforeNextRead ||= serviced; }; + expect((await resolveRemotePiCompanion({serverRoot:f.serverRoot}))?.manifestSha256).toBe(f.sha256); + expect(reads).toBeGreaterThanOrEqual(8); expect(servicedBeforeNextRead).toBe(true); +}); +it("cancels during a large existing-cache file before reading the whole file and preserves it", async () => { + const f = await largeFixture(); await importRemotePiCompanion(f); + const file = join(f.serverRoot, "remote-companions/linux-x64/provider-pack/large"); let readBytes = 0; + hooks.read = (path, bytes) => { if(path === file) readBytes += bytes; }; + await expect(importRemotePiCompanion({...f, checkCancelled(){ if(readBytes) throw Error("cancel during cache bytes"); }})).rejects.toThrow("cancel during cache bytes"); + expect(readBytes).toBeGreaterThan(0); expect(readBytes).toBeLessThan(8 * 1024 ** 2); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); +it("rejects read-only directory modes before claiming a staging or output path", async () => { + const f = await fixture(); const dir = join(f.directory, "provider-pack"); chmodSync(dir, 0o500); + try { + await expect(importRemotePiCompanion({...f, checkCancelled(){}})).rejects.toThrow("owner read/write/search"); + expect(() => readdirSync(join(f.serverRoot, "remote-companions"))).toThrow(); + } finally { chmodSync(dir, 0o755); } +}); diff --git a/server/src/services/native-runtime/remote-pi-companion.ts b/server/src/services/native-runtime/remote-pi-companion.ts new file mode 100644 index 0000000000..9280be6f5a --- /dev/null +++ b/server/src/services/native-runtime/remote-pi-companion.ts @@ -0,0 +1,163 @@ +/** Explicit operator-imported Linux authority. No downloads or executable probes. */ +import { setImmediate as yieldToSignals } from "node:timers/promises"; +import { createHash } from "node:crypto"; +import { type Stats, constants } from "node:fs"; +import * as fs from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js"; + +const SERVER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const MODEL = "openrouter/deepseek/deepseek-v4-flash-0731"; +const MAX_BYTES = 4 * 1024 ** 3; +const MAX_FILE = 512 * 1024 ** 2; +const MANIFEST = "companion.json"; +const AUTHORITY = ".import-authority.json"; +type Entry = { path: string; mode: number; kind: "directory" } | { path: string; mode: number; kind: "file"; size: number; sha256: string } | { path: string; mode: number; kind: "symlink"; target: string }; +export interface RemotePiCompanionManifest { schema: "paperclip.remote-pi-companion/v1"; sourceRevision: string; target: "linux-x64"; profileDigest: string; providerPackDigest: string; daemonSha256: string; entries: Entry[] } +function require(value: unknown, reason: string): asserts value { if (!value) throw new Error(`runner_remote_companion_invalid: ${reason}`); } +const digest = (value: Buffer | string) => createHash("sha256").update(value).digest("hex"); +const safe = (path: string) => path.length > 0 && path.length < 4096 && !isAbsolute(path) && !/[\\\x00-\x1f\x7f]/u.test(path) && path.split("/").every(p => p !== "" && p !== "." && p !== ".."); +const inside = (root: string, path: string) => path === root || (!relative(root, path).startsWith("..") && !isAbsolute(relative(root, path))); +const same = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mode === b.mode && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs && a.nlink === b.nlink; +type Checkpoint = () => Promise; +function checkpoints(cancel?: () => void): Checkpoint { + const deadline = Date.now() + 600_000; + return async () => { await yieldToSignals(); cancel?.(); require(Date.now() < deadline, "import deadline exceeded"); }; +} +async function directory(path: string) { const st = await fs.lstat(path); require(st.isDirectory() && !st.isSymbolicLink() && await fs.realpath(path) === path, "directory is linked or noncanonical"); return st; } +/** One descriptor and 1MiB buffer; no whole executable allocation or sync hashing. */ +async function readStable(path: string, maximum: number, privateFile: boolean, check: Checkpoint, consume?: (chunk: Buffer) => Promise) { + require(await fs.realpath(dirname(path)) === dirname(path), "linked parent"); + const file = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = await file.stat(); require(before.isFile() && before.size <= maximum && (!privateFile || before.nlink === 1), "file type, link or byte bound"); + const hash = createHash("sha256"); let size = 0; const buffer = Buffer.alloc(1024 * 1024); let header = Buffer.alloc(0); + for (;;) { + await check(); const { bytesRead } = await file.read(buffer); if (!bytesRead) break; + size += bytesRead; require(size <= maximum && size <= before.size, "file grew beyond bound"); + const chunk = buffer.subarray(0, bytesRead); hash.update(chunk); if (!header.length) header = Buffer.from(chunk.subarray(0, 64)); + await consume?.(chunk); + } + require(size === before.size && same(before, await file.stat()) && same(before, await fs.lstat(path)), "file changed during read"); + return { size, sha256: hash.digest("hex"), header, stat: before }; + } finally { await file.close(); } +} +async function readOwned(path: string, maximum: number, check: Checkpoint, privateFile = false): Promise { + const chunks: Buffer[] = []; await readStable(path, maximum, privateFile, check, async chunk => { chunks.push(Buffer.from(chunk)); }); return Buffer.concat(chunks); +} +/** Complete no-follow inventory, including modes and contained symbolic links. */ +export async function inventoryRemoteCompanion(root: string, check = checkpoints()): Promise { + await directory(root); const entries: Entry[] = []; const links = new Map(); let total = 0; + const visit = async (path: string): Promise => { + const before = await directory(path); + for (const name of (await fs.readdir(path)).sort()) { + await check(); + if (path === root && [MANIFEST, AUTHORITY].includes(name)) continue; + const file = join(path, name); const rel = relative(root, file); require(safe(rel), "unsafe path"); + const st = await fs.lstat(file); const mode = st.mode & 0o7777; + require(entries.length < 100_000 && (st.isSymbolicLink() || (mode & 0o7022) === 0), "entry count or unsafe mode"); + if (st.isDirectory()) { require((mode & 0o700) === 0o700, "directory requires owner read/write/search for owned cleanup"); entries.push({ path: rel, mode, kind: "directory" }); await visit(file); } + else if (st.isFile()) { + total += st.size; require(total <= MAX_BYTES, "tree byte bound"); const read = await readStable(file, MAX_FILE, false, check); + require(same(st, read.stat), "discovered file changed"); + entries.push({ path: rel, mode, kind: "file", size: read.size, sha256: read.sha256 }); + const key = `${st.dev}:${st.ino}`; const group = links.get(key) ?? { count: 0, links: st.nlink }; group.count++; require(group.links === st.nlink, "hardlink identity drift"); links.set(key, group); + } else if (st.isSymbolicLink()) { + const target = await fs.readlink(file); require(!isAbsolute(target) && !/[\x00-\x1f\x7f]/u.test(target) && inside(root, resolve(dirname(file), target)) && inside(root, await fs.realpath(file)), "escaping symbolic link"); + require(same(st, await fs.lstat(file)), "link changed"); entries.push({ path: rel, mode, kind: "symlink", target }); + } else throw new Error("runner_remote_companion_invalid: special entry"); + } + const after = await directory(path); require(before.dev === after.dev && before.ino === after.ino && before.mtimeMs === after.mtimeMs, "directory changed"); + }; + await visit(root); require([...links.values()].every(g => g.count === g.links), "external hardlink"); + return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); +} +async function installedIdentity(serverRoot: string, check: Checkpoint) { + require(process.platform === "darwin" || process.platform === "linux", "companion import supports macOS and Linux controllers"); + await directory(serverRoot); const pkg = JSON.parse((await readOwned(join(serverRoot, "package.json"), 65536, check, true)).toString()); require(pkg.name === "@paperclipai/server", "public server package required"); + const source = JSON.parse((await readOwned(join(serverRoot, "dist/build-info.json"), 65536, check, true)).toString()).commit; + require(typeof source === "string" && /^[a-f0-9]{40}$/u.test(source), "installed build source is missing"); + const profile = resolveQualifiedAcpxProfile("pi", MODEL); require(profile.qualificationStatus !== "pending", "Pi is not qualified"); + return { source, profileDigest: profile.commandDigest }; +} +async function validate(root: string, manifest: RemotePiCompanionManifest, identity: Awaited>, check: Checkpoint) { + require(manifest.schema === "paperclip.remote-pi-companion/v1" && manifest.target === "linux-x64" && manifest.sourceRevision === identity.source && manifest.profileDigest === identity.profileDigest, "source/profile/target mismatch"); + require(JSON.stringify(await inventoryRemoteCompanion(root, check)) === JSON.stringify(manifest.entries), "complete inventory mismatch"); + const daemon = manifest.entries.find(e => e.path === "bin/paperclip-runnerd"); + require(daemon?.kind === "file" && daemon.size > 64 && (daemon.mode & 0o111) !== 0 && daemon.sha256 === manifest.daemonSha256, "daemon identity"); + const elf = (await readStable(join(root, daemon.path), MAX_FILE, false, check)).header; require(elf.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && elf[4] === 2 && elf[5] === 1 && elf.readUInt16LE(18) === 62, "Linux x64 ELF required"); + const pack = JSON.parse((await readOwned(join(root, "provider-pack/provider-pack.json"), 16 * 1024 ** 2, check)).toString()); + const canonical = (v: unknown): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical((v as Record)[k])}`).join(",")}}` : JSON.stringify(v); + require(pack.schema === "paperclip-runner/remote-provider-pack/v1" && pack.digest === manifest.providerPackDigest && pack.digest === `sha256:${digest(canonical(pack.payload))}`, "pack manifest digest"); + require(pack.payload.runnerSourceRevision === identity.source && pack.payload.target.platform === "linux" && pack.payload.target.architecture === "x64", "pack source/target"); + const pi = pack.payload.candidateProviders?.pi; require(pi?.qualification === "qualified" && pi.profileDigest === identity.profileDigest && pi.path === "provider-assets/pi/linux-x64", "normal Pi pack required"); +} +/** Release-side command uses this same inventory contract before publication. */ +export async function createRemotePiCompanionManifest(root: string, sourceRevision: string): Promise { + const check = checkpoints(); + const profile = resolveQualifiedAcpxProfile("pi", MODEL); const pack = JSON.parse((await readOwned(join(root, "provider-pack/provider-pack.json"), 16 * 1024 ** 2, check)).toString()); const entries = await inventoryRemoteCompanion(root, check); + const daemon = entries.find(e => e.path === "bin/paperclip-runnerd"); require(daemon?.kind === "file", "daemon missing"); + const manifest: RemotePiCompanionManifest = { schema: "paperclip.remote-pi-companion/v1", sourceRevision, target: "linux-x64", profileDigest: profile.commandDigest, providerPackDigest: pack.digest, daemonSha256: daemon.sha256, entries }; + require(/^[a-f0-9]{40}$/u.test(sourceRevision), "release source"); await validate(root, manifest, { source: sourceRevision, profileDigest: profile.commandDigest }, check); return manifest; +} +function cacheParent(serverRoot: string) { return join(serverRoot, "remote-companions"); } +async function removeOwned(path: string, owned: Stats) { const st = await fs.lstat(path); require(st.dev === owned.dev && st.ino === owned.ino && !st.isSymbolicLink(), "cleanup root ownership changed"); await fs.rm(path, { recursive: true }); } +export async function importRemotePiCompanion(input: { directory: string; sha256: string; serverRoot?: string; checkCancelled?: () => void }) { + const checkpoint = checkpoints(input.checkCancelled); + await checkpoint(); + const serverRoot = input.serverRoot ?? SERVER_ROOT; const identity = await installedIdentity(serverRoot, checkpoint); const source = await fs.realpath(input.directory); require(source === resolve(input.directory), "linked import root"); await directory(source); + require(/^[a-f0-9]{64}$/u.test(input.sha256), "expected manifest SHA256 required"); const bytes = await readOwned(join(source, MANIFEST), 32 * 1024 ** 2, checkpoint); require(digest(bytes) === input.sha256, "operator manifest digest mismatch"); + const manifest = JSON.parse(bytes.toString()) as RemotePiCompanionManifest; await validate(source, manifest, identity, checkpoint); await checkpoint(); + const parent = cacheParent(serverRoot); try { await fs.mkdir(parent, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new Error("Remote companion setup requires a writable installed server package", { cause: error }); } + const parentInfo = await directory(parent); require((parentInfo.mode & 0o077) === 0 && parentInfo.uid === process.getuid?.(), "cache must be private and operator-owned"); + const lockPath = join(parent, ".import-linux-x64.lock"); const lock = await fs.open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); const lockInfo = await lock.stat(); + let staging: string | undefined; let stagingInfo: Stats | undefined; let outputInfo: Stats | undefined; let committed = false; const output = join(parent, "linux-x64"); + try { + await checkpoint(); + try { await fs.lstat(output); const existing = await resolveRemotePiCompanion({ serverRoot, checkpoint }); require(existing?.manifestSha256 === input.sha256, "existing companion differs; remove only after stopping all runs"); await checkpoint(); return { status: "verified_existing", ...existing }; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + staging = await fs.mkdtemp(join(parent, ".import-")); stagingInfo = await fs.lstat(staging); + for (const entry of manifest.entries.filter(e => e.kind === "directory").sort((a, b) => a.path.split("/").length - b.path.split("/").length)) await fs.mkdir(join(staging, entry.path), { mode: 0o700 }); + for (const entry of manifest.entries) { + if (entry.kind !== "file") continue; + const file = join(staging, entry.path); const outputFile = await fs.open(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + try { + const read = await readStable(join(source, entry.path), MAX_FILE, false, checkpoint, async chunk => { + let offset = 0; while (offset < chunk.length) { await checkpoint(); offset += (await outputFile.write(chunk, offset)).bytesWritten; } + }); + require(read.size === entry.size && read.sha256 === entry.sha256, "source changed while copied"); + } finally { await outputFile.close(); } + await fs.chmod(file, entry.mode); await checkpoint(); + } + for (const entry of manifest.entries) if (entry.kind === "symlink") await fs.symlink(entry.target, join(staging, entry.path)); + for (const entry of manifest.entries.filter(e => e.kind === "directory").reverse()) await fs.chmod(join(staging, entry.path), entry.mode); + await fs.writeFile(join(staging, MANIFEST), bytes, { flag: "wx", mode: 0o600 }); await validate(staging, manifest, identity, checkpoint); await validate(source, manifest, identity, checkpoint); await checkpoint(); + // Claim the final path exclusively; no rename may replace a concurrent directory. + await fs.mkdir(output, { mode: 0o700 }); outputInfo = await fs.lstat(output); + for (const name of await fs.readdir(staging)) { const now = await directory(output); require(now.dev === outputInfo.dev && now.ino === outputInfo.ino, "publication ownership changed"); await fs.rename(join(staging, name), join(output, name)); } + await fs.writeFile(join(output, AUTHORITY), JSON.stringify({ sha256: input.sha256 }) + "\n", { flag: "wx", mode: 0o600 }); + const result = await resolveRemotePiCompanion({ serverRoot, checkpoint }); require(result?.manifestSha256 === input.sha256, "publication verification"); await checkpoint(); committed = true; return { status: "imported_verified", ...result }; + } finally { + const failures: unknown[] = []; const cleanup = async (fn: () => Promise) => { try { await fn(); } catch (error) { failures.push(error); } }; + if (outputInfo && !committed) await cleanup(() => removeOwned(output, outputInfo!)); + if (staging && stagingInfo) await cleanup(() => removeOwned(staging!, stagingInfo!)); + await cleanup(async () => { const now = await fs.lstat(lockPath); require(now.dev === lockInfo.dev && now.ino === lockInfo.ino, "lock ownership changed"); await fs.unlink(lockPath); }); await lock.close(); + if (failures.length) throw new AggregateError(failures, "Remote companion cleanup incomplete; inspect owned paths before retrying"); + } +} +export async function resolveRemotePiCompanion(input: { serverRoot?: string; workspaceRoot?: string; checkpoint?: Checkpoint } = {}) { + const check = input.checkpoint ?? checkpoints(); + const serverRoot = input.serverRoot ?? SERVER_ROOT; const parent = cacheParent(serverRoot); const root = join(parent, "linux-x64"); + try { await fs.lstat(root); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + const parentInfo = await directory(parent); require((parentInfo.mode & 0o077) === 0 && parentInfo.uid === process.getuid?.(), "cache privacy"); await directory(root); + if (input.workspaceRoot) { const workspace = await fs.realpath(input.workspaceRoot); require(!inside(workspace, root) && !inside(root, workspace), "companion cache cannot overlap a workspace"); } + const authority = JSON.parse((await readOwned(join(root, AUTHORITY), 65536, check, true)).toString()); const bytes = await readOwned(join(root, MANIFEST), 32 * 1024 ** 2, check, true); require(digest(bytes) === authority.sha256, "installed authority changed"); + const manifest = JSON.parse(bytes.toString()) as RemotePiCompanionManifest; await validate(root, manifest, await installedIdentity(serverRoot, check), check); + return { root, runnerBinary: join(root, "bin/paperclip-runnerd"), providerPack: join(root, "provider-pack"), manifestSha256: authority.sha256 as string, sourceRevision: manifest.sourceRevision, target: manifest.target }; +} + +/** Explicit legacy overrides remain authoritative; C/C never gain this Pi path. */ +export async function selectRemotePiCompanion(input: { provider: { kind: string; agent?: string }; remote: boolean; binaryOverride?: string | null; packOverride?: string | null; workspaceRoot: string; serverRoot?: string }) { + if (!input.remote || input.provider.kind !== "acpx" || input.provider.agent !== "pi" || input.binaryOverride?.trim() || input.packOverride?.trim()) return null; + return resolveRemotePiCompanion({ serverRoot: input.serverRoot, workspaceRoot: input.workspaceRoot }); +}