mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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>
790 lines
29 KiB
TypeScript
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"});
|
|
});
|
|
});
|