535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
issueExecutionWorkspaceSettingsSchema,
|
|
projectExecutionWorkspacePolicySchema,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
buildExecutionWorkspaceAdapterConfig,
|
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
|
gateProjectExecutionWorkspacePolicy,
|
|
isUnrunnableWorktreeCombo,
|
|
issueExecutionWorkspaceModeForPersistedWorkspace,
|
|
parseIssueExecutionWorkspaceSettings,
|
|
parseProjectExecutionWorkspacePolicy,
|
|
ManagedSandboxUnavailableError,
|
|
resolveExecutionWorkspaceEnvironmentId,
|
|
resolvePinnedIssueWorkspaceStrategyType,
|
|
resolveExecutionWorkspaceMode,
|
|
resolveSharedWorkspaceConcurrency,
|
|
selectEnvironmentExecutionWorkspaceSettings,
|
|
} from "../services/execution-workspace-policy.ts";
|
|
|
|
describe("execution workspace policy helpers", () => {
|
|
it("defaults new issue settings from enabled project policy", () => {
|
|
expect(
|
|
defaultIssueExecutionWorkspaceSettingsForProject({
|
|
enabled: true,
|
|
defaultMode: "isolated_workspace",
|
|
}),
|
|
).toEqual({ mode: "isolated_workspace" });
|
|
expect(
|
|
defaultIssueExecutionWorkspaceSettingsForProject({
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
}),
|
|
).toEqual({ mode: "shared_workspace" });
|
|
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
|
|
});
|
|
|
|
it("prefers explicit issue mode over project policy and legacy overrides", () => {
|
|
expect(
|
|
resolveExecutionWorkspaceMode({
|
|
projectPolicy: { enabled: true, defaultMode: "shared_workspace" },
|
|
issueSettings: { mode: "isolated_workspace" },
|
|
legacyUseProjectWorkspace: false,
|
|
}),
|
|
).toBe("isolated_workspace");
|
|
});
|
|
|
|
it("resolves shared-workspace concurrency from issue override, project policy, then auto", () => {
|
|
expect(
|
|
resolveSharedWorkspaceConcurrency({
|
|
projectPolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" },
|
|
issueSettings: { sharedWorkspaceConcurrency: "allow" },
|
|
}),
|
|
).toBe("allow");
|
|
expect(
|
|
resolveSharedWorkspaceConcurrency({
|
|
projectPolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" },
|
|
issueSettings: null,
|
|
}),
|
|
).toBe("serialize");
|
|
expect(
|
|
resolveSharedWorkspaceConcurrency({
|
|
projectPolicy: { enabled: false, sharedWorkspaceConcurrency: "serialize" },
|
|
issueSettings: null,
|
|
}),
|
|
).toBe("auto");
|
|
expect(resolveSharedWorkspaceConcurrency({ projectPolicy: null, issueSettings: null })).toBe("auto");
|
|
});
|
|
|
|
it("validates the shared-workspace concurrency enum on project and issue settings", () => {
|
|
expect(projectExecutionWorkspacePolicySchema.parse({
|
|
enabled: true,
|
|
sharedWorkspaceConcurrency: "auto",
|
|
}).sharedWorkspaceConcurrency).toBe("auto");
|
|
expect(issueExecutionWorkspaceSettingsSchema.parse({
|
|
sharedWorkspaceConcurrency: "allow",
|
|
}).sharedWorkspaceConcurrency).toBe("allow");
|
|
expect(projectExecutionWorkspacePolicySchema.safeParse({
|
|
enabled: true,
|
|
sharedWorkspaceConcurrency: "parallel",
|
|
}).success).toBe(false);
|
|
});
|
|
|
|
it("accepts an existing-branch pin only with isolated mode and a git_worktree strategy", () => {
|
|
expect(issueExecutionWorkspaceSettingsSchema.parse({
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
existingBranch: "PAP-14380-salvage-pap-9514",
|
|
},
|
|
}).workspaceStrategy?.existingBranch).toBe("PAP-14380-salvage-pap-9514");
|
|
|
|
// Fail closed at the contract layer: an exact-branch pin outside an
|
|
// isolated git worktree could silently land in the shared checkout.
|
|
expect(issueExecutionWorkspaceSettingsSchema.safeParse({
|
|
workspaceStrategy: { type: "git_worktree", existingBranch: "some-branch" },
|
|
}).success).toBe(false);
|
|
expect(issueExecutionWorkspaceSettingsSchema.safeParse({
|
|
mode: "shared_workspace",
|
|
workspaceStrategy: { type: "git_worktree", existingBranch: "some-branch" },
|
|
}).success).toBe(false);
|
|
expect(issueExecutionWorkspaceSettingsSchema.safeParse({
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "project_primary", existingBranch: "some-branch" },
|
|
}).success).toBe(false);
|
|
expect(issueExecutionWorkspaceSettingsSchema.safeParse({
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
existingBranch: "some-branch",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
},
|
|
}).success).toBe(false);
|
|
|
|
for (const invalidBranch of ["-leading-dash", "a..b", "has space", "ends/", "back\\slash", "a.lock", "../escape"]) {
|
|
expect(issueExecutionWorkspaceSettingsSchema.safeParse({
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree", existingBranch: invalidBranch },
|
|
}).success).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("carries the existing-branch pin through issue settings parsing", () => {
|
|
expect(
|
|
parseIssueExecutionWorkspaceSettings({
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree", existingBranch: " PAP-14754-run-redaction " },
|
|
})?.workspaceStrategy,
|
|
).toEqual({ type: "git_worktree", existingBranch: "PAP-14754-run-redaction" });
|
|
});
|
|
|
|
it("centralizes unrunnable isolated worktree detection", () => {
|
|
expect(
|
|
isUnrunnableWorktreeCombo({
|
|
issue: {
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
},
|
|
resolvedMode: "isolated_workspace",
|
|
resolvedStrategy: "git_worktree",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
isUnrunnableWorktreeCombo({
|
|
issue: {
|
|
projectId: "project-1",
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
},
|
|
resolvedMode: "isolated_workspace",
|
|
resolvedStrategy: "git_worktree",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isUnrunnableWorktreeCombo({
|
|
issue: {
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: "workspace-1",
|
|
executionWorkspacePreference: "reuse_existing",
|
|
},
|
|
resolvedMode: "isolated_workspace",
|
|
resolvedStrategy: "git_worktree",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isUnrunnableWorktreeCombo({
|
|
issue: {
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
},
|
|
resolvedMode: "shared_workspace",
|
|
resolvedStrategy: "git_worktree",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isUnrunnableWorktreeCombo({
|
|
issue: {
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
},
|
|
resolvedMode: "agent_default",
|
|
resolvedStrategy: "git_worktree",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isUnrunnableWorktreeCombo({
|
|
issue: {
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
},
|
|
resolvedMode: "operator_branch",
|
|
resolvedStrategy: "git_worktree",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
isUnrunnableWorktreeCombo({
|
|
issue: {
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
},
|
|
resolvedMode: "isolated_workspace",
|
|
resolvedStrategy: "git_worktree",
|
|
hasResolvablePriorSessionWorkspace: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("mirrors runtime default (project_primary) when pinned settings omit strategy type", () => {
|
|
// Mode-only pin without explicit workspaceStrategy.type → same project_primary default as runtime.
|
|
expect(
|
|
resolvePinnedIssueWorkspaceStrategyType({
|
|
mode: "isolated_workspace",
|
|
issueSettings: { mode: "isolated_workspace" },
|
|
}),
|
|
).toBe("project_primary");
|
|
// Explicit strategy type is always respected.
|
|
expect(
|
|
resolvePinnedIssueWorkspaceStrategyType({
|
|
mode: "isolated_workspace",
|
|
issueSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "git_worktree" },
|
|
},
|
|
}),
|
|
).toBe("git_worktree");
|
|
expect(
|
|
resolvePinnedIssueWorkspaceStrategyType({
|
|
mode: "isolated_workspace",
|
|
issueSettings: {
|
|
mode: "isolated_workspace",
|
|
workspaceStrategy: { type: "project_primary" },
|
|
},
|
|
}),
|
|
).toBe("project_primary");
|
|
});
|
|
|
|
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
|
|
expect(
|
|
resolveExecutionWorkspaceMode({
|
|
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
|
issueSettings: null,
|
|
legacyUseProjectWorkspace: false,
|
|
}),
|
|
).toBe("isolated_workspace");
|
|
expect(
|
|
resolveExecutionWorkspaceMode({
|
|
projectPolicy: null,
|
|
issueSettings: null,
|
|
legacyUseProjectWorkspace: false,
|
|
}),
|
|
).toBe("agent_default");
|
|
});
|
|
|
|
it("applies project policy strategy and runtime defaults when isolation is enabled", () => {
|
|
const result = buildExecutionWorkspaceAdapterConfig({
|
|
agentConfig: {
|
|
workspaceStrategy: { type: "project_primary" },
|
|
},
|
|
projectPolicy: {
|
|
enabled: true,
|
|
defaultMode: "isolated_workspace",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/main",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh",
|
|
},
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev" }],
|
|
},
|
|
},
|
|
issueSettings: null,
|
|
mode: "isolated_workspace",
|
|
legacyUseProjectWorkspace: null,
|
|
});
|
|
|
|
expect(result.workspaceStrategy).toEqual({
|
|
type: "git_worktree",
|
|
baseRef: "origin/main",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh",
|
|
});
|
|
expect(result.workspaceRuntime).toEqual({
|
|
services: [{ name: "web", command: "pnpm dev" }],
|
|
});
|
|
});
|
|
|
|
it("preserves project authorization policy for trust-preset resolution", () => {
|
|
expect(parseProjectExecutionWorkspacePolicy({
|
|
enabled: true,
|
|
authorizationPolicy: {
|
|
trustBoundary: {
|
|
mode: "low_trust_review",
|
|
projectIds: ["33333333-3333-4333-8333-333333333333"],
|
|
},
|
|
},
|
|
})?.authorizationPolicy).toEqual({
|
|
trustBoundary: {
|
|
mode: "low_trust_review",
|
|
projectIds: ["33333333-3333-4333-8333-333333333333"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("clears managed workspace strategy when issue opts out to project primary or agent default", () => {
|
|
const baseConfig = {
|
|
workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" },
|
|
workspaceRuntime: { services: [{ name: "web" }] },
|
|
};
|
|
|
|
expect(
|
|
buildExecutionWorkspaceAdapterConfig({
|
|
agentConfig: baseConfig,
|
|
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
|
issueSettings: { mode: "shared_workspace" },
|
|
mode: "shared_workspace",
|
|
legacyUseProjectWorkspace: null,
|
|
}).workspaceStrategy,
|
|
).toBeUndefined();
|
|
|
|
const agentDefault = buildExecutionWorkspaceAdapterConfig({
|
|
agentConfig: baseConfig,
|
|
projectPolicy: null,
|
|
issueSettings: { mode: "agent_default" },
|
|
mode: "agent_default",
|
|
legacyUseProjectWorkspace: null,
|
|
});
|
|
expect(agentDefault.workspaceStrategy).toBeUndefined();
|
|
expect(agentDefault.workspaceRuntime).toBeUndefined();
|
|
});
|
|
|
|
it("parses persisted JSON payloads into typed project and issue workspace settings", () => {
|
|
expect(
|
|
parseProjectExecutionWorkspacePolicy({
|
|
enabled: true,
|
|
sharedWorkspaceConcurrency: "serialize",
|
|
defaultMode: "isolated",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh",
|
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
|
},
|
|
}),
|
|
).toEqual({
|
|
enabled: true,
|
|
sharedWorkspaceConcurrency: "serialize",
|
|
defaultMode: "isolated_workspace",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh",
|
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
|
},
|
|
});
|
|
expect(
|
|
parseIssueExecutionWorkspaceSettings({
|
|
mode: "project_primary",
|
|
environmentId: "11111111-1111-4111-8111-111111111111",
|
|
}),
|
|
).toEqual({
|
|
mode: "shared_workspace",
|
|
});
|
|
expect(
|
|
parseIssueExecutionWorkspaceSettings(
|
|
{
|
|
mode: "project_primary",
|
|
environmentId: "11111111-1111-4111-8111-111111111111",
|
|
},
|
|
{ includeEnvironmentId: true },
|
|
),
|
|
).toEqual({
|
|
mode: "shared_workspace",
|
|
environmentId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
expect(
|
|
parseIssueExecutionWorkspaceSettings({
|
|
mode: "isolated_workspace",
|
|
sharedWorkspaceConcurrency: "allow",
|
|
networkEgress: {
|
|
allowFqdns: ["github.com", "pypi.org"],
|
|
allowCidrs: ["203.0.113.0/24"],
|
|
},
|
|
}),
|
|
).toEqual({
|
|
mode: "isolated_workspace",
|
|
sharedWorkspaceConcurrency: "allow",
|
|
networkEgress: {
|
|
allowFqdns: ["github.com", "pypi.org"],
|
|
allowCidrs: ["203.0.113.0/24"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps egress grants independent from isolated workspace mode", () => {
|
|
const parsedSettings = {
|
|
mode: "isolated_workspace" as const,
|
|
workspaceRuntime: { image: "example/image" },
|
|
networkEgress: {
|
|
allowFqdns: ["github.com"],
|
|
allowCidrs: ["203.0.113.0/24"],
|
|
},
|
|
};
|
|
|
|
expect(selectEnvironmentExecutionWorkspaceSettings(parsedSettings, false)).toEqual({
|
|
networkEgress: parsedSettings.networkEgress,
|
|
});
|
|
expect(selectEnvironmentExecutionWorkspaceSettings(parsedSettings, true)).toEqual(parsedSettings);
|
|
expect(selectEnvironmentExecutionWorkspaceSettings({ mode: "isolated_workspace" }, false)).toBeNull();
|
|
});
|
|
|
|
it("prefers the agent default environment", () => {
|
|
expect(
|
|
resolveExecutionWorkspaceEnvironmentId({
|
|
agentDefaultEnvironmentId: "agent-env",
|
|
instanceDefaultEnvironmentId: "instance-env",
|
|
localDefaultEnvironmentId: "local-env",
|
|
}),
|
|
).toEqual({
|
|
environmentId: "agent-env",
|
|
source: "agent",
|
|
});
|
|
});
|
|
|
|
it("falls back to the instance default environment when the agent has none", () => {
|
|
expect(
|
|
resolveExecutionWorkspaceEnvironmentId({
|
|
agentDefaultEnvironmentId: null,
|
|
instanceDefaultEnvironmentId: "instance-env",
|
|
localDefaultEnvironmentId: "local-env",
|
|
}),
|
|
).toEqual({
|
|
environmentId: "instance-env",
|
|
source: "instance",
|
|
});
|
|
});
|
|
|
|
it("falls back to the built-in local environment when neither agent nor instance selects one", () => {
|
|
expect(
|
|
resolveExecutionWorkspaceEnvironmentId({
|
|
agentDefaultEnvironmentId: null,
|
|
instanceDefaultEnvironmentId: null,
|
|
localDefaultEnvironmentId: "local-env",
|
|
}),
|
|
).toEqual({
|
|
environmentId: "local-env",
|
|
source: "default",
|
|
});
|
|
});
|
|
|
|
it("redirects local-landing selections to the managed sandbox under managed-sandbox-only", () => {
|
|
// The default fallback and an explicit local selection both land on the
|
|
// managed environment; a non-local selection stays untouched.
|
|
expect(
|
|
resolveExecutionWorkspaceEnvironmentId({
|
|
agentDefaultEnvironmentId: null,
|
|
instanceDefaultEnvironmentId: null,
|
|
localDefaultEnvironmentId: "local-env",
|
|
managedSandboxOnly: true,
|
|
managedSandboxEnvironmentId: "managed-env",
|
|
}),
|
|
).toEqual({ environmentId: "managed-env", source: "managed" });
|
|
expect(
|
|
resolveExecutionWorkspaceEnvironmentId({
|
|
agentDefaultEnvironmentId: "local-env",
|
|
instanceDefaultEnvironmentId: null,
|
|
localDefaultEnvironmentId: "local-env",
|
|
managedSandboxOnly: true,
|
|
managedSandboxEnvironmentId: "managed-env",
|
|
}),
|
|
).toEqual({ environmentId: "managed-env", source: "managed" });
|
|
expect(
|
|
resolveExecutionWorkspaceEnvironmentId({
|
|
agentDefaultEnvironmentId: "ssh-env",
|
|
instanceDefaultEnvironmentId: null,
|
|
localDefaultEnvironmentId: "local-env",
|
|
managedSandboxOnly: true,
|
|
managedSandboxEnvironmentId: "managed-env",
|
|
}),
|
|
).toEqual({ environmentId: "ssh-env", source: "agent" });
|
|
});
|
|
|
|
it("fails closed — never local — when managed-sandbox-only has no managed environment", () => {
|
|
expect(() =>
|
|
resolveExecutionWorkspaceEnvironmentId({
|
|
agentDefaultEnvironmentId: null,
|
|
instanceDefaultEnvironmentId: null,
|
|
localDefaultEnvironmentId: "local-env",
|
|
managedSandboxOnly: true,
|
|
managedSandboxEnvironmentId: null,
|
|
}),
|
|
).toThrow(ManagedSandboxUnavailableError);
|
|
});
|
|
|
|
it("maps persisted execution workspace modes back to issue settings", () => {
|
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace");
|
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch");
|
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace");
|
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default");
|
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default");
|
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace(null)).toBe("agent_default");
|
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace(undefined)).toBe("agent_default");
|
|
});
|
|
|
|
it("disables project execution workspace policy when the instance flag is off", () => {
|
|
expect(
|
|
gateProjectExecutionWorkspacePolicy(
|
|
{ enabled: true, defaultMode: "isolated_workspace" },
|
|
false,
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
gateProjectExecutionWorkspacePolicy(
|
|
{ enabled: true, defaultMode: "isolated_workspace" },
|
|
true,
|
|
),
|
|
).toEqual({ enabled: true, defaultMode: "isolated_workspace" });
|
|
});
|
|
});
|