fix: preserve scoped sandbox home in legacy adapters
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
30b9cd8c00
commit
53e7067413
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue