mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
First PR: runtime. UI: #15728. Qualification: #15729.
## Thinking Path
> - Paperclip manages agent work through tasks and governed connections.
> - Chat connections already bind external conversations to those tasks.
> - Speko handles voice and telephony, while the selected Paperclip
agent does the work.
> - A call needs durable session authority, ordered follow-ups, and safe
result delivery.
> - This PR adds an explicit voice transport to the existing connection
runtime.
> - The UI and qualification suites follow in linked PRs. Speko stays
experimental.
## Linked Issues or Issue Description
**Problem or motivation**
Phone and browser voice conversations cannot use the chat connection
task lifecycle. A separate voice runtime is needed to preserve task
authority, retries, and reply history.
**Proposed solution**
Add company-scoped voice sessions, signed callbacks, durable replies,
inbound intake, and an agent-facing outbound-call tool. Reuse existing
tasks, queues, secrets, and publications.
**Roadmap alignment**
This extends the experimental chat connector entry points under Agent
Chat and Connected Apps. The project owner requested this feature.
Refs #11418 for the original provider catalog proposal. This
implementation uses the newer chat connection infrastructure rather than
an MCP catalog-only integration. Refs #15721 for remote OpenCode
startup.
## What Changed
- Add additive voice schema, shared contracts, session APIs, and
provider callbacks.
- Bind browser, inbound, and outbound sessions to an endpoint, task, and
selected agent.
- Add low-trust guest intake in an explicitly selected sandbox and keep
ordinary approval rules.
- Submit task work through session-scoped tools and push approved
replies through Speko POST /v1/calls/{id}/messages.
- Distinguish provider acceptance from confirmed spoken playback.
Reconcile reports without downloading recordings.
- Add protocol, migration, persistence, provider, and
delivery-diagnostic tests.
## Verification
- Master 3389bea708 is integrated. Database/shared builds, server
typecheck, migration upgrade/replay checks, and 46
communication-guidance/provider/protocol tests passed.
- The final persistence suite passed 85 real-database tests. Five
focused native continuation/authorization regressions passed. Five
callback ownership/rotation tests passed after the last runtime repair.
- Review repairs cover reconnect ownership, current caller authority,
duplicate delivery, multipart answers, task-page answers, guest question
handling, and saved sandbox recovery.
- A real OpenCode/OpenRouter DeepSeek task ran in Daytona and saved a
correct disk-space answer. This did not establish live-call playback.
- Full local typecheck/build passed before the final repairs; focused
typechecks passed after them. Latest-head CI runs the repository gates.
- The separate integrated browser qualification passed two journeys and
failed browser answer retrieval. The feature remains experimental. The
report in #15729 records this failure and outstanding real-provider
acceptance.
- No lockfiles are included. Speko migrations follow master as
0324–0330.
## Risks
- Voice sessions add migrations and provider-dependent lifecycle
behavior. Migrations preserve existing chat and AI transports.
- Callback origins must be reachable HTTPS. Provider keys and signing
secrets remain vaulted.
- Unverified callers receive task-scoped low-trust authority. Caller ID
does not grant private task access.
- Outbound telephony incurs provider charges. Provider acceptance is not
proof that the caller heard an answer.
- Keep the feature behind the existing experimental chat setting until
the real browser, inbound, outbound, and delayed-result acceptance
matrix passes.
## Model Used
OpenAI Codex, GPT-6, with tool use and code execution. The exact serving
model ID and context window are 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 `#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
- [ ] 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
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
768 lines
27 KiB
TypeScript
768 lines
27 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);
|
|
});
|
|
});
|
|
|
|
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"});
|
|
});
|
|
});
|