mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Fix low-trust sandbox preflight ordering
This commit is contained in:
1 parent
468b52a3e9
commit
e8c17bec49
2 files changed
+201
-1
No files matched your search
@@ -11,14 +11,17 @@ import {
|
||||
formatRuntimeWorkspaceWarningLog,
|
||||
mergeExecutionWorkspaceMetadataForPersistence,
|
||||
mergeCoalescedContextSnapshot,
|
||||
preflightLowTrustWorkspaceIsolation,
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
parseSessionCompactionPolicy,
|
||||
resolveNextSessionState,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
stripHostWorkspaceProvisionForLowTrustSandbox,
|
||||
stripWorkspaceRuntimeFromExecutionRunConfig,
|
||||
shouldResetTaskSessionForWake,
|
||||
type ResolvedWorkspaceForRun,
|
||||
} from "../services/heartbeat.ts";
|
||||
import type { TrustPresetResolution } from "../services/trust-preset-resolver.ts";
|
||||
|
||||
function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}): ResolvedWorkspaceForRun {
|
||||
return {
|
||||
@@ -85,6 +88,131 @@ const truncatingHermesSessionCodec = {
|
||||
},
|
||||
};
|
||||
|
||||
function lowTrustResolution(): TrustPresetResolution {
|
||||
return {
|
||||
kind: "low_trust_review",
|
||||
preset: "low_trust_review",
|
||||
boundary: {
|
||||
mode: "low_trust_review",
|
||||
companyId: "company-1",
|
||||
rootIssueId: "issue-1",
|
||||
},
|
||||
sourcePresets: { agent: "low_trust_review" },
|
||||
};
|
||||
}
|
||||
|
||||
function standardTrustResolution(): TrustPresetResolution {
|
||||
return {
|
||||
kind: "standard",
|
||||
preset: "standard",
|
||||
boundary: null,
|
||||
sourcePresets: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("stripHostWorkspaceProvisionForLowTrustSandbox", () => {
|
||||
it("removes only the host-side provision command for sandbox-backed low-trust runs", () => {
|
||||
const config = {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = stripHostWorkspaceProvisionForLowTrustSandbox({
|
||||
config,
|
||||
trustPreset: lowTrustResolution(),
|
||||
selectedEnvironmentDriver: "sandbox",
|
||||
});
|
||||
|
||||
expect(result).not.toBe(config);
|
||||
expect(result.workspaceStrategy).toEqual({
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
});
|
||||
expect(result.workspaceRuntime).toBe(config.workspaceRuntime);
|
||||
expect(config.workspaceStrategy.provisionCommand).toBe("bash ./scripts/provision-worktree.sh");
|
||||
});
|
||||
|
||||
it("preserves provision commands for standard-trust runs", () => {
|
||||
const config = {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
};
|
||||
|
||||
expect(stripHostWorkspaceProvisionForLowTrustSandbox({
|
||||
config,
|
||||
trustPreset: standardTrustResolution(),
|
||||
selectedEnvironmentDriver: "sandbox",
|
||||
})).toBe(config);
|
||||
});
|
||||
|
||||
it("preserves provision commands when a low-trust run is not sandbox-backed", () => {
|
||||
const config = {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
};
|
||||
|
||||
expect(stripHostWorkspaceProvisionForLowTrustSandbox({
|
||||
config,
|
||||
trustPreset: lowTrustResolution(),
|
||||
selectedEnvironmentDriver: "local",
|
||||
})).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preflightLowTrustWorkspaceIsolation", () => {
|
||||
it("fails non-sandbox low-trust runs before the caller reaches host workspace side effects", async () => {
|
||||
let hostWorkspaceSideEffectReached = false;
|
||||
|
||||
await expect((async () => {
|
||||
await preflightLowTrustWorkspaceIsolation({
|
||||
trustPreset: lowTrustResolution(),
|
||||
isolatedWorkspacesEnabled: true,
|
||||
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
||||
issue: {
|
||||
companyId: "company-1",
|
||||
id: "issue-1",
|
||||
projectId: "project-1",
|
||||
},
|
||||
resolveSelectedEnvironmentDriver: async () => "local",
|
||||
});
|
||||
hostWorkspaceSideEffectReached = true;
|
||||
})()).rejects.toMatchObject({
|
||||
status: 422,
|
||||
details: expect.objectContaining({
|
||||
code: "low_trust_requires_sandbox_environment",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(hostWorkspaceSideEffectReached).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the sandbox driver for sandbox-backed low-trust runs", async () => {
|
||||
await expect(preflightLowTrustWorkspaceIsolation({
|
||||
trustPreset: lowTrustResolution(),
|
||||
isolatedWorkspacesEnabled: true,
|
||||
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
||||
issue: {
|
||||
companyId: "company-1",
|
||||
id: "issue-1",
|
||||
projectId: "project-1",
|
||||
},
|
||||
resolveSelectedEnvironmentDriver: async () => "sandbox",
|
||||
})).resolves.toBe("sandbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
||||
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
||||
const agentId = "agent-123";
|
||||
|
||||
@@ -754,6 +754,53 @@ function buildExecutionWorkspaceConfigSnapshot(
|
||||
return hasSnapshot ? snapshot : null;
|
||||
}
|
||||
|
||||
export function stripHostWorkspaceProvisionForLowTrustSandbox(input: {
|
||||
config: Record<string, unknown>;
|
||||
trustPreset: TrustPresetResolution;
|
||||
selectedEnvironmentDriver: string | null | undefined;
|
||||
}): Record<string, unknown> {
|
||||
if (input.trustPreset.kind !== "low_trust_review") return input.config;
|
||||
if (input.selectedEnvironmentDriver !== "sandbox") return input.config;
|
||||
|
||||
const workspaceStrategy = parseObject(input.config.workspaceStrategy);
|
||||
if (typeof workspaceStrategy.provisionCommand !== "string") return input.config;
|
||||
|
||||
const nextWorkspaceStrategy = { ...workspaceStrategy };
|
||||
delete nextWorkspaceStrategy.provisionCommand;
|
||||
|
||||
return {
|
||||
...input.config,
|
||||
workspaceStrategy: nextWorkspaceStrategy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function preflightLowTrustWorkspaceIsolation(input: {
|
||||
trustPreset: TrustPresetResolution;
|
||||
isolatedWorkspacesEnabled: boolean;
|
||||
effectiveExecutionWorkspaceMode: string | null | undefined;
|
||||
issue: { companyId: string; id?: string | null; projectId?: string | null } | null;
|
||||
resolveSelectedEnvironmentDriver: () => Promise<string | null | undefined>;
|
||||
}): Promise<string | null> {
|
||||
if (input.trustPreset.kind !== "denied" && input.trustPreset.kind !== "low_trust_review") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedEnvironmentDriver =
|
||||
input.trustPreset.kind === "low_trust_review"
|
||||
? await input.resolveSelectedEnvironmentDriver()
|
||||
: null;
|
||||
|
||||
assertLowTrustWorkspaceIsolation({
|
||||
resolution: input.trustPreset,
|
||||
isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled,
|
||||
effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode,
|
||||
selectedEnvironmentDriver,
|
||||
issue: input.issue,
|
||||
});
|
||||
|
||||
return selectedEnvironmentDriver ?? null;
|
||||
}
|
||||
|
||||
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
||||
const trimmed = repoUrl?.trim() ?? "";
|
||||
if (!trimmed) return null;
|
||||
@@ -7599,6 +7646,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
? persistedExecutionWorkspaceMode
|
||||
: requestedExecutionWorkspaceMode;
|
||||
const selectedEnvironmentId = environmentResolution.environmentId;
|
||||
const lowTrustPreflightEnvironmentDriver = await preflightLowTrustWorkspaceIsolation({
|
||||
trustPreset,
|
||||
isolatedWorkspacesEnabled,
|
||||
effectiveExecutionWorkspaceMode,
|
||||
issue: issueRef
|
||||
? {
|
||||
companyId: agent.companyId,
|
||||
id: issueRef.id,
|
||||
projectId: issueRef.projectId,
|
||||
}
|
||||
: null,
|
||||
resolveSelectedEnvironmentDriver: async () => {
|
||||
const preflightEnvironment = await envOrchestrator.resolveEnvironment({
|
||||
companyId: agent.companyId,
|
||||
selectedEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
});
|
||||
return preflightEnvironment.driver;
|
||||
},
|
||||
});
|
||||
const workspaceManagedConfig = shouldReuseExisting
|
||||
? { ...config }
|
||||
: buildExecutionWorkspaceAdapterConfig({
|
||||
@@ -7685,6 +7752,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
...effectiveResolvedConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const hostExecutionWorkspaceConfig = stripHostWorkspaceProvisionForLowTrustSandbox({
|
||||
config: runtimeConfig,
|
||||
trustPreset,
|
||||
selectedEnvironmentDriver: lowTrustPreflightEnvironmentDriver,
|
||||
});
|
||||
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
|
||||
companyId: agent.companyId,
|
||||
heartbeatRunId: run.id,
|
||||
@@ -7733,7 +7805,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
: null;
|
||||
const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({
|
||||
base: executionWorkspaceBase,
|
||||
config: runtimeConfig,
|
||||
config: hostExecutionWorkspaceConfig,
|
||||
issue: issueRef,
|
||||
agent: {
|
||||
id: agent.id,
|
||||
|
||||
Reference in new issue
Block a user