mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
8ced6f9285
commit
f96195a7a6
7 files changed
+464
-9
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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<string> {
|
||||
return provisioner;
|
||||
}
|
||||
|
||||
export async function resolveRemoteCompanionImporter(serverUrl: string): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
export function registerRuntimeCommands(program: Command): void {
|
||||
program.command("runtime").description("Manage explicitly installed agent runtimes")
|
||||
.command("setup <provider>")
|
||||
const runtime = program.command("runtime").description("Manage explicitly installed agent runtimes");
|
||||
runtime.command("import-remote <directory>")
|
||||
.description("Import a verified Linux Pi companion into the installed server (no downloads)")
|
||||
.requiredOption("--sha256 <digest>", "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<unknown> };
|
||||
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 <provider>")
|
||||
.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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }));
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<typeof import("node:fs/promises")>();
|
||||
return { ...fs,
|
||||
mkdir: async (...args: Parameters<typeof fs.mkdir>) => { hooks.mkdir?.(String(args[0])); return fs.mkdir(...args); },
|
||||
rename: async (...args: Parameters<typeof fs.rename>) => { hooks.rename?.(String(args[0]), String(args[1])); return fs.rename(...args); },
|
||||
open: async (...args: Parameters<typeof fs.open>) => { 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); }
|
||||
});
|
||||
@@ -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<void>;
|
||||
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<void>) {
|
||||
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<Buffer> {
|
||||
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<Entry[]> {
|
||||
await directory(root); const entries: Entry[] = []; const links = new Map<string, { count: number; links: number }>(); let total = 0;
|
||||
const visit = async (path: string): Promise<void> => {
|
||||
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<ReturnType<typeof installedIdentity>>, 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<string, unknown>)[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<RemotePiCompanionManifest> {
|
||||
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<void>) => { 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 });
|
||||
}
|
||||
Reference in new issue
Block a user