Files
PaperClipAI/server/src/__tests__/execution-workspace-policy.test.ts
T
Nicky LeachandClaude Opus 5.5 369e238295 fix(projects): make workspace storage the project's only placement choice (#20835)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A repository project gets its working files from core staging or
from a workspace storage plugin
> - #20459 reported that an installed storage plugin attached every new
repository project, so sandbox agents could not use those projects
> - #20587 fixed creation with an **Available in** choice that selects
storage
> - #20651 then saved an environment on every new project and used it
before the agent default to place runs. That solved a problem that did
not exist: the project needs storage, not a run environment
> - Creation saves the first environment of the chosen group, so a
project silently sends every agent's runs to that environment. Project
Configuration also shows two controls (Environment and Workspace
storage) for one decision
> - This pull request removes the project run environment and makes
storage the only placement choice a project owns. Storage is fixed at
creation
> - The benefit is that runs follow the agent again, and project
settings show one clear, read-only fact about storage

## Linked Issues or Issue Description

Refs #20459. Follow-up to #20587 and #20651.

Refs #11644. That issue asks for a project-level run environment
override. This pull request removes the override that #20651 added for
it, because the override is not necessary to fix #20459. #11644 stays a
separate feature request.

Related: #18176 (open) repairs placement through the legacy
workspace-policy field. This pull request does not restore project
placement, so it conflicts with the direction of #18176.

## What Changed

- **Remove the project run environment.** Migration `0466` drops
`projects.environment_id`. Run placement no longer reads the project.
Runs use the agent default, then the instance default, then local
execution. The low-trust task sandbox and the enforced sandbox policies
keep their precedence.
- **Keep the #20651 recovery and binding guards.** A recovered run still
uses its admitted environment lease (source `run`). Company-binding
checks before admission and during lease creation stay.
- **Keep the creation choice.** **Available in** in New Project still
selects storage. It still shows only when the environments need more
than one storage owner. That can only occur when a storage plugin is
installed. `POST /projects` with `environmentId` selects the storage of
that environment and does not save the environment.
- **Fix storage at creation.** `PATCH /projects/:id` rejects any storage
change with `409 project_storage_locked`, from core to a plugin and from
a plugin to core. Before, a core project could move to a plugin.
- **Drop legacy writes.** Older clients can still send
`executionWorkspacePolicy.environmentId`. The service removes it on
create and update.
- **Simplify Project Configuration.** Remove the Environment and
Workspace storage selectors. Show an advisory **Available in** list of
the environments that can use the project's storage. Show it when a
storage plugin is installed or when the project uses one. When the
project's plugin is not installed, show **Storage plugin unavailable**.
Without a storage plugin, show nothing.
- **Issue workspace card.** Remove the "project default" environment
label.
- **Shared contract.** Remove `Project.environmentId`, the
`environmentId` project field, and `expectedStorageProviderPluginId`
from the update schema. The create schema keeps `environmentId` and
`expectedStorageProviderPluginId` from #20587.
- **Docs and maps.** Update `doc/project-environment-compatibility.md`,
`docs/api/goals-and-projects.md`, and the project feature and
agent-access maps.
- **SDK source pins.** Refresh
`tests/runner-e2e/source-sdk-public-inputs.json` for the two shared
files. The shared file blobs return to their values before #20651. The
refresh also corrects the `pnpm-lock.yaml` pin and `lockSha256`:
`master` pins an older lockfile blob than the one it contains.

## Verification

- Targeted server suites pass (227 tests): `project-environments`,
`project-workspace-provider.integration`,
`heartbeat/environment-selection`, `execution-workspace-policy`,
`environment-selection-route-guards`, `environment-run-orchestrator`,
`openapi-routes`.
- Related server suites pass (349 tests): `environment-service`,
`environment-routes`, `plugin-workspaces.integration`, `issues-service`.
- Targeted UI suites pass: `ProjectProperties.concurrency`,
`IssueWorkspaceCard`, `NewProjectDialog`,
`NewProjectDialog.managed-sandbox`, `ProjectDetail`.
- New tests:
- Creation saves storage, not an environment. Core and plugin projects
both reject a storage change with `project_storage_locked`.
  - A legacy policy `environmentId` is dropped on create and update.
- A new run follows the agent default for any project. A recovered run
keeps its admitted lease after the agent default changes.
- Project Configuration has no storage or environment selector. The note
is hidden without a plugin. It lists the core or plugin environments for
the project's storage. It shows **Storage plugin unavailable** for a
removed plugin.
- `pnpm --filter @paperclipai/server typecheck`, `pnpm --filter
@paperclipai/ui typecheck`, and `pnpm --filter @paperclipai/shared
typecheck` pass.
- `pnpm --filter @paperclipai/db check:migrations`, `pnpm
check:token-gates`, `node scripts/check-maps.mjs`, and `node
tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check` pass.
- Not run locally: the full `pnpm test:run`, `pnpm build`, and browser
suites. CI supplies that coverage.
- Manual check: open Project Configuration on an instance with a storage
plugin. Confirm that there is no Environment or Workspace storage
selector, and that **Available in** lists the environments.

## Risks

- **Migration.** `0466` drops `projects.environment_id` and its foreign
key. The migration deletes the saved project environments. Apply it
before you serve the new revision. An old server revision that reads
`projects` fails until it stops. This is the same pattern as earlier
drop-column migrations (`0229`, `0289`).
- **Behavior change for existing projects.** Projects created after
#20651 shipped have a saved environment that overrides the agent. After
this change, their runs follow the agent default again. This is the
intended fix. Before #20651, a project environment selection was not
applied to runs (#11644), so no older behavior is lost.
- **No path from core to a plugin.** A core project, including one
created before a plugin was installed, cannot move to the plugin. To use
plugin storage, create a new project.
- **API.** A `PATCH` that sends `environmentId` or
`expectedStorageProviderPluginId` no longer has an effect. The schema
strips these fields.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) by Anthropic, in Claude Code, with
tool use (shell, file edits, test runs). Extended thinking was
available.

## 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>
2026-11-11 08:00:21 -08:00

790 lines
29 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
issueExecutionWorkspaceSettingsSchema,
projectExecutionWorkspacePolicySchema,
} from "@paperclipai/shared";
import {
applyDefaultIsolatedExecutionWorkspacePolicy,
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
isUnrunnableWorktreeCombo,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings,
parseIssueAssigneeAdapterOverrides,
parseProjectExecutionWorkspacePolicy,
ManagedSandboxUnavailableError,
resolveEffectiveWorkspaceStrategyType,
resolveExecutionWorkspaceEnvironmentId,
resolvePinnedIssueWorkspaceStrategyType,
resolveExecutionWorkspaceMode,
resolveSharedWorkspaceConcurrency,
selectEnvironmentExecutionWorkspaceSettings,
} from "../services/execution-workspace-policy.ts";
describe("execution workspace policy helpers", () => {
it("ignores operator-owned homeMode in stored task overrides while retaining task settings", () => {
expect(parseIssueAssigneeAdapterOverrides({ adapterConfig: { homeMode: "preconfigured", model: "task-model" } }))
.toEqual({ adapterConfig: { model: "task-model" }, useProjectWorkspace: null });
});
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();
expect(defaultIssueExecutionWorkspaceSettingsForProject({ enabled: true })).toBeNull();
});
it.each(["shared_workspace", "operator_branch"] as const)("keeps declared provider strategy for %s", mode => {
const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, issueSettings: null, mode, legacyUseProjectWorkspace: null,
projectPolicy: { enabled: true, storageProviderPluginId: "10000000-0000-4000-8000-000000000001", defaultMode: mode,
workspaceStrategy: { type: "git_worktree", baseRef: "release" } } });
expect(config.workspaceStrategy).toEqual({ type: "git_worktree", baseRef: "release" });
expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree");
});
it.each(["shared_workspace", "operator_branch"] as const)("keeps the primary-source fallback for %s providers without a project strategy", mode => {
const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: { workspaceStrategy: { type: "git_worktree", baseRef: "agent-branch" } },
issueSettings: null, mode, legacyUseProjectWorkspace: null,
projectPolicy: { enabled: true, storageProviderPluginId: "10000000-0000-4000-8000-000000000001" } });
expect(config.workspaceStrategy).toEqual({ type: "project_primary" });
expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("project_primary");
});
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" }],
});
});
describe("partial issue workspace strategies", () => {
const projectStrategy = {
type: "git_worktree" as const,
baseRef: "origin/main",
branchTemplate: "{{issue.identifier}}-{{slug}}",
worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "true",
runtimeProvisionCommand: "npm run setup:runtime",
teardownCommand: "npm run teardown",
};
function resolveStrategy(
strategy: Record<string, unknown>,
enabled = true,
) {
return buildExecutionWorkspaceAdapterConfig({
agentConfig: { workspaceStrategy: { type: "git_worktree", provisionCommand: "agent-setup" } },
projectPolicy: parseProjectExecutionWorkspacePolicy({
enabled,
defaultMode: "isolated_workspace",
workspaceStrategy: projectStrategy,
}),
issueSettings: parseIssueExecutionWorkspaceSettings({
mode: "isolated_workspace",
workspaceStrategy: strategy,
}),
mode: "isolated_workspace",
legacyUseProjectWorkspace: null,
}).workspaceStrategy;
}
it("retains project hooks when an issue changes only its base branch", () => {
expect(resolveStrategy({ type: "git_worktree", baseRef: "origin/release" })).toEqual({
...projectStrategy,
baseRef: "origin/release",
});
});
it.each(["npm run issue-setup", "", null])("honors an explicit provisioning override of %j", (provisionCommand) => {
expect(resolveStrategy({ type: "git_worktree", provisionCommand })).toEqual({
...projectStrategy,
provisionCommand,
});
});
it("preserves explicit null clears through persisted JSON parsing", () => {
const strategy = {
type: "git_worktree",
baseRef: null,
branchTemplate: null,
worktreeParentDir: null,
provisionCommand: null,
runtimeProvisionCommand: null,
teardownCommand: null,
};
expect(resolveStrategy(strategy)).toEqual(strategy);
});
it.each(["cloud_sandbox", "adapter_managed", "project_primary"])("does not carry project hooks into %s", (type) => {
expect(resolveStrategy({ type })).toEqual({ type });
});
it("does not inherit a disabled project strategy", () => {
expect(resolveStrategy({ type: "git_worktree", baseRef: "origin/release" }, false)).toEqual({
type: "git_worktree",
baseRef: "origin/release",
});
expect(resolveStrategy({}, false)).toEqual({
type: "git_worktree",
provisionCommand: "agent-setup",
});
});
it("keeps project hooks for an exact branch pin without inheriting a branch template", () => {
const resolved = resolveStrategy({ type: "git_worktree", existingBranch: "fix/existing" });
expect(resolved).toEqual({
...projectStrategy,
branchTemplate: undefined,
existingBranch: "fix/existing",
});
expect(issueExecutionWorkspaceSettingsSchema.safeParse({
mode: "isolated_workspace",
workspaceStrategy: resolved,
}).success).toBe(true);
});
it("does not mutate the project or issue strategy", () => {
const issueStrategy = { type: "git_worktree" as const, baseRef: "origin/release" };
const result = buildExecutionWorkspaceAdapterConfig({
agentConfig: {},
projectPolicy: { enabled: true, workspaceStrategy: Object.freeze({ ...projectStrategy }) },
issueSettings: { workspaceStrategy: Object.freeze(issueStrategy) },
mode: "isolated_workspace",
legacyUseProjectWorkspace: null,
});
expect(result.workspaceStrategy).not.toBe(issueStrategy);
expect(issueStrategy).toEqual({ type: "git_worktree", baseRef: "origin/release" });
expect(projectStrategy.baseRef).toBe("origin/main");
});
});
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" });
});
});
describe("operator default isolated execution workspaces", () => {
const withDefault = (
projectPolicy: Parameters<
typeof applyDefaultIsolatedExecutionWorkspacePolicy
>[0]["projectPolicy"],
hasProjectWorkspace = true,
defaultIsolatedWorkspacesEnabled = true,
) =>
applyDefaultIsolatedExecutionWorkspacePolicy({
projectPolicy,
defaultIsolatedWorkspacesEnabled,
hasProjectWorkspace,
});
it("substitutes an isolated policy for a project that stores none", () => {
expect(withDefault(null)).toEqual({
enabled: true,
defaultMode: "isolated_workspace",
});
});
it("leaves everything alone while the operator default is off", () => {
expect(withDefault(null, true, false)).toBeNull();
});
it("keeps a task that has no project on its existing behavior", () => {
// Isolation needs a repository to cut a worktree from. A project-less task
// (agent chat, for example) must not be pulled into worktree mode.
expect(withDefault(null, false)).toBeNull();
});
it("keeps a project without a configured workspace on its existing behavior", () => {
const projectPolicy = withDefault(null, false);
expect(projectPolicy).toBeNull();
expect(resolveExecutionWorkspaceMode({
projectPolicy,
issueSettings: null,
legacyUseProjectWorkspace: null,
})).toBe("shared_workspace");
expect(withDefault({ enabled: true, defaultMode: "isolated_workspace" }, false))
.toEqual({ enabled: true, defaultMode: "isolated_workspace" });
});
it("never overrides a policy the project already stores", () => {
expect(withDefault({ enabled: true, defaultMode: "shared_workspace" })).toEqual({
enabled: true,
defaultMode: "shared_workspace",
});
// `enabled: false` is a tenant decision to stay on the shared checkout,
// not an absent policy to fill in.
expect(withDefault({ enabled: false })).toEqual({ enabled: false });
});
it("resolves an unpolicied project's tasks to an isolated workspace", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: withDefault(null),
issueSettings: null,
legacyUseProjectWorkspace: null,
}),
).toBe("isolated_workspace");
});
it("still lets an explicit issue setting win over the operator default", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: withDefault(null),
issueSettings: { mode: "shared_workspace" },
legacyUseProjectWorkspace: null,
}),
).toBe("shared_workspace");
});
it("keeps mode and strategy coherent for the substituted policy", () => {
// Substituting a policy (rather than moving the terminal fallback) is what
// makes `hasWorkspaceControl` true, so the default git_worktree strategy is
// supplied instead of leaving isolated mode on a project_primary strategy.
const projectPolicy = withDefault(null);
const mode = resolveExecutionWorkspaceMode({
projectPolicy,
issueSettings: null,
legacyUseProjectWorkspace: null,
});
const config = buildExecutionWorkspaceAdapterConfig({
agentConfig: {},
projectPolicy,
issueSettings: null,
mode,
legacyUseProjectWorkspace: null,
});
expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree");
});
it("does not strand a project-less task as an unrunnable worktree", () => {
const projectPolicy = withDefault(null, false);
const mode = resolveExecutionWorkspaceMode({
projectPolicy,
issueSettings: null,
legacyUseProjectWorkspace: null,
});
const config = buildExecutionWorkspaceAdapterConfig({
agentConfig: {},
projectPolicy,
issueSettings: null,
mode,
legacyUseProjectWorkspace: null,
});
expect(
isUnrunnableWorktreeCombo({
issue: {
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
},
resolvedMode: mode,
resolvedStrategy: resolveEffectiveWorkspaceStrategyType(mode, config),
}),
).toBe(false);
});
});
describe("task-scoped low-trust environment", () => {
it("uses the explicit phone-task sandbox while preserving ordinary agent defaults", () => {
const defaults = {agentDefaultEnvironmentId: "agent-env", instanceDefaultEnvironmentId: "instance-env", localDefaultEnvironmentId: "local-env"};
expect(resolveExecutionWorkspaceEnvironmentId({...defaults, lowTrustIssueEnvironmentId: "phone-sandbox"})).toEqual({environmentId: "phone-sandbox", source: "issue"});
expect(resolveExecutionWorkspaceEnvironmentId(defaults)).toEqual({environmentId: "agent-env", source: "agent"});
});
});