From a5e26ecc04d414e9b7e968c7c7c04aa19fe304a8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:33:29 -0500 Subject: [PATCH] Resolve native provider assets through the runner package authority Co-Authored-By: Paperclip --- .../drivers/acpx/provider-assets-root.test.ts | 47 ++++++++++++++ .../src/drivers/acpx/provider-assets-root.ts | 64 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts new file mode 100644 index 00000000000..f6c3cfeb5c3 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, expect, it, vi } from "vitest"; +import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js"; + +const roots: string[] = []; +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +async function root() { const path = await mkdtemp(join(tmpdir(), "runner-provider-assets-")); roots.push(path); return path; } + +it("uses one fixed provider directory for source, compiled and bundled runner layouts", async () => { + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + const path = await root(); + for (const relative of ["src/drivers/acpx/cursor-installation.ts", "dist/drivers/acpx/cursor-installation.js", "dist/cli/acpx-runtime-sidecar.cjs"]) { + expect(resolveRunnerProviderAssetsRoot(pathToFileURL(join(path, relative)).href, "cursor")).toMatch(/provider-assets\/cursor$/); + } + expect(() => resolveRunnerProviderAssetsRoot(pathToFileURL(join(path, "workspace/arbitrary.ts")).href, "cursor")).toThrow("verified package layout"); +}); + +it("resolves descriptor sidecars only through the runner-bound canonical package manifest", async () => { + const path = await root(); + await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + expect(resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toMatch(/provider-assets\/pi$/); + await writeFile(join(path, "package.json"), JSON.stringify({ name: "untrusted" })); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("runner package"); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "relative"); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("normalized absolute"); +}); + +it("rejects external manifests, links, asset escapes and incomplete authority", async () => { + const path = await root(); const outside = await root(); + await writeFile(join(outside, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(outside, "package.json")); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toThrow("escapes"); + await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + await mkdir(join(path, "provider-assets")); + await symlink(outside, join(path, "provider-assets/copilot")); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toThrow("contained"); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toThrow("no bound"); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts new file mode 100644 index 00000000000..326740c00c1 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts @@ -0,0 +1,64 @@ +import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +type NativeProvider = "cursor" | "copilot" | "pi"; +const RUNNER_PACKAGE_NAME = "@paperclipai/paperclip-runner"; + +/** Resolve only runner-owned package assets, including descriptor-loaded sidecars. */ +export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: NativeProvider): string { + if (!["cursor", "copilot", "pi"].includes(provider)) throw new Error("Unknown native ACP provider assets"); + const boundRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT; + const boundManifest = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST; + let packageRoot: string; + if (boundRoot !== undefined) { + const root = normalizedAbsolute(boundRoot); + const manifest = normalizedAbsolute(boundManifest ?? join(root, "package.json")); + packageRoot = realpathSync(root); + const canonicalManifest = realpathSync(manifest); + if (!inside(packageRoot, canonicalManifest)) throw new Error("Runner provider manifest escapes its bound package root"); + // The authority comes from runnerd's selected provider pack, never provider + // environment. Verify the selected manifest itself before deriving assets. + const fd = openSync(manifest, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > 64n * 1024n) throw new Error("Runner provider manifest is not a bounded regular file"); + const bytes = readFileSync(fd); + const after = fstatSync(fd, { bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs + || bytes.length !== Number(before.size) || realpathSync(manifest) !== canonicalManifest) throw new Error("Runner provider manifest changed during admission"); + const value = JSON.parse(bytes.toString("utf8")) as { name?: unknown }; + if (value?.name !== RUNNER_PACKAGE_NAME) throw new Error("Runner provider manifest does not name the runner package"); + } finally { closeSync(fd); } + packageRoot = dirname(canonicalManifest); + } else { + if (boundManifest !== undefined) throw new Error("Runner provider manifest has no bound package root"); + const url = new URL(moduleUrl); + if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Provider factory is outside a verified package layout"); + if (new RegExp(`/(?:src|dist)/drivers/acpx/${provider}-installation\\.(?:ts|js)$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../../", url)); + else if (/\/dist\/cli\/acpx-runtime-sidecar\.(?:cjs|js)$/.test(url.pathname)) packageRoot = fileURLToPath(new URL("../../", url)); + else throw new Error("Provider factory is outside a verified package layout"); + packageRoot = realpathSync(packageRoot); + } + const assets = join(packageRoot, "provider-assets", provider); + // Missing materialization is diagnosed by the installation verifier. Existing + // directory components must not redirect discovery outside this package. + for (const path of [join(packageRoot, "provider-assets"), assets]) { + try { + const stat = lstatSync(path); + if (!stat.isDirectory() || stat.isSymbolicLink() || !inside(packageRoot, realpathSync(path))) throw new Error("Runner provider asset directory is not contained by its package"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + return assets; +} + +function normalizedAbsolute(value: string): string { + if (!isAbsolute(value) || value.includes("\0") || resolve(value) !== value) throw new Error("Runner provider package authority must be a normalized absolute path"); + return value; +} +function inside(root: string, path: string): boolean { + const rel = relative(root, path); + return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); +}