fix: preserve scoped sandbox home in legacy adapters

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 13:53:45 -05:00
parent 30b9cd8c00
commit 53e7067413
10 changed files with 106 additions and 33 deletions

View File

@ -72,6 +72,10 @@ uploads remain unchanged. Task plans and documents are not materialized.
The agent starts in `$HOME`. `PAPERCLIP_PRIMARY_REPO` and the workspace context
identify the repository for project commands. `AGENT_HOME` and
`PAPERCLIP_{TASK,AGENT,USER,PROJECT,REPOS}_DIR` expose the bound directories.
Legacy adapters use the host-bound sandbox home for both CLI launch and skill
discovery; a private per-run runtime directory must not override it. CLI-specific
configuration remains separately staged beneath that home. Local and SSH homes
are unchanged.
CLI state is separate from the four shared collections. A change of task,
agent, responsible user, or project cannot reuse a sandbox with another binding.

View File

@ -7,6 +7,7 @@ import {
cleanupGitHubOperationLaunchers,
prepareGitHubOperationLaunchers,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetManagedHomeDir,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
@ -201,6 +202,18 @@ describe("runAdapterExecutionTargetShellCommand", () => {
expect(onLog).toHaveBeenCalledWith("stderr", "partial stderr");
});
it("uses the host-bound sandbox home while retaining managed CLI configuration", () => {
const target = { kind: "remote" as const, transport: "sandbox" as const,
remoteCwd: "/home/daytona/repos/main", workFolderHome: "/home/daytona" };
expect(adapterExecutionTargetManagedHomeDir(target, "/private/run-runtime")).toBe("/home/daytona");
expect(adapterExecutionTargetManagedHomeDir(target, null)).toBe("/home/daytona");
expect(adapterExecutionTargetUsesManagedHome(target)).toBe(true);
expect(adapterExecutionTargetManagedHomeDir({ ...target, workFolderHome: undefined }, "/private/run-runtime"))
.toBe("/private/run-runtime");
expect(adapterExecutionTargetManagedHomeDir(null, "/private/run-runtime")).toBeNull();
expect(adapterExecutionTargetManagedHomeDir({ kind: "local" }, "/private/run-runtime")).toBeNull();
});
it("keeps managed homes disabled for both local and SSH targets", () => {
expect(adapterExecutionTargetUsesManagedHome(null)).toBe(false);
expect(adapterExecutionTargetUsesManagedHome({

View File

@ -441,6 +441,15 @@ export function adapterExecutionTargetUsesManagedHome(
return target?.kind === "remote" && target.transport === "sandbox";
}
/** Resolve the sandbox home without replacing a host-bound work-folder home. */
export function adapterExecutionTargetManagedHomeDir(
target: AdapterExecutionTarget | null | undefined,
runtimeRootDir: string | null | undefined,
): string | null {
if (target?.kind !== "remote" || target.transport !== "sandbox") return null;
return target.workFolderHome ?? runtimeRootDir ?? null;
}
/**
* Read the per-run duplex bridge kill switch off a target. Only a sandbox
* target with `enableSandboxDuplexBridge` set to `true` returns `true`. Every

View File

@ -9,7 +9,7 @@ import {
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetManagedHomeDir,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
@ -396,13 +396,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
executionCwd: effectiveExecutionCwd,
});
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
const managedRemoteHomeDir = adapterExecutionTargetManagedHomeDir(
executionTarget, preparedExecutionTargetRuntime.runtimeRootDir,
);
if (managedRemoteHomeDir) {
env.HOME = managedRemoteHomeDir;
}
const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir
? preparedExecutionTargetRuntime.runtimeRootDir
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
const remoteHomeDir = managedRemoteHomeDir
?? await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
cwd,
env,
timeoutSec,

View File

@ -86,12 +86,14 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
};
});
import * as targets from "@paperclipai/adapter-utils/execution-target";
import { execute } from "./execute.js";
describe("gemini remote execution", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
vi.clearAllMocks();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
@ -230,12 +232,20 @@ describe("gemini remote execution", () => {
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
});
it("pre-selects gemini-api-key auth in the managed HOME for sandbox execution", async () => {
it.each(["/home/daytona", undefined])("pre-selects gemini-api-key auth in the sandbox HOME (%s)", async (workFolderHome) => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-sandbox-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
await mkdir(workspaceDir, { recursive: true });
// The command recorder does not implement archive transfer; this test
// exercises CLI authentication paths, while transport tests cover syncing.
vi.spyOn(targets, "prepareAdapterExecutionTargetRuntime").mockResolvedValue({
target: { kind: "remote", transport: "sandbox", remoteCwd: "/remote/workspace", workFolderHome },
runtimeRootDir: "/remote/workspace/.paperclip-runtime/gemini",
workspaceRemoteDir: "/remote/workspace", assetDirs: {}, additionalSourceDirs: {},
additionalSourceFailures: [], workspaceSyncSnapshot: null, restoreWorkspace: async () => {},
});
const geminiOutput = [
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-2", model: "gemini-2.5-pro" }),
JSON.stringify({ type: "message", role: "assistant", content: "hello" }),
@ -288,6 +298,7 @@ describe("gemini remote execution", () => {
kind: "remote",
transport: "sandbox",
providerKey: "kubernetes",
workFolderHome,
remoteCwd: "/remote/workspace",
runner: { execute: runnerExecute },
},
@ -300,8 +311,7 @@ describe("gemini remote execution", () => {
const settingsWrite = runnerScripts.find((script) => script.includes(".gemini/settings.json"));
expect(settingsWrite).toBeDefined();
expect(settingsWrite).toContain("gemini-api-key");
// The managed HOME lives under the per-run runtime root, never a real home.
expect(settingsWrite).toContain(".paperclip-runtime");
expect(settingsWrite).toContain(workFolderHome ?? "/remote/workspace/.paperclip-runtime/gemini");
});
it("resumes saved Gemini sessions for remote SSH execution only when the identity matches", async () => {

View File

@ -10,7 +10,7 @@ import {
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetManagedHomeDir,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
@ -398,11 +398,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
executionCwd: effectiveExecutionCwd,
});
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
const managedRemoteHomeDir =
managedHome && preparedExecutionTargetRuntime.runtimeRootDir
? preparedExecutionTargetRuntime.runtimeRootDir
: null;
const managedRemoteHomeDir = adapterExecutionTargetManagedHomeDir(
executionTarget, preparedExecutionTargetRuntime.runtimeRootDir,
);
if (managedRemoteHomeDir) {
env.HOME = managedRemoteHomeDir;
}
@ -432,7 +430,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// is provided. Both settings schema generations are written (legacy
// selectedAuthType + current security.auth.selectedType). An existing
// settings.json (user-shipped via workspace) is left untouched.
// Only the managed HOME (the per-run runtime root) is touched: on
// Only the managed sandbox HOME is touched: on
// non-managed remote targets remoteHomeDir is the user's real home, where
// creating files is out of scope and existing settings remain visible.
// Key presence check spans the run env AND the host process env: in the

View File

@ -9,7 +9,7 @@ import {
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetManagedHomeDir,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
@ -375,11 +375,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
executionCwd: effectiveExecutionCwd,
});
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
const managedRemoteHomeDir =
managedHome && preparedExecutionTargetRuntime.runtimeRootDir
? preparedExecutionTargetRuntime.runtimeRootDir
: null;
const managedRemoteHomeDir = adapterExecutionTargetManagedHomeDir(
executionTarget, preparedExecutionTargetRuntime.runtimeRootDir,
);
if (managedRemoteHomeDir) {
env.HOME = managedRemoteHomeDir;
}

View File

@ -9,7 +9,7 @@ import {
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetManagedHomeDir,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
@ -427,16 +427,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
executionCwd: effectiveExecutionCwd,
});
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
preparedRuntimeConfig.env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
const managedRemoteHomeDir = adapterExecutionTargetManagedHomeDir(
executionTarget, preparedExecutionTargetRuntime.runtimeRootDir,
);
if (managedRemoteHomeDir) {
preparedRuntimeConfig.env.HOME = managedRemoteHomeDir;
}
if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) {
preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig;
}
const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir
? preparedExecutionTargetRuntime.runtimeRootDir
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
const remoteHomeDir = managedRemoteHomeDir
?? await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
cwd,
env: preparedRuntimeConfig.env,
timeoutSec,

View File

@ -90,6 +90,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
};
});
import * as targets from "@paperclipai/adapter-utils/execution-target";
import { execute } from "./execute.js";
describe("pi remote execution", () => {
@ -104,6 +105,41 @@ describe("pi remote execution", () => {
}
});
it.each(["/home/daytona", undefined])("launches Pi with the correct sandbox home (%s)", async (workFolderHome) => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-sandbox-home-"));
cleanupDirs.push(rootDir);
const runtimeRootDir = "/home/daytona/repos/main/.paperclip-runtime/runs/home-test/pi";
const processSpy = vi.spyOn(targets, "runAdapterExecutionTargetProcess").mockResolvedValue({
exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null,
startedAt: new Date().toISOString(),
});
vi.spyOn(targets, "prepareAdapterExecutionTargetRuntime").mockResolvedValue({
runtimeRootDir, workspaceRemoteDir: "/home/daytona/repos/main", assetDirs: {},
restoreWorkspace: async () => {}, target: { kind: "remote", transport: "sandbox", remoteCwd: "/home/daytona/repos/main", workFolderHome },
additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null,
});
vi.spyOn(targets, "ensureAdapterExecutionTargetCommandResolvable").mockResolvedValue(undefined);
vi.spyOn(targets, "resolveAdapterExecutionTargetCommandForLogs").mockResolvedValue("pi");
vi.spyOn(targets, "ensureAdapterExecutionTargetFile").mockResolvedValue(undefined);
try {
await execute({
runId: "home-test",
agent: { id: "agent-1", companyId: "company-1", name: "Pi Builder", adapterType: "pi_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: { command: "pi", model: "openai/gpt-5.4-mini", env: { HOME: "/home/daytona" } },
context: { paperclipWorkspace: { cwd: rootDir, source: "project_primary" } },
executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/home/daytona/repos/main", workFolderHome },
onLog: async () => {},
});
expect(processSpy).toHaveBeenCalled();
const options = processSpy.mock.calls[0][4];
expect(options.env.HOME).toBe(workFolderHome ?? runtimeRootDir);
expect(options.env.PAPERCLIP_WORKSPACE_CWD).toBe("/home/daytona/repos/main");
} finally {
vi.restoreAllMocks();
}
});
it("prepares the workspace, syncs Pi skills, and restores workspace changes for remote SSH execution", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-"));
cleanupDirs.push(rootDir);

View File

@ -9,7 +9,7 @@ import {
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetManagedHomeDir,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
@ -458,8 +458,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (adapterExecutionTargetUsesManagedHome(executionTarget) && preparedRemoteRuntime.runtimeRootDir) {
env.HOME = preparedRemoteRuntime.runtimeRootDir;
const managedRemoteHomeDir = adapterExecutionTargetManagedHomeDir(
executionTarget, preparedRemoteRuntime.runtimeRootDir,
);
if (managedRemoteHomeDir) {
env.HOME = managedRemoteHomeDir;
}
remoteRuntimeRootDir = preparedRemoteRuntime.runtimeRootDir;
remoteSkillsDir = preparedRemoteRuntime.assetDirs.skills ?? null;