diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2b4450de2cc..959658e603a 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -759,7 +759,7 @@ transaction boundary. ### Task workspace records (2026-10-09) -Migration [0336_big_crystal.sql](../packages/db/src/migrations/0336_big_crystal.sql) makes `execution_workspaces.project_id` nullable and changes its project reference to `ON DELETE RESTRICT`: source-project policy cannot disappear while a workspace still depends on it. `issues.execution_workspace_id` remains the sole active task binding. Versioned selection/pending-selection JSON and a binding revision record intent and protect transitions; they are not additional active bindings. +Migration [0336_big_crystal.sql](../packages/db/src/migrations/0336_big_crystal.sql) makes `execution_workspaces.project_id` nullable and changes its project reference to `ON DELETE RESTRICT`: source-project policy cannot disappear while a workspace still depends on it. `issues.execution_workspace_id` remains the sole active task binding. Versioned selection/pending-selection JSON and a binding revision record intent and protect transitions; they are not additional active bindings. Accepting a new pending choice advances the revision without changing the active folder. Its receipt retains the original request revision, so current-choice retries are idempotent and superseded retries fail the revision check. `execution_workspace_repositories` is company-scoped inventory beneath an execution workspace. It records repository identity, contained relative path, requested ref, pinned commit, preparation state and idempotency receipts. Credentials are resolved at use and are never stored in this inventory. Connection/destination `execution_defaults` JSON contains optional typed project/workspace choices, never arbitrary host paths or commands. @@ -769,9 +769,28 @@ Migration [0338_absurd_vengeance.sql](../packages/db/src/migrations/0338_absurd_ Git-source capture checks the actual common Git storage directory and verified checkout owners. Separately stored metadata does not make its parent a checkout. -When Git cannot identify the primary checkout, the service verifies company-known -configured, execution, and retained roots against that metadata identity. Ordinary -repositories and registered worktrees avoid this fallback scan. +When a linked worktree cannot identify its primary checkout, the service verifies +company-known configured, execution, and retained roots against that metadata +identity. A primary checkout identifies itself when its Git directory equals its +common directory, including separate metadata layouts. Verified primary sources +and linked worktrees with a verified registered primary avoid the fallback scan. + +Writable selection, source-copy preparation, admission, and publication check the trust policy of +current and retained physical source projects. A projectless alias cannot bypass +a source's sandbox requirement. An incompatible preset or boundary is rejected; +the check never silently changes the selected execution environment. Admitted +and retained readers use their persisted effective run policy; absence of a +retained low-trust policy counts as standard. Live policy edits cannot relabel +a standard local run as sandboxed. Read-only file access +keeps its existing authorization rules. + +Source-copy checks retain deny-only ownership on failure but create a new +isolated-copy proof only after physical identity, access, and trust checks pass. +For low-trust sandbox runs, the harness also disables host worktree provisioning +on fresh realization, reuse, and rebuild. This covers configured commands and +the repository's built-in provision script; an empty configured command alone +would still allow that fallback. The capability comes from the server's selected +trust policy and environment, never repository configuration. After source authorization and retained-reader checks pass, the existing `source` and `project_source` kinds retain ownership on shared Git storage. diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md index 68d2896510b..b8fe2e72100 100644 --- a/doc/plans/2026-10-09-task-workspaces.md +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -112,7 +112,7 @@ await issues.create(companyId, { }); ``` -A task can later select an authorized existing workspace with its current binding revision and an idempotency key. The response states that the selection applies on the next normal admission; it does not restart the current process. A repository request similarly records preparation intent when the active provider root cannot safely accept a new checkout. Project assignment alone never requests this transition. +A task can later select an authorized existing workspace with its current binding revision and an idempotency key. Accepting a new pending selection atomically advances that revision, so retrying a superseded choice cannot replace a newer one. Retrying the current pending or applied request remains idempotent. The response states that the selection applies on the next normal admission; it does not restart the current process. A repository request similarly records preparation intent when the active provider root cannot safely accept a new checkout. Project assignment alone never requests this transition. Example channel defaults: diff --git a/doc/run-log-events.md b/doc/run-log-events.md index 9641c092e72..7c9080d96c6 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -217,6 +217,41 @@ The payload never carries a command, an argument, a path, an environment value, or a raw identifier. The event rides the `ctx.onEvent` run-event bridge and is run-log-only. It needs no OTLP endpoint. +## Workspace Restore and Settled Run Measurements + +The heartbeat controller writes these records with `eventType: "lifecycle"`, +`stream: "system"`, and `level: "info"`. The event name below is its `message` +and its payload's `phase`. They remain in the instance run log and add no +Paperclip Telemetry or OpenTelemetry export. + +| Event | Payload fields besides `phase` | Meaning | +| --- | --- | --- | +| `workspace_checkpoint` | `mode`, `scannedEntries`, `changedFiles`, `payloadBytes` | Metrics for a validated sparse checkpoint, or a full restore fallback. | +| `workspace.restore` | `durationMs` | Wall time around native workspace restoration. | +| `task.run.measured` | `durationMs`, `outcome`, `durableSettlement` | Wall time through terminal owner cleanup, with the persisted terminal run status and `durableSettlement: true`. | + +For `workspace_checkpoint`, `mode` is `"sparse"` or `"full"`. Sparse metrics +count snapshot entries in `scannedEntries`, regular files in the validated +downloaded payload in `changedFiles`, and their total bytes in `payloadBytes`. +Deleted entries and symlinks do not add to the regular-file count or byte total. +Full fallback reports all three counts as `null`: unavailable, not zero. A run +can emit multiple checkpoint records while restoring its repositories. These +payloads contain no paths, filenames, file contents, or credentials. + +`workspace.restore` is emitted when the timed restoration exits, including an +exception. It has no outcome field and does not by itself prove restore success. +Its producer is the native workspace sync `onPhase` callback. + +`task.run.measured` is emitted only after the controller observes a terminal run +with no remaining native runner ownership. Its duration starts at `startedAt`, +or `createdAt` if the run never started, and includes preparation, execution, +finalization, and cleanup. It is not model-only time; the creation-time fallback +also includes time spent queued. `outcome` is the actual terminal status, such as +`succeeded`, `failed`, or `cancelled`. Provider turn completion can precede this +settlement record. The record does not imply exactly-once emission across +controller recovery, and failure to write it is logged without changing the +already-settled outcome. + ## ACP terminal failure diagnostics The shared ACP adapter engine preserves typed terminal session failures in the diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md index 74d7fce034c..cd7702790de 100644 --- a/maps/agent-access-map/workspaces.md +++ b/maps/agent-access-map/workspaces.md @@ -33,8 +33,15 @@ preset and task ownership where required. Task workspace writes require `issue:mutate`, an active owned task/run for agents, and current source visibility. Shared physical roots also require source-project -workspace write authority. Binding revisions and request keys fence retries; +workspace write authority. Accepting a new pending choice advances its binding +revision; superseded retries fail while current-choice retries remain idempotent. +Binding revisions and request keys fence retries; selection applies at the next normal admission without interrupting a live run. +Writable aliases must also be compatible with current and retained physical +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. 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 492b2bab0c5..994f17b9186 100644 --- a/maps/feature-map/workspaces.md +++ b/maps/feature-map/workspaces.md @@ -36,12 +36,22 @@ Manual: Run a task that writes a small file, follow the bound workspace, and ins For task workspace selection, use the authorized task workspace API to choose a task directory, project source, or existing workspace. Verify that a change -made during a run stays pending until the next normal admission. A projectless +made during a run stays pending until the next normal admission. Submit another +choice using the returned revision, then retry the superseded request; it must +fail without replacing the newer pending choice. A projectless task must retain its files across resume and reassignment. Use the repository preparation API to add an authorized repository beneath that workspace; repeat the request and verify that it reuses the checkout and pinned revision. See [task binding tests](../../server/src/__tests__/task-workspace-binding.test.ts) and [repository access tests](../../server/src/__tests__/task-workspace-repositories-trust.test.ts). +An existing projectless folder alias of a sandbox-restricted project must be +rejected for a standard local task before its adapter executes. Repeat after +relocating the configured source; retained ownership must still enforce the +boundary. This is separate from ordinary authorized file preview/download. +For a registered low-trust worktree running in a sandbox, a configured or built-in +provision script must not run on the host during fresh creation, reuse, or rebuild. +Verify the adapter still selects the sandbox and ordinary trusted provisioning +continues to work. ### `workspace-management` diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 4d187a833bf..24b488c4efe 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -892,6 +892,7 @@ export interface Issue { monitorAttemptCount?: number; monitorNotes?: string | null; monitorScheduledBy?: IssueMonitorScheduledBy | null; + /** CAS revision of the active binding and accepted next-admission selection. */ workspaceBindingRevision?: number; workspaceSelection?: TaskWorkspaceIntent | null; workspacePendingSelection?: TaskWorkspacePendingSelection | null; diff --git a/packages/shared/src/types/task-workspace.ts b/packages/shared/src/types/task-workspace.ts index c4e816d3e01..7c3f51e09d1 100644 --- a/packages/shared/src/types/task-workspace.ts +++ b/packages/shared/src/types/task-workspace.ts @@ -12,6 +12,7 @@ export type TaskWorkspaceIntent = { export type TaskWorkspacePendingSelection = { version: 1; requestKey: string; + /** Current task CAS revision; first-folder realization may advance it. */ expectedBindingRevision: number; intent: TaskWorkspaceIntent; }; diff --git a/server/src/__tests__/heartbeat-project-repositories.test.ts b/server/src/__tests__/heartbeat-project-repositories.test.ts index d91930d004d..2eef011ad38 100644 --- a/server/src/__tests__/heartbeat-project-repositories.test.ts +++ b/server/src/__tests__/heartbeat-project-repositories.test.ts @@ -344,6 +344,75 @@ suite("task project repository provisioning", () => { expect((await heartbeat.getRun(run!.id))?.contextSnapshot).toMatchObject({ executionPolicy: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { issueIds: [issueId] } } } }); }, 25_000); + it.each(["current", "retained", "sandbox", "provision_current", "provision_retained"] as const)("enforces physical source containment before dispatch (%s)", async scenario => { + const sandbox = scenario === "sandbox"; + const provision = scenario.startsWith("provision_"); + const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + const cwd = path.join(root, companyId, "low-trust-source"); + await mkdir(cwd, { recursive: true }); + await writeFile(path.join(cwd, "work.txt"), "Low-trust source files"); + const base = path.join(root, companyId, "base"); + if (provision || sandbox) { + await mkdir(base, { recursive: true }); + await writeFile(path.join(base, "source.txt"), "source"); + if (sandbox) { + await mkdir(path.join(base, "scripts")); + await writeFile(path.join(base, "scripts", "provision-worktree.sh"), `if [ "$FIXTURE_SANDBOX_EXECUTION" != 1 ]; then printf host-provisioned > '${path.join(cwd, "provision-ran")}'; fi\n`); + } + const git = (...args: string[]) => execFileSync("git", args, { cwd: base, stdio: "ignore" }); + git("init", "-b", "main"); git("add", "."); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"); + await rm(cwd, { recursive: true }); + git("worktree", "add", "-b", "task", cwd); + } + const provisionCommand = `printf host-provisioned > '${path.join(cwd, "provision-ran")}'`; + await db.insert(companies).values({ id: companyId, name: "Physical trust", issuePrefix: `F${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" }); + await db.insert(projects).values({ id: projectId, companyId, name: "Low-trust folder owner", executionWorkspacePolicy: { + trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: issueId, projectIds: [projectId] } }, + } }); + await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId, name: "Source", sourceType: "local_path", cwd: sandbox ? base : cwd, isPrimary: true }); + await db.insert(agents).values({ id: agentId, companyId, name: "Standard local worker", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} }); + await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, status: "active", membershipRole: "member" }); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Projectless alias", cwd, projectId: sandbox ? projectId : null, mode: sandbox ? "isolated_workspace" : "shared_workspace", strategyType: sandbox ? "git_worktree" : "project_primary", + ...(sandbox ? { projectWorkspaceId: sourceId, providerRef: cwd, branchName: "task", baseRef: "main", config: { provisionCommand: "bash ./scripts/provision-worktree.sh" } } : {}), + ...(provision ? { branchName: "task", baseRef: "main", config: { provisionCommand } } : {}) }).returning(); + await db.insert(issues).values({ id: issueId, companyId, title: "Standard task", status: "todo", assigneeAgentId: agentId, + executionWorkspaceId: workspace.id, projectWorkspaceId: sandbox ? sourceId : null, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: sandbox || provision ? "isolated_workspace" : "shared_workspace", + ...(provision ? { workspaceStrategy: { type: "git_worktree", provisionCommand } } : {}) }, + workspaceSelection: { version: 1, source: "explicit", selection: { kind: "existing", workspaceId: workspace.id } } }); + if (scenario === "retained" || scenario === "provision_retained") { + const { projectService } = await import("../services/projects.js"); + await projectService(db).updateWorkspace(projectId, sourceId, { cwd: path.join(root, companyId, "relocated") }); + } + let driver = heartbeat; + if (sandbox) { + const environmentId = randomUUID(); + await db.insert(environments).values({ id: environmentId, name: "Source sandbox", driver: "sandbox", status: "active", config: { provider: "fake", image: "fake:test" } }); + await db.update(agents).set({ defaultEnvironmentId: environmentId }).where(eq(agents.id, agentId)); + driver = heartbeatService(db, { environmentRuntime: { ...environmentRuntimeService(db), execute: async input => ({ + exitCode: 0, stdout: execFileSync(input.command, input.args ?? [], { cwd: root, input: input.stdin, encoding: "utf8", env: { ...process.env, ...input.env, FIXTURE_SANDBOX_EXECUTION: "1" } }), + stderr: "", signal: null, timedOut: false, + }) } }); + } + const run = await driver.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } }); + expect(run).not.toBeNull(); + await vi.waitFor(async () => expect((await driver.getRun(run!.id))?.status).toMatch(/^(succeeded|failed)$/), { timeout: 15_000 }); + await drainHeartbeatRunsToQuiescence(db, driver); + if (sandbox) { + expect(await driver.getRun(run!.id)).toMatchObject({ status: "succeeded", error: null }); + const calls = execute.mock.calls.filter(([input]) => input.runId === run!.id); + expect(calls).toHaveLength(1); + expect(calls[0][0].context.paperclipEnvironment.driver).toBe("sandbox"); + await expect(readFile(path.join(cwd, "provision-ran"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + } else { + expect(await driver.getRun(run!.id)).toMatchObject({ status: "failed", error: expect.stringContaining("Physical workspace source trust is incompatible") }); + expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(0); + if (provision) await expect(readFile(path.join(cwd, "provision-ran"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + } + }); + it.each(["shared_workspace", "isolated_workspace"] as const)("blocks shared source adapter admission after grant revocation with retained mode %s", async (retainedMode) => { const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); const cwd = path.join(root, companyId, "protected-shared-source"); diff --git a/server/src/__tests__/task-workspace-api.test.ts b/server/src/__tests__/task-workspace-api.test.ts index 05fa730a56e..054f4763480 100644 --- a/server/src/__tests__/task-workspace-api.test.ts +++ b/server/src/__tests__/task-workspace-api.test.ts @@ -97,8 +97,8 @@ const support = await getEmbeddedPostgresTestSupport(); const listed = await call("list_workspaces", {}) as Array<{ id: string }>; const candidates = new Set(rows.slice(0, 3).map(row => row.id)); expect(listed.filter(row => rows.some(candidate => candidate.id === row.id)).map(row => row.id).sort()).toEqual([...candidates].sort()); - const view = await call("get_workspace", {}) as { bindingRevision: number }; for (const row of rows) { + const view = await call("get_workspace", {}) as { bindingRevision: number }; const selection = call("select_workspace", { selection: { kind: "existing", workspaceId: row.id }, expectedBindingRevision: view.bindingRevision, requestKey: `select-${row.id}` }); if (candidates.has(row.id)) await expect(selection).resolves.toMatchObject({ kind: "scheduled" }); diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts index 37dce015cd8..16e2589df76 100644 --- a/server/src/__tests__/task-workspace-binding.test.ts +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -281,7 +281,7 @@ const support = await getEmbeddedPostgresTestSupport(); expectedBindingRevision: 0, requestKey: "superseded-before-raw-binding" }); const updated = await issueService(db).update(task.id, { executionWorkspaceId: f.workspaceId }); expect(updated).toMatchObject({ projectId: project.id, executionWorkspaceId: f.workspaceId, projectWorkspaceId: null, - workspaceBindingRevision: 1, workspacePendingSelection: null }); + workspaceBindingRevision: 2, workspacePendingSelection: null }); expect(await svc.applyPendingTaskWorkspaceSelection({ ...f, issueId: task.id, runId: randomUUID() })).toBe(false); expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).workspace) .toMatchObject({ id: f.workspaceId, projectId: null, projectWorkspaceId: null, cwd: `/tmp/task-${f.issueId}` }); @@ -304,13 +304,64 @@ const support = await getEmbeddedPostgresTestSupport(); const view = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor); expect(view.workspace).toBeNull(); expect(view.selection).toMatchObject({ selection: { kind: "task_directory" } }); - expect(view.bindingRevision).toBe(2); - expect(await svc.selectTaskWorkspace(request)).toMatchObject({ kind: "applied", bindingRevision: 2 }); + expect(view.bindingRevision).toBe(3); + expect(await svc.selectTaskWorkspace(request)).toMatchObject({ kind: "applied", bindingRevision: 3 }); expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]?.metadata) .toMatchObject({ _issuePrivacySources: { [f.issueId]: true } }); await expect(svc.selectTaskWorkspace({ ...request, requestKey: "stale" })).rejects.toThrow("binding changed"); }); + it.each(["before admission", "after admission"] as const)("rejects a superseded selection retry %s without replacing the newer choice", async phase => { + const f = await fixture(), svc = executionWorkspaceService(db); + await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); + const [nextWorkspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, + mode: "shared_workspace", strategyType: "project_primary", name: "Newer choice", cwd: `/tmp/newer-${f.issueId}` }).returning(); + const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Running worker" }).returning(); + const immutableInput = { cwd: `/tmp/task-${f.issueId}`, binding: { executionWorkspaceId: f.workspaceId } }; + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent.id, + status: "running", contextSnapshot: { issueId: f.issueId, persistedNativeExecutionInput: immutableInput } }).returning(); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = f.actor; next(); }); + app.use("/api", taskWorkspaceRoutes(db)); + app.use(errorHandler); + const select = (body: object) => request(app).put(`/api/issues/${f.issueId}/workspace`).send(body); + const first = { selection: { kind: "task_directory" }, expectedBindingRevision: 1, requestKey: "choice-A" }; + const acceptedA = await select(first); + expect(acceptedA.status).toBe(200); + const second = { selection: { kind: "existing", workspaceId: nextWorkspace.id }, + expectedBindingRevision: acceptedA.body.bindingRevision, requestKey: "choice-B" }; + const acceptedB = await select(second); + expect(acceptedB.status).toBe(200); + const admission = { ...f, runId: randomUUID() }; + await expect(svc.applyPendingTaskWorkspaceSelection(admission)).rejects.toThrow("Previous task run must finish"); + expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ + workspace: { id: f.workspaceId }, pendingSelection: { requestKey: second.requestKey }, + }); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toMatchObject({ + status: "running", contextSnapshot: { persistedNativeExecutionInput: immutableInput }, + }); + if (phase === "after admission") { + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id)); + expect(await svc.applyPendingTaskWorkspaceSelection(admission)).toBe(true); + } + const beforeRetry = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor); + const retryA = await select(first); + expect(retryA.status).toBe(409); + expect(retryA.body.error).toContain("binding changed"); + expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toEqual(beforeRetry); + const retryB = await select(second); + expect(retryB.status).toBe(200); + expect(retryB.body.kind).toBe(phase === "before admission" ? "scheduled" : "applied"); + if (phase === "before admission") { + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id)); + expect(await svc.applyPendingTaskWorkspaceSelection(admission)).toBe(true); + } + expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ + workspace: { id: nextWorkspace.id }, selection: { request: { key: second.requestKey } }, pendingSelection: null, + }); + }); + describe.each(["pending selection", "legacy update"] as const)("native settlement before %s", (entryPoint) => { let isolatedWorkspacesEnabled: boolean; beforeAll(async () => { @@ -350,7 +401,7 @@ const support = await getEmbeddedPostgresTestSupport(); it.each(["native_retry_cancelled", "native_permission_declined"])("allows a settled %s without deleting its historical failure", async (failureCode) => { const f = await settledNativeTask(failureCode); await f.change(); - expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: 2, pendingSelection: null }); + expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: entryPoint === "pending selection" ? 3 : 2, pendingSelection: null }); expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.run.id)))[0]) .toMatchObject({ phase: "terminal_failure", failureCode }); }); @@ -367,13 +418,13 @@ const support = await getEmbeddedPostgresTestSupport(); if (obligation === "failed output") await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId, heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "failed" }); await expect(f.change()).rejects.toThrow(/workspace|Previous task run/); - expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: { id: f.workspaceId }, bindingRevision: 1 }); + expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: { id: f.workspaceId }, bindingRevision: entryPoint === "pending selection" ? 2 : 1 }); if (obligation === "failed output" || obligation === "accepted output") { await db.update(workspaceOperations).set({ createdAt: new Date(0) }).where(eq(workspaceOperations.heartbeatRunId, f.run.id)); await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId, heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "succeeded", finishedAt: new Date() }); await f.change(); - expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: 2 }); + expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: entryPoint === "pending selection" ? 3 : 2 }); } }); @@ -384,7 +435,7 @@ const support = await getEmbeddedPostgresTestSupport(); heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "succeeded", metadata: { workspaceSync: { omitted: true } }, finishedAt: new Date() }); await f.change(); - expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: 2 }); + expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: entryPoint === "pending selection" ? 3 : 2 }); }); }); @@ -400,23 +451,23 @@ const support = await getEmbeddedPostgresTestSupport(); await svc.selectTaskWorkspace(selectionRequest); await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ - workspace: { id: f.workspaceId }, bindingRevision: 1, - pendingSelection: { expectedBindingRevision: 1, intent: { request: { expectedBindingRevision: 0 } } }, + workspace: { id: f.workspaceId }, bindingRevision: 2, + pendingSelection: { expectedBindingRevision: 2, intent: { request: { expectedBindingRevision: 0 } } }, }); - expect(await svc.selectTaskWorkspace(selectionRequest)).toMatchObject({ kind: "scheduled", bindingRevision: 1 }); + expect(await svc.selectTaskWorkspace(selectionRequest)).toMatchObject({ kind: "scheduled", bindingRevision: 2 }); await expect(svc.selectTaskWorkspace({ ...selectionRequest, expectedBindingRevision: 1 })).rejects.toThrow("different intent"); const admission = { ...f, runId: randomUUID() }; await expect(svc.applyPendingTaskWorkspaceSelection(admission)).rejects.toThrow("Previous task run must finish"); await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id)); expect(await svc.applyPendingTaskWorkspaceSelection(admission)).toBe(true); expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ - workspace: { id: nextWorkspace.id }, bindingRevision: 2, pendingSelection: null, + workspace: { id: nextWorkspace.id }, bindingRevision: 3, pendingSelection: null, }); - expect(await svc.selectTaskWorkspace(selectionRequest)).toMatchObject({ kind: "applied", bindingRevision: 2 }); + expect(await svc.selectTaskWorkspace(selectionRequest)).toMatchObject({ kind: "applied", bindingRevision: 3 }); // A later, unrelated replacement still invalidates the revision fence. await svc.selectTaskWorkspace({ ...f, selection: { kind: "task_directory" }, - expectedBindingRevision: 2, requestKey: "later-choice" }); + expectedBindingRevision: 3, requestKey: "later-choice" }); await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); await expect(svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() })).rejects.toThrow("Pending workspace selection is stale"); }); @@ -676,13 +727,13 @@ const support = await getEmbeddedPostgresTestSupport(); await tasks.update(f.issueId, patch); expect(await svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() })).toBe(false); const view = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor); - expect(view).toMatchObject({ bindingRevision: 2, pendingSelection: null, selection: null }); + expect(view).toMatchObject({ bindingRevision: 3, pendingSelection: null, selection: null }); expect(view.workspace?.id ?? null).toBe(kind === "binding" ? workspace.id : null); const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId)); expect(task.projectWorkspaceId).toBe(kind === "configured_source" ? source.id : null); await expect(svc.selectTaskWorkspace(request)).rejects.toThrow("binding changed"); await tasks.update(f.issueId, { projectId: project.id, ...patch }); - expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).bindingRevision).toBe(2); + expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).bindingRevision).toBe(3); } finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); } }); diff --git a/server/src/__tests__/task-workspace-repositories-persistence.test.ts b/server/src/__tests__/task-workspace-repositories-persistence.test.ts index 5fc834021f1..3b73dd87775 100644 --- a/server/src/__tests__/task-workspace-repositories-persistence.test.ts +++ b/server/src/__tests__/task-workspace-repositories-persistence.test.ts @@ -254,6 +254,6 @@ const support = await getEmbeddedPostgresTestSupport(); const [issue] = await db.select().from(issues).where(and(eq(issues.companyId, companyId), eq(issues.id, issueId))); expect(issue.executionWorkspaceId).toBe(workspaceId); expect(issue.workspacePendingSelection?.requestKey).toBe("move"); - expect(issue.workspaceBindingRevision).toBe(0); + expect(issue.workspaceBindingRevision).toBe(1); }); }); diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts index c2477043879..a9692406ebc 100644 --- a/server/src/__tests__/task-workspace-repositories-trust.test.ts +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -74,6 +74,75 @@ const support = await getEmbeddedPostgresTestSupport(); return { companyId, agentId, issueId, runId, userId, workspace, policy, service, actor, request, admission }; } + it.each(["current", "retained", "standard_run", "absent_run_policy", "narrower_boundary"] as const)("rejects incompatible physical project trust (%s) without changing file read authority", async scenario => { + const f = await fixture(); + const projectId = randomUUID(); + const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [projectId] } } }; + const [owner] = await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Low-trust physical owner", executionWorkspacePolicy: policy }).returning(); + const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: owner.id, name: "Physical source", cwd: f.admission.cwd, isPrimary: true }).returning(); + if (scenario === "retained") await projectService(db).updateWorkspace(owner.id, source.id, { cwd: path.join(root, randomUUID()) }); + if (scenario === "standard_run" || scenario === "absent_run_policy") { + await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, f.issueId)); + if (scenario === "standard_run") await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: { trustPreset: "standard" } } }).where(eq(heartbeatRuns.id, f.runId)); + } + if (scenario === "narrower_boundary") { + const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, trustBoundary: { + mode: "low_trust_review", companyId: f.companyId, issueIds: [f.issueId], projectIds: [owner.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)); + } + await expect(assertExecutionWorkspaceRootReadAccess(db, { ...f.admission, actor: f.actor, sourceWorkspaceId: f.workspace.id })).resolves.toBeUndefined(); + await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...f.admission, actor: f.actor, sourceWorkspaceId: f.workspace.id })).rejects.toThrow("Physical workspace source trust is incompatible"); + await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow("Physical workspace source trust is incompatible"); + expect(acquisition.materialize).not.toHaveBeenCalled(); + }); + + it("keeps a declared low-trust source compatible with its retained effective policy", async () => { + const f = await fixture(); + const projectId = randomUUID(); + const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [projectId] } } }; + const [owner] = await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Declared low-trust source", executionWorkspacePolicy: policy }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: owner.id, name: "Source", cwd: f.admission.cwd, isPrimary: true }); + await db.update(executionWorkspaces).set({ projectId: owner.id }).where(eq(executionWorkspaces.id, f.workspace.id)); + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId)); + await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); + expect(acquisition.materialize).not.toHaveBeenCalled(); + }); + + it.each(["writer", "retained_reader"] as const)("rejects incompatible %s trust before copy provisioning without minting copy proof", async principal => { + const f = await fixture(), exec = promisify(execFileCallback); + if (principal === "retained_reader") await f.service.prepareForAdmission(f.admission); + const sourceCwd = path.join(root, `${f.issueId}-low-trust-source`); + await mkdir(sourceCwd); + await exec("git", ["init", "--initial-branch=main", sourceCwd]); + await writeFile(path.join(sourceCwd, "source.txt"), "Low-trust source\n"); + await exec("git", ["-C", sourceCwd, "add", "."]); + await exec("git", ["-C", sourceCwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + await rm(f.admission.cwd, { recursive: true }); + await exec("git", ["-C", sourceCwd, "worktree", "add", "-b", "target", f.admission.cwd]); + const projectId = randomUUID(); + const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, + trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [projectId] } } }; + await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Low-trust copied source", executionWorkspacePolicy: policy }); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId, name: "Source", cwd: sourceCwd }); + let actor = f.actor; + if (principal === "retained_reader") { + const runId = randomUUID(); + await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, f.issueId)); + await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, responsibleUserId: f.userId, + status: "running", contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }); + actor = { ...f.actor, runId }; + } + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor }); + await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd: f.admission.cwd, + actor, issueId: f.issueId, captured, phase: "verified" })).rejects.toThrow("Physical workspace source trust is incompatible"); + const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(entries.some(entry => entry.kind === "isolated_project_source" && entry.scopeId === projectId)).toBe(true); + expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false); + expect(await readFile(path.join(f.admission.cwd, "source.txt"), "utf8")).toBe("Low-trust source\n"); + }); + async function lowTrustPeer(f: Awaited>, cwd = f.admission.cwd) { const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Low-trust alias", cwd, mode: "isolated_workspace", strategyType: "task_directory" }).returning(); @@ -423,9 +492,10 @@ const support = await getEmbeddedPostgresTestSupport(); expect(entries.filter(entry => entry.kind === "reader")).toHaveLength(peer ? 1 : 0); }); - it.each(["primary", "linked"] as const)("does not probe unrelated company Git paths when the normal %s source is identified", async kind => { + it.each(["primary", "linked", "separate primary"] as const)("does not probe unrelated company Git paths when the %s source is identified", async kind => { const f = await fixture(), exec = promisify(execFileCallback); - await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); + await exec("git", ["init", "--initial-branch=main", + ...(kind === "separate primary" ? ["--separate-git-dir", path.join(root, `${f.issueId}-metadata`)] : []), f.admission.cwd]); const privateCwd = path.join(f.admission.cwd, "private"); await mkdir(privateCwd); await writeFile(path.join(privateCwd, "secret.txt"), "Private tracked bytes\n"); await exec("git", ["-C", f.admission.cwd, "add", "."]); @@ -1145,8 +1215,12 @@ exec "$PROBE_REAL_GIT" "$@" const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Restricted", executionWorkspacePolicy: f.policy }).returning(); if (source === "project") await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issueId)); else { - await db.update(projects).set({ executionWorkspacePolicy: { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, - trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [project.id] } } } }).where(eq(projects.id, project.id)); + const admittedPolicy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, + trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [project.id] } } }; + await db.update(projects).set({ executionWorkspacePolicy: admittedPolicy }).where(eq(projects.id, project.id)); + // This case models an admitted sandbox run, not a standard run whose + // source changed after dispatch (covered independently above). + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: admittedPolicy } }).where(eq(heartbeatRuns.id, f.runId)); await db.update(executionWorkspaces).set({ projectId: project.id, strategyType: "git_worktree" }).where(eq(executionWorkspaces.id, f.workspace.id)); } } diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 23e5fd89c72..821879fe482 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -1742,6 +1742,49 @@ process.stderr.write(${JSON.stringify(stderr)}, () => { process.exitCode = ${cod expect(path.basename(realized.cwd)).toBe("PAP-992.hotfix-april-1"); }); + it.each(["configured", "builtin"] as const)("skips %s host provisioning for fresh, reused and restored sandbox worktrees", async kind => { + const repoRoot = await createTempRepo(); + const marker = path.join(repoRoot, "host-provision-ran"); + await fs.mkdir(path.join(repoRoot, "scripts")); + const script = kind === "configured" ? "custom.sh" : "provision-worktree.sh"; + await fs.writeFile(path.join(repoRoot, "scripts", script), + `#!/bin/sh\nprintf 'host execution\n' > '${marker}'\n`); + await runGit(repoRoot, ["add", "scripts"]); + await runGit(repoRoot, ["commit", "-m", "Repository-controlled provision script"]); + const command = kind === "configured" ? `bash ./scripts/${script}` : undefined; + const input = { + base: { baseCwd: repoRoot, source: "project_primary" as const, projectId: "project-1", + workspaceId: "source-1", repoUrl: null, repoRef: "HEAD" }, + config: { workspaceStrategy: { type: "git_worktree", baseRef: "main", provisionCommand: command } }, + issue: { id: "issue-sandbox", identifier: "PAP-449", title: "Sandbox files" }, + agent: { id: "agent-1", name: "Sandbox worker", companyId: "company-1" }, + skipHostProvision: true, + }; + const fresh = await realizeExecutionWorkspace(input); + expect(fresh.created).toBe(true); + expect(existsSync(marker)).toBe(false); + const reused = await realizeExecutionWorkspace(input); + expect(reused.cwd).toBe(fresh.cwd); + expect(reused.created).toBe(false); + expect(existsSync(marker)).toBe(false); + const restoreInput = { base: input.base, issue: input.issue, agent: input.agent, skipHostProvision: true, + workspace: { mode: "isolated_workspace", strategyType: "git_worktree", cwd: fresh.cwd, + providerRef: fresh.cwd, projectId: "project-1", projectWorkspaceId: "source-1", repoUrl: null, + baseRef: "main", branchName: fresh.branchName, config: { provisionCommand: command }, + metadata: RUNTIME_OWNED_GIT_BRANCH_METADATA } }; + expect((await ensurePersistedExecutionWorkspaceAvailable(restoreInput))?.cwd).toBe(fresh.cwd); + expect(existsSync(marker)).toBe(false); + await fs.rm(fresh.cwd, { recursive: true, force: true }); + expect(existsSync(fresh.cwd)).toBe(false); + expect((await ensurePersistedExecutionWorkspaceAvailable(restoreInput))?.cwd).toBe(fresh.cwd); + expect(existsSync(marker)).toBe(false); + await expect(fs.readFile(path.join(fresh.cwd, "README.md"), "utf8")).resolves.toBe("hello\n"); + // The capability is per admission. Trusted default behavior still runs the + // exact same repository script, including automatic builtin discovery. + await ensurePersistedExecutionWorkspaceAvailable({ ...restoreInput, skipHostProvision: undefined }); + await expect(fs.readFile(marker, "utf8")).resolves.toBe("host execution\n"); + }); + it("runs a configured provision command inside the derived worktree", async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index d3cfcf5de3c..e7254d955f3 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { execFile as execFileCallback } from "node:child_process"; -import { promisify } from "node:util"; +import { isDeepStrictEqual, promisify } from "node:util"; import fs from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import path from "node:path"; @@ -393,10 +393,10 @@ async function isolatedWorkspaceSourceRoots(db: Db | DbTransaction, companyId: s const checkoutRoot = await fs.realpath(path.resolve(await git(sourceCwd, "rev-parse", "--show-toplevel"))); const commonRoot = await fs.realpath(path.resolve(checkoutRoot, await git(checkoutRoot, "rev-parse", "--git-common-dir"))); const roots = new Set(copyKind === "repository" ? [localRoot, checkoutRoot, commonRoot] : [checkoutRoot, commonRoot]); - // An ordinary primary checkout identifies its own metadata directly. Linked - // worktrees can identify that primary through Git's registered checkout list. - let primaryIdentified = commonRoot === path.join(checkoutRoot, ".git") - && await fs.realpath(await git(checkoutRoot, "rev-parse", "--absolute-git-dir")) === commonRoot; + // A primary checkout uses the common Git directory itself, including when + // --separate-git-dir stores it outside the checkout. Only linked worktrees + // need another primary owner resolved through registration or known paths. + let primaryIdentified = await fs.realpath(await git(checkoutRoot, "rev-parse", "--absolute-git-dir")) === commonRoot; const considerPrimary = async (candidatePath: string): Promise => { const candidate = await fs.realpath(candidatePath).catch(() => null); if (!candidate || candidate === checkoutRoot || candidate === commonRoot) return false; @@ -540,6 +540,7 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: { // A failed Git operation in an existing ordinary folder cannot mint it. const phase = input.phase ?? "preparing"; let preparingCopyProjectIds: Set | undefined; + let verifiedCopyProjectIds: Set | undefined; try { const targetEntry = await fs.lstat(input.targetCwd).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; @@ -553,9 +554,10 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: { const verifiedTarget = linkedWorktree && await verifiedIsolatedWorktree(canonicalCwd, roots); if (linkedWorktree && (phase === "verified" || verifiedTarget)) { if (!verifiedTarget) throw forbidden("Isolated copy target is not a verified Git worktree"); - for (const scopeId of projectIds) if (!existing.some(entry => entry.kind === "isolated_copy" && entry.scopeId === scopeId)) { - await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "isolated_copy", scopeId }); - } + // Physical verification permits this check's ancestor exemption, but + // persist no new copy proof unless all target policies/readers allow it. + preparingCopyProjectIds = projectIds; + verifiedCopyProjectIds = projectIds; } else if (linkedWorktree && await unusedIsolatedCopyTarget(input.targetCwd)) { // This allowance exists only for this pre-copy check. No proof row is // written until the harness confirms Git's completed registration. @@ -566,20 +568,73 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: { // READ authority never grants permission to provision/write that target. try { const target = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd: canonicalCwd, preparingCopyProjectIds }); + await assertRootProjectTrust(tx, { companyId: input.companyId, actor: input.actor, issueId: input.issueId, projectIds: target.sourceProjectIds }); for (const id of target.sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id); await assertRootProjectAccess(tx, input.actor, input.companyId, target.sourceProjectIds, { sharedProjectIds: target.sharedProjectIds, issueId: input.issueId }); for (const reader of target.overlappingEntries.filter(entry => entry.kind === "reader")) { - const { actor } = await retainedRootReader(tx, input.companyId, reader); + const { actor, issue } = await retainedRootReader(tx, input.companyId, reader); + await assertRootProjectTrust(tx, { companyId: input.companyId, actor, issueId: issue.id, projectIds: target.sourceProjectIds }); for (const id of target.sourceIds) await assertTaskWorkspaceAccess(tx, actor, input.companyId, id); await assertRootProjectAccess(tx, actor, input.companyId, target.sourceProjectIds); } } catch (error) { failure ??= error; } + if (!failure && verifiedCopyProjectIds) { + for (const scopeId of verifiedCopyProjectIds) if (!existing.some(entry => entry.kind === "isolated_copy" && entry.scopeId === scopeId)) { + await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "isolated_copy", scopeId }); + } + } return failure; }); if (denied) throw denied; } +/** Physical ownership cannot silently change a run's already selected containment. */ +async function assertRootProjectTrust(db: Db | DbTransaction, input: { + companyId: string; projectIds: Set; actor: AuthorizationActor; + issueId?: string | null; sourceWorkspaceId?: string; assigneeAgentId?: string | null; +}) { + if (!input.projectIds.size) return; + const owners = await db.select().from(projects).where(and(eq(projects.companyId, input.companyId), + or(...[...input.projectIds].map(id => eq(projects.id, id))))); + 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 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(); + // The stored effective policy is the containment the harness actually chose. + // Live policy edits must not relabel an already admitted local run as sandboxed. + const retainedPolicy = run?.contextSnapshot?.executionPolicy; + const current = resolveCoreTrustPreset({ companyId: input.companyId, agent, project, workspaceSourceProject: source, issue, + run: run ? { companyId: input.companyId, executionPolicy: retainedPolicy } : null }); + // Standard admitted runs intentionally omit executionPolicy; absence on an + // existing run is still standard, not permission to adopt live policy edits. + const selected = run + ? resolveCoreTrustPreset({ companyId: input.companyId, run: { companyId: input.companyId, executionPolicy: retainedPolicy } }) + : current; + if (selected.kind !== "low_trust_review" || current.kind !== "low_trust_review" + || !isDeepStrictEqual(selected.boundary, current.boundary)) throw incompatible(); + const selectedPolicy = { trustPreset: selected.preset, authorizationPolicy: { + trustPreset: selected.preset, trustBoundary: selected.boundary, + } }; + for (const owner of restricted) { + const combined = resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: owner, + run: { companyId: input.companyId, executionPolicy: selectedPolicy } }); + // Narrower boundaries need a new admission too; do not silently change the + // capabilities of an existing provider session or a retained reader. + if (combined.kind !== "low_trust_review" || !isDeepStrictEqual(combined.boundary, selected.boundary)) throw incompatible(); + } +} + /** Selection validates writable source scope without registering a reader or changing policy. */ export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransaction, input: { companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string; @@ -587,6 +642,7 @@ export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransac const { sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true }); for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId); await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds, { ...input, sharedProjectIds }); + await assertRootProjectTrust(db, { ...input, projectIds: sourceProjectIds }); } /** Workspace aggregate child. It owns acquisition receipts, never task binding or provider sessions. */ @@ -688,6 +744,8 @@ export function executionWorkspaceRepositoryService(db: Db) { && readers.every(reader => reader.issueId === input.reader!.issueId); for (const reader of readers) { const { actor, issue } = await retainedRootReader(tx, input.companyId, reader); + await assertRootProjectTrust(tx, { companyId: input.companyId, projectIds: sourceProjectIds, actor, issueId: issue.id, + sourceWorkspaceId: input.reader?.runId === reader.scopeId ? input.sourceWorkspaceId : undefined }); await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds, input.reader?.runId === reader.scopeId ? { sharedProjectIds, issueId: issue.id } : undefined); if (ownAdmissionOnly) { @@ -713,6 +771,7 @@ export function executionWorkspaceRepositoryService(db: Db) { } if (input.actor) { if (!input.issueId) throw forbidden("Workspace write authorization requires a task"); + await assertRootProjectTrust(tx, { ...input, projectIds: sourceProjectIds, actor: input.actor }); await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds, { sharedProjectIds, issueId: input.issueId }); for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, sourceId); } diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 76458e80e8c..704bcef7ef0 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1482,9 +1482,15 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic const intent: TaskWorkspaceIntent = { version: 1, request: { key: input.requestKey, expectedBindingRevision: input.expectedBindingRevision }, selection: input.selection, source: "explicit" }; // Root changes always wait for a new admission. The current provider and // any export-only recovery retain their immutable roots and input evidence. - await tx.update(issues).set({ workspacePendingSelection: { version: 1, requestKey: input.requestKey, - expectedBindingRevision: task.workspaceBindingRevision, intent }, updatedAt: new Date() }).where(eq(issues.id, task.id)); - return { kind: "scheduled" as const, applies: "next_normal_admission" as const, bindingRevision: task.workspaceBindingRevision }; + // Acceptance advances the task's CAS revision even though its active folder + // stays unchanged. Otherwise a superseded request can be retried against + // the same revision and overwrite a newer pending choice. The intent keeps + // the original request revision so current-choice retries remain idempotent. + const nextRevision = task.workspaceBindingRevision + 1; + await tx.update(issues).set({ workspaceBindingRevision: nextRevision, + workspacePendingSelection: { version: 1, requestKey: input.requestKey, + expectedBindingRevision: nextRevision, intent }, updatedAt: new Date() }).where(eq(issues.id, task.id)); + return { kind: "scheduled" as const, applies: "next_normal_admission" as const, bindingRevision: nextRevision }; }); } diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index eacbddd203d..f681cd6d1d2 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -326,6 +326,8 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac : workspace; }, }); + const skipHostProvision = trustPreset.kind === "low_trust_review" + && lowTrustPreflightEnvironmentDriver === "sandbox"; const hostExecutionWorkspaceConfig = isDotRun ? {} : stripHostWorkspaceProvisionForLowTrustSandbox({ config: mergedConfig, @@ -509,6 +511,7 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac restoreExistingWorkspace: reusableExistingExecutionWorkspace ? () => ensurePersistedExecutionWorkspaceAvailable({ + skipHostProvision, db, base: executionWorkspaceBase, workspace: { @@ -567,6 +570,7 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac : null, realizeWorkspace: () => realizeExecutionWorkspace({ + skipHostProvision, db, base: executionWorkspaceBase, config: hostExecutionWorkspaceConfig, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 4c40721f3fd..a354e18d94e 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -3239,8 +3239,10 @@ async function provisionExecutionWorktree(input: { issue: ExecutionWorkspaceIssueRef | null; agent: ExecutionWorkspaceAgentRef; created: boolean; + skipHostProvision?: boolean; recorder?: WorkspaceOperationRecorder | null; }) { + if (input.skipHostProvision) return; const provisionCommand = resolveWorkspaceProvisionCommand(input.strategy, input.repoRoot); if (!provisionCommand) return; const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot); @@ -3335,6 +3337,8 @@ export async function realizeExecutionWorkspace(input: { enableWorkspaceDirtyQuarantineRepair?: boolean; recorder?: WorkspaceOperationRecorder | null; resolveGitAuth?: GitRemoteAuthProvider | null; + /** Server-selected containment capability; never derived from repository configuration. */ + skipHostProvision?: boolean; beforeSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise; afterSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise; }): Promise { @@ -3485,6 +3489,7 @@ export async function realizeExecutionWorkspace(input: { } await input.afterSourceCopy?.(repoRoot, reusablePath); await provisionExecutionWorktree({ + skipHostProvision: input.skipHostProvision, strategy: rawStrategy, base: input.base, repoRoot, @@ -3644,6 +3649,7 @@ export async function realizeExecutionWorkspace(input: { } await input.afterSourceCopy?.(repoRoot, worktreePath); await provisionExecutionWorktree({ + skipHostProvision: input.skipHostProvision, strategy: rawStrategy, base: input.base, repoRoot, @@ -3740,6 +3746,7 @@ export async function realizeExecutionWorkspace(input: { } await input.afterSourceCopy?.(repoRoot, worktreePath); await provisionExecutionWorktree({ + skipHostProvision: input.skipHostProvision, strategy: rawStrategy, base: input.base, repoRoot, @@ -3792,6 +3799,8 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { enableWorkspaceDirtyQuarantineRepair?: boolean; recorder?: WorkspaceOperationRecorder | null; resolveGitAuth?: GitRemoteAuthProvider | null; + /** Server-selected containment capability; never derived from repository configuration. */ + skipHostProvision?: boolean; beforeSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise; afterSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise; }): Promise { @@ -3927,6 +3936,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { realized.baseRefSha = refresh.baseRefSha ?? recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha; await input.afterSourceCopy?.(repoRoot, realized.worktreePath ?? cwd); await provisionExecutionWorktree({ + skipHostProvision: input.skipHostProvision, strategy: { type: "git_worktree", ...(provisionCommand ? { provisionCommand } : {}), @@ -4020,6 +4030,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { await input.afterSourceCopy?.(repoRoot, worktreePath); await provisionExecutionWorktree({ + skipHostProvision: input.skipHostProvision, strategy: { type: "git_worktree", ...(provisionCommand ? { provisionCommand } : {}),