diff --git a/server/src/services/native-runtime/native-provider-artifact-capture-confinement.test.ts b/server/src/services/native-runtime/native-provider-artifact-capture-confinement.test.ts new file mode 100644 index 00000000000..3cc1aca0fb1 --- /dev/null +++ b/server/src/services/native-runtime/native-provider-artifact-capture-confinement.test.ts @@ -0,0 +1,148 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, posix } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, it, vi } from "vitest"; +import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; +import type { NativeProviderArtifactSource } from "./native-provider-artifacts.js"; +import { captureRemoteNativeProviderArtifacts } from "./native-provider-artifact-capture.js"; + +const run = promisify(execFile); + +// Mirrors the Daytona provider's outbound source guard (`assertConfinedSandboxPath` +// in packages/plugins/sandbox-providers/daytona/src/file-sync.ts). Kubernetes and +// CreateOS apply the same workspace-root confinement to export sources. +function assertConfinedSandboxPath(remoteDir: string, candidate: string, label: string) { + const root = posix.normalize(remoteDir); + const normalized = posix.normalize(candidate); + if (normalized !== root && !normalized.startsWith(`${root}/`)) { + throw new Error(`Daytona sync ${label} path escapes the workspace remote dir: ${candidate}`); + } +} +const names = ["nodeCommand", "opencodeProxy", "opencodeExecutable"] as const; +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +/** + * A sandbox whose bulk export is confined to the workspace remote dir, as the + * Daytona, Kubernetes, and CreateOS providers are. The qualified launch root + * lives under the sandbox account's home, outside that workspace. + */ +async function confinedSandbox() { + const sandbox = await mkdtemp(join(tmpdir(), "pc-confined-sandbox-")); + roots.push(sandbox); + const home = join(sandbox, "home", "daytona"); + const remoteCwd = join(home, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const launchRoot = join(home, ".paperclip-runtime", "paperclip-runner", "provider-artifacts", "dfe9e4b1d660"); + const artifacts: Record = {}; + const paths = {} as Record<(typeof names)[number], string>; + for (const name of names) { + const relative = `bin/${name}`; + const bytes = `qualified ${name} bytes`; + await mkdir(join(launchRoot, "bin"), { recursive: true }); + await writeFile(join(launchRoot, relative), bytes, { mode: 0o500 }); + artifacts[name] = { path: relative, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` }; + paths[name] = join(launchRoot, relative); + } + const syncOut = vi.fn>(async (operations) => { + for (const operation of operations) { + for (const file of operation.files) { + assertConfinedSandboxPath(remoteCwd, file.sourcePath, "source"); + await copyFile(file.sourcePath, file.targetPath); + } + } + return { operations: [] }; + }); + const runner = { + execute: vi.fn(async (input: { command: string; args?: string[] }) => { + try { + const result = await run(input.command, input.args ?? []); + return { exitCode: 0, signal: null, timedOut: false, stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const failed = error as { code?: number; stdout?: string; stderr?: string }; + return { exitCode: failed.code ?? 1, signal: null, timedOut: false, stdout: failed.stdout ?? "", stderr: failed.stderr ?? "" }; + } + }), + syncOut, + } as unknown as CommandManagedRuntimeRunner; + const manifest = { digest: `sha256:${"d".repeat(64)}`, payload: { artifacts } } as unknown as NativeProviderArtifactSource["manifest"]; + return { remoteCwd, launchRoot, paths, runner, syncOut, manifest }; +} + +it("exports qualified launch files that live outside the workspace through a confined staging copy", async () => { + const sandbox = await confinedSandbox(); + const published: Record = {}; + const result = await captureRemoteNativeProviderArtifacts( + { + runner: sandbox.runner, + remoteCwd: sandbox.remoteCwd, + manifest: sandbox.manifest, + signal: new AbortController().signal, + launch: { root: sandbox.launchRoot, accountUid: 1000, paths: sandbox.paths }, + }, + async (source) => { + for (const name of names) { + published[name] = await readFile(join(source.sourceRoot, `bin/${name}`), "utf8"); + } + expect(source.launchRoot).toBe(sandbox.launchRoot); + return "published"; + }, + ); + + expect(result).toBe("published"); + expect(published).toEqual(Object.fromEntries(names.map((name) => [name, `qualified ${name} bytes`]))); + for (const file of sandbox.syncOut.mock.calls.flatMap(([operations]) => operations.flatMap((op) => op.files))) { + expect(file.sourcePath.startsWith(`${sandbox.remoteCwd}/.paperclip-runtime/`)).toBe(true); + } + // The export copy is scratch: it is removed and the qualified launch root is untouched. + const exportParent = posix.join(sandbox.remoteCwd, ".paperclip-runtime", "paperclip-runner", "provider-artifact-export"); + expect(await readdir(exportParent)).toEqual([]); + for (const name of names) await access(sandbox.paths[name]); +}); + +it("removes the confined staging copy when the export fails", async () => { + const sandbox = await confinedSandbox(); + sandbox.syncOut.mockRejectedValueOnce(new Error("transfer failed")); + const publish = vi.fn(async () => "must not publish"); + await expect(captureRemoteNativeProviderArtifacts( + { + runner: sandbox.runner, + remoteCwd: sandbox.remoteCwd, + manifest: sandbox.manifest, + signal: new AbortController().signal, + launch: { root: sandbox.launchRoot, accountUid: 1000, paths: sandbox.paths }, + }, + publish, + )).rejects.toThrow("transfer failed"); + expect(publish).not.toHaveBeenCalled(); + const exportParent = posix.join(sandbox.remoteCwd, ".paperclip-runtime", "paperclip-runner", "provider-artifact-export"); + expect(await readdir(exportParent)).toEqual([]); +}); + +it("refuses a launch file replaced by a symlink instead of exporting its target", async () => { + const sandbox = await confinedSandbox(); + const secret = join(sandbox.remoteCwd, "..", "secret"); + await writeFile(secret, "not a qualified artifact"); + await rm(sandbox.paths.opencodeProxy); + await symlink(secret, sandbox.paths.opencodeProxy); + const publish = vi.fn(async () => "must not publish"); + await expect(captureRemoteNativeProviderArtifacts( + { + runner: sandbox.runner, + remoteCwd: sandbox.remoteCwd, + manifest: sandbox.manifest, + signal: new AbortController().signal, + launch: { root: sandbox.launchRoot, accountUid: 1000, paths: sandbox.paths }, + }, + publish, + )).rejects.toThrow("native_provider_artifacts_remote_unavailable"); + expect(sandbox.syncOut).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + const exportParent = posix.join(sandbox.remoteCwd, ".paperclip-runtime", "paperclip-runner", "provider-artifact-export"); + expect(await readdir(exportParent)).toEqual([]); +}); diff --git a/server/src/services/native-runtime/native-provider-artifact-capture.test.ts b/server/src/services/native-runtime/native-provider-artifact-capture.test.ts index 6fcc707cd26..d296dfea4a6 100644 --- a/server/src/services/native-runtime/native-provider-artifact-capture.test.ts +++ b/server/src/services/native-runtime/native-provider-artifact-capture.test.ts @@ -34,9 +34,16 @@ it.each(["stop", "deadline"])( entered = done; }); const runner = { - execute: vi.fn(async () => { - throw new Error("unexpected provider command"); - }), + // The confined export staging and its cleanup run as sandbox commands. + execute: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: null, + })), syncOut: async (operations) => { const destination = operations[0].files[0].targetPath; root = dirname(destination); @@ -61,6 +68,7 @@ it.each(["stop", "deadline"])( const result = captureRemoteNativeProviderArtifacts( { runner, + remoteCwd: "/workspace", manifest, context, signal: context.signal, diff --git a/server/src/services/native-runtime/native-provider-artifact-capture.ts b/server/src/services/native-runtime/native-provider-artifact-capture.ts index 9c267ea2069..4ea9fe30a84 100644 --- a/server/src/services/native-runtime/native-provider-artifact-capture.ts +++ b/server/src/services/native-runtime/native-provider-artifact-capture.ts @@ -2,16 +2,53 @@ import { logger } from "../../middleware/logger.js"; import { randomUUID } from "node:crypto"; import { mkdtemp, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, dirname } from "node:path"; +import { join, dirname, posix } from "node:path"; import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import type { NativeProviderArtifactSource } from "./native-provider-artifacts.js"; import type { OpenCodeLaunchArtifacts } from "./remote-opencode-artifacts.js"; import type { NativeExecutionContext } from "./native-execution-context.js"; import { removeNativeHarnessBackup } from "./native-harness-backup-cleanup.js"; +const names = ["nodeCommand", "opencodeProxy", "opencodeExecutable"] as const; + +// Copies the qualified launch files into a fresh export directory. The launch +// root sits under the sandbox account's home, outside the workspace remote dir, +// and sandbox providers confine bulk-export sources to that workspace. The copy +// refuses symlinks, so the confined export can only carry these regular files; +// Core still verifies every byte against the manifest digests after download. +const stageExportScript = [ + "set -eu", + "umask 077", + 'parent=$1; target=$2; shift 2', + 'mkdir -p -- "$parent"', + '{ [ -d "$parent" ] && [ ! -L "$parent" ]; } || exit 41', + 'mkdir -- "$target"', + "for name in nodeCommand opencodeProxy opencodeExecutable; do", + ' source=$1; shift', + ' { [ -f "$source" ] && [ ! -L "$source" ]; } || exit 42', + ' cp -P -- "$source" "$target/$name"', + ' { [ -f "$target/$name" ] && [ ! -L "$target/$name" ]; } || exit 43', + "done", +].join("\n"); +const removeExportScript = 'set -eu\nrm -rf -- "$1"'; + +async function runExportCommand(runner: CommandManagedRuntimeRunner, script: string, args: string[]) { + const result = await runner.execute({ + command: "sh", + args: ["-c", script, "sh", ...args], + bypassSession: true, + timeoutMs: 120_000, + }); + if (result.exitCode !== 0 || result.timedOut) { + throw new Error("native_provider_artifacts_remote_unavailable"); + } +} + export async function captureRemoteNativeProviderArtifacts( input: { runner: CommandManagedRuntimeRunner; + /** Workspace remote dir: the only root a sandbox bulk export may read. */ + remoteCwd: string; launch: OpenCodeLaunchArtifacts; manifest: NativeProviderArtifactSource["manifest"]; signal: AbortSignal; @@ -22,8 +59,15 @@ export async function captureRemoteNativeProviderArtifacts( const root = await mkdtemp(join(tmpdir(), "paperclip-provider-launch-")); let ownedByOperation = false; try { + const exportParent = posix.join( + input.remoteCwd, + ".paperclip-runtime", + "paperclip-runner", + "provider-artifact-export", + ); + const exportRoot = posix.join(exportParent, randomUUID()); const files = await Promise.all( - (["nodeCommand", "opencodeProxy", "opencodeExecutable"] as const).map( + names.map( async (name) => { const targetPath = join( root, @@ -31,7 +75,7 @@ export async function captureRemoteNativeProviderArtifacts( ); await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 }); return { - sourcePath: input.launch.paths[name], + sourcePath: posix.join(exportRoot, name), targetPath, kind: "file" as const, mode: 0o500, @@ -46,9 +90,24 @@ export async function captureRemoteNativeProviderArtifacts( .then(async () => { input.signal.throwIfAborted(); input.context?.assertOpen(); - await input.runner.syncOut!([ - { operationId: `provider-artifacts-${randomUUID()}`, files }, - ]); + try { + await runExportCommand(input.runner, stageExportScript, [ + exportParent, + exportRoot, + ...names.map((name) => input.launch.paths[name]), + ]); + input.signal.throwIfAborted(); + input.context?.assertOpen(); + await input.runner.syncOut!([ + { operationId: `provider-artifacts-${randomUUID()}`, files }, + ]); + } finally { + // The export copy is scratch under the excluded runtime dir. A failed + // removal must not replace the transfer outcome; it is logged instead. + await runExportCommand(input.runner, removeExportScript, [exportRoot]).catch((err) => { + logger.warn({ err }, "native provider artifact export cleanup failed"); + }); + } input.signal.throwIfAborted(); input.context?.assertOpen(); return publish({ diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 0a0df1b620c..67d591fbdc7 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -2274,6 +2274,14 @@ describe("remote provider pack manifest", () => { expect(managedBackend.providerAdmissionRequired?.()).toBe(true); await managedBackend.providerAdmissionPersisted!(); expect(providerArtifactCapture.captureRemoteNativeProviderArtifacts).toHaveBeenCalledTimes(1); + expect(providerArtifactCapture.captureRemoteNativeProviderArtifacts).toHaveBeenCalledWith( + expect.objectContaining({ remoteCwd: "/workspace" }), expect.any(Function)); + // Sandbox providers confine export sources to the workspace remote dir; + // the home-rooted launch files leave through a workspace staging copy. + const exportSources = (syncOut.mock.calls as unknown as Array<[Array<{ files: Array<{ kind: string; sourcePath: string }> }>]>) + .flatMap(([operations]) => operations.flatMap(operation => operation.files.map(file => file.sourcePath))); + expect(exportSources.length).toBeGreaterThan(0); + expect(exportSources.every(source => source.startsWith("/workspace/"))).toBe(true); expect(providerArtifactStorage.retainNativeProviderArtifacts).toHaveBeenCalledWith(expect.objectContaining({ owner: expect.objectContaining({ runId: remoteExecution.binding.runId }), source: expect.objectContaining({ manifest: installed, launchRoot, accountUid: 1000 }) })); await driver.mock.calls.at(-1)![0].checkpoint!("settled"); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 77f89139901..74a0298c9fa 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -16212,8 +16212,9 @@ async function createRunnerdBackendWithinSessionClaim( } if (!openCodeLaunchArtifacts) throw new Error("native_provider_artifacts_admission_missing"); const signal = providerArtifactsSignal(); + if (!remoteTarget) throw new Error("native_provider_artifacts_admission_missing"); providerArtifacts = await captureRemoteNativeProviderArtifacts({ runner: remoteCommandRunner, - launch: openCodeLaunchArtifacts, manifest: expectedProviderPackManifest, signal, context: input.executionContext }, + remoteCwd: remoteTarget.remoteCwd, launch: openCodeLaunchArtifacts, manifest: expectedProviderPackManifest, signal, context: input.executionContext }, source => retainNativeProviderArtifacts({ db: input.db, storage: artifactStorage, owner: backupOwner, source, signal })); } : undefined, bindDiagnosticSession: session => {