From 474aed9fe5b7a1e1a9d497c3db63d3dc106654ed Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 18:59:58 -0500 Subject: [PATCH] fix(workspaces): guard reopen provisioning and checkpoint ancestry Resolve authoritative trust during reopening and suppress configured and builtin host provision scripts for low-trust worktrees. Reject returned checkpoint entries whose parent is not a directory before host mutation. Keep Honeycomb links compatible with native and legacy root spans. Remove prose SQL headers without changing statements or generated separators. Update safety docs and reviewed Storybook recipe fingerprints. Co-Authored-By: Paperclip --- doc/observability.md | 2 + doc/plans/2026-10-09-task-workspaces.md | 4 ++ maps/agent-access-map/workspaces.md | 1 + maps/feature-map/workspaces.md | 1 + maps/storybook-map/INDEX.md | 24 ++++---- maps/storybook-map/REVIEW.md | 4 ++ maps/storybook-map/catalog.json | 48 +++++++-------- .../src/workspace-checkpoint.test.ts | 60 +++++++++++++++++++ .../adapter-utils/src/workspace-checkpoint.ts | 4 ++ .../db/src/migrations/0336_big_crystal.sql | 1 - .../migrations/0337_exotic_proemial_gods.sql | 1 - .../src/migrations/0338_absurd_vengeance.sql | 1 - .../execution-workspace-reopen.test.ts | 53 ++++++++++++++++ server/src/services/execution-workspaces.ts | 25 +++++++- ui/src/lib/honeycomb-run-link.test.ts | 8 ++- ui/src/lib/honeycomb-run-link.ts | 2 +- 16 files changed, 195 insertions(+), 44 deletions(-) diff --git a/doc/observability.md b/doc/observability.md index d8993aa32da..4a331900469 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -171,6 +171,8 @@ ends before outer workspace persistence. The heartbeat-owned local run-log every other native span carries a real OpenTelemetry parent context rather than only a descriptive `parentName` field. +The run link in Honeycomb matches both `task.provider_session` for native runs and `task.run` for legacy adapters. It retains the exact hashed run-ID and service filters and groups by trace ID. The URL builder uses Honeycomb’s supported `in` operator for the two root names; see the [query specification](https://github.com/honeycombio/honeycomb-mcp#query-specification-for-run_query). This changes only the query link. The operator endpoint gate and span attribute allowlist remain unchanged. + The canonical lifecycle is: ```text diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md index f98bcac3884..8e0774d737d 100644 --- a/doc/plans/2026-10-09-task-workspaces.md +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -49,6 +49,8 @@ Shared physical roots also retain source workspace and admitted reader identitie Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. Physical-source authority includes canonical ancestor and descendant overlaps, current configured/execution roots, and retained source/readers. Every admission/publication rechecks current roots even after a reader has been recorded. Configured workspace move/removal and project removal preserve source provenance before their first admission too. Valid local file URLs and absolute repository paths also identify physical configured owners when no usable cwd exists. The same source resolver protects copying, file reads and configuration detachment. File listing, previews and downloads use the same read-only physical-source resolution. Those reads create no policy or reader records. Git worktree preparation checks actual checkout and common-repository source roots before copying. Server-owned captured source identities are retained on the isolated target before Git/provisioning and rechecked before exposure. The isolated_source and isolated_project_source text kinds preserve task/project privacy before any copying. They are deny-only intent, not proof of isolation. The separate isolated_copy proof is recorded only after Git validates a registered linked worktree; the ancestor-source write exemption also requires the target to remain a real worktree. Preparation may temporarily allow a validated missing/empty target, without persisting that exemption. An intact existing linked worktree without a proof record is first verified against the captured source and Git registration. Only that verification can establish its proof and let it resume after an upgrade or interrupted preparation; occupied ordinary folders remain strict. Existing ordinary folders and exact/descendant target owners keep their write requirements. Deny-only copy provenance survives permission revocation or source deletion during preparation, including failed copies. Managed local sources used by main or referenced projects receive the same checks and committed staging ownership before Git reads bytes. Closed-worktree reopening commits source ownership before its lifecycle transaction, uses read-only checks under that lock, and retains completed-copy evidence after releasing it. Source and destination identities must remain unchanged; no company root fence is acquired while the lifecycle lock is held. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults. +Reopening has no selected execution lease. Before rebuilding a worktree, resolve the bound task’s current task, assignee, organizational-project and filesystem-source policies inside the lifecycle transaction. Any effective low-trust policy suppresses both configured and built-in host provision scripts. Missing referenced authority or denied policy blocks reopening; trusted provisioning keeps its existing behavior. A board comment does not authorize sandbox repository scripts to execute on the host. + Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally. The organizational task project retains task identity, environment, and secret scope regardless of the filesystem source. An organizational project with no usable configured folder or repository is not a filesystem source. Unbound tasks without typed selection still retain that project's explicit workspace requirements; persisted and typed source choices retain precedence. Both task-project and source-project policies constrain run trust; source policy controls workspace realization. Channel task creation separately checks assignment authority for the effective organizational project and actual assignee against locked endpoint values inside its transaction. Saving defaults does not grant future senders that authority. @@ -65,6 +67,8 @@ Concurrent remote sharing requires proven common physical placement, separate se Sparse filesystem checkpoints use disk-backed full manifests, changed bytes/deletions, safe-link and existing ignore semantics, and separate nested Git history. Capture still enumerates and hashes files with stable-read validation; a metadata hash cache is deferred. No-change sends zero file-content payload where capability is supported; manifest metadata still transfers; unsupported transport uses explicit confined full fallback. Sparse capture capacity failures before outbound data is read also use the existing confined full transfer, so a tiny output can recover when the sandbox cannot preserve the sparse manifest reserve. Returned manifest, path, digest and payload validation failures remain errors and never trigger fallback. Full checkpoint exports validate the completed staging root before merge, reject unsupported entries, and omit absolute or escaping links without following them; safe relative links remain supported. Reuse immutable durable seed generations, not only warm sandbox contents; reuse still verifies archive digests. Existing run references pin seeds/deltas until all admitted runs/recovery obligations release them. Retain the final host rescan because the run ownership fence does not exclude external writers to shared folders; do not reuse a stale post-merge manifest. +A returned sparse snapshot must describe a coherent tree before any host mutation. Every nested entry requires an immediate parent marked as a directory in that same returned snapshot, including baseline-unchanged files. Checking every directory also verifies recursive ancestry. Top-level entries use the implicit root. Invalid trees fail without overwriting retained host files and keep recovery evidence. The check uses the existing indexed manifest interface; it adds no host-path scans or unbounded in-memory inventory. + Seed recovery deliberately uses **no delta chain** in this implementation. An unchanged generation reuses its verified content-addressed immutable full seed; a changed generation creates a fresh full seed. Sparse output transfer is separate from seed recovery. This revises the sketch's 16-delta compaction proposal: composing recovery generations would add a new failure surface before the simpler cache has been qualified. Existing references retain old seeds; no automatic eviction/deletion policy is introduced. Optional cache publication is capped at 16 generations/1 GiB per workspace and 256 generations/256 workspace directories/8 GiB per company. A company-scoped filesystem lock serializes quota checks and publication across workspace roots. Archive and receipt bytes, corrupt entries and abandoned pending generations count against capacity; publication also preserves the manifest storage free-space reserve (256 MiB by default), accounting for a full-copy fallback. Saturation or cache failure skips publication and keeps the already-written per-run recovery seed. Existing over-capacity caches stop growing without deleting admitted evidence. This deliberately trades cache hit rate after saturation for bounded additional storage and unchanged recovery semantics; no global cross-company quota is added. Changed input generations still pay full snapshot creation. New cache entries are reconstructed and checked against the admitted baseline before publication, so concurrent writes cannot label an archive as the wrong generation; this adds cold verification work. A corrupted cache entry remains a miss and falls back to a fresh seed; automatic cache repair and garbage collection are deferred. The existing Git seed contract captures the selected branch/HEAD, not unrelated refs. Ordinary settlement remains host-durable before successful completion. Preserve export-only recovery and exact-source lease retention. Preserve the specific unsafe-export omission exception and its run-log-only diagnostic. Do not change global merge conflict semantics as a side effect. diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md index f0da0fb978a..a0d074a9c65 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. +Closed-worktree reopening checks current task, agent, organizational and source policies without assuming a future driver. Effective low-trust reopens suppress configured and built-in host scripts. Returned checkpoints must have directory parents for every nested entry before merging, including unchanged entries; invalid output leaves retained host files intact and keeps recovery evidence. 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), diff --git a/maps/feature-map/workspaces.md b/maps/feature-map/workspaces.md index 8f1727b6e35..55d5558cef8 100644 --- a/maps/feature-map/workspaces.md +++ b/maps/feature-map/workspaces.md @@ -52,6 +52,7 @@ For a registered low-trust worktree running in a sandbox, a configured or built- 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. +Low-trust closed-worktree reopening, including a board-comment reopen of a projectless task, must also suppress both kinds of host provision script. Returned checkpoints must reject file, symlink or missing parents before host writes, even if a nested file is marked unchanged. The retained host file and recovery evidence must survive rejection. These are automated safety contracts, not new live runtime acceptance. 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. diff --git a/maps/storybook-map/INDEX.md b/maps/storybook-map/INDEX.md index 268838d130f..85ebd42d8b2 100644 --- a/maps/storybook-map/INDEX.md +++ b/maps/storybook-map/INDEX.md @@ -179,9 +179,9 @@ Status describes product alignment; implementation describes the rendered subjec | [Tasks and conversations/Documents, attachments, and work products/Product/Documents/Annotations](../../ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx) | `documents-artifacts/annotations` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Tasks and conversations/Documents, attachments, and work products/Product/Document Comments](../../ui/storybook/stories/documents-artifacts/document-comments.stories.tsx) | `documents-artifacts/annotations` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Tasks and conversations/Documents, attachments, and work products/Components/File Viewer Sheet](../../ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx) | `documents-artifacts/attachments` | unreviewed / production / fixtures | 13 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | -| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | -| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | +| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | +| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | +| [Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | | [Tasks and conversations/Documents, attachments, and work products/Task Chat/Rich Work Product Cards](../../ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx) | `documents-artifacts/work-products` | unreviewed / production / fixtures | 9 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery](../../ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx) | `documents-artifacts/library`, `documents-artifacts/work-products` | unreviewed / production / fixtures | 8 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | | [Tasks and conversations/Documents, attachments, and work products/Tasks/Text file tabs](../../ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx) | `documents-artifacts/attachments` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | @@ -257,9 +257,9 @@ Status describes product alignment; implementation describes the rendered subjec | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | | [Projects and execution/Projects and repositories/Proposals/Project repos/New project](../../ui/storybook/stories/projects/new-project-repos.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 18 / 5 | RepositoryEditor is shipped, but NewProjectPrototype composes its own creation flow. Preserve proposal until route parity is checked. | -| [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) | `projects/defaults`, `workspaces/provisioning` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | +| [Projects and execution/Projects and repositories/Workspaces/Project runtime provision command](../../ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx) | `projects/defaults`, `workspaces/provisioning` | unreviewed / production / fixtures | 2 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | | [Projects and execution/Projects and repositories/Proposals/Project repos/Configuration](../../ui/storybook/stories/projects/project-repos-configuration.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 12 / 0 | Production properties plus local configuration page composition; repository interaction is simulated. | -| [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) | `projects/lifecycle`, `goals/hierarchy`, `workspaces/binding` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed nullable project references and guarded runtime invalidations. Existing summaries remain project-bound and retain their routes; null-project execution workspace rendering and controls remain a coverage gap. | +| [Projects and execution/Projects and repositories/Product/Projects Goals Workspaces](../../ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx) | `projects/lifecycle`, `goals/hierarchy`, `workspaces/binding` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed nullable project references and guarded runtime invalidations. Existing summaries remain project-bound and retain their routes; null-project execution workspace rendering and controls remain a coverage gap. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | ## questions-and-approvals @@ -424,13 +424,13 @@ Status describes product alignment; implementation describes the rendered subjec | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 21 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | -| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 28 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | -| [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) | `workspaces/binding` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | -| [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) | `workspaces/files-git` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | -| [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) | `workspaces/provisioning` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed ExecutionWorkspaceDetail null-project handling. This suite mounts only the unchanged RuntimeProvisionStatusValue inside a local context row; it does not exercise the full detail page or projectless queries. | -| [Projects and execution/Workspaces, services, and files/Workspaces/Service control bar](../../ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx) | `workspaces/services` | unreviewed / production / fixtures | 16 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity](../../ui/storybook/stories/workspaces/browser-use-activity.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 7 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer](../../ui/storybook/stories/workspaces/browser-use-footer.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 21 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | +| [Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel](../../ui/storybook/stories/workspaces/browser-use.stories.tsx) | `workspaces/services`, `tasks/discussion` | unreviewed / production / fixtures | 28 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | +| [Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/workspaces/searchable-select.stories.tsx) | `workspaces/binding` | unreviewed / production / fixtures | 9 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | +| [Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser](../../ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx) | `workspaces/files-git` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | +| [Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx) | `workspaces/provisioning` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed ExecutionWorkspaceDetail null-project handling. This suite mounts only the unchanged RuntimeProvisionStatusValue inside a local context row; it does not exercise the full detail page or projectless queries. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | +| [Projects and execution/Workspaces, services, and files/Workspaces/Service control bar](../../ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx) | `workspaces/services` | unreviewed / production / fixtures | 16 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture. | ## Capability lookup diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md index c9d79ca87e4..507117f2284 100644 --- a/maps/storybook-map/REVIEW.md +++ b/maps/storybook-map/REVIEW.md @@ -200,3 +200,7 @@ Not run in this pass: Storybook build, browser rendering or `play`, screenshot comparisons, provider calls and live production acceptance. No new visual baselines were accepted. Fixture tests are boundary simulations, not server persistence or permission tests. + +## Workspace safety recipe follow-up — 2026-10-12 + +Reviewed the twelve entries that depend on the workspace feature recipe: browser-use activity/footer/page, the three HTML-artifact suites, project execution strategy and project workspace summaries, searchable selection, workspace files, runtime provisioning and service controls. Their production subjects, fixtures, feature folders, sidebar titles and stable IDs are unchanged. The recipe now requires prospective child boundary checks, safe low-trust reopening, and coherent returned checkpoint ancestry. These backend safety contracts are not exercised by those fixtures. Each entry remains unreviewed for full production parity. Only the reviewed recipe fingerprints were refreshed; no browser render, play, screenshot baseline or production acceptance was run for this follow-up. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index 2651ca1166a..f4131b33af5 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -1208,7 +1208,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/tasks.md", @@ -1219,7 +1219,7 @@ "ui/storybook/fixtures/browser-use.tsx", "ui/storybook/stories/workspaces/browser-use-activity.stories.tsx" ], - "fingerprint": "3e1151c7391d4a75cfe71d29108a2d02daab83a0eac38629a57acafd6feb9145", + "fingerprint": "6cc5f1faeef9fcf1b5659c54768e034e3cc22d4574488ad4d55fc16706c8f1ce", "stableId": "tasks-browser-use-task-activity", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity" }, @@ -1233,7 +1233,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/tasks.md", @@ -1243,7 +1243,7 @@ "ui/storybook/fixtures/browser-use.tsx", "ui/storybook/stories/workspaces/browser-use-footer.stories.tsx" ], - "fingerprint": "613e1680cb816a7dfc5b266944b606cfe33dfb657bdb3ba01920c7c3da48e811", + "fingerprint": "5177955d3d027e278fdd5a4037c3c5980d7b966a133ade56dcee797ec80c5ce1", "stableId": "tasks-browser-use-footer", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer" }, @@ -1279,7 +1279,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/tasks.md", @@ -1290,7 +1290,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/workspaces/browser-use.stories.tsx" ], - "fingerprint": "1ebd3b1723108395a097562b096c83c361ba8de896856e93f1de267589776a30", + "fingerprint": "fc58d3850fd8c0739ddd5a8ce0b46ee177fe8eab3af35767cd6ba2a24a824bc9", "stableId": "tasks-browser-use-panel", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel" }, @@ -2664,7 +2664,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/documents-artifacts.md", @@ -2673,7 +2673,7 @@ "ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx", "ui/storybook/support/html-artifacts/fixtures.ts" ], - "fingerprint": "40947edd6214de75fea30b48f0ef86a240c4c902cefab2766f080796e4cbf7f4", + "fingerprint": "bcc90a3a8706dee7b74667fe2898c5a346ca5575746d1cff0045770ca223e6d2", "stableId": "html-artifacts-01-preview", "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview" }, @@ -2687,7 +2687,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/documents-artifacts.md", @@ -2697,7 +2697,7 @@ "ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx", "ui/storybook/support/html-artifacts/fixtures.ts" ], - "fingerprint": "9428839bdddad9ebb39c4a17e28c355a27a626a82f61d085f1323351db4548a9", + "fingerprint": "e19cc06690585d75315283bddcb19caad7a444447dce03c0b0b66fc7942bce3b", "stableId": "html-artifacts-02-workspace", "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace" }, @@ -2711,7 +2711,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/documents-artifacts.md", @@ -2724,7 +2724,7 @@ "ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx", "ui/storybook/support/html-artifacts/fixtures.ts" ], - "fingerprint": "1188502a2840cb15c12e06c432871faef9c9a57ccf735dbe3b8ae85cfbbc0a7d", + "fingerprint": "180397152fab8d5ab6768438dc51be59a3bffe193bc6616ee4afbcde8e5a1f31", "stableId": "html-artifacts-03-task-journey", "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey" }, @@ -3940,7 +3940,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/projects.md", @@ -3949,7 +3949,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx" ], - "fingerprint": "085390f326c00881e26775b6ff5f1ecb8831e88fb3383f7e1cdbb6d79466ada9", + "fingerprint": "4801904641a1a9728fbf9200784ac0941d773462e6775692f24f2f48ec1ef55e", "stableId": "workspaces-project-runtime-provision-command", "sidebarTitle": "Projects and execution/Projects and repositories/Workspaces/Project runtime provision command" }, @@ -3993,7 +3993,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed nullable project references and guarded runtime invalidations. Existing summaries remain project-bound and retain their routes; null-project execution workspace rendering and controls remain a coverage gap.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed nullable project references and guarded runtime invalidations. Existing summaries remain project-bound and retain their routes; null-project execution workspace rendering and controls remain a coverage gap. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/goals.md", @@ -4009,7 +4009,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx" ], - "fingerprint": "4b808790405c69d24dcd87f7c086e0c348bcb26559cc6a33a7a1b8d016c75a71", + "fingerprint": "78ca1e35c4ffc444182a0d5b700b40194291d2fc0a45c8654ed624d57b3a847a", "stableId": "product-projects-goals-workspaces", "sidebarTitle": "Projects and execution/Projects and repositories/Product/Projects Goals Workspaces" }, @@ -4678,14 +4678,14 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/workspaces.md", "ui/src/components/SearchableSelect.tsx", "ui/storybook/stories/workspaces/searchable-select.stories.tsx" ], - "fingerprint": "07e6bf69886f137b4c04faa166fa12ea0dfb1bc4c98f5984cac6419b6f220139", + "fingerprint": "91c53ef285a9b66f826844fccf76cff3610ef3f17c2a790ebcd9ce9136b6e4b0", "stableId": "components-searchableselect-workspace-picker", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker" }, @@ -5545,7 +5545,7 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/workspaces.md", @@ -5553,7 +5553,7 @@ "ui/src/components/IssueWorkspaceCard.tsx", "ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx" ], - "fingerprint": "f1e1fcd710ac215bff805e58647c7fe423df046c7e76b951c711893c652a5fac", + "fingerprint": "17143b289ff1f961d8c7a62bc3885a0cf7be625b1d488084e6d1ccddd1f5365e", "stableId": "issue-workspace-file-browser", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser" }, @@ -5566,14 +5566,14 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed ExecutionWorkspaceDetail null-project handling. This suite mounts only the unchanged RuntimeProvisionStatusValue inside a local context row; it does not exercise the full detail page or projectless queries.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed ExecutionWorkspaceDetail null-project handling. This suite mounts only the unchanged RuntimeProvisionStatusValue inside a local context row; it does not exercise the full detail page or projectless queries. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/workspaces.md", "ui/src/pages/ExecutionWorkspaceDetail.tsx", "ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx" ], - "fingerprint": "98e44aa2751d7559f8b2d9b184163f798fa1a2ba04baff2d5e46a34e012d23a0", + "fingerprint": "18d507bd030d34ea196fd8525b498de790c2b9372596cf669f51a2d051f86723", "stableId": "workspaces-runtime-provisioning-status", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status" }, @@ -5586,14 +5586,14 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the expanded workspace recipe; these examples do not establish durable task-folder continuity, repository authorization or sandbox admission. The workspace recipe now requires child boundary, safe reopen and checkpoint ancestry controls; these backend checks are not established by this fixture.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/workspaces.md", "ui/src/components/WorkspaceServiceControlBar.tsx", "ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx" ], - "fingerprint": "e8291e71e152744af82c5550305bb32bf8786d70fec40aeb9687b3c8ef6350cc", + "fingerprint": "53376ddaeab2e4ade78ad68c244cd224f76f491278df9c258a8199fac94413f7", "stableId": "workspaces-service-control-bar", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Service control bar" } diff --git a/packages/adapter-utils/src/workspace-checkpoint.test.ts b/packages/adapter-utils/src/workspace-checkpoint.test.ts index 78eaf8b7994..064cfa6eeae 100644 --- a/packages/adapter-utils/src/workspace-checkpoint.test.ts +++ b/packages/adapter-utils/src/workspace-checkpoint.test.ts @@ -31,6 +31,66 @@ async function fixture() { return { temp, root, baseline, capture }; } describe("workspace sparse checkpoint", () => { + it.each((["file", "symlink", "missing"] as const).flatMap(attack => + (["a/kept", "a/nested/kept"] as const).flatMap(child => + (["manifest", "memory"] as const).map(storage => ({ attack, child, storage })))))( + "rejects a returned $attack ancestor of $child ($storage) before any host mutation", async ({ attack, child, storage }) => { + const f = await fixture(); await disposeDirectorySnapshot(f.baseline); + await fs.mkdir(path.dirname(path.join(f.root, child)), { recursive: true }); + await fs.writeFile(path.join(f.root, child), "retained child"); + const baseline = await captureDirectorySnapshot(f.root, { diskBacked: true, exclude: ["cache"] }); + const payload = path.join(f.temp, "forged-payload"); await fs.mkdir(payload); + await fs.writeFile(path.join(payload, "unchanged"), "otherwise valid edit"); + if (attack === "file") await fs.writeFile(path.join(payload, "a"), "replace directory"); + if (attack === "symlink") await fs.symlink("unchanged", path.join(payload, "a")); + const captured = await captureDirectorySnapshot(payload); + const entries = new Map(baseline.entries); + entries.delete("deleted"); entries.delete("a"); + for (const [relative, entry] of captured.entries) entries.set(relative, entry); + const manifest = path.join(f.temp, "forged.sqlite"); + await writeCheckpointBaseline({ ...baseline, entries }, manifest); + const evidence = await fs.readFile(manifest); + const snapshot = storage === "manifest" ? readCheckpointSnapshot(manifest, baseline) + : { ...baseline, entries: new Map([...entries].reverse()) }; + let failure: unknown; + try { + await validateCheckpointPayload({ baseline, snapshot, payload }); + await mergeDirectoryWithBaseline({ baseline, sourceDir: payload, targetDir: f.root, snapshots: { source: snapshot } }); + } catch (error) { failure = error; } + expect(await fs.readFile(path.join(f.root, child), "utf8")).toBe("retained child"); + expect(await fs.readFile(path.join(f.root, "unchanged"), "utf8")).toBe("retained"); + expect(await fs.readFile(path.join(f.root, "deleted"), "utf8")).toBe("delete me"); + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).toContain("Invalid workspace checkpoint parent"); + expect(await fs.readFile(manifest)).toEqual(evidence); + const replay = readCheckpointSnapshot(manifest, baseline); + await expect(validateCheckpointPayload({ baseline, snapshot: replay, payload })).rejects.toThrow("Invalid workspace checkpoint parent"); + await disposeDirectorySnapshot(replay); await disposeDirectorySnapshot(snapshot); await disposeDirectorySnapshot(baseline); + }); + it.each(["file_to_directory", "directory_to_file"] as const)("restores a coherent %s checkpoint", async transition => { + const f = await fixture(); await disposeDirectorySnapshot(f.baseline); + if (transition === "file_to_directory") await fs.writeFile(path.join(f.root, "a"), "old file"); + else { + await fs.mkdir(path.join(f.root, "a")); await fs.writeFile(path.join(f.root, "a", "old"), "old child"); + } + const baseline = await captureDirectorySnapshot(f.root, { diskBacked: true, exclude: ["cache"] }); + const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true }); + await fs.rm(path.join(f.root, "a"), { recursive: true }); + if (transition === "file_to_directory") { + await fs.mkdir(path.join(f.root, "a", "nested"), { recursive: true }); + await fs.writeFile(path.join(f.root, "a", "nested", "new"), "new child"); + } else await fs.writeFile(path.join(f.root, "a"), "new file"); + const manifest = path.join(f.temp, "transition-baseline.sqlite"); await writeCheckpointBaseline(baseline, manifest); + const output = path.join(f.temp, "transition-output"); + await execute(process.execPath, [path.join(f.temp, "checkpoint.mjs"), f.root, manifest, output, JSON.stringify(baseline.exclude)]); + const snapshot = readCheckpointSnapshot(path.join(output, "manifest.sqlite"), baseline), payload = path.join(output, "payload"); + await validateCheckpointPayload({ baseline, snapshot, payload }); + await mergeDirectoryWithBaseline({ baseline, sourceDir: payload, targetDir: host, snapshots: { source: snapshot } }); + expect(await fs.readFile(path.join(host, transition === "file_to_directory" ? "a/nested/new" : "a"), "utf8")) + .toBe(transition === "file_to_directory" ? "new child" : "new file"); + expect(await fs.readFile(path.join(host, "unchanged"), "utf8")).toBe("retained"); + await disposeDirectorySnapshot(snapshot); await disposeDirectorySnapshot(baseline); + }); it("enumerates unchanged files but transfers zero workspace payload", async () => { const f = await fixture(); const result = await f.capture(); expect(result.receipt).toMatchObject({ files: 0, bytes: 0, scanned: 2 }); diff --git a/packages/adapter-utils/src/workspace-checkpoint.ts b/packages/adapter-utils/src/workspace-checkpoint.ts index 933baf7b187..aacac54e2a8 100644 --- a/packages/adapter-utils/src/workspace-checkpoint.ts +++ b/packages/adapter-utils/src/workspace-checkpoint.ts @@ -126,6 +126,10 @@ export async function validateCheckpointPayload(input: { baseline: DirectorySnap actual = await captureDirectorySnapshot(input.payload, { diskBacked: true }); for (const [relative, entry] of input.snapshot.entries) { assertIncluded(relative); + const separator = relative.lastIndexOf("/"); + if (separator >= 0 && input.snapshot.entries.get(relative.slice(0, separator))?.kind !== "dir") { + throw new Error("Invalid workspace checkpoint parent"); + } if (entry.kind === "symlink") { const target = path.resolve(input.payload, path.dirname(relative), entry.target); if (path.isAbsolute(entry.target) || (target !== path.resolve(input.payload) && !target.startsWith(`${path.resolve(input.payload)}/`))) { diff --git a/packages/db/src/migrations/0336_big_crystal.sql b/packages/db/src/migrations/0336_big_crystal.sql index 2d2329ad991..3f760467adf 100644 --- a/packages/db/src/migrations/0336_big_crystal.sql +++ b/packages/db/src/migrations/0336_big_crystal.sql @@ -1,4 +1,3 @@ --- Reapplication is safe for instances that used the earlier development migration number. CREATE TABLE IF NOT EXISTS "execution_workspace_repositories" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "company_id" uuid NOT NULL, diff --git a/packages/db/src/migrations/0337_exotic_proemial_gods.sql b/packages/db/src/migrations/0337_exotic_proemial_gods.sql index 0f5b18c5168..1fcc5b148de 100644 --- a/packages/db/src/migrations/0337_exotic_proemial_gods.sql +++ b/packages/db/src/migrations/0337_exotic_proemial_gods.sql @@ -1,4 +1,3 @@ --- Reapplication preserves root reservations from earlier development migration numbers. CREATE TABLE IF NOT EXISTS "execution_workspace_repository_roots" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "company_id" uuid NOT NULL, diff --git a/packages/db/src/migrations/0338_absurd_vengeance.sql b/packages/db/src/migrations/0338_absurd_vengeance.sql index 5969a2aad47..e05c80bb305 100644 --- a/packages/db/src/migrations/0338_absurd_vengeance.sql +++ b/packages/db/src/migrations/0338_absurd_vengeance.sql @@ -1,4 +1,3 @@ --- Reapplication preserves provenance from earlier development migration numbers. CREATE TABLE IF NOT EXISTS "execution_workspace_repository_access" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "company_id" uuid NOT NULL, diff --git a/server/src/__tests__/execution-workspace-reopen.test.ts b/server/src/__tests__/execution-workspace-reopen.test.ts index 704f156bf38..f9405df786b 100644 --- a/server/src/__tests__/execution-workspace-reopen.test.ts +++ b/server/src/__tests__/execution-workspace-reopen.test.ts @@ -11,6 +11,7 @@ import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, + agents, authUsers, companyMemberships, issueAccessGrants, @@ -63,6 +64,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(companyMemberships); + await db.delete(agents); await db.delete(companies); await db.delete(authUsers); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); @@ -258,6 +260,57 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { .then((rows) => rows[0] ?? null); } + describe.each(["configured", "builtin"] as const)("reopen %s host provision containment", kind => { + it.each(["task", "agent", "organization", "source", "same-source", "trusted"] as const)("honors persisted %s policy before running repository scripts", async restriction => { + const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject(); + const directory = await makeExistingDir(); + const base = join(directory, "source"), target = join(directory, "target"), marker = join(directory, "host-provision-ran"); + initManagedGitRepo(base, "reopen-feature"); + const command = `printf 'host execution\n' > '${marker}'`; + mkdirSync(join(base, "scripts")); + writeFileSync(join(base, "scripts", "provision-worktree.sh"), command + "\n"); + const git = (...args: string[]) => execFileSync("git", args, { cwd: base, stdio: "ignore", + env: { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" } }); + git("add", "scripts"); git("commit", "-m", "Host provision fixture"); git("branch", "-f", "reopen-feature", "HEAD"); + await db.update(projectWorkspaces).set({ cwd: base }).where(eq(projectWorkspaces.id, projectWorkspaceId)); + const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId, + cwd: target, repoUrl: pathToFileURL(base).href, branchName: "reopen-feature" }); + if (kind === "configured") await db.update(executionWorkspaces).set({ metadata: { + [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: command }, + } }).where(eq(executionWorkspaces.id, workspaceId)); + const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4199 }); + let organizationId: string | null = restriction === "same-source" ? projectId : null; + if (restriction === "organization") { + organizationId = randomUUID(); + await db.insert(projects).values({ id: organizationId, companyId, name: "Organizational policy" }); + } + const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review", + trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: issueId, + projectIds: [...new Set([projectId, ...(organizationId ? [organizationId] : [])])] } } }; + const agentId = randomUUID(); + await db.insert(agents).values({ id: agentId, companyId, name: "Reopen assignee", + permissions: restriction === "agent" ? policy : {} }); + await db.update(issues).set({ projectId: organizationId, assigneeAgentId: agentId, + executionPolicy: restriction === "task" ? policy : null }).where(eq(issues.id, issueId)); + if (["source", "same-source", "organization"].includes(restriction)) { + await db.update(projects).set({ executionWorkspacePolicy: policy }) + .where(eq(projects.id, restriction === "organization" ? organizationId! : projectId)); + } + const result = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ + workspaceId, issue: { id: issueId, companyId, projectId: organizationId }, + actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, + }); + expect(result).toMatchObject({ ok: true, reopened: true }); + await expect(readFile(join(target, "README.md"), "utf8")).resolves.toBe("seed\n"); + if (restriction === "trusted") await expect(readFile(marker, "utf8")).resolves.toBe("host execution\n"); + else await expect(readFile(marker, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readWorkspace(workspaceId)).toMatchObject({ status: "active", projectId, projectWorkspaceId }); + expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]) + .toMatchObject({ projectId: organizationId, executionWorkspaceId: workspaceId, assigneeAgentId: agentId }); + }); + }); + it("reopens the archived row in place, keeps the issue link, and raises the generation", async () => { const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject(); const cwd = await makeExistingDir(); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 1624efcd1fd..14c791d844d 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -3323,7 +3323,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic } const result = await db.transaction(async (tx): Promise => { await acquireExecutionWorkspaceLifecycleLock(tx, input.workspaceId); - const [boundTask] = await tx.select({ id: issues.id }).from(issues).where(and( + const [boundTask] = await tx.select().from(issues).where(and( eq(issues.id, issue.id), eq(issues.companyId, issue.companyId), eq(issues.executionWorkspaceId, input.workspaceId), await issueReadSqlCondition(tx, input.authorizationActor), @@ -3395,8 +3395,31 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic let rebuildError: string | null = null; try { + const [assignee, organization, sourceProject] = await Promise.all([ + boundTask.assigneeAgentId ? tx.select({ companyId: agents.companyId, permissions: agents.permissions }).from(agents) + .where(and(eq(agents.id, boundTask.assigneeAgentId), eq(agents.companyId, row.companyId))) + .then(rows => rows[0] ?? null) : null, + boundTask.projectId ? tx.select({ companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy }).from(projects) + .where(and(eq(projects.id, boundTask.projectId), eq(projects.companyId, row.companyId))) + .then(rows => rows[0] ?? null) : null, + row.projectId ? tx.select({ companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy }).from(projects) + .where(and(eq(projects.id, row.projectId), eq(projects.companyId, row.companyId))) + .then(rows => rows[0] ?? null) : row.projectWorkspaceId + ? tx.select({ companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projectWorkspaces).innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)) + .where(and(eq(projectWorkspaces.id, row.projectWorkspaceId), eq(projectWorkspaces.companyId, row.companyId), eq(projects.companyId, row.companyId))) + .then(rows => rows[0] ?? null) : null, + ]); + if ((boundTask.assigneeAgentId && !assignee) || (boundTask.projectId && !organization) + || ((row.projectId || row.projectWorkspaceId) && !sourceProject)) { + throw forbidden("Execution workspace trust policy is unavailable"); + } + const trust = resolveCoreTrustPreset({ companyId: row.companyId, agent: assignee, issue: boundTask, + project: organization, workspaceSourceProject: sourceProject }); + if (trust.kind === "denied") throw forbidden(trust.detail); const realized = await ensurePersistedExecutionWorkspaceAvailable({ db: tx as unknown as Db, + skipHostProvision: trust.kind === "low_trust_review", beforeSourceCopy: verifyCopyIdentity, afterSourceCopy: verifyCopyIdentity, base: { diff --git a/ui/src/lib/honeycomb-run-link.test.ts b/ui/src/lib/honeycomb-run-link.test.ts index c563691a60d..61ca8a0c221 100644 --- a/ui/src/lib/honeycomb-run-link.test.ts +++ b/ui/src/lib/honeycomb-run-link.test.ts @@ -13,12 +13,13 @@ describe("Honeycomb run links", () => { ).resolves.toBe("ba7816bf8f01"); }); - it("builds an exact task.run query with clickable trace-id breakdowns", () => { + it("finds native and legacy root spans for the exact run with clickable trace-id breakdowns", () => { const url = new URL(buildHoneycombRunQueryUrl("ba7816bf8f01")); const query = JSON.parse(url.searchParams.get("query") ?? "null") as { calculations: Array<{ op: string }>; breakdowns: string[]; - filters: Array<{ column: string; op: string; value: string }>; + filters: Array<{ column: string; op: string; value: string | string[] }>; + filter_combination: string; }; expect(url.origin).toBe("https://ui.honeycomb.io"); @@ -27,9 +28,10 @@ describe("Honeycomb run links", () => { ); expect(query.calculations).toEqual([{ op: "COUNT" }]); expect(query.breakdowns).toEqual(["trace.trace_id"]); + expect(query.filter_combination).toBe("AND"); expect(query.filters).toEqual([ { column: "service.name", op: "=", value: "paperclip" }, - { column: "name", op: "=", value: "task.run" }, + { column: "name", op: "in", value: ["task.run", "task.provider_session"] }, { column: HONEYCOMB_RUN_HASH_ATTRIBUTE, op: "=", diff --git a/ui/src/lib/honeycomb-run-link.ts b/ui/src/lib/honeycomb-run-link.ts index 9efb8218edb..5478a8a0032 100644 --- a/ui/src/lib/honeycomb-run-link.ts +++ b/ui/src/lib/honeycomb-run-link.ts @@ -26,7 +26,7 @@ export function buildHoneycombRunQueryUrl(runIdHash: string): string { breakdowns: ["trace.trace_id"], filters: [ { column: "service.name", op: "=", value: "paperclip" }, - { column: "name", op: "=", value: "task.run" }, + { column: "name", op: "in", value: ["task.run", "task.provider_session"] }, { column: HONEYCOMB_RUN_HASH_ATTRIBUTE, op: "=", value: runIdHash }, ], filter_combination: "AND",