mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## 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>
152 lines
5.5 KiB
TypeScript
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);
|
|
}
|
|
}
|