diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md index b8fe2e72100..f98bcac3884 100644 --- a/doc/plans/2026-10-09-task-workspaces.md +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -41,6 +41,8 @@ Independent repository acquisition does not inherit private-repository authority Admission with no repository requests remains heartbeat-owned and requires no additional repository-mutation permission. Low-trust admission still rejects managed acquisition bytes in the same physical folder. Immutable recovery uses its admitted folder and skips new repository acquisition even if the task's current binding has changed. A reservation-only recovery hook fences legacy admitted host roots before input capture or provider dispatch. Local recovery uses its immutable local root; sandbox recovery obtains the host path from its verified immutable sync descriptor. SSH recovery uses its remote filesystem directly and does not require a host snapshot descriptor. Older projectless native runs may use the exact persisted run-ID workspace binding without a workspace row. +Task creation supplies an explicit internal prospective-task context to workspace selection. An admitted agent choosing an existing workspace for a child must have an authoritative saved run, a matching company and agent identity, and a verified parent lineage. The server checks current parent and prospective child policies against the saved run boundary and the physical source policy before saving the child. The creator run belongs to its existing task, not the unsaved child. Ordinary existing-task selection and writes still require their exact saved task/run identity; a parent ID alone does not enable the creation path. + Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Anonymous admission also checks the readable candidate repository namespace against company-managed receipt identities, including on an existing reservation. A published identity collision is denied even when the old receipt path is unreadable; mutable ownership markers cannot authorize it. This conservatively requires a fresh root when anonymous and managed publications share an identity. Reservations also compare retained overlapping parent/child roots; component-boundary siblings remain independent. Initial reservations compare legacy managed intents through accessible canonical ancestors, conservatively rejecting overlapping inaccessible paths while unavailable unrelated historical folders do not block admission. Arbitrary external file writers remain subject to the existing shared-folder constraints. Shared physical roots also retain source workspace and admitted reader identities separately from acquisition mode. Empty inventories do not bypass peer privacy: admission and recovery record the reader; requests and publication must remain readable to every retained reader. Admission registers the root before preparing configured sibling repositories. Each local sibling source is authorized before materialization, including local file URLs without a configured cwd and reuse of an existing sibling checkout. File-URL-only sources keep ordinary clone semantics; their source permissions still follow the copied files. Its captured task and project owners are retained on the bound task root before copying, so staging, published and detached files keep source privacy. A publication callback rechecks source permissions and every retained destination reader. Ordinary sibling clones add only read restrictions; they never create a worktree-isolation proof. The fence uses authoritative workspace/run/task identities and current source privacy, never writable Git ownership markers. Ordinary heartbeat-owned admission to the same task's sole source adds no repository-mutation requirement, while any peer source or task requires the full read intersection. Missing retained identity fails closed; automatic release is deferred. Current writers must also satisfy `project:write_workspace` for every shared physical source project, including configured aliases with no execution row. Sharing classification is retained separately from read provenance and survives pointer changes. Selection checks without registering a reader; admission and publication independently recheck current writer authority. Proven isolated worktree copies require source read authority; past readers never receive an added mutation requirement. diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md index cd7702790de..f0da0fb978a 100644 --- a/maps/agent-access-map/workspaces.md +++ b/maps/agent-access-map/workspaces.md @@ -42,6 +42,7 @@ source projects' trust boundaries. Incompatible choices are rejected before dispatch; a projectless alias cannot bypass required sandbox containment. Source-copy checks apply the same trust boundary before provisioning. Low-trust sandbox runs skip host worktree provisioning, including built-in scripts. +Child creation by an admitted agent checks an explicit prospective-task context before the child is saved. The creator run must have an authoritative saved policy and a verified company/agent/parent lineage. Current parent, prospective child and physical source policies must fit that boundary. This creation path does not relax exact task/run checks for existing-task writes. The [child trust regression](../../server/src/__tests__/task-workspace-child-trust.test.ts) is automated source-level evidence, not live agent qualification. Source review for these additions: PR #15749 and [task workspace routes](../../server/src/routes/task-workspaces.ts), [native authority](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), diff --git a/maps/feature-map/workspaces.md b/maps/feature-map/workspaces.md index 994f17b9186..8f1727b6e35 100644 --- a/maps/feature-map/workspaces.md +++ b/maps/feature-map/workspaces.md @@ -53,6 +53,8 @@ provision script must not run on the host during fresh creation, reuse, or rebui Verify the adapter still selects the sandbox and ordinary trusted provisioning continues to work. +For a child created by an admitted agent with an explicit existing workspace, verify that a matching sandbox boundary permits creation before the child has an ID. A changed child policy, unrelated parent, or forged creator run must fail. See the [child workspace trust regression](../../server/src/__tests__/task-workspace-child-trust.test.ts). This automated contract does not add live acceptance evidence. + ### `workspace-management` Automated: [workspace detail](../../ui/src/pages/ExecutionWorkspaceDetail.test.tsx) and [runtime service](../../server/src/__tests__/workspace-runtime.test.ts) cover UI and runtime contracts separately. diff --git a/server/src/__tests__/task-workspace-api.test.ts b/server/src/__tests__/task-workspace-api.test.ts index 3fe26b6e229..393702a3354 100644 --- a/server/src/__tests__/task-workspace-api.test.ts +++ b/server/src/__tests__/task-workspace-api.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces, principalPermissionGrants, companyMemberships } from "@paperclipai/db"; +import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { startRunnerApiTestServer } from "./helpers/runner-api-server.js"; import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js"; import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; @@ -14,6 +15,30 @@ const support = await getEmbeddedPostgresTestSupport(); const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000); afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; }); + it("creates an explicit-workspace child through the authenticated low-trust API without repeating its saved policy", async () => { + const f = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true }); + const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review", + trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId, projectIds: [f.projectId] } } }; + const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: f.projectId, + projectWorkspaceId: f.projectWorkspaceId, sourceIssueId: f.issueId, name: "Review files", cwd: f.workspace, + mode: "isolated_workspace", strategyType: "task_directory" }).returning(); + await server.db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, f.projectId)); + await server.db.update(issues).set({ executionPolicy: policy, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId)); + await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId)); + const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId); + const create = (parentId: string) => fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, { + method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ title: `Review child ${randomUUID()}`, parentId, projectId: f.projectId, + assigneeAgentId: f.agentId, status: "backlog", workspaceSelection: { kind: "existing", workspaceId: workspace.id } }), + }); + const response = await create(f.issueId), body = await response.json(); + expect({ status: response.status, body }).toMatchObject({ status: 201, body: { + parentId: f.issueId, executionWorkspaceId: workspace.id, + executionPolicy: { authorizationPolicy: { trustBoundary: policy.authorizationPolicy.trustBoundary } }, + } }); + const denied = await create(f.blockerId); + expect(denied.status).toBe(403); + }); it("inspects an idle admitted workspace, requests a repository, and stages root selection without interrupting", async () => { const f = await server.fixture({ conversation: true, disableWakeOnDemand: true }); const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: null, sourceIssueId: f.issueId, name: "Task files", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory", status: "idle" }).returning(); diff --git a/server/src/__tests__/task-workspace-child-trust.test.ts b/server/src/__tests__/task-workspace-child-trust.test.ts new file mode 100644 index 00000000000..7c1dee68714 --- /dev/null +++ b/server/src/__tests__/task-workspace-child-trust.test.ts @@ -0,0 +1,81 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { agents, authUsers, companyMemberships, createDb, executionWorkspaces, heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { executionWorkspaceService } from "../services/execution-workspaces.js"; +import { issueService } from "../services/issues.js"; +import { createRepositoryTrustFixture } from "./helpers/repository-trust-fixture.js"; +import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("child task workspace trust", () => { + const context = createRepositoryTrustFixture({ managedAuth: vi.fn(), materialize: vi.fn(), catalog: vi.fn() }); + let db: ReturnType; + beforeAll(async () => { ({ db } = await context.setup()); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(context.cleanup); + + async function fixture() { + const f = await context.fixture(); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Review source" }).returning(); + const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, + trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [project.id] } } }; + await db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, project.id)); + await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, f.workspace.id)); + await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, f.issueId)); + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId)); + const input = { title: `Child ${randomUUID()}`, parentId: f.issueId, assigneeAgentId: f.agentId, + executionPolicy: policy, createdByAgentId: f.agentId, workspaceSelectionActor: f.actor, + workspaceSelection: { kind: "existing" as const, workspaceId: f.workspace.id } }; + return { ...f, project, policy, input }; + } + + it.each(["direct", "descendant", "omitted_policy"] as const)("creates a matching low-trust child with an explicit existing workspace (%s)", async scenario => { + const f = await fixture(); + if (scenario === "descendant") { + const [parent] = await db.insert(issues).values({ companyId: f.companyId, parentId: f.issueId, + title: "Intermediate task", assigneeAgentId: f.agentId, executionPolicy: f.policy }).returning(); + f.input.parentId = parent.id; + } + const input = scenario === "omitted_policy" ? { ...f.input, executionPolicy: undefined } : f.input; + await expect(issueService(db).create(f.companyId, input)).resolves.toMatchObject({ + parentId: f.input.parentId, executionWorkspaceId: f.workspace.id, + }); + }); + it.each(["child_policy", "unrelated_parent", "missing_parent", "missing_run", "cross_company_run", "sponsor", "live_parent_policy", "absent_saved_policy", "child_agent_policy"] as const)( + "rejects incompatible child creation (%s)", async scenario => { + const f = await fixture(); + const different = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, + trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, rootIssueId: randomUUID() } } }; + if (scenario === "child_policy") f.input.executionPolicy = different; + if (scenario === "unrelated_parent") { + const [parent] = await db.insert(issues).values({ companyId: f.companyId, title: "Other root" }).returning(); + f.input.parentId = parent.id; + } + if (scenario === "missing_parent") f.input.parentId = randomUUID(); + if (scenario === "missing_run") f.input.workspaceSelectionActor = { ...f.actor, runId: randomUUID() }; + if (scenario === "cross_company_run") f.input.workspaceSelectionActor = { ...f.actor, runId: (await fixture()).runId }; + if (scenario === "sponsor") { + const id = randomUUID(); + await db.insert(authUsers).values({ id, name: "Other operator", email: `${id}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: id, membershipRole: "operator", status: "active" }); + f.input.workspaceSelectionActor = { ...f.actor, onBehalfOfUserId: id }; + } + if (scenario === "live_parent_policy") await db.update(issues).set({ executionPolicy: different }).where(eq(issues.id, f.issueId)); + if (scenario === "absent_saved_policy") await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId } }).where(eq(heartbeatRuns.id, f.runId)); + if (scenario === "child_agent_policy") { + const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Other boundary", adapterType: "process", permissions: different }).returning(); + f.input.assigneeAgentId = agent.id; + } + const before = await db.select().from(issues).where(eq(issues.companyId, f.companyId)); + await expect(issueService(db).create(f.companyId, f.input)).rejects.toThrow(); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before); + }, + ); + + it("does not infer child creation from an arbitrary issue-less workspace selection", async () => { + const f = await fixture(); + await expect(executionWorkspaceService(db).validateSelection({ companyId: f.companyId, actor: f.actor, + selection: f.input.workspaceSelection, parentIssueId: f.issueId, executionPolicy: f.policy, + assigneeAgentId: f.agentId })).rejects.toThrow(/Physical workspace source trust/); + }); +}); diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index 8c030adb113..c91685ea9a0 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -12,7 +12,7 @@ import { conflict, forbidden, notFound } from "../errors.js"; import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js"; import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js"; import { authorizationService, canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition, issueReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js"; -import { resolveCoreTrustPreset } from "./trust-preset-resolver.js"; +import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, resolveCoreTrustPreset } from "./trust-preset-resolver.js"; import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js"; import { captureRunIdentity } from "./run-identity.js"; import { toolAccessService } from "./tool-access.js"; @@ -370,9 +370,13 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: { if (denied) throw denied; } +export type TaskWorkspaceCreationTrustContext = { + parentIssueId?: string | null; projectId?: string | null; assigneeAgentId?: string | null; executionPolicy?: unknown; +}; + async function assertRootProjectTrust(db: Db | DbTransaction, input: { companyId: string; projectIds: Set; actor: AuthorizationActor; - issueId?: string | null; sourceWorkspaceId?: string; assigneeAgentId?: string | null; + issueId?: string | null; sourceWorkspaceId?: string; assigneeAgentId?: string | null; taskCreation?: TaskWorkspaceCreationTrustContext; }) { if (!input.projectIds.size) return; const owners = await db.select().from(projects).where(and(eq(projects.companyId, input.companyId), @@ -380,15 +384,18 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: { const restricted = owners.filter(owner => resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: owner }).kind !== "standard"); if (!restricted.length) return; const incompatible = () => forbidden("Physical workspace source trust is incompatible with the task's execution policy"); - const [issue] = input.issueId ? await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))) : []; + const runId = input.actor.runId; + const [run] = runId ? await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, input.companyId))) : []; + const creation = !input.issueId && input.actor.type === "agent" && runId ? input.taskCreation : undefined; + const runIssueId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId ?? run?.issueId; + const issueId = input.issueId ?? (creation && typeof runIssueId === "string" ? runIssueId : null); + const [issue] = issueId ? await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) : []; const agentId = input.actor.type === "agent" ? input.actor.agentId : issue?.assigneeAgentId ?? input.assigneeAgentId; const [agent] = agentId ? await db.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, input.companyId))) : []; const [project] = issue?.projectId ? await db.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, input.companyId))) : []; const workspaceId = input.sourceWorkspaceId ?? issue?.executionWorkspaceId; const [workspace] = workspaceId ? await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, input.companyId))) : []; const [source] = workspace?.projectId ? await db.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, input.companyId))) : []; - const runId = input.actor.runId; - const [run] = runId ? await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, input.companyId))) : []; if ((agentId && !agent) || (runId && (!run || run.agentId !== agentId || !issue || (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== issue.id))) throw incompatible(); const retainedPolicy = run?.contextSnapshot?.executionPolicy; @@ -399,6 +406,37 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: { : current; if (selected.kind !== "low_trust_review" || current.kind !== "low_trust_review" || !isDeepStrictEqual(selected.boundary, current.boundary)) throw incompatible(); + if (creation) { + const sponsor = run!.responsibleUserId === "local-board" ? null : run!.responsibleUserId; + if ((input.actor.onBehalfOfUserId ?? null) !== sponsor) throw incompatible(); + let parentId = creation.parentIssueId; + const ancestors: Array = []; + for (let depth = 0; parentId && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth++) { + const [parent] = await db.select().from(issues).where(and(eq(issues.id, parentId), eq(issues.companyId, input.companyId))); + if (!parent || ancestors.some(ancestor => ancestor.id === parent.id)) throw incompatible(); + ancestors.push(parent); + if (parent.id === issue!.id) break; + parentId = parent.parentId; + } + if (ancestors.at(-1)?.id !== issue!.id) throw incompatible(); + const decision = await authorizationService(db).decide({ actor: input.actor, action: "issue:mutate", + resource: { type: "issue", companyId: input.companyId, parentIssueId: creation.parentIssueId, + projectId: creation.projectId, assigneeAgentId: creation.assigneeAgentId } }); + if (!decision.allowed) throw incompatible(); + const [childAgent] = creation.assigneeAgentId ? await db.select().from(agents).where(and( + eq(agents.id, creation.assigneeAgentId), eq(agents.companyId, input.companyId))) : []; + if (creation.assigneeAgentId && !childAgent) throw incompatible(); + for (const candidate of [...ancestors, { companyId: input.companyId, projectId: creation.projectId, + executionPolicy: creation.executionPolicy }]) { + const [candidateProject] = candidate.projectId ? await db.select().from(projects).where(and( + eq(projects.id, candidate.projectId), eq(projects.companyId, input.companyId))) : []; + if (candidate.projectId && !candidateProject) throw incompatible(); + const prospective = resolveCoreTrustPreset({ companyId: input.companyId, + agent: "id" in candidate ? agent : childAgent, project: candidateProject, workspaceSourceProject: source, + issue: candidate, run: { companyId: input.companyId, executionPolicy: retainedPolicy } }); + if (prospective.kind !== "low_trust_review" || !isDeepStrictEqual(prospective.boundary, selected.boundary)) throw incompatible(); + } + } const selectedPolicy = { trustPreset: selected.preset, authorizationPolicy: { trustPreset: selected.preset, trustBoundary: selected.boundary, } }; @@ -410,7 +448,7 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: { } export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransaction, input: { - companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string; + companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string; taskCreation?: TaskWorkspaceCreationTrustContext; } & Omit[4]>, "write">): Promise { const { sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true }); for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 9c9711246a9..1624efcd1fd 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -3,7 +3,7 @@ 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, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess, assertExecutionWorkspaceRootReadAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, canonicalRepositoryRoot, type IsolatedWorkspaceSourceAccess } from "./execution-workspace-repositories.js"; +import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess, assertExecutionWorkspaceRootReadAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, canonicalRepositoryRoot, type IsolatedWorkspaceSourceAccess, type TaskWorkspaceCreationTrustContext } from "./execution-workspace-repositories.js"; import { forbidden } from "../errors.js"; import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared"; import { authorizationService, executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js"; @@ -1393,7 +1393,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic return resolveEffectiveWorkspaceStrategyType(mode, config); } - 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) { + async function validateSelection(input: { taskCreation?: TaskWorkspaceCreationTrustContext; 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( @@ -1404,7 +1404,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic if (!workspace) throw notFound("Workspace is unavailable or inaccessible"); await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId }); if (workspace.cwd) await assertExecutionWorkspaceRootWriteAccess(reader, { - companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor, + companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor, taskCreation: input.taskCreation, issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId, }); return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 793207036c0..90272161c30 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10169,6 +10169,8 @@ export function issueService(db: Db) { const projection = await executionWorkspaceService(db).validateSelection({ companyId, actor: workspaceActor, selection: workspaceSelection, parentIssueId: issueData.parentId, + taskCreation: { parentIssueId: issueData.parentId, projectId: issueData.projectId, + assigneeAgentId: issueData.assigneeAgentId, executionPolicy: issueData.executionPolicy }, assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides, projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx); projectWorkspaceId = projection.projectWorkspaceId; executionWorkspaceId = projection.executionWorkspaceId;