From b014a5ad1e4e34b26152bf8f475f64bdfa87a0a7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:25:25 -0500 Subject: [PATCH] fix: align workspace source authorization with admission strategy Co-Authored-By: Paperclip --- .../issue-activity-events-routes.test.ts | 1 + server/src/__tests__/run-trust-preset.test.ts | 5 +- .../__tests__/task-workspace-binding.test.ts | 50 +++++++++++++++ .../services/execution-workspace-policy.ts | 26 +++++++- server/src/services/execution-workspaces.ts | 62 +++++++++++++++---- server/src/services/heartbeat.ts | 35 ++--------- server/src/services/issues.ts | 18 +++--- 7 files changed, 142 insertions(+), 55 deletions(-) diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 3806285217..9f9a5f26a5 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -195,6 +195,7 @@ function issueUpdateWithReceipt(issue: ReturnType, patch: Reco actorRunId: _actorRunId, actorRunStopId: _actorRunStopId, actorUserId: _actorUserId, + workspaceSelectionActor: _workspaceSelectionActor, blockedByIssueIds: _blockedByIssueIds, ...issuePatch } = patch; diff --git a/server/src/__tests__/run-trust-preset.test.ts b/server/src/__tests__/run-trust-preset.test.ts index d323f0f2b7..a5d72d7087 100644 --- a/server/src/__tests__/run-trust-preset.test.ts +++ b/server/src/__tests__/run-trust-preset.test.ts @@ -62,7 +62,10 @@ it("dispatch retains raw trust before workspace and broker setup", () => { const dispatch = heartbeat.slice(start, end); expect(start).toBeGreaterThan(0); expect(dispatch).toContain( - "executionWorkspacePolicy: projectContext.executionWorkspacePolicy", + "executionWorkspacePolicy: taskProjectContext.executionWorkspacePolicy", + ); + expect(dispatch).toContain( + "executionWorkspacePolicy: workspaceProjectContext.executionWorkspacePolicy", ); expect(dispatch).toContain( "context.executionPolicy = retainedTrust.executionPolicy", diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts index 53d32edc49..91542a6ac8 100644 --- a/server/src/__tests__/task-workspace-binding.test.ts +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -334,6 +334,56 @@ const support = await getEmbeddedPostgresTestSupport(); } finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); } }); + it.each(["create", "patch", "selection"] as const)("uses the assigned agent's effective source strategy for %s authorization", async operation => { + const f = await fixture(), userId = randomUUID(), settings = instanceSettingsService(db); + const previous = await settings.getExperimental(); + await settings.updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: false }); + try { + await db.insert(authUsers).values({ id: userId, name: "Source reader", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" }); + const [isolatedAgent, sharedAgent] = await db.insert(agents).values(["git_worktree", "project_primary"].map(type => ({ + companyId: f.companyId, name: type, status: "idle", adapterConfig: { workspaceStrategy: { type } }, + runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } }, + }))).returning(); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Read-only source", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: `/tmp/source-${f.issueId}` }).returning(); + const actor = { type: "board" as const, source: "session" as const, userId, companyIds: [f.companyId], isInstanceAdmin: false }; + const sharedOverrides = { adapterConfig: { workspaceStrategy: { type: "project_primary" } } }; + const tasks = issueService(db), workspaces = executionWorkspaceService(db); + if (operation === "create") { + const input = { title: "Read source in separate checkout", assigneeAgentId: isolatedAgent.id, + projectWorkspaceId: source.id, workspaceSelectionActor: actor, createdByUserId: userId }; + await expect(tasks.create(f.companyId, { ...input, assigneeAdapterOverrides: sharedOverrides })).rejects.toThrow(/protected/); + await expect(tasks.create(f.companyId, input)).resolves.toMatchObject({ assigneeAgentId: isolatedAgent.id, projectWorkspaceId: source.id }); + await expect(tasks.create(f.companyId, { ...input, workspaceSelection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "managed_isolated" }, + assigneeAdapterOverrides: sharedOverrides })).rejects.toThrow(/protected/); + } else if (operation === "patch") { + await db.update(issues).set({ assigneeAgentId: sharedAgent.id }).where(eq(issues.id, f.issueId)); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api", issueRoutes(db, {} as never)); app.use(errorHandler); + const patch = { projectWorkspaceId: source.id, assigneeAgentId: isolatedAgent.id }; + const denied = await request(app).patch(`/api/issues/${f.issueId}`).send({ ...patch, assigneeAdapterOverrides: sharedOverrides }); + expect(denied.status, JSON.stringify(denied.body)).toBe(403); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ assigneeAgentId: sharedAgent.id, projectWorkspaceId: null }); + const allowed = await request(app).patch(`/api/issues/${f.issueId}`).send(patch); + expect(allowed.status, JSON.stringify(allowed.body)).toBe(200); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ assigneeAgentId: isolatedAgent.id, projectWorkspaceId: source.id }); + } else { + await db.update(issues).set({ assigneeAgentId: isolatedAgent.id, assigneeAdapterOverrides: sharedOverrides }).where(eq(issues.id, f.issueId)); + const input = { companyId: f.companyId, issueId: f.issueId, actor, + selection: { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "managed_isolated" as const } }; + await expect(workspaces.validateSelection(input)).rejects.toThrow(/protected/); + await db.update(issues).set({ assigneeAdapterOverrides: null }).where(eq(issues.id, f.issueId)); + await expect(workspaces.validateSelection(input)).resolves.toMatchObject({ projectWorkspaceId: source.id }); + await db.update(issues).set({ assigneeAgentId: sharedAgent.id }).where(eq(issues.id, f.issueId)); + await expect(workspaces.validateSelection(input)).rejects.toThrow(/protected/); + } + } finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces, enableIsolatedWorkspacesByDefault: previous.enableIsolatedWorkspacesByDefault }); } + }); + it("requires source project assignment authority for shared files while preserving isolated reads", async () => { const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db); const settings = instanceSettingsService(db), previous = await settings.getExperimental(); diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 6d0cff02df..b815f18c5e 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -393,6 +393,29 @@ export function resolveExecutionWorkspaceMode(input: { return "shared_workspace"; } +interface ParsedIssueAssigneeAdapterOverrides { + adapterConfig: Record | null; + useProjectWorkspace: boolean | null; +} + +export function parseIssueAssigneeAdapterOverrides( + raw: unknown, +): ParsedIssueAssigneeAdapterOverrides | null { + const parsed = parseObject(raw); + const parsedAdapterConfig = parseObject(parsed.adapterConfig); + const adapterConfig = + Object.keys(parsedAdapterConfig).length > 0 ? parsedAdapterConfig : null; + const useProjectWorkspace = + typeof parsed.useProjectWorkspace === "boolean" + ? parsed.useProjectWorkspace + : null; + if (!adapterConfig && useProjectWorkspace === null) return null; + return { + adapterConfig, + useProjectWorkspace, + }; +} + function parseSharedWorkspaceConcurrency(raw: unknown): SharedWorkspaceConcurrency | undefined { return raw === "auto" || raw === "serialize" || raw === "allow" ? raw : undefined; } @@ -408,6 +431,7 @@ export function resolveSharedWorkspaceConcurrency(input: { export function buildExecutionWorkspaceAdapterConfig(input: { agentConfig: Record; + adapterConfigOverrides?: Record | null; projectPolicy: ProjectExecutionWorkspacePolicy | null; issueSettings: IssueExecutionWorkspaceSettings | null; mode: ParsedExecutionWorkspaceMode; @@ -450,7 +474,7 @@ export function buildExecutionWorkspaceAdapterConfig(input: { } } - return nextConfig; + return { ...nextConfig, ...input.adapterConfigOverrides }; } /** Persisted filesystem authority survives UI feature-flag changes. */ diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 46417d5b2a..0e82f0d25d 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1,4 +1,7 @@ import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js"; +import { parseObject } from "../adapters/utils.js"; +import { resolveCoreTrustPreset } from "./trust-preset-resolver.js"; +import { instanceSettingsService } from "./instance-settings.js"; import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js"; import { executionWorkspaceRepositoryService } from "./execution-workspace-repositories.js"; import { forbidden } from "../errors.js"; @@ -15,6 +18,7 @@ import { and, asc, desc, eq, gt, inArray, isNull, lte, ne, or, sql } from "drizz import type { Db } from "@paperclipai/db"; import { executionWorkspaces, + agents, heartbeatRuns, nativeRunFinalizations, issueComments, @@ -50,7 +54,7 @@ import { normalizeIssueExecutionPolicy, parseIssueExecutionState, } from "./issue-execution-policy.js"; -import { buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType, parseProjectExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy, parseIssueExecutionWorkspaceSettings, resolveExecutionWorkspaceMode } from "./execution-workspace-policy.js"; +import { buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType, parseProjectExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy, parseIssueExecutionWorkspaceSettings, resolveExecutionWorkspaceMode, parseIssueAssigneeAdapterOverrides, applyDefaultIsolatedExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { issueRecoveryActionService } from "./issue-recovery-actions.js"; import { logActivity } from "./activity-log.js"; import { @@ -1287,8 +1291,43 @@ type WorkspaceOverviewIssueRow = WorkspaceOverviewLinkedIssue & { const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseReadiness); export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) { + /** Resolve prospective source use from the same deterministic inputs as admission. */ + async function resolveSourceWorkspaceStrategy(input: { + companyId: string; issueId?: string | null; assigneeAgentId?: string | null; + assigneeAdapterOverrides?: unknown; executionWorkspaceSettings?: unknown; + executionWorkspacePolicy: unknown; typedSelection?: boolean; projectId?: string | null; executionPolicy?: unknown; + }, reader: Db | DbTransaction = db) { + const [task] = input.issueId ? await reader.select().from(issues).where(and( + eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), + )) : []; + const assigneeAgentId = task ? task.assigneeAgentId : input.assigneeAgentId; + const [agent] = assigneeAgentId ? await reader.select({ adapterConfig: agents.adapterConfig, permissions: agents.permissions }).from(agents) + .where(and(eq(agents.id, assigneeAgentId), eq(agents.companyId, input.companyId))) : []; + const overrides = assigneeAgentId ? parseIssueAssigneeAdapterOverrides(task ? task.assigneeAdapterOverrides : input.assigneeAdapterOverrides) : null; + const experimental = await instanceSettingsService(db).getExperimental(); + const projectPolicy = applyDefaultIsolatedExecutionWorkspacePolicy({ + projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(input.executionWorkspacePolicy), experimental.enableIsolatedWorkspaces), + defaultIsolatedWorkspacesEnabled: experimental.enableIsolatedWorkspaces && experimental.enableIsolatedWorkspacesByDefault, + hasProjectWorkspace: true, + }); + const settingsEnabled = experimental.enableIsolatedWorkspaces || input.typedSelection || task?.workspaceSelection || task?.executionWorkspaceId; + const issueSettings = settingsEnabled ? parseIssueExecutionWorkspaceSettings(input.executionWorkspaceSettings) : null; + const projectId = task ? task.projectId : input.projectId; + const [organization] = projectId ? await reader.select({ companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projects).where(and(eq(projects.id, projectId), eq(projects.companyId, input.companyId))) : []; + const trust = resolveCoreTrustPreset({ companyId: input.companyId, agent, + project: organization, workspaceSourceProject: { companyId: input.companyId, executionWorkspacePolicy: input.executionWorkspacePolicy }, + issue: { companyId: input.companyId, executionPolicy: task ? task.executionPolicy : input.executionPolicy } }); + if (trust.kind === "denied") throw forbidden(trust.detail); + const resolvedMode = resolveExecutionWorkspaceMode({ projectPolicy, issueSettings, legacyUseProjectWorkspace: overrides?.useProjectWorkspace ?? null }); + const mode = trust.kind === "low_trust_review" && resolvedMode === "shared_workspace" ? "isolated_workspace" : resolvedMode; + const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: parseObject(agent?.adapterConfig), projectPolicy, issueSettings, mode, + legacyUseProjectWorkspace: overrides?.useProjectWorkspace ?? null, adapterConfigOverrides: overrides?.adapterConfig }); + return resolveEffectiveWorkspaceStrategyType(mode, config); + } + /** Validate intent without allocating files or changing a task binding. */ - async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null }, reader: Db | DbTransaction = db) { + async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; assigneeAdapterOverrides?: unknown; projectId?: string | null; executionPolicy?: unknown }, reader: Db | DbTransaction = db) { const selection = taskWorkspaceSelectionSchema.parse(input.selection); if (selection.kind === "existing") { const [workspace] = await reader.select().from(executionWorkspaces).where(and( @@ -1309,11 +1348,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic )); if (!source) throw notFound("Workspace source is unavailable or inaccessible"); const mode = selection.mode === "shared" ? "shared_workspace" : "isolated_workspace"; - const sourceConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, - projectPolicy: parseProjectExecutionWorkspacePolicy(source.executionWorkspacePolicy), - issueSettings: { mode }, mode, legacyUseProjectWorkspace: null }); + const strategy = await resolveSourceWorkspaceStrategy({ ...input, typedSelection: true, + executionWorkspaceSettings: { mode }, executionWorkspacePolicy: source.executionWorkspacePolicy }, reader); await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, { - write: resolveEffectiveWorkspaceStrategyType(mode, sourceConfig) !== "git_worktree", + write: strategy !== "git_worktree", issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId, }); @@ -1420,7 +1458,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic /** Ordinary issue edits participate in the same binding revision transaction. */ async function assertTaskWorkspaceUpdateAccess(input: { - task: typeof issues.$inferSelect; actor: AuthorizationActor; isolatedWorkspacesEnabled: boolean; + task: typeof issues.$inferSelect; actor: AuthorizationActor; }, tx: DbTransaction) { const { task, actor } = input; if (task.executionWorkspaceId) { @@ -1433,12 +1471,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .where(and(eq(projectWorkspaces.id, task.projectWorkspaceId), eq(projectWorkspaces.companyId, task.companyId), await projectReadSqlCondition(tx, actor))); if (!source) throw notFound("Workspace source is unavailable or inaccessible"); - const projectPolicy = gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(source.executionWorkspacePolicy), input.isolatedWorkspacesEnabled); - const issueSettings = parseIssueExecutionWorkspaceSettings(task.executionWorkspaceSettings); - const mode = resolveExecutionWorkspaceMode({ projectPolicy, issueSettings, legacyUseProjectWorkspace: null }); - const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy, issueSettings, mode, legacyUseProjectWorkspace: null }); + const strategy = await resolveSourceWorkspaceStrategy({ companyId: task.companyId, issueId: task.id, + executionWorkspaceSettings: task.executionWorkspaceSettings, executionWorkspacePolicy: source.executionWorkspacePolicy }, tx); await assertTaskWorkspaceSourceProjectAccess(tx, actor, task.companyId, source.projectId, { - issueId: task.id, write: resolveEffectiveWorkspaceStrategyType(mode, config) !== "git_worktree", + issueId: task.id, write: strategy !== "git_worktree", }); } @@ -2075,7 +2111,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic requestTaskRepository: executionWorkspaceRepositoryService(db).request, listTaskRepositories: executionWorkspaceRepositoryService(db).list, prepareTaskRepositoriesForAdmission: executionWorkspaceRepositoryService(db).prepareForAdmission, - validateSelection, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, prepareTaskWorkspaceUpdate, assertTaskWorkspaceUpdateAccess, + resolveSourceWorkspaceStrategy, validateSelection, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, prepareTaskWorkspaceUpdate, assertTaskWorkspaceUpdateAccess, listOverview: async ( companyId: string, filters: WorkspaceOverviewQuery, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 765b088b5e..e49e35170e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -538,6 +538,7 @@ import { import { applyDefaultIsolatedExecutionWorkspacePolicy, buildExecutionWorkspaceAdapterConfig, + parseIssueAssigneeAdapterOverrides, gateProjectExecutionWorkspacePolicy, issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, @@ -755,33 +756,10 @@ export { resolveReusableSandboxLifecycle } from "./heartbeat/runtime-selection.j export { resolveNativeSandboxLifecycle } from "./heartbeat/runtime-selection.js"; -interface ParsedIssueAssigneeAdapterOverrides { - adapterConfig: Record | null; - useProjectWorkspace: boolean | null; -} - function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } -function parseIssueAssigneeAdapterOverrides( - raw: unknown, -): ParsedIssueAssigneeAdapterOverrides | null { - const parsed = parseObject(raw); - const parsedAdapterConfig = parseObject(parsed.adapterConfig); - const adapterConfig = - Object.keys(parsedAdapterConfig).length > 0 ? parsedAdapterConfig : null; - const useProjectWorkspace = - typeof parsed.useProjectWorkspace === "boolean" - ? parsed.useProjectWorkspace - : null; - if (!adapterConfig && useProjectWorkspace === null) return null; - return { - adapterConfig, - useProjectWorkspace, - }; -} - export function formatRuntimeWorkspaceWarningLog(warning: string) { return { stream: "stdout" as const, @@ -3142,19 +3120,18 @@ export function heartbeatService( workspaceReuseRequest.existingExecutionWorkspaceAvailable ? existingExecutionWorkspace : null; - const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({ + const sourceWorkspaceConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: config, projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, mode: requestedExecutionWorkspaceMode, legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, + adapterConfigOverrides: { + ...Object.fromEntries(Object.entries(issueAssigneeOverrides?.adapterConfig ?? {}).filter(([key]) => requestedAiBinding?.mode !== "router" || !["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"].includes(key))), + ...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}), + }, }); - const sourceWorkspaceConfig = { - ...workspaceManagedConfig, - ...Object.fromEntries(Object.entries(issueAssigneeOverrides?.adapterConfig ?? {}).filter(([key]) => requestedAiBinding?.mode !== "router" || !["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"].includes(key))), - ...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}), - }; const workspaceAuthorizationActor = { type: "agent" as const, agentId: agent.id, companyId: agent.companyId, source: "agent_jwt" as const, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId }; if (!persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 98830888de..ded4421c3d 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -123,8 +123,6 @@ import { SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES, } from "./successful-run-handoff-state.js"; import { - buildExecutionWorkspaceAdapterConfig, - resolveEffectiveWorkspaceStrategyType, defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, issueExecutionWorkspaceModeForPersistedWorkspace, @@ -132,7 +130,6 @@ import { parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolvePinnedIssueWorkspaceStrategyType, - resolveExecutionWorkspaceMode, WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, @@ -10170,7 +10167,7 @@ export function issueService(db: Db) { const projection = await executionWorkspaceService(db).validateSelection({ companyId, actor: workspaceActor, selection: workspaceSelection, parentIssueId: issueData.parentId, - assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId }, tx); + assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides, projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx); projectWorkspaceId = projection.projectWorkspaceId; executionWorkspaceId = projection.executionWorkspaceId; executionWorkspacePreference = projection.executionWorkspacePreference; @@ -10269,14 +10266,13 @@ export function issueService(db: Db) { .innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)) .where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, companyId), await projectReadSqlCondition(tx, workspaceActor))); if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible"); - const sourcePolicy = gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(readableSource.executionWorkspacePolicy), isolatedWorkspacesEnabled); - const sourceSettings = parseIssueExecutionWorkspaceSettings(executionWorkspaceSettings); - const sourceMode = resolveExecutionWorkspaceMode({ projectPolicy: sourcePolicy, issueSettings: sourceSettings, legacyUseProjectWorkspace: null }); - const sourceConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy: sourcePolicy, - issueSettings: sourceSettings, mode: sourceMode, legacyUseProjectWorkspace: null }); + const sourceStrategy = await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId, + assigneeAgentId: issueData.assigneeAgentId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides, + executionWorkspaceSettings, executionWorkspacePolicy: readableSource.executionWorkspacePolicy, + typedSelection: Boolean(workspaceIntent), projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx); await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId, { parentIssueId: issueData.parentId, assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId, - write: !executionWorkspaceId && resolveEffectiveWorkspaceStrategyType(sourceMode, sourceConfig) !== "git_worktree", + write: !executionWorkspaceId && sourceStrategy !== "git_worktree", }); } if (executionWorkspaceId && workspaceSelectionActor) { @@ -11288,7 +11284,7 @@ export function issueService(db: Db) { // assignment/workspace edit checks the actual new assignee. A denial // rolls back the binding, revision and retained-privacy trigger too. await executionWorkspaceService(db).assertTaskWorkspaceUpdateAccess({ - task: updated, actor: workspaceSelectionActor, isolatedWorkspacesEnabled, + task: updated, actor: workspaceSelectionActor, }, tx); } if (issueData.description !== undefined) await attachOwnedDraftImages(tx, updated, updated.description,