fix: align workspace source authorization with admission strategy

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 10:25:25 -05:00
1 parent 0049e01678
commit b014a5ad1e
7 files changed
+142 -55

No files matched your search

@@ -195,6 +195,7 @@ function issueUpdateWithReceipt(issue: ReturnType<typeof makeIssue>, patch: Reco
actorRunId: _actorRunId,
actorRunStopId: _actorRunStopId,
actorUserId: _actorUserId,
workspaceSelectionActor: _workspaceSelectionActor,
blockedByIssueIds: _blockedByIssueIds,
...issuePatch
} = patch;
@@ -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",
@@ -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();
@@ -393,6 +393,29 @@ export function resolveExecutionWorkspaceMode(input: {
return "shared_workspace";
}
interface ParsedIssueAssigneeAdapterOverrides {
adapterConfig: Record<string, unknown> | 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<string, unknown>;
adapterConfigOverrides?: Record<string, unknown> | 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. */
+49 -13
View File
@@ -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,
+6 -29
View File
@@ -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<string, unknown> | 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) {
+7 -11
View File
@@ -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,