mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Resolve native provider assets through the runner package authority
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
37e6ebbc51
commit
a5e26ecc04
2 files changed
+111
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user