mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 20:50:08 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Project workspace policies define how isolated worktrees are set up. > - Tasks can override a branch without providing every setup field. > - The resolver currently replaces the entire project strategy with that partial override. > - Losing an explicit setup command can run the repository fallback script and block the task. > - This pull request keeps enabled project defaults when the task uses the same strategy type. ## Linked Issues or Issue Description **What happened?** A project uses `git_worktree` with `provisionCommand: "true"`. A task overrides only `baseRef`. The resolver drops the command. Worktree creation then invokes `scripts/provision-worktree.sh`, which can fail because its required setup is absent. **Expected behavior** A branch override keeps the project's provision, runtime provision, and teardown commands unless the task explicitly overrides them. A different strategy type must not inherit those commands. **Steps to reproduce** Configure the project with an enabled `git_worktree` strategy and `provisionCommand: "true"`. Give the task an isolated workspace with a `git_worktree` strategy and a different `baseRef`. Add a failing repository fallback provisioner. Before this change, worktree creation invokes that script. After this change, it uses the project's explicit command and succeeds. Related: #4968 concerns agent strategy and working-directory fallback. #13903 concerns gated API fields and reusable-workspace updates. #11091 concerns provision hooks on workspace reuse. None fixes partial task overrides discarding project defaults. ## What Changed - Merge a partial task strategy over the enabled project's strategy only when their types match. - Preserve explicit null values when parsing nullable strategy fields, so they can clear project values. - Keep explicit empty-string overrides and agent fallback behavior. - Exclude disabled project strategies and avoid an inherited branch template when a task pins an existing branch. - Add policy regression coverage and a real Git worktree test with a failing fallback script. - Document inheritance, explicit clearing, and no-op provisioning in the development guide. ## Verification - Policy regression: eight failures before the fix; all 41 policy tests pass after it. - Real worktree regression: passes and creates a worktree using the task's base branch without invoking the failing fallback provisioner. - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - `pnpm test:run`: general-server phase completed with 13,873 passed, 86 skipped, and 14 failures in unchanged macOS skills-cache and Git long-path tests. The same failures reproduce on unmodified base code. The command stops at that phase, so no full local pass is claimed. - CI initially failed the existing Telegram subscription recovery test on a 15-second timeout. The separate fix and investigation are in #14501. A serialized job also lost its runner; GitHub reported lost communication, and that job was rerun without source changes. All 52 final-commit checks pass, with two intentional skips. Greptile is 5/5, with no unresolved comments or merge conflicts. The chat shard passed on one unchanged rerun. The timeout cause remains unproven; #14501 adds phase diagnostics for a recurrence. ## Risks Tasks that specify a partial strategy now retain the project's omitted fields, including setup and teardown hooks. This is the intended behavior change. Inheritance requires an enabled project policy and matching strategy types. Explicit task values still win. Null and empty commands restore existing runtime defaults; they do not guarantee that no script runs. Use `"true"` for an explicit no-op provision command. No migration, live configuration change, or task replay is included. ## Model Used OpenAI GPT-6 (Codex), with reasoning, terminal tools, and code execution. The context window size is not exposed in this session. ## 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 references) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run focused tests locally and they pass; full-suite status is recorded above - [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 Co-authored-by: Paperclip <noreply@paperclip.ing>
760 lines
26 KiB
TypeScript
760 lines
26 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
issueExecutionWorkspaceSettingsSchema,
|
|
projectExecutionWorkspacePolicySchema,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
applyDefaultIsolatedExecutionWorkspacePolicy,
|
|
buildExecutionWorkspaceAdapterConfig,
|
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
|
gateProjectExecutionWorkspacePolicy,
|
|
isUnrunnableWorktreeCombo,
|
|
issueExecutionWorkspaceModeForPersistedWorkspace,
|
|
parseIssueExecutionWorkspaceSettings,
|
|
parseProjectExecutionWorkspacePolicy,
|
|
ManagedSandboxUnavailableError,
|
|
resolveEffectiveWorkspaceStrategyType,
|
|
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" }],
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|