Files
PaperClipAI/server/src/services/native-runtime/native-provider-artifact-capture.ts
T
DottaandClaude Opus 5.5 24cbea136f fix(native): export OpenCode launch artifacts through a confined workspace copy (#19743)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native recovery saves the qualified OpenCode launch files so a cold
allocation can resume the same session (#19622)
> - Core reads those files from the sandbox with the provider's bulk
`syncOut`
> - The launch root is under the sandbox account home, not under the
workspace remote dir
> - Daytona, Kubernetes, and CreateOS confine every export source to the
workspace remote dir, so the export always fails
> - Every remote OpenCode run on a durable-storage instance now fails
40-90 s after start with `native_session_interrupted`
> - This pull request exports a confined scratch copy of the three
launch files from inside the workspace runtime dir
> - The benefit is that remote OpenCode runs work again, and the
provider confinement guard stays unchanged

## Linked Issues or Issue Description

Refs #19622

**What happened?**

After #19622, every remote OpenCode run on Daytona fails at provider
admission. The run error is `Daytona sync source path escapes the
workspace remote dir:
/home/daytona/.paperclip-runtime/paperclip-runner/provider-artifacts/<digest>`.
The error code is `native_session_interrupted`. The last successful run
of the affected agent was immediately before #19622 reached the
deployment.

**Expected behavior**

Core saves the qualified launch files and the run continues. The
provider confinement guard still refuses any export source outside the
workspace remote dir.

**Steps to reproduce**

1. Configure an OpenCode agent on a Daytona environment with durable
harness storage.
2. Start a run.
3. The run fails when Core captures the provider artifacts, before the
first turn.

## What Changed

- `captureRemoteNativeProviderArtifacts` now takes the workspace
`remoteCwd`.
- Before the export, a sandbox command copies the three launch files
into a new scratch directory at
`<remoteCwd>/.paperclip-runtime/paperclip-runner/provider-artifact-export/<uuid>/`.
The command refuses a symlink or a non-regular file. It uses `cp -P`, so
it never follows a link.
- `syncOut` reads from that scratch copy. A `finally` step removes the
scratch copy after success or failure. A failed removal is logged and
does not replace the transfer result.
- The launch root does not move. OpenCode still binds to the same
absolute paths. Core still verifies each downloaded byte against the
manifest SHA-256 values.
- `.paperclip-runtime` is already excluded from workspace sync, so the
scratch copy never enters the user workspace.
- The Daytona `assertConfinedSandboxPath` guard is not changed.

## Verification

- New regression
`server/src/services/native-runtime/native-provider-artifact-capture-confinement.test.ts`.
It runs the real sandbox commands on a local directory tree that has a
home-rooted launch root and a separate workspace. Its `syncOut` applies
the same workspace confinement rule as the Daytona provider.
- Before the fix: it fails with `Daytona sync source path escapes the
workspace remote dir:
.../home/daytona/.paperclip-runtime/paperclip-runner/provider-artifacts/dfe9e4b1d660/bin/nodeCommand`.
This is the staging error.
- After the fix: it passes. The published bytes match, every export
source is under the workspace runtime dir, and the scratch directory is
empty after the capture.
- Safety cases: a failed transfer removes the scratch copy and does not
publish. A launch file replaced by a symlink is refused before any
export, and nothing is published.
- `native-session-executor.test.ts`: the durable OpenCode admission case
now asserts that the capture receives `remoteCwd` and that every
`syncOut` source is under `/workspace/`.
- `pnpm vitest run` for the capture, confinement, remote OpenCode
artifact, and session root entries tests: 14 passed.
- `vitest run native-session-executor.test.ts -t "opencode|provider
artifact|durable"`: 62 passed.
- `pnpm --filter @paperclipai/server typecheck`: passed.
- Not run: a live Daytona run with this revision. The fix is not
deployed to staging.

## Risks

- Low risk. The capture now runs two short `sh` commands in the sandbox
and holds a second copy of the launch files during the transfer. The
files are bounded by the existing 512 MiB limit and are deleted
afterwards.
- The scratch copy uses `cp -P`, `mkdir`, and `rm -rf` from POSIX `sh`.
The Daytona image already uses these tools for the workspace sync.
- If the sandbox stops between the copy and the removal, a scratch
directory can remain under `.paperclip-runtime`. Workspace sync excludes
that directory.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edit, and read-only browser access to
staging).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:04:21 -06:00

152 lines
5.5 KiB
TypeScript

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, 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<T>(
input: {
runner: CommandManagedRuntimeRunner;
/** Workspace remote dir: the only root a sandbox bulk export may read. */
remoteCwd: string;
launch: OpenCodeLaunchArtifacts;
manifest: NativeProviderArtifactSource["manifest"];
signal: AbortSignal;
context?: NativeExecutionContext;
},
publish: (source: NativeProviderArtifactSource) => Promise<T>,
): Promise<T> {
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(
names.map(
async (name) => {
const targetPath = join(
root,
input.manifest.payload.artifacts[name].path,
);
await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 });
return {
sourcePath: posix.join(exportRoot, name),
targetPath,
kind: "file" as const,
mode: 0o500,
};
},
),
);
input.signal.throwIfAborted();
const settled = input.context?.trackWorker();
ownedByOperation = true;
const operation = Promise.resolve()
.then(async () => {
input.signal.throwIfAborted();
input.context?.assertOpen();
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({
sourceRoot: root,
manifest: input.manifest,
launchRoot: input.launch.root,
accountUid: input.launch.accountUid,
});
})
.finally(async () => {
try {
await removeNativeHarnessBackup(root);
} catch (err) {
logger.warn(
{ err },
"native provider artifact capture cleanup failed",
);
throw err;
} finally {
settled?.();
}
});
void operation.catch(() => undefined);
let abort!: () => void;
const stopped = new Promise<never>((_resolve, reject) => {
abort = () => reject(input.signal.reason);
input.signal.addEventListener("abort", abort, { once: true });
if (input.signal.aborted) abort();
});
try {
return await Promise.race([
input.context ? input.context.wait(() => operation) : operation,
stopped,
]);
} finally {
input.signal.removeEventListener("abort", abort);
}
} finally {
if (!ownedByOperation) await removeNativeHarnessBackup(root);
}
}