diff --git a/maps/storybook-map/INDEX.md b/maps/storybook-map/INDEX.md index f66a50ca757..268838d130f 100644 --- a/maps/storybook-map/INDEX.md +++ b/maps/storybook-map/INDEX.md @@ -97,20 +97,20 @@ Status describes product alignment; implementation describes the rendered subjec | Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | | --- | --- | --- | --- | --- | -| [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 19 / 16 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) | `chat-channels/conversation` | unreviewed / prototype / fixtures | 9 / 2 | Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. | -| [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) | `chat-channels/providers` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 15 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | unreviewed / production / fixtures | 33 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | historical / prototype / fixtures | 48 / 9 | Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. | -| [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 7 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 18 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | -| [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 5 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps, channels, and extensions/External chat and email/Connections/AgentMail setup](../../ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 19 / 16 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/AgentMail settings](../../ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 8 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here. | +| [Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks](../../ui/storybook/stories/chat-channels/agentmail.stories.tsx) | `chat-channels/conversation` | unreviewed / prototype / fixtures | 9 / 2 | Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Chat connection actions](../../ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx) | `chat-channels/providers` | unreviewed / production / fixtures | 1 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Chat management](../../ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx) | `chat-channels/lifecycle` | unreviewed / production / fixtures | 15 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified. | +| [Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management](../../ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | unreviewed / production / fixtures | 33 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified. | +| [Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes](../../ui/storybook/stories/chat-channels/github-chat.stories.tsx) | `chat-channels/providers`, `chat-channels/conversation` | historical / prototype / fixtures | 48 / 9 | Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot](../../ui/storybook/stories/chat-channels/github-connections.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 7 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/iMessage Photon](../../ui/storybook/stories/chat-channels/imessage-photon.stories.tsx) | `chat-channels/conversation` | unreviewed / production / fixtures | 13 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup](../../ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 18 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings](../../ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx) | `chat-channels/identity` | current / 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 the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings](../../ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx) | `chat-channels/identity` | current / production / fixtures | 4 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup](../../ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx) | `chat-channels/providers`, `connection-setup/channel-setup` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | +| [Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools](../../ui/storybook/stories/chat-channels/slack-tools.stories.tsx) | `chat-channels/conversation` | 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 added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance. | ## companies @@ -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. | -| [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. | -| [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. | +| [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/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. | +| [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/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. | +| [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. | ## 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. | -| [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. | -| [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. | -| [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. | -| [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. | -| [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. | -| [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. | +| [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. | ## Capability lookup diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md index a914397c46f..c9d79ca87e4 100644 --- a/maps/storybook-map/REVIEW.md +++ b/maps/storybook-map/REVIEW.md @@ -158,3 +158,45 @@ reasons below. The owned static preview was left running for the requested test- Use `maintain-storybook` to correct drift and `review-storybook` to record separate source, render, interaction, visual-comparison, and production evidence. Both skills consume the feature map; neither changes its upstream contract. + +## Task workspace reconciliation — 2026-10-12 + +Scope: the 26 declared-source entries affected by task-workspace work at +`88b1d3a2`, compared with master `dabb8c5daf`, including subsequent comment-only +cleanup and the fixture corrections below. This is a bounded source review, +not a new review of all Storybook states. Reconciliation onto `b5046ec022` +preserves its new feature folders, sidebar titles and explicit stable IDs, along +with the existing exports, classification and simulation boundaries. Shared GitHub +fixtures now live under `ui/storybook/support/github-bot-management/`. + +Twenty-one entries changed only through the expanded chat/workspace recipes. +Their setup, identity, browser, artifact, picker and service examples are retained; +the new recipe requirements do not become covered merely because they reference +the same capability. Five entries also depend on changed production subjects: + +| Story file under `ui/storybook/stories/` | Source review and remaining scope | +| --- | --- | +| `chat-channels/chat-management/chat-management.stories.tsx` | Mounted endpoint includes connection/destination defaults. Added the missing selectable-workspace fixture response; existing endpoint PATCH and resource PUT retain defaults. No browser save, error, reload or inheritance proof in this pass. | +| `chat-channels/github-bot-management/management.stories.tsx` | Mounted endpoint/repository editors include defaults. Fixed the endpoint mock to persist PATCH values instead of returning the original endpoint. Resource PUT already distinguishes omitted, null and concrete values. Project/workspace choices are intentionally empty fixtures. | +| `chat-channels/imessage-photon.stories.tsx` | Existing scenarios mount setup/access and simulate task conversations. They do not exercise the newly added settings editor or its workspace-choice request. Kept unreviewed rather than claiming settings coverage. | +| `projects/projects-goals-workspaces.stories.tsx` | Existing project-bound summaries still match the rendered components. New nullable project references and runtime invalidation branches have no projectless story scenario here. | +| `workspaces/workspace-runtime-provisioning.stories.tsx` | The demonstrated status subcomponent is unchanged. Its local context row does not prove the full detail page's new projectless behavior. | + +The three endpoint-management entries now declare `ChatExecutionDefaults` and its +API clients as review dependencies. AgentMail settings still demonstrates only +the inbox subsection, not the surrounding endpoint defaults. Historical GitHub +designs remain historical; existing unreviewed entries remain unreviewed. None of +these fixture examples establishes source authorization, retained file privacy, +pending workspace selection, sandbox admission or existing-conversation continuity. + +Executed: the GitHub fixture's two Vitest cases passed, including default save, +subsequent GET, omitted-value preservation, explicit null clearing and fresh-mount +reset. After the folder rebase, both fixture tests and all nine catalog checker +tests passed again; token gates passed before the path-only reconciliation. After the final +source freeze, only the 26 reviewed fingerprints were refreshed; catalog validation +passed for all 223 files and all 2,210 source-parsed story IDs were unchanged. +All 223 upstream file paths, explicit stable IDs and sidebar titles were preserved. +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. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index 3bdb0a34160..2651ca1166a 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -633,7 +633,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.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", @@ -643,7 +643,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx" ], - "fingerprint": "a1d017e03f98f45224826a5c6a2bebeecb8a9fc42eba667a8164d0be6168f735", + "fingerprint": "e25771274378d2ff2f2447b2841ff20b91319dba4e50f697c5b2c6cc74698b4b", "stableId": "connections-agentmail-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail setup" }, @@ -656,14 +656,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.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/EmailEndpointSetup.tsx", "ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx" ], - "fingerprint": "1fda3e7570e13c73c9bb9908efe05508e3eb14c716a7c9462ae6a370b77b5a5c", + "fingerprint": "a57256936459ba14d7ad8e0a73ed494c0f269c294240a67c0d27af555e0c736d", "stableId": "connections-agentmail-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail settings" }, @@ -676,7 +676,7 @@ "status": "unreviewed", "implementation": "prototype", "boundary": "fixtures", - "notes": "Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity.", + "notes": "Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", @@ -687,7 +687,7 @@ "ui/src/components/task-chat/presentation-mode.tsx", "ui/storybook/stories/chat-channels/agentmail.stories.tsx" ], - "fingerprint": "9f476aac6ea2ecd4a5cb72226b460fc1c78e3bfda05926d6bfd1b8581e68fdcd", + "fingerprint": "4433454864b43dce30061f1b7e8f65e7bc5fa2f386bde115e276c30de4e6898f", "stableId": "connections-agentmail-tasks", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks" }, @@ -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.", + "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.", "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": "0fb038daa922a0b61864f4ebf0096a50c9caf5dc290887cbf97a0bb63dad22af", + "fingerprint": "3e1151c7391d4a75cfe71d29108a2d02daab83a0eac38629a57acafd6feb9145", "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.", + "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.", "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": "f42487c77335ac64cbe0822c51b1706098f26d9b5823e20837089f261277fd58", + "fingerprint": "613e1680cb816a7dfc5b266944b606cfe33dfb657bdb3ba01920c7c3da48e811", "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.", + "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.", "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": "ce0948c8a9dade2bcab999dccf4f5daa1ddbc4f3ecc72a70e88a1f40861d5199", + "fingerprint": "1ebd3b1723108395a097562b096c83c361ba8de896856e93f1de267589776a30", "stableId": "tasks-browser-use-panel", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel" }, @@ -1387,14 +1387,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.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/Browse.tsx", "ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx" ], - "fingerprint": "c80c99044431f1cfe75e7b3da1b28d776595217d5bbda14aa82c78b0b272aab0", + "fingerprint": "b5c4a820f42d7b3d5e665161b95056b80b5591f6100e6847488eea49b876d36d", "stableId": "connections-chat-connection-actions", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat connection actions" }, @@ -1443,16 +1443,20 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: the mounted endpoint adds connection and destination editors. Local endpoint PATCH/resource PUT preserve defaults; workspace-choice requests now return only active/idle, unclosed fixture workspaces. Browser save/error/reload and new-conversation inheritance remain unverified.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", + "ui/src/api/chatEndpoints.ts", + "ui/src/api/execution-workspaces.ts", + "ui/src/api/projects.ts", "ui/src/components/Layout.tsx", "ui/src/pages/apps/chat/ChatEndpointDetail.tsx", + "ui/src/pages/apps/chat/ChatExecutionDefaults.tsx", "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx" ], - "fingerprint": "5025a812c84d5700fea3907dd09f484ae3ded0d2f1cfc0f8e83f0fb5bc6d181e", + "fingerprint": "593f980a74e21b1e8bce9f48ee8ef7f1247546e4b4519a0916b64881ba5224c8", "stableId": "connections-chat-management", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat management" }, @@ -2521,14 +2525,18 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: mounted repository and endpoint editors use simulated defaults. Endpoint PATCH now persists omitted/null/concrete values across GET and resets on remount; repository PUT already preserves them. Choice caches intentionally contain no projects/workspaces. Browser editor interactions, access denials and new-task inheritance remain unverified.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", + "ui/src/api/chatEndpoints.ts", + "ui/src/api/execution-workspaces.ts", + "ui/src/api/projects.ts", "ui/src/components/Layout.tsx", "ui/src/components/OnboardingWizard.tsx", "ui/src/pages/apps/chat/ChatConversationList.tsx", "ui/src/pages/apps/chat/ChatEndpointDetail.tsx", + "ui/src/pages/apps/chat/ChatExecutionDefaults.tsx", "ui/src/pages/apps/chat/GitHubAppIdentity.tsx", "ui/src/pages/apps/chat/GitHubBotConfiguration.tsx", "ui/src/pages/apps/chat/GitHubBotManagement.tsx", @@ -2536,11 +2544,11 @@ "ui/src/pages/apps/chat/GitHubConnectionComplete.tsx", "ui/storybook/fixtures/onboardingDraft.ts", "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx", "ui/storybook/support/github-bot-management/fixture-api.tsx", - "ui/storybook/support/github-bot-management/fixtures.ts", - "ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx" + "ui/storybook/support/github-bot-management/fixtures.ts" ], - "fingerprint": "9a432840be84fdfd9e437b851d5a975d13edba7eeca4c6273eb21cf69ed29e32", + "fingerprint": "8688291f425ae517d5a655e9ab237bb035ef2cc8853dfc24129bd7258e36acab", "stableId": "connectors-github-bot-management", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management" }, @@ -2554,7 +2562,7 @@ "status": "historical", "implementation": "prototype", "boundary": "fixtures", - "notes": "Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx.", + "notes": "Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", @@ -2567,7 +2575,7 @@ "ui/storybook/stories/chat-channels/github-chat.stories.tsx" ], "replacement": "ui/storybook/stories/chat-channels/github-connections.stories.tsx", - "fingerprint": "acb56c908e107266128dd0d817162007a0a7ec75b24fa059714cee490e4ef642", + "fingerprint": "1b98e4a1e023630f9d27105f47ee8e04c7e49e31f1eb77430e55cf0518bca033", "stableId": "historical-github-chat-prototypes", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes" }, @@ -2581,7 +2589,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.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", @@ -2593,7 +2601,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/github-connections.stories.tsx" ], - "fingerprint": "737355c3a9a9e1fe69b606edf543d0e7f0b94b7332a344a0f077c4cccfb544b9", + "fingerprint": "1db66005c3710754f307003ce6907073e8eca0319074c323d57992c0b26d8b23", "stableId": "connections-github-and-code-review-bot", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot" }, @@ -2656,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.", + "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.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/documents-artifacts.md", @@ -2665,7 +2673,7 @@ "ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx", "ui/storybook/support/html-artifacts/fixtures.ts" ], - "fingerprint": "7289b022aeb669441639942cb709e07a1f03f1cf4e0ca5db3672c654a50da92a", + "fingerprint": "40947edd6214de75fea30b48f0ef86a240c4c902cefab2766f080796e4cbf7f4", "stableId": "html-artifacts-01-preview", "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview" }, @@ -2679,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.", + "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.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/documents-artifacts.md", @@ -2689,7 +2697,7 @@ "ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx", "ui/storybook/support/html-artifacts/fixtures.ts" ], - "fingerprint": "2463cc23aa39bca80133e8b19bb4899f9dfbab7272747796db7855da51642ec2", + "fingerprint": "9428839bdddad9ebb39c4a17e28c355a27a626a82f61d085f1323351db4548a9", "stableId": "html-artifacts-02-workspace", "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace" }, @@ -2703,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.", + "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.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/documents-artifacts.md", @@ -2716,7 +2724,7 @@ "ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx", "ui/storybook/support/html-artifacts/fixtures.ts" ], - "fingerprint": "d2b3ecd2b4490ca3bec6c8dfacad4c02b794f78fd4d348c6c0fd165fb8928f33", + "fingerprint": "1188502a2840cb15c12e06c432871faef9c9a57ccf735dbe3b8ae85cfbbc0a7d", "stableId": "html-artifacts-03-task-journey", "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey" }, @@ -2729,19 +2737,23 @@ "status": "unreviewed", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the endpoint editor addition; this suite selects setup/access and locally simulated conversation states. Endpoint PATCH retains fields, but settings workspace-choice requests and default selection are not covered by these scenarios.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", + "ui/src/api/chatEndpoints.ts", + "ui/src/api/execution-workspaces.ts", + "ui/src/api/projects.ts", "ui/src/components/task-chat/TaskChatBubble.tsx", "ui/src/components/task-chat/task-chat-model.ts", "ui/src/pages/apps/Browse.tsx", "ui/src/pages/apps/chat/ChatEndpointDetail.tsx", "ui/src/pages/apps/chat/ChatEndpointSetup.tsx", + "ui/src/pages/apps/chat/ChatExecutionDefaults.tsx", "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx" ], - "fingerprint": "e808e8533ef84eaa5f0c49144f7fa23554a0956217177a25fe7386ddaf4c9ffc", + "fingerprint": "90d113f72e3f6db48428bf498fba3d14d713eaab194bfb66eb1d27fe17843063", "stableId": "connections-imessage-photon", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon" }, @@ -3928,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.", + "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.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/projects.md", @@ -3937,7 +3949,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx" ], - "fingerprint": "d3c1ddd40e4acf1b30ee80655773c475e71885b75fe61b94c5a7ab1010fcb7ed", + "fingerprint": "085390f326c00881e26775b6ff5f1ecb8831e88fb3383f7e1cdbb6d79466ada9", "stableId": "workspaces-project-runtime-provision-command", "sidebarTitle": "Projects and execution/Projects and repositories/Workspaces/Project runtime provision command" }, @@ -3981,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.", + "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.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/goals.md", @@ -3997,7 +4009,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx" ], - "fingerprint": "4aa89b2f36fd6db30b20528b2e95063a8846279d28623f927de35acd75ce64f5", + "fingerprint": "4b808790405c69d24dcd87f7c086e0c348bcb26559cc6a33a7a1b8d016c75a71", "stableId": "product-projects-goals-workspaces", "sidebarTitle": "Projects and execution/Projects and repositories/Product/Projects Goals Workspaces" }, @@ -4666,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.", + "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.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/workspaces.md", "ui/src/components/SearchableSelect.tsx", "ui/storybook/stories/workspaces/searchable-select.stories.tsx" ], - "fingerprint": "a09a2ecf9c05843dc29ea4fd173fa7e9b0968defc8a83d48b2ffc81503b2f98f", + "fingerprint": "07e6bf69886f137b4c04faa166fa12ea0dfb1bc4c98f5984cac6419b6f220139", "stableId": "components-searchableselect-workspace-picker", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker" }, @@ -4814,7 +4826,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.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", @@ -4825,7 +4837,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx" ], - "fingerprint": "42fb2d53434f7ba8cc5cce556818d6469e02da559bd40fb6dbd4fd5bec9aecb5", + "fingerprint": "df0600372c76d7ff7eb15ccc14a7a5bf7f0b5f5d64a366013686b6aaf888ee00", "stableId": "connections-slack-automatic-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup" }, @@ -4838,7 +4850,7 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", @@ -4847,7 +4859,7 @@ "ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx", "ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx" ], - "fingerprint": "ed2053a543507e9ad110084b6974204897193fcc1d7a1c4cb23f15e1cfe5dcba", + "fingerprint": "725869cfc3280b8384899e0f692bcf17663368f389c38fe1a16ec34c27faf20d", "stableId": "connections-slack-avatar-in-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings" }, @@ -4860,7 +4872,7 @@ "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", @@ -4869,7 +4881,7 @@ "ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx", "ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx" ], - "fingerprint": "c581843a009938f16173f6874a1edbe6a562c3c4382b3a674fcd3487d37a50ce", + "fingerprint": "f0d8df504be6e6e5a1ea09b868ee8a75e35d76041a792f48955dc1e834297cb6", "stableId": "connections-slack-communication-in-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings" }, @@ -4883,7 +4895,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.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", @@ -4894,7 +4906,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx" ], - "fingerprint": "d947a4bd8dec88fd77c0b68e51ff12bab050342661e455a4f72443d82916d7c9", + "fingerprint": "24e3f986568fad11d604fddc48db890e2c75091b8b7c0ff916c6d609dd4c033d", "stableId": "connections-slack-managed-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup" }, @@ -4907,14 +4919,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.", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/SlackToolSettings.tsx", "ui/storybook/stories/chat-channels/slack-tools.stories.tsx" ], - "fingerprint": "a8464a4cbed26f72dacc14e77326caa7d649b23eec075729667d48f267bb8a03", + "fingerprint": "9eaae8672c83f1be3d6bdea09e6a77fd62f2828ab410addfe82f9c08c3c34a4a", "stableId": "connections-slack-task-tools", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools" }, @@ -5533,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.", + "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.", "reviewedAt": "2026-10-12", "sources": [ "maps/feature-map/workspaces.md", @@ -5541,7 +5553,7 @@ "ui/src/components/IssueWorkspaceCard.tsx", "ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx" ], - "fingerprint": "e8ece699c3f156643552e788ba86cedda4f2e3a4e6e1a40fbf8b6bef1103254f", + "fingerprint": "f1e1fcd710ac215bff805e58647c7fe423df046c7e76b951c711893c652a5fac", "stableId": "issue-workspace-file-browser", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser" }, @@ -5554,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.", + "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.", "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": "4ec295039bb17a9590ab1fb0be2864845a903a625fcaf79459541ddd5a4670f4", + "fingerprint": "98e44aa2751d7559f8b2d9b184163f798fa1a2ba04baff2d5e46a34e012d23a0", "stableId": "workspaces-runtime-provisioning-status", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status" }, @@ -5574,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.", + "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.", "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": "cbe4b5d61324f8eec1dfff9583dc84eab5c83947ff947f5c381d00217b29b5e2", + "fingerprint": "e8291e71e152744af82c5550305bb32bf8786d70fec40aeb9687b3c8ef6350cc", "stableId": "workspaces-service-control-bar", "sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Service control bar" } diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 4596b999246..0fad2165327 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -546,17 +546,14 @@ export async function prepareCommandManagedRuntime(input: { syncWorkspace?: boolean; workspaceInboundMode?: WorkspaceInboundMode; workspaceDurableSeed?: WorkspaceDurableSeedPaths; - /** Probe bounded remote manifest capture; unsupported hosts keep full transfer. */ workspaceCheckpoint?: boolean; onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise; workspaceSeedCacheDirectory?: string; workspaceSeedCacheCompanyDirectory?: string; workspaceBaseline?: DirectorySnapshot; workspaceGitSnapshot?: GitWorkspaceSnapshot | null; - /** Without a Git snapshot, undefined discovers managed task repositories; [] keeps plain directories uninterpreted. */ workspaceRepositories?: NonNullable; workspaceExclude?: string[]; - /** Includes ordinary ignored/cache files; plain directories also set workspaceGitSnapshot: null and workspaceRepositories: []. */ workspaceFileMode?: "all"; preserveAbsentOnRestore?: string[]; assets?: CommandManagedRuntimeAsset[]; diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index dc2d5f73f47..5a5b9d79b78 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1440,17 +1440,14 @@ export async function prepareAdapterExecutionTargetRuntime(input: { syncWorkspace?: boolean; workspaceInboundMode?: WorkspaceInboundMode; workspaceDurableSeed?: WorkspaceDurableSeedPaths; - /** Probe bounded remote manifest capture; unsupported hosts keep full transfer. */ workspaceCheckpoint?: boolean; onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise; workspaceSeedCacheDirectory?: string; workspaceSeedCacheCompanyDirectory?: string; workspaceBaseline?: DirectorySnapshot; workspaceGitSnapshot?: GitWorkspaceSnapshot | null; - /** Without a Git snapshot, undefined discovers managed task repositories; [] keeps plain directories uninterpreted. */ workspaceRepositories?: NonNullable; workspaceExclude?: string[]; - /** Includes ordinary ignored/cache files; plain directories also set workspaceGitSnapshot: null and workspaceRepositories: []. */ workspaceFileMode?: "all"; preserveAbsentOnRestore?: string[]; assets?: AdapterManagedRuntimeAsset[]; diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index 207a98e92ed..d49486aaebc 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -15,7 +15,6 @@ export interface GitCommandResult { export interface GitWorkspaceSnapshot { headCommit: string; branchName: string | null; - /** Selected remote captured with the Git generation; omitted on legacy descriptors. */ originUrl?: string | null; overlayPaths: WorkspacePaths; deletedPaths: WorkspacePaths; diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 4bc6ba4e34f..a2164603e9f 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -368,8 +368,6 @@ function isWorkspaceGitScanSaturatedError(error: unknown): boolean { ); } -// Remote command transports may preserve only stderr, not the original error -// code. This is used only for capture-command failures, never returned data. function isCheckpointCaptureCapacityError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const failure = error as { code?: unknown; message?: unknown; stderr?: unknown }; @@ -714,8 +712,6 @@ function buildWorkspaceTarExtractCommand(input: { // already-confined relative paths from the git snapshot. function buildRemoveDeletedPathsCommand(input: { remoteDir: string; manifestPath: string }): string { // NUL input plus bounded xargs batches preserve whitespace and never create - // one argument list for the full snapshot. Validate the whole batch before - // removing anything, then avoid starting a separate rm process per path. const remove = `for entry do parent=$entry while [ "\${parent#*/}" != "$parent" ]; do @@ -919,8 +915,6 @@ async function* walkDirectory(root: string, relative = "", childrenFirst = false } } -// Native providers validate their own transfers, but full-export confinement -// must also hold for the returned host tree before baseline merge can write. async function confineFullWorkspaceExport(root: string): Promise { const unsafe = () => Object.assign(new Error("Unsafe workspace export directory"), { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" }); if (!(await fs.lstat(root)).isDirectory()) throw unsafe(); @@ -934,7 +928,6 @@ async function confineFullWorkspaceExport(root: string): Promise { else if (stats.isSymbolicLink()) { const target = await fs.readlink(entryPath); const resolved = path.resolve(directory, target); - // Match sparse capture: omit unsafe links, preserving unrelated bytes. if (path.isAbsolute(target) || (resolved !== canonicalRoot && !resolved.startsWith(`${canonicalRoot}${path.sep}`))) { await fs.unlink(entryPath); } @@ -1181,7 +1174,6 @@ export async function prepareSandboxManagedRuntime(input: { /** Selects authoritative host staging, exact durable-seed replay, or no-overwrite adoption. */ workspaceInboundMode?: WorkspaceInboundMode; workspaceDurableSeed?: WorkspaceDurableSeedPaths; - /** Probe bounded remote manifest capture; unsupported hosts keep full transfer. */ workspaceCheckpoint?: boolean; onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise; workspaceSeedCacheDirectory?: string; @@ -1189,10 +1181,8 @@ export async function prepareSandboxManagedRuntime(input: { /** Durable snapshots supplied when reconstructing an interrupted restore. */ workspaceBaseline?: DirectorySnapshot; workspaceGitSnapshot?: GitWorkspaceSnapshot | null; - /** Without a Git snapshot, undefined discovers managed task repositories; [] keeps plain directories uninterpreted. */ workspaceRepositories?: NonNullable; workspaceExclude?: string[]; - /** Includes ordinary ignored/cache files; plain directories also set workspaceGitSnapshot: null and workspaceRepositories: []. */ workspaceFileMode?: "all"; preserveAbsentOnRestore?: string[]; assets?: SandboxManagedRuntimeAsset[]; @@ -1366,8 +1356,6 @@ export async function prepareSandboxManagedRuntime(input: { } if (input.workspaceFileMode === "all" && baselineSnapshot && (isPathManifest(deletedPaths) ? deletedPaths.count : deletedPaths.length) > 0) { - // The filesystem is authoritative in all-files mode. Git can report a - // staged untracking as deleted while its file remains in the admitted tree. filesystemDeleted = await createWorkspaceManifest(); try { for (const relative of workspacePaths(deletedPaths)) { @@ -1650,11 +1638,6 @@ export async function prepareSandboxManagedRuntime(input: { if (seedGeneration && !cachedSeed && input.workspaceDurableSeed) { const seed = input.workspaceDurableSeed; - // A digest proves archive integrity, not that a live source stayed - // equal to the baseline while tar was reading it. Reconstruct the - // exact seed and verify its selected contents before labeling it a - // reusable generation. A mismatch leaves ordinary per-run recovery - // intact, but never publishes a mislabeled reusable seed. await publishWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration, { ...seed, gitArchivePath: gitSnapshot || repositories.length ? seed.gitArchivePath : null }, { companyDirectory: input.workspaceSeedCacheCompanyDirectory, @@ -1668,7 +1651,7 @@ export async function prepareSandboxManagedRuntime(input: { for (const segment of relative.split("/").slice(0, -1)) { parent = path.join(parent, segment); const stat = await fs.lstat(parent).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; throw error; }); - if (stat && !stat.isDirectory()) return false; // Never follow archive links. + if (stat && !stat.isDirectory()) return false; } await fs.rm(path.join(verifiedRoot, relative), { recursive: true, force: true }); } @@ -2071,9 +2054,6 @@ export async function prepareSandboxManagedRuntime(input: { await input.client.run(["node", remoteHelper, workspaceRemoteDir, remoteManifest, remoteOutput, JSON.stringify(workspaceRestoreExclude)].map(shellQuote).join(" "), { timeoutMs: input.spec.timeoutMs }); captured = true; } catch (error) { - // A sparse capture needs extra remote disk for its manifest - // and payload. Full transfer needs neither; it still passes - // the normal confinement and baseline-merge validation. if (!isCheckpointCaptureCapacityError(error)) throw error; } if (captured) { diff --git a/packages/adapter-utils/src/sandbox-managed-seed.test.ts b/packages/adapter-utils/src/sandbox-managed-seed.test.ts index a3781d73c47..e54bdc7d8e7 100644 --- a/packages/adapter-utils/src/sandbox-managed-seed.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-seed.test.ts @@ -48,7 +48,6 @@ it.each([undefined, "all"] as const)("keeps nested Git metadata sanitized in col for (const file of operation.files) { await fs.mkdir(path.dirname(file.targetPath), { recursive: true }); await fs.cp(file.sourcePath, file.targetPath, { recursive: true, verbatimSymlinks: true }); - // Optional verification scratch must be gone before required upload. if (file.sourcePath.endsWith("/workspace.tar")) { await expect(fs.stat(path.join(path.dirname(file.sourcePath), "verify-seed"))).rejects.toMatchObject({ code: "ENOENT" }); } @@ -88,7 +87,6 @@ it.each([undefined, "all"] as const)("keeps nested Git metadata sanitized in col await cold.cleanupWorkspaceSnapshot(); await fs.writeFile(path.join(repository, ".git/hooks/post-commit"), "changed private hook"); const cached = await prepare(); await assertSanitized(); expect(verifications).toBe(1); - // Older descriptors may include raw metadata in their immutable overlay/baseline. const oldBaseline = await captureDirectorySnapshot(host, { diskBacked: true }); await createTarballFromDirectory({ localDir: host, archivePath: seed.workspaceArchivePath }); const recovered = await prepare({ workspaceInboundMode: "durable_seed", workspaceBaseline: oldBaseline, diff --git a/packages/adapter-utils/src/workspace-checkpoint.test.ts b/packages/adapter-utils/src/workspace-checkpoint.test.ts index 554d2644e7f..78eaf8b7994 100644 --- a/packages/adapter-utils/src/workspace-checkpoint.test.ts +++ b/packages/adapter-utils/src/workspace-checkpoint.test.ts @@ -122,9 +122,9 @@ describe("workspace sparse checkpoint", () => { if (attack === "ancestor_link") await fs.symlink("unchanged", path.join(payload, "config")); const captured = await captureDirectorySnapshot(payload); const entries = new Map(baseline.entries); - entries.delete("deleted"); // An otherwise valid deletion must not apply on rejection. + entries.delete("deleted"); for (const [relative, entry] of captured.entries) if (!(attack === "payload_only" && relative === ".env")) entries.set(relative, entry); - if (attack === "matching_descendant") entries.delete("private"); // Reject the descendant itself, too. + if (attack === "matching_descendant") entries.delete("private"); const manifestPath = path.join(f.temp, "forged.sqlite"); await writeCheckpointBaseline({ ...baseline, entries }, manifestPath); const snapshot = readCheckpointSnapshot(manifestPath, baseline); @@ -255,7 +255,6 @@ it("uses sparse native transfer end-to-end, including replay and unsupported-run expect(await fs.readFile(path.join(remote, "new"), "utf8")).toBe("verification recovery"); await verificationRecovery.cleanupWorkspaceSnapshot(); failVerification = false; - // A saturated optional cache must still admit and recover from its per-run seed. mutateDuringPack = false; for (let count = (await fs.readdir(cache)).length; count < 16; count++) { await fs.mkdir(path.join(cache, `.pending-retained-${count}`)); @@ -316,8 +315,6 @@ async function exportFailureFixture(failure: "low_disk" | "full_root" | "full_sp if (file.sourcePath === remote && failure === "full_special") await execute("mkfifo", [path.join(file.targetPath, "unsafe-pipe")]); if (["low_disk", "full_root", "full_special"].includes(failure) && file.targetPath.endsWith("/checkpoint.mjs")) { const script = await fs.readFile(file.targetPath, "utf8"); - // Execute the real capture helper against a provider filesystem whose - // reported remaining space is below the optional SQLite reserve. await fs.writeFile(file.targetPath, script.replace("const __name", "fs.statfs = async () => ({ bavail: 128n * 1024n, bsize: 1024n });\nconst __name")); } if (file.sourcePath.endsWith("/output")) { diff --git a/packages/adapter-utils/src/workspace-checkpoint.ts b/packages/adapter-utils/src/workspace-checkpoint.ts index 9318d9bbd90..933baf7b187 100644 --- a/packages/adapter-utils/src/workspace-checkpoint.ts +++ b/packages/adapter-utils/src/workspace-checkpoint.ts @@ -5,8 +5,6 @@ import { WorkspaceManifestWriter, WorkspaceManifestMap, workspacePaths, workspac import { shouldExcludePath, excludePatternMatches, isRelativePathOrDescendant } from "./exclude-patterns.js"; import { openDirectorySnapshot, type DirectorySnapshot } from "./workspace-restore-merge.js"; -/** Transfer metadata is disk-backed on both sides. No file list or payload is - * buffered in a command argument, JSON array or controller heap. */ export async function writeCheckpointBaseline(snapshot: DirectorySnapshot, filePath: string): Promise { const writer = new WorkspaceManifestWriter(filePath); try { @@ -16,8 +14,6 @@ export async function writeCheckpointBaseline(snapshot: DirectorySnapshot, fileP } catch (error) { writer.close(false); throw error; } } -// This function is serialized as the provider helper. Keep dependencies explicit -// in workspaceCheckpointScript: the same path policy functions run on both ends. async function checkpointMain( fs: typeof import("node:fs/promises"), path: typeof import("node:path"), DatabaseSync: typeof import("node:sqlite").DatabaseSync, @@ -69,8 +65,6 @@ async function checkpointMain( else if (before.isSymbolicLink()) { const target = await fs.readlink(source); const resolved = path.resolve(path.dirname(source), target); - // Match confined full exports: omit the unsafe entry without losing - // unrelated changed files. Never follow the link or copy its target. if (path.isAbsolute(target) || (resolved !== root && !resolved.startsWith(`${root}/`))) continue; entry = { kind: "symlink", target }; } else continue; @@ -100,13 +94,9 @@ async function checkpointMain( } export function workspaceCheckpointScript(): string { - // Keep wildcard policy in one source. The private segment predicate is the - // only dependency not exported by exclude-patterns. return `import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\nimport { createHash } from 'node:crypto';\nimport { createReadStream } from 'node:fs';\nconst __name = (fn) => fn;\n${isRelativePathOrDescendant.toString()}\nfunction pathContainsSegmentOrDescendant(relative, segment) { return relative === segment || relative.startsWith(segment + '/') || relative.endsWith('/' + segment) || relative.includes('/' + segment + '/'); }\n${excludePatternMatches.toString()}\n${shouldExcludePath.toString()}\nawait (${checkpointMain.toString()})(fs, path, DatabaseSync, createHash, createReadStream, shouldExcludePath);\n`; } -/** Validate every manifest record before allowing a sparse source to authorize - * deletions. SQLite is read-only, extensions and trusted schema are disabled. */ export function readCheckpointSnapshot(filePath: string, baseline: DirectorySnapshot): DirectorySnapshot { const db = new DatabaseSync(filePath, { readOnly: true, allowExtension: false }); let count: number; @@ -116,13 +106,11 @@ export function readCheckpointSnapshot(filePath: string, baseline: DirectorySnap } finally { db.close(); } const snapshot = openDirectorySnapshot({ version: 2, exclude: baseline.exclude, ignoredPaths: baseline.ignoredPaths, entries: { kind: "path_manifest", version: 1, filePath, category: "baseline", count } }); - try { for (const _entry of snapshot.entries) { /* bounded record validation */ } } + try { for (const _entry of snapshot.entries) { } } catch (error) { (snapshot.entries as WorkspaceManifestMap).close(); throw error; } return snapshot; } -/** The manifest is provider data, not proof that the downloaded payload matches - * it. Verify changed entries before the existing merge engine sees the source. */ export async function validateCheckpointPayload(input: { baseline: DirectorySnapshot; snapshot: DirectorySnapshot; payload: string }): Promise { const { captureDirectorySnapshot, disposeDirectorySnapshot } = await import("./workspace-restore-merge.js"); const ignored = workspacePathMatcher(input.baseline.ignoredPaths); @@ -151,10 +139,6 @@ export async function validateCheckpointPayload(input: { baseline: DirectorySnap if (!equal(entry, input.snapshot.entries.get(relative))) throw new Error("Unexpected workspace checkpoint payload"); if (entry.kind === "file") { changedFiles++; payloadBytes += (await fs.stat(path.join(input.payload, relative))).size; } } - // Replacing an ancestor directory with a file/link recursively removes its - // host-only ignored children. Directory entries and ordinary tombstones - // remain valid: the merge removes empty directories without deleting those - // retained children. Stream the trusted ignore manifest, never buffer it. if (input.baseline.ignoredPaths) for (const relative of workspacePaths(input.baseline.ignoredPaths)) { for (let slash = relative.lastIndexOf("/"); slash >= 0; slash = relative.lastIndexOf("/", slash - 1)) { const ancestor = relative.slice(0, slash); diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index faf18e740e3..23bdcd7b9c2 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -507,8 +507,6 @@ export async function withDirectoryMergeLock( return withCanonicalDirectoryMergeLock(canonicalTargetDir, fn, env, diagnosticOperation, waitMs); } -/** Lock an atomic directory publication before its target exists. The parent - * must already exist; existing leaf symlinks and non-directories are refused. */ export async function withDirectoryPublicationLock( targetDir: string, fn: (canonicalTargetDir: string) => Promise, diff --git a/packages/adapter-utils/src/workspace-seed-cache.ts b/packages/adapter-utils/src/workspace-seed-cache.ts index 29c14c71a7e..c35bcd4dfcc 100644 --- a/packages/adapter-utils/src/workspace-seed-cache.ts +++ b/packages/adapter-utils/src/workspace-seed-cache.ts @@ -20,8 +20,6 @@ async function digest(file: string): Promise { return hash.digest("hex"); } -/** Cache is durable but never authority: every archive is checked before reuse. - * Admitted runs retain independent per-run archives; cache entries are never evicted. */ export async function readWorkspaceSeedGeneration(root: string, generation: string): Promise { const directory = path.join(root, generation); try { @@ -52,8 +50,6 @@ export const WORKSPACE_SEED_CACHE_LIMITS = Object.freeze({ }); type SeedCacheLimits = { [Key in keyof typeof WORKSPACE_SEED_CACHE_LIMITS]: number }; -/** Count even abandoned pending entries and corrupt archives against capacity. - * Never follow links or recurse into an unexpected cache layout. */ async function cacheUsage(root: string, limits: SeedCacheLimits): Promise<{ generations: number; bytes: number } | null> { let generations = 0, bytes = 0; for await (const entry of await fs.opendir(root)) { @@ -73,8 +69,6 @@ async function cacheUsage(root: string, limits: SeedCacheLimits): Promise<{ gene return { generations, bytes }; } -/** Optional optimization: saturation or cache failure leaves per-run recovery - * untouched. No eviction: readers may still be using a returned cache path. */ export async function publishWorkspaceSeedGeneration( root: string, generation: string, seed: WorkspaceDurableSeedPaths, options: { companyDirectory?: string; limits?: Partial; verify?: () => Promise } = {}, @@ -105,9 +99,8 @@ export async function publishWorkspaceSeedGeneration( if (totalGenerations > limits.companyGenerations || totalBytes > limits.companyBytes) return false; if (directory === root) { workspace = usage; workspaceExists = true; } } - // An existing immutable entry adds no storage, including when saturated. if (workspaceExists && await fs.lstat(path.join(root, generation)).then(() => true, () => false)) return true; - let incomingBytes = 4096; // conservative receipt allowance + let incomingBytes = 4096; for (const archive of [seed.workspaceArchivePath, seed.gitArchivePath]) { if (!archive) continue; const stat = await fs.lstat(archive); @@ -117,7 +110,6 @@ export async function publishWorkspaceSeedGeneration( if (workspace.generations + 1 > limits.workspaceGenerations || workspace.bytes + incomingBytes > limits.workspaceBytes || totalGenerations + 1 > limits.companyGenerations || totalBytes + incomingBytes > limits.companyBytes || workspaces + (workspaceExists ? 0 : 1) > limits.companyWorkspaces) return false; - // Reserve enough for a full copy even when hard links are unavailable. assertWorkspaceManifestDiskSpace(company, incomingBytes); if (options.verify && !await options.verify()) return false; await fs.mkdir(root, { recursive: true, mode: 0o700 }); diff --git a/packages/db/src/schema/execution_workspace_repositories.ts b/packages/db/src/schema/execution_workspace_repositories.ts index 17d7e2d10c6..5ee96ff9633 100644 --- a/packages/db/src/schema/execution_workspace_repositories.ts +++ b/packages/db/src/schema/execution_workspace_repositories.ts @@ -3,7 +3,6 @@ import { companies } from "./companies.js"; import { executionWorkspaces } from "./execution_workspaces.js"; import { issues } from "./issues.js"; -/** Durable acquisition receipt and inventory. Credentials never belong in this table. */ export const executionWorkspaceRepositories = pgTable("execution_workspace_repositories", { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), diff --git a/packages/db/src/schema/execution_workspace_repository_access.ts b/packages/db/src/schema/execution_workspace_repository_access.ts index ce3fff12f1c..af1a2cd1f11 100644 --- a/packages/db/src/schema/execution_workspace_repository_access.ts +++ b/packages/db/src/schema/execution_workspace_repository_access.ts @@ -1,7 +1,6 @@ import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; -/** Sticky source/reader provenance; missing referenced owners fail closed instead of releasing root access. */ export const executionWorkspaceRepositoryAccess = pgTable("execution_workspace_repository_access", { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), diff --git a/packages/db/src/schema/execution_workspace_repository_roots.ts b/packages/db/src/schema/execution_workspace_repository_roots.ts index 59abd8e7a82..3aae21bef6d 100644 --- a/packages/db/src/schema/execution_workspace_repository_roots.ts +++ b/packages/db/src/schema/execution_workspace_repository_roots.ts @@ -1,7 +1,6 @@ import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; -/** Sticky root ownership survives workspace moves, archival, and metadata changes. */ export const executionWorkspaceRepositoryRoots = pgTable("execution_workspace_repository_roots", { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts index c9277e8c6d4..576ff3ce1db 100644 --- a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts +++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts @@ -19,9 +19,6 @@ export class AcpxApprovalRequiredError extends Error { // Review additions explicitly: a newly catalogued mutation must not inherit // automatic provider permission. These operations implement the bounded plan, // task, and handoff workflow; governance decisions, generic APIs, and workspace -// controls deliberately stay outside it. Repository acquisition also stays -// outside: it can trigger host-side clones with unbounded cumulative disk use. -// Asking a human does not approve work. const AUTOMATIC_PAPERCLIP_WORKFLOW_ACTIONS = new Set([ "report_progress", "answer_status_question", "write_document", "request_human_input", "register_deliverable", "finish_task", "block_task", diff --git a/packages/paperclip-runner/src/protocol-actions/get-workspace.ts b/packages/paperclip-runner/src/protocol-actions/get-workspace.ts index 5f017f2371c..d4d4812f3af 100644 --- a/packages/paperclip-runner/src/protocol-actions/get-workspace.ts +++ b/packages/paperclip-runner/src/protocol-actions/get-workspace.ts @@ -1,4 +1,3 @@ -/** Canonical task workspace operation. */ export const getWorkspaceAction = { "id": "get_workspace", "canonical": { diff --git a/packages/paperclip-runner/src/protocol-actions/list-workspaces.ts b/packages/paperclip-runner/src/protocol-actions/list-workspaces.ts index 6ac54b7415b..c8c0f16d12e 100644 --- a/packages/paperclip-runner/src/protocol-actions/list-workspaces.ts +++ b/packages/paperclip-runner/src/protocol-actions/list-workspaces.ts @@ -1,4 +1,3 @@ -/** Canonical task workspace operation. */ export const listWorkspacesAction = { "id": "list_workspaces", "canonical": { diff --git a/packages/paperclip-runner/src/protocol-actions/prepare-repository.ts b/packages/paperclip-runner/src/protocol-actions/prepare-repository.ts index 26ce01c3549..d0570ece72b 100644 --- a/packages/paperclip-runner/src/protocol-actions/prepare-repository.ts +++ b/packages/paperclip-runner/src/protocol-actions/prepare-repository.ts @@ -1,4 +1,3 @@ -/** Canonical task workspace operation. */ export const prepareRepositoryAction = { "id": "prepare_repository", "canonical": { diff --git a/packages/paperclip-runner/src/protocol-actions/select-workspace.ts b/packages/paperclip-runner/src/protocol-actions/select-workspace.ts index e35d6023e69..2596f49dc84 100644 --- a/packages/paperclip-runner/src/protocol-actions/select-workspace.ts +++ b/packages/paperclip-runner/src/protocol-actions/select-workspace.ts @@ -1,4 +1,3 @@ -/** Canonical task workspace operation. */ export const selectWorkspaceAction = { "id": "select_workspace", "canonical": { diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 7a989eac8df..b1e2980315b 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -408,7 +408,6 @@ export interface PluginExecutionWorkspaceMetadata { id: string; /** UUID of the owning company. */ companyId: string; - /** Source project whose access policy protects these files; null for task-owned sources. */ projectId: string | null; /** UUID of the backing project workspace, when present. */ projectWorkspaceId: string | null; diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index 6249d07f4d1..42cdb367382 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -1,6 +1,5 @@ import type { TaskWorkspaceSelection } from "./task-workspace.js"; -/** Omitted fields inherit endpoint defaults; null explicitly clears them. */ export interface ChatExecutionDefaults { projectId?: string | null; workspace?: TaskWorkspaceSelection | null; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 24b488c4efe..4d187a833bf 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -892,7 +892,6 @@ 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 7c3f51e09d1..0938a8dca28 100644 --- a/packages/shared/src/types/task-workspace.ts +++ b/packages/shared/src/types/task-workspace.ts @@ -1,4 +1,3 @@ -/** Filesystem intent is independent of a task's organizational project. */ export type TaskWorkspaceSelection = | { kind: "task_directory" } | { kind: "existing"; workspaceId: string } @@ -12,7 +11,6 @@ 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/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index fe28324a255..e7b3197a550 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -996,7 +996,5 @@ export { restoreAgentInstructionSchema } from "./agent.js"; export * from "./skill-source.js"; export * from "./agent-commentary.js"; - export * from "./task-workspace.js"; - export * from "./workspace-repository.js"; diff --git a/packages/shared/src/validators/workspace-repository.ts b/packages/shared/src/validators/workspace-repository.ts index 2b0a5c66cd7..e753942ce57 100644 --- a/packages/shared/src/validators/workspace-repository.ts +++ b/packages/shared/src/validators/workspace-repository.ts @@ -5,7 +5,6 @@ export const prepareWorkspaceRepositorySchema = z.object({ z.object({ kind: z.literal("catalog"), id: z.string().min(1).max(255) }).strict(), z.object({ kind: z.literal("url"), url: z.string().url().max(2048) }).strict(), ]), - // A revision is data passed as one argv entry, never a shell expression. ref: z.string().min(1).max(255).regex(/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/).refine(value => !value.includes("..") && !value.includes("//"), "Invalid Git ref").optional(), requestKey: z.string().min(1).max(240), }).strict(); diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 9cb6c3f3058..a550acedf8b 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -8154,7 +8154,6 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const [project] = await db.insert(projects).values({ companyId: fixture.companyId, name: "Protected task project", executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); const defaults = { projectId: project.id, workspace: { kind: "task_directory" as const } }; - // Saving readable defaults does not grant future senders assignment authority. await service.update(endpoint.id, { executionDefaults: defaults }, "owner-user"); await db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent", principalId: fixture.assignedAgentId, status: "active", membershipRole: "member" }); @@ -65215,8 +65214,6 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ]); expect(wakeup).toHaveBeenCalledTimes(2); - // A new topic must snapshot the migrated destination's defaults, while the - // existing topic above keeps its original task and workspace selection. const newTopic = makeThread({ channelId: `telegram:${migratedChatId}`, id: `telegram:${migratedChatId}:${topicId + 1}`, name: "New migrated topic" }); await deliverMessage({ callbacks, endpointId: endpoint.id, provider: "telegram", thread: newTopic.thread, diff --git a/server/src/__tests__/email-channels.integration.test.ts b/server/src/__tests__/email-channels.integration.test.ts index 7965da27f05..bf297326a1c 100644 --- a/server/src/__tests__/email-channels.integration.test.ts +++ b/server/src/__tests__/email-channels.integration.test.ts @@ -532,8 +532,6 @@ describe("AgentMail durable email pipeline", () => { const f = await fixture(); const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected email tasks", executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - // Change defaults after provider fetch has captured the endpoint, before the - // creation transaction locks it. Only the locked defaults are authoritative. const fetcher = vi.mocked(f.fetcher); const fetch = fetcher.getMockImplementation()!; let changedDefaults = false; diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 4ef2bd8ba00..fee0730d7b9 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -3600,8 +3600,6 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { ].join(" "); const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serverScript)}`; - // This case proves the DB reservation/start race, not host-wide lsof latency. - // Keep the real process and HTTP readiness; its freshly allocated port has no prior owner. const portOwner = vi.spyOn(localServiceSupervisor, "readLocalServicePortOwner").mockResolvedValue(null); const adoption = vi.spyOn(localServiceSupervisor, "findAdoptableLocalService").mockResolvedValue(null); const runtimeOutput: string[] = []; diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index 025aed077b8..ae556531bab 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -320,8 +320,6 @@ describeEmbeddedPostgres("workspace file resources", () => { const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace(); try { const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot }); - // Current task files have their own source; the task's organizational - // project still contributes a configured alternative to auto lookup. await db.update(executionWorkspaces).set({ projectId: null, projectWorkspaceId: null, providerType: "local_fs" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)); await fs.writeFile(path.join(executionRoot, "report.txt"), "CURRENT_TASK_REPORT"); if (alternative === "missing" || alternative === "symlink loop") { @@ -415,8 +413,6 @@ describeEmbeddedPostgres("workspace file resources", () => { } }; await deny(); - // A closed local workspace exposes no bytes, but its resource metadata - // still must not bypass the same physical source boundary. await db.update(executionWorkspaces).set({ status: "archived" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)); await deny(); await db.update(executionWorkspaces).set({ status: "active" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)); @@ -426,7 +422,6 @@ describeEmbeddedPostgres("workspace file resources", () => { for (const pending of reads()) expect((await pending).status).toBe(200); const content = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "secret.txt" }).expect(200); expect(content.body.content.data).toBe("PRIVATE_ROOT_BYTES"); - // A previous admission retains the source even if its logical cwd moves. await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: graph.companyId, rootKey, canonicalCwd, kind: "source", scopeId: source.id }); const provenance = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId)); await db.update(executionWorkspaces).set({ cwd: targetProjectRoot }).where(eq(executionWorkspaces.id, source.id)); @@ -519,8 +514,6 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(response.body.content.data).toBe("PRIVATE_OVERLAPPING_ROOT_BYTES"); } expect(await readState()).toEqual(beforeAllowedReads); - // A prior admission's deny-only owner remains attached to the physical - // tree after the logical owner selects a different folder. const canonicalCwd = await fs.realpath(privateRoot); await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: graph.companyId, rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), canonicalCwd, diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index c83249709c1..eb9c505e5d7 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -220,8 +220,6 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { }); async function grantFixtureWorkspaceAccess(companyId: string, userIds: string[]) { - // Later admissions reauthorize the retained workspace for the responsible - // user, so synthetic human commenters need a real identity and membership. await db.insert(authUsers).values(userIds.map(id => ({ id, name: "Fixture commenter", email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date(), }))).onConflictDoNothing(); diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 4bce7c688c3..25916217a7d 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -437,8 +437,6 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", () }); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); - // Both successful runs publish their final summary on behalf of the real - // responsible user. Recovery notices are additional system comments. const summaries = comments.filter((comment) => comment.authorType === "agent"); expect(summaries).toHaveLength(2); expect(summaries).toEqual(expect.arrayContaining( diff --git a/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts b/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts index 64ef4636569..ea2b997ad26 100644 --- a/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts +++ b/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts @@ -148,7 +148,6 @@ exit 128 const task = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-remote-sibling-")); try { const remote = "https://example.test/remote.git"; - // Exercise the real clone without contacting a network provider. const resolveGitAuth = vi.fn(async () => ({ configArgs: ["-c", `url.${pathToFileURL(source).href}.insteadOf=${remote}`], env: {}, source: "company_secret" as const, secretName: "fixture", diff --git a/server/src/__tests__/heartbeat-project-repositories.test.ts b/server/src/__tests__/heartbeat-project-repositories.test.ts index 2eef011ad38..eb4cb0513c1 100644 --- a/server/src/__tests__/heartbeat-project-repositories.test.ts +++ b/server/src/__tests__/heartbeat-project-repositories.test.ts @@ -344,75 +344,6 @@ 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"); @@ -447,7 +378,6 @@ suite("task project repository provisioning", () => { expect(bound.projectId).toBeNull(); expect(bound.executionWorkspaceId).not.toBeNull(); if (retainedMode === "isolated_workspace") { - // Legacy or malformed metadata can label the shared physical root isolated. await db.update(executionWorkspaces).set({ mode: retainedMode }).where(eq(executionWorkspaces.id, bound.executionWorkspaceId!)); await db.update(issues).set({ executionWorkspaceSettings: { mode: retainedMode, workspaceStrategy: { type: "project_primary" } } }).where(eq(issues.id, issueId)); } @@ -593,7 +523,6 @@ suite("task project repository provisioning", () => { { companyId, projectId: privateProjectId, subjectType: "agent", subjectId: agentId }, { companyId, projectId: privateProjectId, subjectType: "user", subjectId: "responsible-user" }, ]); - // Isolated copies need source read access, never an assignment/write grant. expect(await accessService(db).decide({ actor: { type: "agent", agentId, companyId, source: "agent_jwt", onBehalfOfUserId: "responsible-user" }, action: "tasks:assign", resource: { type: "issue", companyId, projectId, assigneeAgentId: agentId }, scope: { projectId, assigneeAgentId: agentId } })).toMatchObject({ allowed: false }); const allowed = await admit(); @@ -615,14 +544,12 @@ suite("task project repository provisioning", () => { await expect(readFile(path.join(copiedCwd, "provision-ran"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); } expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId).toBeNull(); - // A later public alias must not expose bytes from failed provisioning. const aliasId = randomUUID(); await db.insert(executionWorkspaces).values({ id: aliasId, companyId, projectId: null, sourceIssueId: issueId, name: "Public alias after failed provisioning", mode: "shared_workspace", strategyType: "project_primary", cwd: copiedCwd, providerType: "local_fs", status: "active" }); await db.update(issues).set({ executionWorkspaceId: aliasId }).where(eq(issues.id, issueId)); if (scenario === "occupied_target") { - // A failed copy intent must never relabel an ordinary private directory as isolated. await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId, cwd: copiedCwd, sourceWorkspaceId: aliasId, issueId, actor: { type: "agent", agentId, companyId, source: "agent_jwt", onBehalfOfUserId: "responsible-user" } })).rejects.toThrow(/protected|assignment grant/); } @@ -857,7 +784,6 @@ suite("task project repository provisioning", () => { expect(initial).toMatchObject({ status: "succeeded", error: null }); const binding = (await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId!; const taskCwd = execute.mock.calls.find(([input]) => input.runId === initial.id)![0].context.paperclipWorkspace.cwd as string; - // The private local source appears only after the original task is bound. await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private source task", status: "todo", visibility: "private", responsibleUserId: "private-owner" }); await db.insert(executionWorkspaces).values({ companyId, projectId: null, sourceIssueId: privateIssueId, name: "Private source files", mode: "shared_workspace", strategyType: "project_primary", cwd: sibling, providerType: "local_fs", status: "active" }); @@ -897,7 +823,6 @@ suite("task project repository provisioning", () => { const fileUrl = "/api/issues/" + issueId + "/file-resources/content"; const relativeFile = path.relative(taskCwd, path.join(copy, "private.txt")); expect((await request(app).get(fileUrl).query({ workspace: "execution", path: relativeFile }).expect(200)).body.content.data).toBe(expectedBytes); - // Removing configuration must not erase permissions of already copied files. await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, siblingId)); await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId)); for (const download of [false, true]) { @@ -946,7 +871,6 @@ suite("task project repository provisioning", () => { await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "private-owner", status: "active", membershipRole: "member" }); await db.insert(issueAccessGrants).values({ issueId: privateIssueId, subjectType: "agent", subjectId: agentId, source: "explicit" }); const [ownedRun] = await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "failed", responsibleUserId: "responsible-user" }).returning(); - // A caller-supplied sponsor cannot replace the responsible user on the run. await expect(resolver.resolveWorkspaceForRun(actualAgent, { issueId, executionIdentityRunId: ownedRun.id, responsibleUserId: "private-owner" }, null)) .rejects.toThrow(/access|source/i); await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId)); diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index 1459676179a..e81f9d3c5ef 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -1049,7 +1049,6 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { const run = await waitForRunToFinish(heartbeat, seeded.runId); await heartbeat.waitForRunExecutionDrain(seeded.runId); if (kind === "foreign_company") { - // Company access is checked before inspecting any retained source. expect(run).toMatchObject({ status: "failed", errorCode: "setup_failed", error: "Task workspace access is no longer available" }); } else { expect(run).toMatchObject({ status: "failed", errorCode: "workspace_validation_failed" }); @@ -1062,9 +1061,6 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId)); expect(issue).toMatchObject({ executionWorkspaceId: seeded.executionWorkspaceId, projectWorkspaceId: kind === "explicit_conflict" ? seeded.newWorkspaceId : null }); - // A different organizational project alone is legal; an unproven source - // registration or a cross-company binding must still fail before dispatch - // without silently replacing the retained files or binding. if (kind === "explicit_conflict" || kind === "unproven") expect(issue.status).toBe("blocked"); expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n"); expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n"); diff --git a/server/src/__tests__/heartbeat-workspace-source-trust.test.ts b/server/src/__tests__/heartbeat-workspace-source-trust.test.ts new file mode 100644 index 00000000000..0c7241b4c6b --- /dev/null +++ b/server/src/__tests__/heartbeat-workspace-source-trust.test.ts @@ -0,0 +1,142 @@ +import express from "express"; +import request from "supertest"; +import { fileResourceRoutes } from "../routes/file-resources.js"; +import { errorHandler } from "../middleware/index.js"; +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { createHash, randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, readFile, realpath, readdir, rename, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { agents, authUsers, companies, companyMemberships, createDb, environments, executionWorkspaces, heartbeatRuns, issues, issueAccessGrants, principalPermissionGrants, projects, projectAccessMembers, projectWorkspaces } from "@paperclipai/db"; +import { setExpensiveWorkspaceGitExecutor } from "@paperclipai/adapter-utils/git-workspace-sync"; +import { buildProjectMentionHref } from "@paperclipai/shared"; +import { createWorkspaceGitOperationScheduler, WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { heartbeatService } from "../services/heartbeat.ts"; +import { createHeartbeatWorkspaceResolver } from "../services/heartbeat/workspaces.js"; +import { resolveManagedProjectWorkspaceDir, resolvePaperclipInstanceRoot } from "../home-paths.js"; +import { accessService } from "../services/access.js"; +import { assertExecutionWorkspaceRootWriteAccess } from "../services/execution-workspace-repositories.js"; +import { instanceSettingsService } from "../services/instance-settings.ts"; +import { environmentRuntimeService } from "../services/environment-runtime.js"; +import { findNativeChatWorkspaceScope } from "../services/native-runtime/native-chat-workspace.js"; +import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; + +const execute = vi.hoisted(() => vi.fn(async (_input: any) => ({ exitCode: 0, signal: null, timedOut: false }))); +vi.mock("../adapters/index.js", () => ({ + getServerAdapter: () => ({ type: "codex_local", execute, supportsLocalAgentJwt: false }), + findActiveServerAdapter: () => ({ type: "codex_local", execute, supportsLocalAgentJwt: false }), + runningProcesses: new Map(), +})); + +const support = await getEmbeddedPostgresTestSupport(); +const suite = support.supported ? describe : describe.skip; +suite("heartbeat physical workspace source trust", () => { + let database: Awaited>; + let db: ReturnType; + let root: string; + let heartbeat: ReturnType; + beforeAll(async () => { + root = await mkdtemp(path.join(os.tmpdir(), "paperclip-project-repos-")); + vi.stubEnv("PAPERCLIP_HOME", path.join(root, "home")); + vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "false"); + database = await startEmbeddedPostgresTestDatabase("project-repositories"); + db = createDb(database.connectionString); + await db.insert(authUsers).values({ id: "responsible-user", name: "Fixture owner", email: "owner@example.test", createdAt: new Date(), updatedAt: new Date() }); + heartbeat = heartbeatService(db); + execute.mockImplementation(async (input) => { + await db.update(issues).set({ status: "done" }).where(eq(issues.id, input.context.issueId)); + return { exitCode: 0, signal: null, timedOut: false }; + }); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { + if (db && heartbeat) await drainHeartbeatRunsToQuiescence(db, heartbeat); + await db?.$client.end({ timeout: 5 }); + await database?.cleanup(); + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + }, 60_000); + afterEach(async () => { + await drainHeartbeatRunsToQuiescence(db, heartbeat); + setExpensiveWorkspaceGitExecutor(null); + vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "false"); + await instanceSettingsService(db).updateExperimental({ + enableIsolatedWorkspaces: false, + enableIsolatedWorkspacesByDefault: false, + }); + }); + + 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" }); + } + }); + +}); diff --git a/server/src/__tests__/helpers/isolated-test-home.ts b/server/src/__tests__/helpers/isolated-test-home.ts index 68f06c794f5..0463f1a6b6d 100644 --- a/server/src/__tests__/helpers/isolated-test-home.ts +++ b/server/src/__tests__/helpers/isolated-test-home.ts @@ -2,10 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -/** Own one test-created home, including immutable runtime cache directories. */ export function createIsolatedTestHome() { - // Capture before suites install filesystem spies: worker-exit cleanup runs - // after their teardown and must not retain a mocked filesystem operation. const { mkdtempSync, lstatSync, openSync, fchmodSync, closeSync, readdirSync, rmSync, constants } = fs; const home = mkdtempSync(path.join(os.tmpdir(), "paperclip-vitest-home-")); const identity = lstatSync(home); @@ -20,8 +17,6 @@ export function createIsolatedTestHome() { throw new Error("Refusing to clean a replaced isolated test home"); } const makeDirectoriesWritable = (directory: string) => { - // Open the directory itself without following a leaf symlink. Only its - // permissions change; read-only files and external symlink targets do not. const fd = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); try { fchmodSync(fd, 0o700); } finally { closeSync(fd); } diff --git a/server/src/__tests__/helpers/repository-trust-fixture.ts b/server/src/__tests__/helpers/repository-trust-fixture.ts new file mode 100644 index 00000000000..e766c2dd68c --- /dev/null +++ b/server/src/__tests__/helpers/repository-trust-fixture.ts @@ -0,0 +1,114 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { vi, type Mock } from "vitest"; +import { eq } from "drizzle-orm"; +import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaces, heartbeatRuns, issueAccessGrants, issues } from "@paperclipai/db"; +import { executionWorkspaceRepositoryService } from "../../services/execution-workspace-repositories.js"; +import { startEmbeddedPostgresTestDatabase } from "./embedded-postgres.js"; + +export function createRepositoryTrustFixture(acquisition: { managedAuth: Mock; materialize: Mock; catalog: Mock }) { + let database: Awaited>; + let db: ReturnType; + let root: string; + async function setup() { + vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); + database = await startEmbeddedPostgresTestDatabase("paperclip-repository-trust-"); + db = createDb(database.connectionString); + root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-trust-")); + return { db, root }; + } + async function cleanup() { + vi.unstubAllEnvs(); + await database?.cleanup(); + if (root) await rm(root, { recursive: true, force: true }); + } + + async function fixture() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + const userId = randomUUID(), cwd = path.join(root, issueId); + await mkdir(cwd); + await db.insert(companies).values({ id: companyId, name: "Repository trust", issuePrefix: `R${companyId.slice(0, 6).toUpperCase()}` }); + await db.insert(authUsers).values({ id: userId, name: "Sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values({ id: agentId, companyId, name: "Chat worker", adapterType: "process", status: "idle" }); + await db.insert(companyMemberships).values([ + { companyId, principalType: "user", principalId: userId, membershipRole: "operator", status: "active" }, + { companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }, + ]); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Task files", cwd, + mode: "isolated_workspace", strategyType: "task_directory" }).returning(); + await db.insert(issues).values({ id: issueId, companyId, title: "Unlinked chat work", assigneeAgentId: agentId, + executionWorkspaceId: workspace.id }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId: userId, + contextSnapshot: { issueId } }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + acquisition.catalog.mockResolvedValue({ repositories: [{ id: "private-repo", fullName: "sponsor/private", private: true, + url: "https://github.com/sponsor/private", connections: ["sponsor-github"] }] }); + acquisition.managedAuth.mockRejectedValue(new Error("Managed credentials must not be acquired")); + const actor = { type: "agent" as const, source: "agent_jwt" as const, companyId, agentId, runId, onBehalfOfUserId: userId }; + const policy = { trustPreset: "low_trust_review" as const, authorizationPolicy: { + trustPreset: "low_trust_review" as const, trustBoundary: { mode: "low_trust_review" as const, companyId, rootIssueId: issueId }, + } }; + const service = executionWorkspaceRepositoryService(db); + const request = (repository: { kind: "catalog"; id: string } | { kind: "url"; url: string }, requestKey = randomUUID()) => + service.request({ companyId, issueId, actor, request: { repository, requestKey } }); + const admission = { companyId, issueId, workspaceId: workspace.id, cwd, agentId, runId, responsibleUserId: userId }; + return { companyId, agentId, issueId, runId, userId, workspace, policy, service, actor, request, admission }; + } + + 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(); + const issueId = randomUUID(), runId = randomUUID(); + await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Low-trust peer", + assigneeAgentId: f.agentId, executionWorkspaceId: workspace.id }); + const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, + trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, rootIssueId: issueId } } }; + await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "running", + responsibleUserId: f.userId, contextSnapshot: { issueId, executionPolicy: policy } }); + return { workspace, admission: { ...f.admission, cwd, issueId, runId, workspaceId: workspace.id }, actor: { ...f.actor, runId } }; + } + + async function standardPeer(f: Awaited>) { + const userId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + await db.insert(authUsers).values({ id: userId, name: "Other sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values({ id: agentId, companyId: f.companyId, name: "Other worker", adapterType: "process" }); + await db.insert(companyMemberships).values([ + { companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" }, + { companyId: f.companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }, + ]); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Other task files", cwd: f.admission.cwd, + mode: "shared_workspace", strategyType: "operator_directory" }).returning(); + await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Other task", assigneeAgentId: agentId, executionWorkspaceId: workspace.id }); + await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId, responsibleUserId: userId, status: "running", contextSnapshot: { issueId } }); + return { userId, agentId, issueId, runId, workspace, + actor: { ...f.actor, agentId, runId, onBehalfOfUserId: userId }, + admission: { ...f.admission, agentId, runId, issueId, workspaceId: workspace.id, responsibleUserId: userId } }; + } + async function privateTask(f: Awaited>) { + await db.update(issues).set({ visibility: "private", privacyRootIssueId: f.issueId, responsibleUserId: f.userId }).where(eq(issues.id, f.issueId)); + } + async function grantPeer(f: Awaited>, peer: Awaited>) { + return db.insert(issueAccessGrants).values([ + { issueId: f.issueId, subjectType: "agent", subjectId: peer.agentId, source: "explicit", grantedByUserId: f.userId }, + { issueId: f.issueId, subjectType: "user", subjectId: peer.userId, source: "explicit", grantedByUserId: f.userId }, + ]).returning(); + } + async function localPrivateClone(f: Awaited>, beforePublish?: () => Promise) { + const source = path.join(root, `${f.issueId}-privacy-source`); + await mkdir(source); + const exec = promisify(execFileCallback); + await exec("git", ["init", "--initial-branch=main", source]); + await writeFile(path.join(source, "private.txt"), "Private task edits\n"); + await exec("git", ["-C", source, "add", "."]); + await exec("git", ["-C", source, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + const actual = await vi.importActual("../../services/managed-repository-checkout.js"); + acquisition.materialize.mockImplementationOnce((cwd, input) => actual.materializeManagedProjectWorkspace(cwd, { ...input, localSource: source, + beforePublish: async cloneCwd => { await beforePublish?.(); await input.beforePublish(cloneCwd); } })); + } + + return { setup, cleanup, fixture, lowTrustPeer, standardPeer, privateTask, grantPeer, localPrivateClone }; +} diff --git a/server/src/__tests__/project-workspace-root-provenance.test.ts b/server/src/__tests__/project-workspace-root-provenance.test.ts index 8e104d0abd1..0bc5ba24329 100644 --- a/server/src/__tests__/project-workspace-root-provenance.test.ts +++ b/server/src/__tests__/project-workspace-root-provenance.test.ts @@ -67,9 +67,8 @@ const support = await getEmbeddedPostgresTestSupport(); expect(retained.some(row => row.kind === "reader")).toBe(false); await expect(assertExecutionWorkspaceRootReadAccess(db, access)).rejects.toThrow(/source project/); await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/source project/); - // The common-prefix sibling is physically independent of the old root. if (mutation !== "move") await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: unrelatedCwd, actor })).resolves.toBeUndefined(); - if (mutation === "remove_project") return; // Missing sticky owner must remain denied. + if (mutation === "remove_project") return; const [readGrant] = await db.insert(projectAccessMembers).values({ companyId, projectId: source.id, subjectType: "user", subjectId: userId }).returning(); await expect(assertExecutionWorkspaceRootReadAccess(db, access)).resolves.toBeUndefined(); await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/protected|not authorized/); diff --git a/server/src/__tests__/remove-test-home.ts b/server/src/__tests__/remove-test-home.ts index 802b3613363..760c3cc9dad 100644 --- a/server/src/__tests__/remove-test-home.ts +++ b/server/src/__tests__/remove-test-home.ts @@ -27,9 +27,6 @@ export function removeTestHome(home: string): void { function isReadOnlyDirectoryRemovalError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | null)?.code; - // Node can report the unremoved parent as ENOTEMPTY on macOS when a - // read-only descendant prevented unlinking. The second removal still fails - // loudly for a genuinely busy directory or an unsuccessful permission repair. return code === "EACCES" || code === "EPERM" || code === "ENOTEMPTY"; } diff --git a/server/src/__tests__/setup-supertest.ts b/server/src/__tests__/setup-supertest.ts index c88a1694a16..82472092bf3 100644 --- a/server/src/__tests__/setup-supertest.ts +++ b/server/src/__tests__/setup-supertest.ts @@ -31,7 +31,6 @@ const { home: paperclipTestHome, cleanup: cleanupPaperclipTestHome } = createIso process.env.PAPERCLIP_HOME = paperclipTestHome; // Setup-file afterAll hooks run before suites drain asynchronous heartbeats. // Cleanup at worker exit, after all suite teardown, avoids deleting live state. -// The owned-home cleanup also handles read-only runtime-context bundles. process.once("exit", cleanupPaperclipTestHome); const require = createRequire(import.meta.url); diff --git a/server/src/__tests__/task-workspace-api.test.ts b/server/src/__tests__/task-workspace-api.test.ts index 054f4763480..3fe26b6e229 100644 --- a/server/src/__tests__/task-workspace-api.test.ts +++ b/server/src/__tests__/task-workspace-api.test.ts @@ -131,9 +131,6 @@ const support = await getEmbeddedPostgresTestSupport(); await server.db.update(agents).set({ permissions: policy([f.projectId]) }).where(eq(agents.id, f.agentId)); await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: source.id, projectId: f.projectId } }); await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).rejects.toThrow(/outside.*boundary/i); - // Project permission cannot expose the unrelated task's files through a - // shared physical root. Give that fixture its own folder before testing - // the permitted source-only preparation path. await expect(call("prepare_repository", repository)).rejects.toThrow(/outside.*boundary/i); expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0); const unrelatedCwd = await mkdtemp(join(dirname(f.workspace), "unrelated-workspace-")); diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts index 16e2589df76..2b2551c8dd3 100644 --- a/server/src/__tests__/task-workspace-binding.test.ts +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -70,7 +70,6 @@ const support = await getEmbeddedPostgresTestSupport(); { companyId: f.companyId, title: "Earlier private source" }, { companyId: f.companyId, title: "Replacement task" }, ]).returning(); - // This legacy source has never been bound through bindTaskWorkspace. await db.update(executionWorkspaces).set({ sourceIssueId: ancestor.id }) .where(eq(executionWorkspaces.id, f.workspaceId)); await db.update(executionWorkspaces).set({ cwd: oldCwd, projectId: project.id, sourceIssueId: f.issueId }) @@ -246,8 +245,6 @@ const support = await getEmbeddedPostgresTestSupport(); expect(original).toMatchObject({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: f.workspaceId }); const { workspace } = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor); expect(workspace).toMatchObject({ projectId: null, projectWorkspaceId: null, cwd: `/tmp/task-${f.issueId}` }); - // Both tasks remain eligible for the same local adapter launch; the - // organization did not turn the shared task folder into a Git source. for (const task of [created, original]) { await expect(assertGitSensitiveAdapterWorkspaceValid({ adapterType: "codex_local", agentId: randomUUID(), issue: task, @@ -465,7 +462,6 @@ const support = await getEmbeddedPostgresTestSupport(); }); 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: 3, requestKey: "later-choice" }); await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); @@ -483,8 +479,6 @@ const support = await getEmbeddedPostgresTestSupport(); companyIds: [f.companyId], isInstanceAdmin: false }; const app = express(); app.use(express.json()); - // Authentication middleware establishes this session actor; all company, - // issue and workspace authorization below uses the real database service. app.use((req, _res, next) => { req.actor = actor; next(); }); app.use("/api", taskWorkspaceRoutes(db)); app.use(errorHandler); @@ -563,7 +557,6 @@ const support = await getEmbeddedPostgresTestSupport(); expect(await readTask()).toMatchObject({ executionWorkspaceId: null, projectWorkspaceId: null, workspaceBindingRevision: 0 }); expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0].metadata).toEqual(beforeMetadata); const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: assignee.id } }).returning(); - // The same transaction must check the final assignee, not the old row. await expect(issueService(db).update(f.issueId, { ...patch, assigneeAgentId: nextAssignee.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/); expect(await readTask()).toMatchObject({ assigneeAgentId: assignee.id, workspaceBindingRevision: 0 }); const allowed = await request(app).patch(`/api/issues/${f.issueId}`).send(patch); @@ -677,8 +670,6 @@ const support = await getEmbeddedPostgresTestSupport(); const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: agent.id, permissionKey: "tasks:assign_scope", scope: { projectId: organization.id, assigneeAgentId: agent.id } }).returning(); const selectionRequest = { ...f, actor, selection: sharedSelection, expectedBindingRevision: 0, requestKey: "authorized-shared" }; - // Task lineage and organizational-project authority cannot grant writes - // to an independently protected source project's folder. await expect(svc.selectTaskWorkspace(selectionRequest)).rejects.toThrow(/protected/); const [otherAgent] = await db.insert(agents).values({ companyId: f.companyId, name: "Another assignee", status: "idle" }).returning(); await db.update(issues).set({ assigneeAgentId: otherAgent.id }).where(eq(issues.id, f.issueId)); @@ -806,7 +797,6 @@ const support = await getEmbeddedPostgresTestSupport(); { id: sourceId, companyId: f.companyId, projectId, name: "Selected", isPrimary: true, sourceType: failure === "clone_failure" ? "git_repo" : "local_path", cwd: failure === "clone_failure" ? null : path.join(root, "missing-source"), - // A nonexistent local remote exercises clone failure without network access. repoUrl: failure === "clone_failure" ? path.join(root, "missing-remote.git") : null }, { id: siblingId, companyId: f.companyId, projectId, name: "Other", sourceType: "local_path", cwd: siblingCwd }, ]); @@ -858,8 +848,6 @@ const support = await getEmbeddedPostgresTestSupport(); } const installed = await svc.inspectTaskWorkspace(f.companyId, writer.id, f.actor); expect(installed.workspace?.id).toBe(f.workspaceId); - // No bindTaskWorkspace call or heartbeat is needed: the migration's issue - // binding trigger retains provenance in the same installation transaction. expect(installed.workspace?.metadata?._issuePrivacySources).toMatchObject({ [writer.id]: true }); await svc.selectTaskWorkspace({ ...f, issueId: writer.id, selection: { kind: "task_directory" }, expectedBindingRevision: installed.bindingRevision, requestKey: "detach-private-writer" }); diff --git a/server/src/__tests__/task-workspace-repositories-persistence.test.ts b/server/src/__tests__/task-workspace-repositories-persistence.test.ts index 3b73dd87775..ebe69894d70 100644 --- a/server/src/__tests__/task-workspace-repositories-persistence.test.ts +++ b/server/src/__tests__/task-workspace-repositories-persistence.test.ts @@ -58,8 +58,6 @@ const support = await getEmbeddedPostgresTestSupport(); await git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"); const pin = await git("rev-parse", "HEAD"); const cloneStarted = path.join(root, "clone-started"), cloneReleased = path.join(root, "clone-released"); - // Exercise actual Git and atomic publication without external networking. - // Only this fixture's exact remote is redirected; persist the real API origin. await writeFile(path.join(bin, "git"), `#!${process.execPath}\n` + `const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const args = process.argv.slice(2);\n` + `const remote = args.includes('clone') ? args.at(-2) : null;\n` + @@ -91,8 +89,6 @@ const support = await getEmbeddedPostgresTestSupport(); await exec(realGit, ["-C", readyCheckout, "remote", "set-url", "origin", readyIntent.repository.repoUrl]); await db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit: pin }) .where(eq(executionWorkspaceRepositories.id, readyIntent.operationId)); - // New service instances emulate the controller restart between request - // and admission; concurrent admissions must publish one owned checkout. const pending = Promise.all([1, 2].map(() => prepareForAdmission(admission))); let results: Awaited; let timeout: ReturnType | undefined; @@ -103,8 +99,6 @@ const support = await getEmbeddedPostgresTestSupport(); new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("Unrelated repository reuse waited for a blocked clone")), 2000); }), ]); expect(ready[0]).toMatchObject({ id: readyIntent.operationId, pinnedCommit: pin }); - // These are the actual SSH and native/sandbox discovery paths while - // the unrelated clone is incomplete, not inventory-only assertions. expect(await listLocalProjectRepositories(taskRoot)).toEqual([readyIntent.repository.relativePath]); const snapshots = await readManagedWorkspaceRepositories(taskRoot); try { expect(snapshots.map(repo => repo.path)).toEqual([readyIntent.repository.relativePath]); } @@ -199,8 +193,6 @@ const support = await getEmbeddedPostgresTestSupport(); expect(prepared).toHaveLength(1); expect(prepared[0].pinnedCommit).toBe(commit); expect(await readFile(path.join(checkout, "source.txt"), "utf8")).toBe("uncommitted work\n"); - // A malicious/non-regular receipt cannot block admission. The durable DB - // pin remains authoritative, so this invalid receipt is ignored. if (process.platform !== "win32") { const receiptPath = path.join(checkout, ".git", "paperclip-workspace-owner.json"); await promisify(execFileCallback)("mkfifo", [receiptPath]); @@ -212,8 +204,6 @@ const support = await getEmbeddedPostgresTestSupport(); ]); } finally { clearTimeout(deadline); - // Release a blocked reader if this regression ever returns, so a failed - // assertion cannot leave the test process stuck in its filesystem pool. const writer = await open(receiptPath, fsConstants.O_WRONLY | fsConstants.O_NONBLOCK).catch(() => null); await writer?.close(); await rm(receiptPath, { force: true }); @@ -231,7 +221,6 @@ const support = await getEmbeddedPostgresTestSupport(); const prepared = await prepareForAdmission(admission); expect(prepared[0]).toMatchObject({ id: intent.operationId, cwd: path.join(await realpath(cwd), original.relativePath), pinnedCommit: original.pinnedCommit }); expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n"); - // There is still one physical checkout and neither its files nor Git metadata were rewritten. expect(await readFile(path.join(prepared[0].cwd, ".git", "paperclip-workspace-owner.json"), "utf8").catch(() => null)).toBeNull(); const [differentRefWorkspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Conflicting ref", cwd, mode: "shared_workspace", strategyType: "operator_directory" }).returning(); const [differentRefIssue] = await db.insert(issues).values({ companyId, title: "Different ref", executionWorkspaceId: differentRefWorkspace.id }).returning(); @@ -244,7 +233,6 @@ const support = await getEmbeddedPostgresTestSupport(); await owner.bindTaskWorkspace(companyId, privateSource.id, movedWorkspace.id); const [retainedWorkspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)); expect(retainedWorkspace.metadata?._issuePrivacySources).toMatchObject({ [privateSource.id]: true }); - // Reauthorization also applies after the second inventory has a durable pin. await expect(prepareForAdmission(admission)).rejects.toThrow(/Task workspace access is no longer available/); expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n"); }); diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts index a9692406ebc..1cd652d13a2 100644 --- a/server/src/__tests__/task-workspace-repositories-trust.test.ts +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -17,6 +17,8 @@ import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime- import { buildGitAuthInvocation } from "../services/git-credentials.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { createRepositoryTrustFixture } from "./helpers/repository-trust-fixture.js"; + const acquisition = vi.hoisted(() => ({ managedAuth: vi.fn(), materialize: vi.fn(), catalog: vi.fn() })); vi.mock("../services/tool-access.js", () => ({ toolAccessService: () => ({ listProjectRepositories: acquisition.catalog }) })); vi.mock("../services/git-credentials.js", async (original) => ({ @@ -30,790 +32,13 @@ vi.mock("../services/managed-repository-checkout.js", async (original) => ({ const support = await getEmbeddedPostgresTestSupport(); (support.supported ? describe : describe.skip)("task repository trust authority", () => { - let database: Awaited>; + const context = createRepositoryTrustFixture(acquisition); let db: ReturnType; let root: string; - beforeAll(async () => { - vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); - database = await startEmbeddedPostgresTestDatabase("paperclip-repository-trust-"); - db = createDb(database.connectionString); - root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-trust-")); - }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + beforeAll(async () => { ({ db, root } = await context.setup()); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); }); - afterAll(async () => { vi.unstubAllEnvs(); await database?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); }); - - async function fixture() { - const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); - const userId = randomUUID(), cwd = path.join(root, issueId); - await mkdir(cwd); - await db.insert(companies).values({ id: companyId, name: "Repository trust", issuePrefix: `R${companyId.slice(0, 6).toUpperCase()}` }); - await db.insert(authUsers).values({ id: userId, name: "Sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); - await db.insert(agents).values({ id: agentId, companyId, name: "Chat worker", adapterType: "process", status: "idle" }); - await db.insert(companyMemberships).values([ - { companyId, principalType: "user", principalId: userId, membershipRole: "operator", status: "active" }, - { companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }, - ]); - const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Task files", cwd, - mode: "isolated_workspace", strategyType: "task_directory" }).returning(); - await db.insert(issues).values({ id: issueId, companyId, title: "Unlinked chat work", assigneeAgentId: agentId, - executionWorkspaceId: workspace.id }); - await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId: userId, - contextSnapshot: { issueId } }); - await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); - acquisition.catalog.mockResolvedValue({ repositories: [{ id: "private-repo", fullName: "sponsor/private", private: true, - url: "https://github.com/sponsor/private", connections: ["sponsor-github"] }] }); - acquisition.managedAuth.mockRejectedValue(new Error("Managed credentials must not be acquired")); - const actor = { type: "agent" as const, source: "agent_jwt" as const, companyId, agentId, runId, onBehalfOfUserId: userId }; - const policy = { trustPreset: "low_trust_review" as const, authorizationPolicy: { - trustPreset: "low_trust_review" as const, trustBoundary: { mode: "low_trust_review" as const, companyId, rootIssueId: issueId }, - } }; - const service = executionWorkspaceRepositoryService(db); - const request = (repository: { kind: "catalog"; id: string } | { kind: "url"; url: string }, requestKey = randomUUID()) => - service.request({ companyId, issueId, actor, request: { repository, requestKey } }); - const admission = { companyId, issueId, workspaceId: workspace.id, cwd, agentId, runId, responsibleUserId: userId }; - 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(); - const issueId = randomUUID(), runId = randomUUID(); - await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Low-trust peer", - assigneeAgentId: f.agentId, executionWorkspaceId: workspace.id }); - const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, - trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, rootIssueId: issueId } } }; - await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "running", - responsibleUserId: f.userId, contextSnapshot: { issueId, executionPolicy: policy } }); - return { workspace, admission: { ...f.admission, cwd, issueId, runId, workspaceId: workspace.id }, actor: { ...f.actor, runId } }; - } - - async function standardPeer(f: Awaited>) { - const userId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); - await db.insert(authUsers).values({ id: userId, name: "Other sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); - await db.insert(agents).values({ id: agentId, companyId: f.companyId, name: "Other worker", adapterType: "process" }); - await db.insert(companyMemberships).values([ - { companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" }, - { companyId: f.companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }, - ]); - const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Other task files", cwd: f.admission.cwd, - mode: "shared_workspace", strategyType: "operator_directory" }).returning(); - await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Other task", assigneeAgentId: agentId, executionWorkspaceId: workspace.id }); - await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId, responsibleUserId: userId, status: "running", contextSnapshot: { issueId } }); - return { userId, agentId, issueId, runId, workspace, - actor: { ...f.actor, agentId, runId, onBehalfOfUserId: userId }, - admission: { ...f.admission, agentId, runId, issueId, workspaceId: workspace.id, responsibleUserId: userId } }; - } - async function privateTask(f: Awaited>) { - await db.update(issues).set({ visibility: "private", privacyRootIssueId: f.issueId, responsibleUserId: f.userId }).where(eq(issues.id, f.issueId)); - } - async function grantPeer(f: Awaited>, peer: Awaited>) { - return db.insert(issueAccessGrants).values([ - { issueId: f.issueId, subjectType: "agent", subjectId: peer.agentId, source: "explicit", grantedByUserId: f.userId }, - { issueId: f.issueId, subjectType: "user", subjectId: peer.userId, source: "explicit", grantedByUserId: f.userId }, - ]).returning(); - } - async function localPrivateClone(f: Awaited>, beforePublish?: () => Promise) { - const source = path.join(root, `${f.issueId}-privacy-source`); - await mkdir(source); - const exec = promisify(execFileCallback); - await exec("git", ["init", "--initial-branch=main", source]); - await writeFile(path.join(source, "private.txt"), "Private task edits\n"); - await exec("git", ["-C", source, "add", "."]); - await exec("git", ["-C", source, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - const actual = await vi.importActual("../services/managed-repository-checkout.js"); - acquisition.materialize.mockImplementationOnce((cwd, input) => actual.materializeManagedProjectWorkspace(cwd, { ...input, localSource: source, - beforePublish: async cloneCwd => { await beforePublish?.(); await input.beforePublish(cloneCwd); } })); - } - - it.each([ - { owner: "configured", direction: "descendant" }, { owner: "configured", direction: "ancestor" }, - { owner: "execution", direction: "descendant" }, { owner: "execution", direction: "ancestor" }, - ])("authorizes newly overlapping private $owner $direction owners before selection, admission and publication", async ({ owner, direction }) => { - const f = await fixture(); - const child = path.join(f.admission.cwd, "private"); - await mkdir(child); - const privateCwd = direction === "descendant" ? child : f.admission.cwd; - if (direction === "ancestor") { - f.admission.cwd = child; - await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, f.workspace.id)); - } - await f.service.prepareForAdmission(f.admission); // A retained reader cannot hide later nested owners. - let grant: () => Promise; - let detach: () => Promise; - if (owner === "configured") { - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Nested private source", visibility: "private" }).returning(); - const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private files", cwd: privateCwd }).returning(); - grant = () => db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - detach = async () => { - await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, source.id)); - await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); - }; - } else { - const peer = await standardPeer(f); - await db.update(executionWorkspaces).set({ cwd: privateCwd }).where(eq(executionWorkspaces.id, peer.workspace.id)); - await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId)); - grant = () => db.insert(issueAccessGrants).values([ - { issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId }, - { issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId }, - ]); - detach = async () => { - const moved = path.join(root, `${peer.issueId}-moved`); await mkdir(moved); - await executionWorkspaceService(db).update(peer.workspace.id, { cwd: moved }); - await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, peer.issueId)); - }; - } - await writeFile(path.join(privateCwd, "private.txt"), "Nested private files\n"); - const read = () => assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, actor: f.actor, sourceWorkspaceId: f.workspace.id }); - const select = () => executionWorkspaceService(db).selectTaskWorkspace({ companyId: f.companyId, issueId: f.issueId, actor: f.actor, - selection: { kind: "existing", workspaceId: f.workspace.id }, expectedBindingRevision: 0, requestKey: randomUUID() }); - await expect(read()).rejects.toThrow(/source project|workspace access|authorization boundary/); - await expect(select()).rejects.toThrow(/source project|workspace access|authorization boundary/); - await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/); - await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/); - await grant(); - await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); - await expect(read()).resolves.toBeUndefined(); - await detach(); - await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/); - await expect(read()).rejects.toThrow(/source project|workspace access|authorization boundary/); - }); - - it("does not confuse sibling component prefixes with overlapping roots", async () => { - const f = await fixture(); - const sibling = `${f.admission.cwd}er`; await mkdir(sibling); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unrelated private source", visibility: "private" }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Sibling", cwd: sibling }); - await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: f.companyId, rootKey: createHash("sha256").update(await realpath(sibling)).digest("hex"), - canonicalCwd: await realpath(sibling), kind: "project_source", scopeId: project.id }); - await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, rootKey: createHash("sha256").update(await realpath(sibling)).digest("hex"), - canonicalCwd: await realpath(sibling), mode: "anonymous_only" }); - await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); - await expect(f.request({ kind: "catalog", id: "private-repo" })).resolves.toMatchObject({ kind: "requires_next_admission" }); - }); - - it.each([ - { mode: "managed", direction: "ancestor" }, { mode: "managed", direction: "descendant" }, - { mode: "anonymous_only", direction: "ancestor" }, { mode: "anonymous_only", direction: "descendant" }, - ] as const)("fences $mode root policy against opposite-mode $direction acquisition", async ({ mode, direction }) => { - const f = await fixture(); - const child = path.join(f.admission.cwd, "nested"); await mkdir(child); - const existingRoot = direction === "ancestor" ? child : f.admission.cwd; - if (direction === "descendant") { - f.admission.cwd = child; - await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, f.workspace.id)); - } - const canonicalCwd = await realpath(existingRoot); - await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, canonicalCwd, - rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), mode }); - if (mode === "managed") { - await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId)); - await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/anonymous public URLs only/); - } else await expect(f.request({ kind: "catalog", id: "private-repo" })).rejects.toThrow(/anonymous public URLs only/); - expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]); - expect(acquisition.materialize).not.toHaveBeenCalled(); - }); - - it("reauthorizes a nested private publication against an already-admitted parent reader", async () => { - const f = await fixture(); - await f.service.prepareForAdmission(f.admission); - const peer = await standardPeer(f), child = path.join(f.admission.cwd, "private"); await mkdir(child); - peer.admission.cwd = child; - await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, peer.workspace.id)); - await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId)); - const request = () => f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor, - request: { repository: { kind: "url", url: "https://github.com/public/nested" }, requestKey: randomUUID() } }); - await expect(request()).rejects.toThrow(/workspace access|authorization boundary/); - await db.insert(issueAccessGrants).values([ - { issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId }, - { issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId }, - ]); - const receipt = await request(); - await localPrivateClone({ ...f, issueId: peer.issueId }, async () => { - await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, peer.issueId)); - }); - await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/); - await expect(fs.lstat(path.join(child, receipt.repository.relativePath))).rejects.toMatchObject({ code: "ENOENT" }); - expect(acquisition.materialize).toHaveBeenCalledOnce(); - }); - - it.each(["subdirectory", "linked_worktree", "nested_target"] as const)("retains private Git owner provenance copied through a %s without requiring source write grants", async sourceKind => { - const f = await fixture(), exec = promisify(execFileCallback); - const privateCwd = path.join(f.admission.cwd, "private"), publicCwd = path.join(f.admission.cwd, "public"); - await mkdir(privateCwd); await mkdir(publicCwd); - await writeFile(path.join(privateCwd, "secret.txt"), "Copied private Git bytes\n"); - await writeFile(path.join(publicCwd, "readme.txt"), "Public nested configuration\n"); - await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); - await exec("git", ["-C", f.admission.cwd, "add", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private copied source", visibility: "private", - executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private nested files", cwd: privateCwd }); - if (sourceKind === "nested_target") { - const [outer] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected ancestor source", - executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: outer.id, name: "Ancestor source", cwd: f.admission.cwd }); - } - let sourceCwd = publicCwd; - if (sourceKind === "linked_worktree") { - sourceCwd = path.join(root, `${f.issueId}-linked`); - await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "linked", sourceCwd]); - } - await expect(assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor: f.actor })).rejects.toThrow(/source project/); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor: f.actor }); - expect(captured.projectIds).toContain(project.id); - const targetCwd = sourceKind === "nested_target" ? path.join(f.admission.cwd, ".paperclip", "worktrees", "copy") : path.join(root, `${f.issueId}-copy`); - await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured }); - await expect(fs.lstat(targetCwd)).rejects.toMatchObject({ code: "ENOENT" }); - if (sourceKind === "nested_target") { - await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, - actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/); - } - await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "copy", targetCwd]); - await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured, phase: "verified" }); - const [target] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Isolated copy", cwd: targetCwd, - mode: "isolated_workspace", strategyType: "git_worktree" }).returning(); - await db.update(issues).set({ executionWorkspaceId: target.id }).where(eq(issues.id, f.issueId)); - await expect(f.service.prepareForAdmission({ ...f.admission, workspaceId: target.id, cwd: targetCwd })).resolves.toEqual([]); - expect(await readFile(path.join(targetCwd, "private", "secret.txt"), "utf8")).toBe("Copied private Git bytes\n"); - if (sourceKind === "nested_target") { - await fs.rename(path.join(targetCwd, ".git"), path.join(targetCwd, ".saved-git")); - await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, - actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/); - await fs.rename(path.join(targetCwd, ".saved-git"), path.join(targetCwd, ".git")); - } - await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); - await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, sourceWorkspaceId: target.id })).rejects.toThrow(/source project/); - }); - - it.each(["file_uri", "absolute_path", "materialized_copy"] as const)("authorizes URL-only private configured owners before a public local clone (%s)", async transport => { - const f = await fixture(), exec = promisify(execFileCallback); - const sourceCwd = path.join(root, `${f.issueId}-url-only-source`); await mkdir(sourceCwd); - await exec("git", ["init", "--initial-branch=main", sourceCwd]); - await writeFile(path.join(sourceCwd, "secret.txt"), "Private URL 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"]); - const repoUrl = transport === "absolute_path" ? sourceCwd : `file://${sourceCwd}`; - const [privateProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Private URL owner", visibility: "private" }).returning(); - const privateCopy = transport === "materialized_copy" ? path.join(root, `${f.issueId}-independent-private`) : null; - if (privateCopy) await exec("git", ["clone", repoUrl, privateCopy]); - const [privateSource] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: privateProject.id, - name: "URL-only private source", cwd: privateCopy, repoUrl }).returning(); - const [publicProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Public URL selection" }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: publicProject.id, name: "Same URL", cwd: null, repoUrl }); - await db.update(issues).set({ projectId: publicProject.id }).where(eq(issues.id, f.issueId)); - const sourceInput = { companyId: f.companyId, sourceCwd, actor: f.actor, copyKind: "repository" as const }; - if (privateCopy) { - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput); - expect(captured.projectIds).not.toContain(privateProject.id); - return; // A private materialized copy does not privatize its public origin. - } - await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/); - const before = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(before).toEqual([]); - // Move the URL before any admission or copy registration: the mutation must - // preserve the old physical source policy even without execution rows. - await projectService(db, { captureWorkspaceUpdates: false }).updateWorkspace(privateProject.id, privateSource.id, - { repoUrl: "https://github.com/example/moved" }); - await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: privateProject.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: privateProject.id, subjectType: "user", subjectId: f.userId }, - ]); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput); - const targetCwd = path.join(root, `${f.issueId}-url-copy`); - await retainIsolatedWorkspaceSourceAccess(db, { ...sourceInput, targetCwd, captured, issueId: f.issueId }); - await exec("git", ["clone", repoUrl, targetCwd]); - await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, privateProject.id)); - await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor })).rejects.toThrow(/source project/); - expect(await readFile(path.join(targetCwd, "secret.txt"), "utf8")).toBe("Private URL source\n"); - }); - - it.each(["checkout", "bare", "protected_ancestor"] as const)("guards unbound local repository staging and publication without worktree authority (%s)", async scenario => { - const f = await fixture(), exec = promisify(execFileCallback); - const checkout = path.join(root, `${f.issueId}-local-origin`); await mkdir(checkout); - await exec("git", ["init", "--initial-branch=main", checkout]); - await writeFile(path.join(checkout, "secret.txt"), "Committed private bytes\n"); - await exec("git", ["-C", checkout, "add", "."]); - await exec("git", ["-C", checkout, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "private source"]); - const sourceCwd = scenario === "bare" ? path.join(root, `${f.issueId}-bare.git`) : checkout; - if (scenario === "bare") await exec("git", ["clone", "--bare", checkout, sourceCwd]); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private local origin", visibility: "private", - executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Local source owner", cwd: sourceCwd }); - const sourceInput = { companyId: f.companyId, sourceCwd, actor: f.actor, copyKind: "repository" as const }; - await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput); - const stage = scenario === "protected_ancestor" ? path.join(sourceCwd, "clone-staging") : path.join(root, `${f.issueId}-staging`); - const input = { ...sourceInput, targetCwd: stage, captured, issueId: f.issueId, phase: "verified" as const }; - if (scenario === "protected_ancestor") { - await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/protected|assignment grant/); - await expect(fs.lstat(stage)).rejects.toMatchObject({ code: "ENOENT" }); - } else { - await retainIsolatedWorkspaceSourceAccess(db, input); - await exec("git", ["clone", sourceCwd, stage]); - const target = path.join(root, `${f.issueId}-published-local`); - await retainIsolatedWorkspaceSourceAccess(db, { ...input, targetCwd: target }); - await fs.rename(stage, target); - await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); - await expect(retainIsolatedWorkspaceSourceAccess(db, { ...input, targetCwd: target })).rejects.toThrow(/source project/); - await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: target, actor: f.actor })).rejects.toThrow(/source project/); - expect(await readFile(path.join(target, "secret.txt"), "utf8")).toBe("Committed private bytes\n"); - } - const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(entries.some(entry => entry.kind === "isolated_project_source" && entry.scopeId === project.id)).toBe(true); - expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false); - }); - - it.each(["url", "catalog"] as const)("rejects independent local-file acquisition through %s before a receipt or clone", async kind => { - const f = await fixture(); - const url = `file://${f.admission.cwd}`; - if (kind === "catalog") acquisition.catalog.mockResolvedValue({ repositories: [{ id: "private-repo", fullName: "private/local", url, connections: ["fixture"] }] }); - await expect(f.request(kind === "url" ? { kind, url } : { kind, id: "private-repo" })).rejects.toThrow(/HTTPS GitHub/); - expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]); - expect(acquisition.materialize).not.toHaveBeenCalled(); - }); - - it.each(["grant_revoked", "source_detached", "retained_reader"] as const)("retains configured sibling source privacy on the task root (%s)", async scenario => { - const f = await fixture(), exec = promisify(execFileCallback); - const sourceCwd = path.join(root, `${f.issueId}-configured-source`); - await mkdir(sourceCwd); - const privateCwd = path.join(sourceCwd, "private"); await mkdir(privateCwd); - await writeFile(path.join(privateCwd, "secret.txt"), "Private configured bytes\n"); - await exec("git", ["init", "--initial-branch=main", sourceCwd]); - await exec("git", ["-C", sourceCwd, "add", "."]); - await exec("git", ["-C", sourceCwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private sibling source", visibility: "private", - executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - const [configuration] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private subtree", cwd: privateCwd }).returning(); - const input = { ...f.admission, sourceCwd }; - const peer = scenario === "retained_reader" ? await standardPeer(f) : null; - if (peer) await f.service.prepareForAdmission(peer.admission); - await expect(f.service.prepareConfiguredRepositorySource(input)).rejects.toThrow(/source project/); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - if (peer) { - await expect(f.service.prepareConfiguredRepositorySource(input)).rejects.toThrow(/source project/); - } else { - const beforePublish = await f.service.prepareConfiguredRepositorySource(input); - const destination = path.join(f.admission.cwd, ".paperclip-repositories", "configured-source"); - await exec("git", ["clone", sourceCwd, destination]); - await expect(beforePublish()).resolves.toBeUndefined(); - if (scenario === "source_detached") await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, configuration.id)); - await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); - await expect(beforePublish()).rejects.toThrow(/source project/); - await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, actor: f.actor })).rejects.toThrow(/source project/); - expect(await readFile(path.join(destination, "private", "secret.txt"), "utf8")).toBe("Private configured bytes\n"); - } - const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(entries.some(entry => entry.kind === "isolated_project_source" && entry.scopeId === project.id)).toBe(true); - expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false); - expect(entries.filter(entry => entry.kind === "reader")).toHaveLength(peer ? 1 : 0); - }); - - 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", - ...(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", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - let sourceCwd = f.admission.cwd; - if (kind === "linked") { - sourceCwd = path.join(root, `${f.issueId}-linked-normal`); - await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "linked-normal", sourceCwd]); - } - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private nested owner", visibility: "private" }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Nested source", cwd: privateCwd }); - const unrelated = path.join(root, `${f.issueId}-unrelated-candidate`); await mkdir(unrelated); - await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Unrelated task files", cwd: unrelated, - mode: "shared_workspace", strategyType: "operator_directory" }); - const wrapper = path.join(root, `${f.issueId}-git-bin`); await mkdir(wrapper); - const probeLog = path.join(wrapper, "probed.txt"); - await writeFile(probeLog, ""); - const realGit = (await exec("sh", ["-c", "command -v git"])).stdout.trim(); - await writeFile(path.join(wrapper, "git"), `#!/bin/sh -if [ "$1" = "-C" ] && [ "$2" = "$PROBE_UNRELATED" ]; then - printf 'unexpected Git probe\\n' >> "$PROBE_LOG" - exit 78 -fi -exec "$PROBE_REAL_GIT" "$@" -`, { mode: 0o755 }); - const previous = { PATH: process.env.PATH, PROBE_UNRELATED: process.env.PROBE_UNRELATED, - PROBE_LOG: process.env.PROBE_LOG, PROBE_REAL_GIT: process.env.PROBE_REAL_GIT }; - try { - vi.stubEnv("PATH", `${wrapper}${path.delimiter}${process.env.PATH}`); - vi.stubEnv("PROBE_UNRELATED", await realpath(unrelated)); - vi.stubEnv("PROBE_LOG", probeLog); vi.stubEnv("PROBE_REAL_GIT", realGit); - const input = { companyId: f.companyId, sourceCwd, actor: f.actor }; - await expect(assertIsolatedWorkspaceSourceReadAccess(db, input)).rejects.toThrow(/source project/); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, input); - expect(captured.projectIds).toContain(project.id); - expect(await readFile(probeLog, "utf8")).toBe(""); - } finally { - for (const [name, value] of Object.entries(previous)) vi.stubEnv(name, value); - } - }); - - it.each(["unrelated", "metadata", "checkout", "linked_main", "linked_retained"] as const)("authorizes actual separate Git storage without inferring its parent (%s)", async owner => { - const f = await fixture(), exec = promisify(execFileCallback); - const storageParent = path.join(root, `${f.issueId}-storage`); - const metadata = path.join(storageParent, "metadata"), unrelated = path.join(storageParent, "private-task"); - await mkdir(unrelated, { recursive: true }); - await exec("git", ["init", "--initial-branch=main", "--separate-git-dir", metadata, f.admission.cwd]); - await writeFile(path.join(f.admission.cwd, "source.txt"), "Source bytes\n"); - await exec("git", ["-C", f.admission.cwd, "add", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - let sourceCwd = f.admission.cwd; - if (owner === "linked_main" || owner === "linked_retained") { - sourceCwd = path.join(root, `${f.issueId}-linked-source`); - await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "source-linked", sourceCwd]); - } - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private actual owner", visibility: "private" }).returning(); - const [configuration] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private source", - cwd: owner === "metadata" ? metadata : owner === "unrelated" ? unrelated : f.admission.cwd }).returning(); - if (owner === "linked_retained") { - const taskRoot = path.join(root, `${f.issueId}-different-task-root`); - await mkdir(taskRoot); - await db.update(executionWorkspaces).set({ cwd: taskRoot }).where(eq(executionWorkspaces.id, f.workspace.id)); - await projectService(db).removeWorkspace(project.id, configuration.id); - } - const input = { companyId: f.companyId, sourceCwd, actor: f.actor, copyKind: "repository" as const }; - if (owner !== "unrelated") { - await expect(assertIsolatedWorkspaceSourceReadAccess(db, input)).rejects.toThrow(/source project/); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - } - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, input); - expect(captured.roots).toContain(await realpath(metadata)); - expect(captured.roots).toContain(await realpath(f.admission.cwd)); - expect(captured.roots).not.toContain(await realpath(storageParent)); - expect(captured.projectIds.includes(project.id)).toBe(owner !== "unrelated"); - }); - - it.each(["deleted_primary", "retained_reader"] as const)("retains separate metadata privacy across %s", async scenario => { - const f = await fixture(), exec = promisify(execFileCallback); - const storageParent = path.join(root, `${f.issueId}-deleted-primary-storage`); - const metadata = path.join(storageParent, "metadata"), linked = path.join(root, `${f.issueId}-surviving-linked`); - await mkdir(storageParent); - await exec("git", ["init", "--initial-branch=main", "--separate-git-dir", metadata, f.admission.cwd]); - await writeFile(path.join(f.admission.cwd, "private.txt"), "PRIVATE_PRIMARY_BYTES"); - await exec("git", ["-C", f.admission.cwd, "add", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "private source"]); - await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "surviving-linked", linked]); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Deleted private primary", visibility: "private" }).returning(); - const [configuration] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private source", cwd: f.admission.cwd }).returning(); - const input = { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor, copyKind: "repository" as const }; - await expect(assertIsolatedWorkspaceSourceReadAccess(db, input)).rejects.toThrow(/source project/); - expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual([]); - const peer = scenario === "retained_reader" ? await standardPeer(f) : null; - if (peer) { - await db.update(executionWorkspaces).set({ cwd: metadata }).where(eq(executionWorkspaces.id, peer.workspace.id)); - await f.service.prepareForAdmission({ ...peer.admission, cwd: metadata }); - } - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - if (peer) { - await expect(assertIsolatedWorkspaceSourceReadAccess(db, input)).rejects.toThrow(/source project/); - const before = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(before.some(entry => entry.kind === "project_source" && entry.scopeId === project.id)).toBe(false); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: peer.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: peer.userId }, - ]); - } - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, input); - expect(captured.projectIds).toContain(project.id); - const metadataRows = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(metadataRows).toContainEqual(expect.objectContaining({ canonicalCwd: await realpath(metadata), kind: "project_source", scopeId: project.id })); - expect(metadataRows.some(entry => entry.kind === "isolated_copy")).toBe(false); - if (peer) return; - const firstCopy = path.join(root, `${f.issueId}-authorized-copy`); - await retainIsolatedWorkspaceSourceAccess(db, { ...input, captured, targetCwd: firstCopy, issueId: f.issueId }); - await exec("git", ["clone", "--no-hardlinks", f.admission.cwd, firstCopy]); - await projectService(db).removeWorkspace(project.id, configuration.id); - await rm(f.admission.cwd, { recursive: true, force: true }); - await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); - expect(await readFile(path.join(linked, "private.txt"), "utf8")).toBe("PRIVATE_PRIMARY_BYTES"); - await expect(assertIsolatedWorkspaceSourceReadAccess(db, { ...input, sourceCwd: linked })).rejects.toThrow(/source project/); - }); - - it.each(["ordinary", "linked"] as const)("requires real linked-worktree identity with separate Git metadata (%s)", async kind => { - const f = await fixture(), exec = promisify(execFileCallback); - const metadata = path.join(root, `${f.issueId}-metadata`); - await exec("git", ["init", "--initial-branch=main", "--separate-git-dir", metadata, f.admission.cwd]); - await writeFile(path.join(f.admission.cwd, "source.txt"), "Source bytes\n"); - await exec("git", ["-C", f.admission.cwd, "add", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected separate checkout", visibility: "private", - executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: f.admission.cwd }); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); - const targetCwd = kind === "ordinary" ? f.admission.cwd : path.join(f.admission.cwd, "linked"); - if (kind === "linked") await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "linked", targetCwd]); - await writeFile(path.join(targetCwd, "source.txt"), "Preserved dirty edit\n"); - const retain = () => retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, - targetCwd, actor: f.actor, issueId: f.issueId, captured, phase: "verified" }); - if (kind === "ordinary") await expect(retain()).rejects.toThrow(/not a verified Git worktree|protected/); - else { - await expect(retain()).resolves.toBeUndefined(); - await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId })).resolves.toBeUndefined(); - } - const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(kind === "linked"); - expect(await readFile(path.join(targetCwd, "source.txt"), "utf8")).toBe("Preserved dirty edit\n"); - }); - - it.each(["upgrade", "interrupted_creation", "unrelated_owner"] as const)("validates a registered dirty worktree without completed copy proof after %s", async scenario => { - const f = await fixture(), exec = promisify(execFileCallback); - const git = async (...args: string[]) => (await exec("git", ["-C", f.admission.cwd, ...args])).stdout; - await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); - await writeFile(path.join(f.admission.cwd, "tracked.txt"), "Original source\n"); - await git("add", "."); - await git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected legacy source", visibility: "private", - executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: f.admission.cwd }); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); - const targetCwd = path.join(f.admission.cwd, ".paperclip", "worktrees", "legacy"); - const input = { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, issueId: f.issueId, captured }; - if (scenario === "interrupted_creation") await retainIsolatedWorkspaceSourceAccess(db, input); - if (scenario === "unrelated_owner") { - const unrelated = path.join(root, `${f.issueId}-unrelated-git`); - await exec("git", ["init", "--initial-branch=main", unrelated]); - await exec("git", ["-C", unrelated, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-m", "unrelated"]); - await exec("git", ["-C", unrelated, "worktree", "add", "-b", "legacy", targetCwd]); - } else await git("worktree", "add", "-b", "legacy", targetCwd); - await writeFile(path.join(targetCwd, "tracked.txt"), "Keep dirty edit\n"); - await writeFile(path.join(targetCwd, "untracked.txt"), "Keep untracked work\n"); - const before = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(before.some(entry => entry.kind === "isolated_copy")).toBe(false); - const gitState = await exec("git", ["-C", targetCwd, "status", "--porcelain"]); - const head = await exec("git", ["-C", targetCwd, "rev-parse", "HEAD"]); - if (scenario === "unrelated_owner") { - await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/protected|assignment grant/); - await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/); - } else { - await expect(retainIsolatedWorkspaceSourceAccess(db, input)).resolves.toBeUndefined(); - await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId })).resolves.toBeUndefined(); - } - const after = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(after.some(entry => entry.kind === "isolated_copy" && entry.scopeId === project.id)).toBe(scenario !== "unrelated_owner"); - expect((await exec("git", ["-C", targetCwd, "status", "--porcelain"])).stdout).toBe(gitState.stdout); - expect((await exec("git", ["-C", targetCwd, "rev-parse", "HEAD"])).stdout).toBe(head.stdout); - expect(await readFile(path.join(targetCwd, "tracked.txt"), "utf8")).toBe("Keep dirty edit\n"); - expect(await readFile(path.join(targetCwd, "untracked.txt"), "utf8")).toBe("Keep untracked work\n"); - }); - - it("does not let an unmaterialized copy intent authorize writes to an existing source folder", async () => { - const f = await fixture(), exec = promisify(execFileCallback); - const targetCwd = path.join(f.admission.cwd, "tracked-private-folder"); - await mkdir(targetCwd); - await writeFile(path.join(targetCwd, "existing.txt"), "Protected original bytes\n"); - await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); - await exec("git", ["-C", f.admission.cwd, "add", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected original source", visibility: "private", - executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Original files", cwd: f.admission.cwd }); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - const writeInput = { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId }; - await expect(assertExecutionWorkspaceRootWriteAccess(db, writeInput)).rejects.toThrow(/protected|assignment grant/); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); - await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, - actor: f.actor, issueId: f.issueId, captured })).rejects.toThrow(/protected|assignment grant/); - await expect(fs.lstat(path.join(targetCwd, ".git"))).rejects.toMatchObject({ code: "ENOENT" }); - expect(await readFile(path.join(targetCwd, "existing.txt"), "utf8")).toBe("Protected original bytes\n"); - await expect(assertExecutionWorkspaceRootWriteAccess(db, writeInput)).rejects.toThrow(/protected|assignment grant/); - }); - - it("does not verify a missing or ordinary empty directory as an isolated copy", async () => { - const f = await fixture(), exec = promisify(execFileCallback); - await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); - await writeFile(path.join(f.admission.cwd, "source.txt"), "Source\n"); - await exec("git", ["-C", f.admission.cwd, "add", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); - const targetCwd = path.join(root, `${f.issueId}-never-materialized`); - const input = { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, captured, phase: "verified" as const }; - await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/not a verified Git worktree/); - await mkdir(targetCwd); - await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/not a verified Git worktree/); - const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false); - }); - - it.each(["exact", "descendant"] as const)("requires write authority for a protected %s target before copying", async relation => { - const f = await fixture(), exec = promisify(execFileCallback); - await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); - await writeFile(path.join(f.admission.cwd, "source.txt"), "Source bytes\n"); - await exec("git", ["-C", f.admission.cwd, "add", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - const targetCwd = path.join(root, `${f.issueId}-protected-target`); - const protectedCwd = relation === "exact" ? targetCwd : path.join(targetCwd, "private"); - await mkdir(protectedCwd, { recursive: true }); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected target", - executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Target owner", cwd: protectedCwd }); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); - await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, - actor: f.actor, issueId: f.issueId, captured })).rejects.toThrow(/protected|assignment grant/); - await expect(fs.lstat(path.join(targetCwd, "source.txt"))).rejects.toMatchObject({ code: "ENOENT" }); - const owners = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); - expect(owners).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "isolated_source", scopeId: f.workspace.id })])); - }); - - it("excludes available lexical descendants whose canonical roots are unrelated", async () => { - const f = await fixture(); - const unrelated = path.join(root, `${f.issueId}-unrelated`); await mkdir(unrelated); - const alias = path.join(f.admission.cwd, "link"); await symlink(unrelated, alias); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unrelated private owner", visibility: "private" }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Unrelated alias", cwd: alias }); - const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Unrelated alias", cwd: alias, - mode: "shared_workspace", strategyType: "project_primary" }).returning(); - await db.insert(executionWorkspaceRepositories).values({ companyId: f.companyId, requestedByIssueId: f.issueId, executionWorkspaceId: workspace.id, - repositoryIdentity: "https://github.com/private/unrelated", repoUrl: "https://github.com/private/unrelated", relativePath: "unused", - requestedRef: "HEAD", requestKey: randomUUID() }); - await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); - }); - - it.each(["revoked", "deleted_source", "deleted_owner", "retained_reader"] as const)("protects already-copied bytes when post-copy authorization fails (%s)", async failure => { - const f = await fixture(), exec = promisify(execFileCallback); - await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); - await writeFile(path.join(f.admission.cwd, "secret.txt"), "Failed copy still private\n"); - await exec("git", ["-C", f.admission.cwd, "add", "."]); - await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); - const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Failed private copy", visibility: "private" }).returning(); - await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private source", cwd: f.admission.cwd }); - await db.insert(projectAccessMembers).values([ - { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, - { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, - ]); - const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); - const targetCwd = path.join(root, `${f.issueId}-failed-copy`); - if (failure === "retained_reader") { - await mkdir(targetCwd); - const peer = await standardPeer(f); - await db.update(executionWorkspaces).set({ cwd: targetCwd }).where(eq(executionWorkspaces.id, peer.workspace.id)); - await f.service.prepareForAdmission({ ...peer.admission, cwd: targetCwd }); - await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, - actor: f.actor, captured })).rejects.toThrow(/source project/); - await expect(fs.lstat(path.join(targetCwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" }); - return; - } - await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "failed-copy", targetCwd]); - if (failure === "revoked") await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); - if (failure === "deleted_owner") await db.delete(projects).where(eq(projects.id, project.id)); - if (failure === "deleted_source") await rm(f.admission.cwd, { recursive: true }); - await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, captured })).rejects.toThrow(); - const canonicalCwd = await realpath(targetCwd); - const owners = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.rootKey, createHash("sha256").update(canonicalCwd).digest("hex"))); - expect(owners).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "isolated_project_source", scopeId: project.id })])); - const peer = await standardPeer(f); - await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: peer.actor })).rejects.toThrow(/source project/); - expect(await readFile(path.join(targetCwd, "secret.txt"), "utf8")).toBe("Failed copy still private\n"); - }); + afterAll(context.cleanup); + const { fixture, lowTrustPeer, standardPeer, privateTask, grantPeer, localPrivateClone } = context; it.each(["empty", "different_identity", "unreadable_alias", "loop_alias", "anonymous_private_edits", "authorized_peer"] as const)( "authorizes every physical repository source for a standard reader with %s", async scenario => { @@ -829,7 +54,6 @@ exec "$PROBE_REAL_GIT" "$@" const alias = path.join(root, `${f.issueId}-private-alias`); await symlink(scenario === "loop_alias" ? alias : f.admission.cwd, alias); await db.update(executionWorkspaces).set({ cwd: alias }).where(eq(executionWorkspaces.id, f.workspace.id)); - // Published path evidence must also bootstrap provenance for old rows. await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); const actual = fs.realpath.bind(fs); vi.spyOn(fs, "realpath").mockImplementation(async pathname => { @@ -892,8 +116,6 @@ exec "$PROBE_REAL_GIT" "$@" await localPrivateClone(f); const [checkout] = await f.service.prepareForAdmission(f.admission); expect(await readFile(path.join(checkout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n"); - // Both receipts/files predate provenance registration. The move itself - // must preserve the old owner before its available cwd becomes unrelated. await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); const moved = path.join(root, `${f.issueId}-legacy-moved`); await mkdir(moved); @@ -963,7 +185,6 @@ exec "$PROBE_REAL_GIT" "$@" await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined(); - // Physical configured ownership overrides a misleading isolation label. await db.update(projectWorkspaces).set({ cwd: f.admission.cwd }).where(eq(projectWorkspaces.id, source.id)); await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/); }); @@ -1218,8 +439,6 @@ exec "$PROBE_REAL_GIT" "$@" 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)); } @@ -1349,7 +568,6 @@ exec "$PROBE_REAL_GIT" "$@" await mkdir(path.join(checkout, ".git"), { recursive: true }); await writeFile(path.join(checkout, "private.txt"), "Private peer bytes"); } else { - // A legacy pending intent has neither a root-policy row nor published bytes. await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId)); } if (scenario !== "unpinned_publication" && scenario !== "pending_intent") await db.update(executionWorkspaceRepositories) @@ -1380,8 +598,6 @@ exec "$PROBE_REAL_GIT" "$@" ])("rejects readable private publication behind an unreadable alias ($marker marker, reserved=$reserved)", async ({ marker, reserved }) => { const f = await fixture(); const receipt = await f.request({ kind: "catalog", id: "private-repo" }); - // Emulate a pre-fence managed publication and, optionally, a reservation - // created by the older unreadable-alias bug. await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId)); const checkout = path.join(f.admission.cwd, receipt.repository.relativePath); await mkdir(checkout, { recursive: true }); @@ -1466,7 +682,6 @@ exec "$PROBE_REAL_GIT" "$@" }); } await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]); - // Rechecking current owners must still tolerate an unrelated unavailable path. vi.restoreAllMocks(); const actual = fs.realpath.bind(fs); vi.spyOn(fs, "realpath").mockImplementation(async pathname => { @@ -1539,10 +754,8 @@ exec "$PROBE_REAL_GIT" "$@" it("keeps anonymous root ownership across workspace moves and blocks future catalog promotion", async () => { const f = await fixture(); - // A standard task's initially anonymous intent may later appear in its catalog. const pending = await f.request({ kind: "url", url: "https://github.com/public/later-catalog" }); const peer = await lowTrustPeer(f); - // This source task belongs to the reader's trusted task subtree. await db.update(issues).set({ parentId: peer.admission.issueId }).where(eq(issues.id, f.issueId)); await f.service.prepareForAdmission(peer.admission); await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-anonymous-workspace"), metadata: {}, status: "archived" }) @@ -1588,8 +801,6 @@ exec "$PROBE_REAL_GIT" "$@" await Promise.race([staged, preparation.then(() => { throw new Error("Managed preparation finished before publication barrier"); })]); const [pending] = await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, receipt.operationId)); expect(pending).toMatchObject({ state: "preparing", pinnedCommit: null }); - // The reservation remains anchored to the physical root even if ordinary - // metadata/placement maintenance changes the originating workspace row. await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-managed-workspace"), metadata: {} }).where(eq(executionWorkspaces.id, f.workspace.id)); await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(peer.admission)).rejects.toThrow(/anonymous public URLs only|trusted task/); } finally { release(); } @@ -1617,7 +828,6 @@ exec "$PROBE_REAL_GIT" "$@" await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); expect(acquisition.catalog).not.toHaveBeenCalled(); expect(acquisition.materialize).not.toHaveBeenCalled(); - // Adding an acquisition still requires the full authorization intersection. await db.insert(executionWorkspaceRepositories).values({ companyId: f.companyId, executionWorkspaceId: f.workspace.id, requestedByIssueId: f.issueId, repositoryIdentity: "https://github.com/public/example", repoUrl: "https://github.com/public/example", relativePath: ".paperclip-repositories/unused", requestedRef: "HEAD", requestKey: "requires-authority", requestKeys: ["requires-authority"] }); diff --git a/server/src/__tests__/task-workspace-source-copy-privacy.test.ts b/server/src/__tests__/task-workspace-source-copy-privacy.test.ts new file mode 100644 index 00000000000..ad2e87b789b --- /dev/null +++ b/server/src/__tests__/task-workspace-source-copy-privacy.test.ts @@ -0,0 +1,732 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, writeFile, readFile, realpath, rm, symlink } from "node:fs/promises"; +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces, + heartbeatRuns, issueAccessGrants, issues, projects, projectWorkspaces, projectAccessMembers, principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { assertExecutionWorkspaceRootReadAccess, assertExecutionWorkspaceRootWriteAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js"; +import { executionWorkspaceService } from "../services/execution-workspaces.js"; +import { projectService } from "../services/projects.js"; +import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js"; +import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js"; +import { buildGitAuthInvocation } from "../services/git-credentials.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +import { createRepositoryTrustFixture } from "./helpers/repository-trust-fixture.js"; + +const acquisition = vi.hoisted(() => ({ managedAuth: vi.fn(), materialize: vi.fn(), catalog: vi.fn() })); +vi.mock("../services/tool-access.js", () => ({ toolAccessService: () => ({ listProjectRepositories: acquisition.catalog }) })); +vi.mock("../services/git-credentials.js", async (original) => ({ + ...await original(), + createGitRemoteAuthProvider: () => acquisition.managedAuth, +})); +vi.mock("../services/managed-repository-checkout.js", async (original) => ({ + ...await original(), + materializeManagedProjectWorkspace: acquisition.materialize, +})); + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("task workspace source copy privacy", () => { + const context = createRepositoryTrustFixture(acquisition); + let db: ReturnType; + let root: string; + beforeAll(async () => { ({ db, root } = await context.setup()); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); }); + afterAll(context.cleanup); + const { fixture, lowTrustPeer, standardPeer, privateTask, grantPeer, localPrivateClone } = context; + + 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"); + }); + + it.each([ + { owner: "configured", direction: "descendant" }, { owner: "configured", direction: "ancestor" }, + { owner: "execution", direction: "descendant" }, { owner: "execution", direction: "ancestor" }, + ])("authorizes newly overlapping private $owner $direction owners before selection, admission and publication", async ({ owner, direction }) => { + const f = await fixture(); + const child = path.join(f.admission.cwd, "private"); + await mkdir(child); + const privateCwd = direction === "descendant" ? child : f.admission.cwd; + if (direction === "ancestor") { + f.admission.cwd = child; + await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, f.workspace.id)); + } + await f.service.prepareForAdmission(f.admission); + let grant: () => Promise; + let detach: () => Promise; + if (owner === "configured") { + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Nested private source", visibility: "private" }).returning(); + const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private files", cwd: privateCwd }).returning(); + grant = () => db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + detach = async () => { + await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, source.id)); + await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); + }; + } else { + const peer = await standardPeer(f); + await db.update(executionWorkspaces).set({ cwd: privateCwd }).where(eq(executionWorkspaces.id, peer.workspace.id)); + await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId)); + grant = () => db.insert(issueAccessGrants).values([ + { issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId }, + { issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId }, + ]); + detach = async () => { + const moved = path.join(root, `${peer.issueId}-moved`); await mkdir(moved); + await executionWorkspaceService(db).update(peer.workspace.id, { cwd: moved }); + await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, peer.issueId)); + }; + } + await writeFile(path.join(privateCwd, "private.txt"), "Nested private files\n"); + const read = () => assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, actor: f.actor, sourceWorkspaceId: f.workspace.id }); + const select = () => executionWorkspaceService(db).selectTaskWorkspace({ companyId: f.companyId, issueId: f.issueId, actor: f.actor, + selection: { kind: "existing", workspaceId: f.workspace.id }, expectedBindingRevision: 0, requestKey: randomUUID() }); + await expect(read()).rejects.toThrow(/source project|workspace access|authorization boundary/); + await expect(select()).rejects.toThrow(/source project|workspace access|authorization boundary/); + await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/); + await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/); + await grant(); + await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); + await expect(read()).resolves.toBeUndefined(); + await detach(); + await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/); + await expect(read()).rejects.toThrow(/source project|workspace access|authorization boundary/); + }); + + it("does not confuse sibling component prefixes with overlapping roots", async () => { + const f = await fixture(); + const sibling = `${f.admission.cwd}er`; await mkdir(sibling); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unrelated private source", visibility: "private" }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Sibling", cwd: sibling }); + await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: f.companyId, rootKey: createHash("sha256").update(await realpath(sibling)).digest("hex"), + canonicalCwd: await realpath(sibling), kind: "project_source", scopeId: project.id }); + await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, rootKey: createHash("sha256").update(await realpath(sibling)).digest("hex"), + canonicalCwd: await realpath(sibling), mode: "anonymous_only" }); + await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); + await expect(f.request({ kind: "catalog", id: "private-repo" })).resolves.toMatchObject({ kind: "requires_next_admission" }); + }); + + it.each([ + { mode: "managed", direction: "ancestor" }, { mode: "managed", direction: "descendant" }, + { mode: "anonymous_only", direction: "ancestor" }, { mode: "anonymous_only", direction: "descendant" }, + ] as const)("fences $mode root policy against opposite-mode $direction acquisition", async ({ mode, direction }) => { + const f = await fixture(); + const child = path.join(f.admission.cwd, "nested"); await mkdir(child); + const existingRoot = direction === "ancestor" ? child : f.admission.cwd; + if (direction === "descendant") { + f.admission.cwd = child; + await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, f.workspace.id)); + } + const canonicalCwd = await realpath(existingRoot); + await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, canonicalCwd, + rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), mode }); + if (mode === "managed") { + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId)); + await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/anonymous public URLs only/); + } else await expect(f.request({ kind: "catalog", id: "private-repo" })).rejects.toThrow(/anonymous public URLs only/); + expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]); + expect(acquisition.materialize).not.toHaveBeenCalled(); + }); + + it("reauthorizes a nested private publication against an already-admitted parent reader", async () => { + const f = await fixture(); + await f.service.prepareForAdmission(f.admission); + const peer = await standardPeer(f), child = path.join(f.admission.cwd, "private"); await mkdir(child); + peer.admission.cwd = child; + await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, peer.workspace.id)); + await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId)); + const request = () => f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor, + request: { repository: { kind: "url", url: "https://github.com/public/nested" }, requestKey: randomUUID() } }); + await expect(request()).rejects.toThrow(/workspace access|authorization boundary/); + await db.insert(issueAccessGrants).values([ + { issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId }, + { issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId }, + ]); + const receipt = await request(); + await localPrivateClone({ ...f, issueId: peer.issueId }, async () => { + await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, peer.issueId)); + }); + await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/); + await expect(fs.lstat(path.join(child, receipt.repository.relativePath))).rejects.toMatchObject({ code: "ENOENT" }); + expect(acquisition.materialize).toHaveBeenCalledOnce(); + }); + + it.each(["subdirectory", "linked_worktree", "nested_target"] as const)("retains private Git owner provenance copied through a %s without requiring source write grants", async sourceKind => { + const f = await fixture(), exec = promisify(execFileCallback); + const privateCwd = path.join(f.admission.cwd, "private"), publicCwd = path.join(f.admission.cwd, "public"); + await mkdir(privateCwd); await mkdir(publicCwd); + await writeFile(path.join(privateCwd, "secret.txt"), "Copied private Git bytes\n"); + await writeFile(path.join(publicCwd, "readme.txt"), "Public nested configuration\n"); + await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); + await exec("git", ["-C", f.admission.cwd, "add", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private copied source", visibility: "private", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private nested files", cwd: privateCwd }); + if (sourceKind === "nested_target") { + const [outer] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected ancestor source", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: outer.id, name: "Ancestor source", cwd: f.admission.cwd }); + } + let sourceCwd = publicCwd; + if (sourceKind === "linked_worktree") { + sourceCwd = path.join(root, `${f.issueId}-linked`); + await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "linked", sourceCwd]); + } + await expect(assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor: f.actor })).rejects.toThrow(/source project/); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor: f.actor }); + expect(captured.projectIds).toContain(project.id); + const targetCwd = sourceKind === "nested_target" ? path.join(f.admission.cwd, ".paperclip", "worktrees", "copy") : path.join(root, `${f.issueId}-copy`); + await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured }); + await expect(fs.lstat(targetCwd)).rejects.toMatchObject({ code: "ENOENT" }); + if (sourceKind === "nested_target") { + await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, + actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/); + } + await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "copy", targetCwd]); + await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured, phase: "verified" }); + const [target] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Isolated copy", cwd: targetCwd, + mode: "isolated_workspace", strategyType: "git_worktree" }).returning(); + await db.update(issues).set({ executionWorkspaceId: target.id }).where(eq(issues.id, f.issueId)); + await expect(f.service.prepareForAdmission({ ...f.admission, workspaceId: target.id, cwd: targetCwd })).resolves.toEqual([]); + expect(await readFile(path.join(targetCwd, "private", "secret.txt"), "utf8")).toBe("Copied private Git bytes\n"); + if (sourceKind === "nested_target") { + await fs.rename(path.join(targetCwd, ".git"), path.join(targetCwd, ".saved-git")); + await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, + actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/); + await fs.rename(path.join(targetCwd, ".saved-git"), path.join(targetCwd, ".git")); + } + await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); + await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, sourceWorkspaceId: target.id })).rejects.toThrow(/source project/); + }); + + it.each(["file_uri", "absolute_path", "materialized_copy"] as const)("authorizes URL-only private configured owners before a public local clone (%s)", async transport => { + const f = await fixture(), exec = promisify(execFileCallback); + const sourceCwd = path.join(root, `${f.issueId}-url-only-source`); await mkdir(sourceCwd); + await exec("git", ["init", "--initial-branch=main", sourceCwd]); + await writeFile(path.join(sourceCwd, "secret.txt"), "Private URL 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"]); + const repoUrl = transport === "absolute_path" ? sourceCwd : `file://${sourceCwd}`; + const [privateProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Private URL owner", visibility: "private" }).returning(); + const privateCopy = transport === "materialized_copy" ? path.join(root, `${f.issueId}-independent-private`) : null; + if (privateCopy) await exec("git", ["clone", repoUrl, privateCopy]); + const [privateSource] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: privateProject.id, + name: "URL-only private source", cwd: privateCopy, repoUrl }).returning(); + const [publicProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Public URL selection" }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: publicProject.id, name: "Same URL", cwd: null, repoUrl }); + await db.update(issues).set({ projectId: publicProject.id }).where(eq(issues.id, f.issueId)); + const sourceInput = { companyId: f.companyId, sourceCwd, actor: f.actor, copyKind: "repository" as const }; + if (privateCopy) { + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput); + expect(captured.projectIds).not.toContain(privateProject.id); + return; + } + await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/); + const before = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(before).toEqual([]); + await projectService(db, { captureWorkspaceUpdates: false }).updateWorkspace(privateProject.id, privateSource.id, + { repoUrl: "https://github.com/example/moved" }); + await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: privateProject.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: privateProject.id, subjectType: "user", subjectId: f.userId }, + ]); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput); + const targetCwd = path.join(root, `${f.issueId}-url-copy`); + await retainIsolatedWorkspaceSourceAccess(db, { ...sourceInput, targetCwd, captured, issueId: f.issueId }); + await exec("git", ["clone", repoUrl, targetCwd]); + await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, privateProject.id)); + await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor })).rejects.toThrow(/source project/); + expect(await readFile(path.join(targetCwd, "secret.txt"), "utf8")).toBe("Private URL source\n"); + }); + + it.each(["checkout", "bare", "protected_ancestor"] as const)("guards unbound local repository staging and publication without worktree authority (%s)", async scenario => { + const f = await fixture(), exec = promisify(execFileCallback); + const checkout = path.join(root, `${f.issueId}-local-origin`); await mkdir(checkout); + await exec("git", ["init", "--initial-branch=main", checkout]); + await writeFile(path.join(checkout, "secret.txt"), "Committed private bytes\n"); + await exec("git", ["-C", checkout, "add", "."]); + await exec("git", ["-C", checkout, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "private source"]); + const sourceCwd = scenario === "bare" ? path.join(root, `${f.issueId}-bare.git`) : checkout; + if (scenario === "bare") await exec("git", ["clone", "--bare", checkout, sourceCwd]); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private local origin", visibility: "private", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Local source owner", cwd: sourceCwd }); + const sourceInput = { companyId: f.companyId, sourceCwd, actor: f.actor, copyKind: "repository" as const }; + await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput); + const stage = scenario === "protected_ancestor" ? path.join(sourceCwd, "clone-staging") : path.join(root, `${f.issueId}-staging`); + const input = { ...sourceInput, targetCwd: stage, captured, issueId: f.issueId, phase: "verified" as const }; + if (scenario === "protected_ancestor") { + await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/protected|assignment grant/); + await expect(fs.lstat(stage)).rejects.toMatchObject({ code: "ENOENT" }); + } else { + await retainIsolatedWorkspaceSourceAccess(db, input); + await exec("git", ["clone", sourceCwd, stage]); + const target = path.join(root, `${f.issueId}-published-local`); + await retainIsolatedWorkspaceSourceAccess(db, { ...input, targetCwd: target }); + await fs.rename(stage, target); + await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); + await expect(retainIsolatedWorkspaceSourceAccess(db, { ...input, targetCwd: target })).rejects.toThrow(/source project/); + await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: target, actor: f.actor })).rejects.toThrow(/source project/); + expect(await readFile(path.join(target, "secret.txt"), "utf8")).toBe("Committed private bytes\n"); + } + const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(entries.some(entry => entry.kind === "isolated_project_source" && entry.scopeId === project.id)).toBe(true); + expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false); + }); + + it.each(["url", "catalog"] as const)("rejects independent local-file acquisition through %s before a receipt or clone", async kind => { + const f = await fixture(); + const url = `file://${f.admission.cwd}`; + if (kind === "catalog") acquisition.catalog.mockResolvedValue({ repositories: [{ id: "private-repo", fullName: "private/local", url, connections: ["fixture"] }] }); + await expect(f.request(kind === "url" ? { kind, url } : { kind, id: "private-repo" })).rejects.toThrow(/HTTPS GitHub/); + expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]); + expect(acquisition.materialize).not.toHaveBeenCalled(); + }); + + it.each(["grant_revoked", "source_detached", "retained_reader"] as const)("retains configured sibling source privacy on the task root (%s)", async scenario => { + const f = await fixture(), exec = promisify(execFileCallback); + const sourceCwd = path.join(root, `${f.issueId}-configured-source`); + await mkdir(sourceCwd); + const privateCwd = path.join(sourceCwd, "private"); await mkdir(privateCwd); + await writeFile(path.join(privateCwd, "secret.txt"), "Private configured bytes\n"); + await exec("git", ["init", "--initial-branch=main", sourceCwd]); + await exec("git", ["-C", sourceCwd, "add", "."]); + await exec("git", ["-C", sourceCwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private sibling source", visibility: "private", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + const [configuration] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private subtree", cwd: privateCwd }).returning(); + const input = { ...f.admission, sourceCwd }; + const peer = scenario === "retained_reader" ? await standardPeer(f) : null; + if (peer) await f.service.prepareForAdmission(peer.admission); + await expect(f.service.prepareConfiguredRepositorySource(input)).rejects.toThrow(/source project/); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + if (peer) { + await expect(f.service.prepareConfiguredRepositorySource(input)).rejects.toThrow(/source project/); + } else { + const beforePublish = await f.service.prepareConfiguredRepositorySource(input); + const destination = path.join(f.admission.cwd, ".paperclip-repositories", "configured-source"); + await exec("git", ["clone", sourceCwd, destination]); + await expect(beforePublish()).resolves.toBeUndefined(); + if (scenario === "source_detached") await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, configuration.id)); + await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); + await expect(beforePublish()).rejects.toThrow(/source project/); + await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, actor: f.actor })).rejects.toThrow(/source project/); + expect(await readFile(path.join(destination, "private", "secret.txt"), "utf8")).toBe("Private configured bytes\n"); + } + const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(entries.some(entry => entry.kind === "isolated_project_source" && entry.scopeId === project.id)).toBe(true); + expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false); + expect(entries.filter(entry => entry.kind === "reader")).toHaveLength(peer ? 1 : 0); + }); + + 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", + ...(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", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + let sourceCwd = f.admission.cwd; + if (kind === "linked") { + sourceCwd = path.join(root, `${f.issueId}-linked-normal`); + await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "linked-normal", sourceCwd]); + } + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private nested owner", visibility: "private" }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Nested source", cwd: privateCwd }); + const unrelated = path.join(root, `${f.issueId}-unrelated-candidate`); await mkdir(unrelated); + await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Unrelated task files", cwd: unrelated, + mode: "shared_workspace", strategyType: "operator_directory" }); + const wrapper = path.join(root, `${f.issueId}-git-bin`); await mkdir(wrapper); + const probeLog = path.join(wrapper, "probed.txt"); + await writeFile(probeLog, ""); + const realGit = (await exec("sh", ["-c", "command -v git"])).stdout.trim(); + await writeFile(path.join(wrapper, "git"), `#!/bin/sh +if [ "$1" = "-C" ] && [ "$2" = "$PROBE_UNRELATED" ]; then + printf 'unexpected Git probe\\n' >> "$PROBE_LOG" + exit 78 +fi +exec "$PROBE_REAL_GIT" "$@" +`, { mode: 0o755 }); + const previous = { PATH: process.env.PATH, PROBE_UNRELATED: process.env.PROBE_UNRELATED, + PROBE_LOG: process.env.PROBE_LOG, PROBE_REAL_GIT: process.env.PROBE_REAL_GIT }; + try { + vi.stubEnv("PATH", `${wrapper}${path.delimiter}${process.env.PATH}`); + vi.stubEnv("PROBE_UNRELATED", await realpath(unrelated)); + vi.stubEnv("PROBE_LOG", probeLog); vi.stubEnv("PROBE_REAL_GIT", realGit); + const input = { companyId: f.companyId, sourceCwd, actor: f.actor }; + await expect(assertIsolatedWorkspaceSourceReadAccess(db, input)).rejects.toThrow(/source project/); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, input); + expect(captured.projectIds).toContain(project.id); + expect(await readFile(probeLog, "utf8")).toBe(""); + } finally { + for (const [name, value] of Object.entries(previous)) vi.stubEnv(name, value); + } + }); + + it.each(["unrelated", "metadata", "checkout", "linked_main", "linked_retained"] as const)("authorizes actual separate Git storage without inferring its parent (%s)", async owner => { + const f = await fixture(), exec = promisify(execFileCallback); + const storageParent = path.join(root, `${f.issueId}-storage`); + const metadata = path.join(storageParent, "metadata"), unrelated = path.join(storageParent, "private-task"); + await mkdir(unrelated, { recursive: true }); + await exec("git", ["init", "--initial-branch=main", "--separate-git-dir", metadata, f.admission.cwd]); + await writeFile(path.join(f.admission.cwd, "source.txt"), "Source bytes\n"); + await exec("git", ["-C", f.admission.cwd, "add", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + let sourceCwd = f.admission.cwd; + if (owner === "linked_main" || owner === "linked_retained") { + sourceCwd = path.join(root, `${f.issueId}-linked-source`); + await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "source-linked", sourceCwd]); + } + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private actual owner", visibility: "private" }).returning(); + const [configuration] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private source", + cwd: owner === "metadata" ? metadata : owner === "unrelated" ? unrelated : f.admission.cwd }).returning(); + if (owner === "linked_retained") { + const taskRoot = path.join(root, `${f.issueId}-different-task-root`); + await mkdir(taskRoot); + await db.update(executionWorkspaces).set({ cwd: taskRoot }).where(eq(executionWorkspaces.id, f.workspace.id)); + await projectService(db).removeWorkspace(project.id, configuration.id); + } + const input = { companyId: f.companyId, sourceCwd, actor: f.actor, copyKind: "repository" as const }; + if (owner !== "unrelated") { + await expect(assertIsolatedWorkspaceSourceReadAccess(db, input)).rejects.toThrow(/source project/); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + } + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, input); + expect(captured.roots).toContain(await realpath(metadata)); + expect(captured.roots).toContain(await realpath(f.admission.cwd)); + expect(captured.roots).not.toContain(await realpath(storageParent)); + expect(captured.projectIds.includes(project.id)).toBe(owner !== "unrelated"); + }); + + it.each(["deleted_primary", "retained_reader"] as const)("retains separate metadata privacy across %s", async scenario => { + const f = await fixture(), exec = promisify(execFileCallback); + const storageParent = path.join(root, `${f.issueId}-deleted-primary-storage`); + const metadata = path.join(storageParent, "metadata"), linked = path.join(root, `${f.issueId}-surviving-linked`); + await mkdir(storageParent); + await exec("git", ["init", "--initial-branch=main", "--separate-git-dir", metadata, f.admission.cwd]); + await writeFile(path.join(f.admission.cwd, "private.txt"), "PRIVATE_PRIMARY_BYTES"); + await exec("git", ["-C", f.admission.cwd, "add", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "private source"]); + await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "surviving-linked", linked]); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Deleted private primary", visibility: "private" }).returning(); + const [configuration] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private source", cwd: f.admission.cwd }).returning(); + const input = { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor, copyKind: "repository" as const }; + await expect(assertIsolatedWorkspaceSourceReadAccess(db, input)).rejects.toThrow(/source project/); + expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual([]); + const peer = scenario === "retained_reader" ? await standardPeer(f) : null; + if (peer) { + await db.update(executionWorkspaces).set({ cwd: metadata }).where(eq(executionWorkspaces.id, peer.workspace.id)); + await f.service.prepareForAdmission({ ...peer.admission, cwd: metadata }); + } + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + if (peer) { + await expect(assertIsolatedWorkspaceSourceReadAccess(db, input)).rejects.toThrow(/source project/); + const before = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(before.some(entry => entry.kind === "project_source" && entry.scopeId === project.id)).toBe(false); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: peer.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: peer.userId }, + ]); + } + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, input); + expect(captured.projectIds).toContain(project.id); + const metadataRows = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(metadataRows).toContainEqual(expect.objectContaining({ canonicalCwd: await realpath(metadata), kind: "project_source", scopeId: project.id })); + expect(metadataRows.some(entry => entry.kind === "isolated_copy")).toBe(false); + if (peer) return; + const firstCopy = path.join(root, `${f.issueId}-authorized-copy`); + await retainIsolatedWorkspaceSourceAccess(db, { ...input, captured, targetCwd: firstCopy, issueId: f.issueId }); + await exec("git", ["clone", "--no-hardlinks", f.admission.cwd, firstCopy]); + await projectService(db).removeWorkspace(project.id, configuration.id); + await rm(f.admission.cwd, { recursive: true, force: true }); + await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); + expect(await readFile(path.join(linked, "private.txt"), "utf8")).toBe("PRIVATE_PRIMARY_BYTES"); + await expect(assertIsolatedWorkspaceSourceReadAccess(db, { ...input, sourceCwd: linked })).rejects.toThrow(/source project/); + }); + + it.each(["ordinary", "linked"] as const)("requires real linked-worktree identity with separate Git metadata (%s)", async kind => { + const f = await fixture(), exec = promisify(execFileCallback); + const metadata = path.join(root, `${f.issueId}-metadata`); + await exec("git", ["init", "--initial-branch=main", "--separate-git-dir", metadata, f.admission.cwd]); + await writeFile(path.join(f.admission.cwd, "source.txt"), "Source bytes\n"); + await exec("git", ["-C", f.admission.cwd, "add", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected separate checkout", visibility: "private", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: f.admission.cwd }); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); + const targetCwd = kind === "ordinary" ? f.admission.cwd : path.join(f.admission.cwd, "linked"); + if (kind === "linked") await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "linked", targetCwd]); + await writeFile(path.join(targetCwd, "source.txt"), "Preserved dirty edit\n"); + const retain = () => retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, + targetCwd, actor: f.actor, issueId: f.issueId, captured, phase: "verified" }); + if (kind === "ordinary") await expect(retain()).rejects.toThrow(/not a verified Git worktree|protected/); + else { + await expect(retain()).resolves.toBeUndefined(); + await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId })).resolves.toBeUndefined(); + } + const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(kind === "linked"); + expect(await readFile(path.join(targetCwd, "source.txt"), "utf8")).toBe("Preserved dirty edit\n"); + }); + + it.each(["upgrade", "interrupted_creation", "unrelated_owner"] as const)("validates a registered dirty worktree without completed copy proof after %s", async scenario => { + const f = await fixture(), exec = promisify(execFileCallback); + const git = async (...args: string[]) => (await exec("git", ["-C", f.admission.cwd, ...args])).stdout; + await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); + await writeFile(path.join(f.admission.cwd, "tracked.txt"), "Original source\n"); + await git("add", "."); + await git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected legacy source", visibility: "private", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: f.admission.cwd }); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); + const targetCwd = path.join(f.admission.cwd, ".paperclip", "worktrees", "legacy"); + const input = { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, issueId: f.issueId, captured }; + if (scenario === "interrupted_creation") await retainIsolatedWorkspaceSourceAccess(db, input); + if (scenario === "unrelated_owner") { + const unrelated = path.join(root, `${f.issueId}-unrelated-git`); + await exec("git", ["init", "--initial-branch=main", unrelated]); + await exec("git", ["-C", unrelated, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-m", "unrelated"]); + await exec("git", ["-C", unrelated, "worktree", "add", "-b", "legacy", targetCwd]); + } else await git("worktree", "add", "-b", "legacy", targetCwd); + await writeFile(path.join(targetCwd, "tracked.txt"), "Keep dirty edit\n"); + await writeFile(path.join(targetCwd, "untracked.txt"), "Keep untracked work\n"); + const before = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(before.some(entry => entry.kind === "isolated_copy")).toBe(false); + const gitState = await exec("git", ["-C", targetCwd, "status", "--porcelain"]); + const head = await exec("git", ["-C", targetCwd, "rev-parse", "HEAD"]); + if (scenario === "unrelated_owner") { + await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/protected|assignment grant/); + await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/); + } else { + await expect(retainIsolatedWorkspaceSourceAccess(db, input)).resolves.toBeUndefined(); + await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId })).resolves.toBeUndefined(); + } + const after = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(after.some(entry => entry.kind === "isolated_copy" && entry.scopeId === project.id)).toBe(scenario !== "unrelated_owner"); + expect((await exec("git", ["-C", targetCwd, "status", "--porcelain"])).stdout).toBe(gitState.stdout); + expect((await exec("git", ["-C", targetCwd, "rev-parse", "HEAD"])).stdout).toBe(head.stdout); + expect(await readFile(path.join(targetCwd, "tracked.txt"), "utf8")).toBe("Keep dirty edit\n"); + expect(await readFile(path.join(targetCwd, "untracked.txt"), "utf8")).toBe("Keep untracked work\n"); + }); + + it("does not let an unmaterialized copy intent authorize writes to an existing source folder", async () => { + const f = await fixture(), exec = promisify(execFileCallback); + const targetCwd = path.join(f.admission.cwd, "tracked-private-folder"); + await mkdir(targetCwd); + await writeFile(path.join(targetCwd, "existing.txt"), "Protected original bytes\n"); + await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); + await exec("git", ["-C", f.admission.cwd, "add", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected original source", visibility: "private", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Original files", cwd: f.admission.cwd }); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + const writeInput = { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId }; + await expect(assertExecutionWorkspaceRootWriteAccess(db, writeInput)).rejects.toThrow(/protected|assignment grant/); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); + await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, + actor: f.actor, issueId: f.issueId, captured })).rejects.toThrow(/protected|assignment grant/); + await expect(fs.lstat(path.join(targetCwd, ".git"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(targetCwd, "existing.txt"), "utf8")).toBe("Protected original bytes\n"); + await expect(assertExecutionWorkspaceRootWriteAccess(db, writeInput)).rejects.toThrow(/protected|assignment grant/); + }); + + it("does not verify a missing or ordinary empty directory as an isolated copy", async () => { + const f = await fixture(), exec = promisify(execFileCallback); + await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); + await writeFile(path.join(f.admission.cwd, "source.txt"), "Source\n"); + await exec("git", ["-C", f.admission.cwd, "add", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); + const targetCwd = path.join(root, `${f.issueId}-never-materialized`); + const input = { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, captured, phase: "verified" as const }; + await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/not a verified Git worktree/); + await mkdir(targetCwd); + await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/not a verified Git worktree/); + const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false); + }); + + it.each(["exact", "descendant"] as const)("requires write authority for a protected %s target before copying", async relation => { + const f = await fixture(), exec = promisify(execFileCallback); + await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); + await writeFile(path.join(f.admission.cwd, "source.txt"), "Source bytes\n"); + await exec("git", ["-C", f.admission.cwd, "add", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + const targetCwd = path.join(root, `${f.issueId}-protected-target`); + const protectedCwd = relation === "exact" ? targetCwd : path.join(targetCwd, "private"); + await mkdir(protectedCwd, { recursive: true }); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected target", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Target owner", cwd: protectedCwd }); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); + await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, + actor: f.actor, issueId: f.issueId, captured })).rejects.toThrow(/protected|assignment grant/); + await expect(fs.lstat(path.join(targetCwd, "source.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + const owners = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId)); + expect(owners).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "isolated_source", scopeId: f.workspace.id })])); + }); + + it("excludes available lexical descendants whose canonical roots are unrelated", async () => { + const f = await fixture(); + const unrelated = path.join(root, `${f.issueId}-unrelated`); await mkdir(unrelated); + const alias = path.join(f.admission.cwd, "link"); await symlink(unrelated, alias); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unrelated private owner", visibility: "private" }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Unrelated alias", cwd: alias }); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Unrelated alias", cwd: alias, + mode: "shared_workspace", strategyType: "project_primary" }).returning(); + await db.insert(executionWorkspaceRepositories).values({ companyId: f.companyId, requestedByIssueId: f.issueId, executionWorkspaceId: workspace.id, + repositoryIdentity: "https://github.com/private/unrelated", repoUrl: "https://github.com/private/unrelated", relativePath: "unused", + requestedRef: "HEAD", requestKey: randomUUID() }); + await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]); + }); + + it.each(["revoked", "deleted_source", "deleted_owner", "retained_reader"] as const)("protects already-copied bytes when post-copy authorization fails (%s)", async failure => { + const f = await fixture(), exec = promisify(execFileCallback); + await exec("git", ["init", "--initial-branch=main", f.admission.cwd]); + await writeFile(path.join(f.admission.cwd, "secret.txt"), "Failed copy still private\n"); + await exec("git", ["-C", f.admission.cwd, "add", "."]); + await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Failed private copy", visibility: "private" }).returning(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private source", cwd: f.admission.cwd }); + await db.insert(projectAccessMembers).values([ + { companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId }, + { companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }, + ]); + const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor }); + const targetCwd = path.join(root, `${f.issueId}-failed-copy`); + if (failure === "retained_reader") { + await mkdir(targetCwd); + const peer = await standardPeer(f); + await db.update(executionWorkspaces).set({ cwd: targetCwd }).where(eq(executionWorkspaces.id, peer.workspace.id)); + await f.service.prepareForAdmission({ ...peer.admission, cwd: targetCwd }); + await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, + actor: f.actor, captured })).rejects.toThrow(/source project/); + await expect(fs.lstat(path.join(targetCwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + return; + } + await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "failed-copy", targetCwd]); + if (failure === "revoked") await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id)); + if (failure === "deleted_owner") await db.delete(projects).where(eq(projects.id, project.id)); + if (failure === "deleted_source") await rm(f.admission.cwd, { recursive: true }); + await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, captured })).rejects.toThrow(); + const canonicalCwd = await realpath(targetCwd); + const owners = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.rootKey, createHash("sha256").update(canonicalCwd).digest("hex"))); + expect(owners).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "isolated_project_source", scopeId: project.id })])); + const peer = await standardPeer(f); + await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: peer.actor })).rejects.toThrow(/source project/); + expect(await readFile(path.join(targetCwd, "secret.txt"), "utf8")).toBe("Failed copy still private\n"); + }); + + +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 821879fe482..e1566b435f3 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -1779,8 +1779,6 @@ process.stderr.write(${JSON.stringify(stderr)}, () => { process.exitCode = ${cod 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"); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f996d7e2960..13c5e848f6a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -4891,7 +4891,6 @@ export function issueRoutes( executionWorkspacePreference?: string | null; executionWorkspaceSettings?: unknown; }) { - // Organizational project inheritance never comes from filesystem sources. if (input.projectId) return input.projectId; const sourceId = input.inheritExecutionWorkspaceFromIssueId ?? input.parentId; const source = sourceId ? await svc.getById(sourceId) : null; @@ -7730,7 +7729,6 @@ export function issueRoutes( // must not install the guard, or it can clear the flag that // the other request still owns. // null - this function sent an error response, so the caller stops. - // The reopen checks the persisted task binding and source access inside the service, and // it runs only after the route already authorized the request on the issue. async function reopenClosedIssueExecutionWorkspaceOrRespond( req: Request, diff --git a/server/src/routes/task-workspaces.ts b/server/src/routes/task-workspaces.ts index feb33024345..2fc458bf4bd 100644 --- a/server/src/routes/task-workspaces.ts +++ b/server/src/routes/task-workspaces.ts @@ -11,7 +11,6 @@ import { forbidden, notFound } from "../errors.js"; import { assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js"; import { validate } from "../middleware/validate.js"; -/** App and agent transports share task/workspace authorization and the aggregate owner. */ export function taskWorkspaceRoutes(db: Db) { const router = Router(); const workspaces = executionWorkspaceService(db); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 6bfa495e480..7bc119b86a4 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -1642,8 +1642,6 @@ export function authorizationService(db: Db | DbTransaction) { } const taskAssignmentAction = input.action === "tasks:assign" || input.action === "project:write_workspace"; const requestedAssignmentPolicy = () => input.action === "project:write_workspace" && input.resource.type === "issue" - // Continuing work does not reassign the task or its agent. Its lineage is - // retained for containment, while only the source project's write policy applies. ? loadProjectAuthorizationPolicy(companyId, input.resource.projectId!).then(policy => evaluateAuthorizationPolicyForAssignment(policy, "Target project")) : assignmentPolicyEffect(input.resource); diff --git a/server/src/services/chat-execution-defaults.ts b/server/src/services/chat-execution-defaults.ts index 14cc9ded3ee..d0ff01df0a2 100644 --- a/server/src/services/chat-execution-defaults.ts +++ b/server/src/services/chat-execution-defaults.ts @@ -14,7 +14,6 @@ type DefaultsAccessInput = { defaults: ChatExecutionDefaults | null | undefined; }; -/** Configuration selects resources; it never grants their access to future senders. */ export async function assertChatExecutionDefaultsAccess( db: Db, input: DefaultsAccessInput, @@ -33,7 +32,6 @@ export async function assertChatExecutionDefaultsAccess( }, reader); } -/** Apply defaults and assignment authority together in the task creation transaction. */ export async function assertChatTaskCreationAccess( db: Db, input: DefaultsAccessInput & { assigneeAgentId: string; parentIssueId?: string | null }, @@ -56,7 +54,6 @@ export async function assertChatTaskCreationAccess( if (!decision.allowed) throw forbidden(decision.explanation); } -/** Resolve once when a conversation creates its task. Later endpoint edits are irrelevant. */ export function resolveChatExecutionDefaults( endpoint: ChatExecutionDefaults | null | undefined, resource: ChatExecutionDefaults | null | undefined, diff --git a/server/src/services/company-deletion.ts b/server/src/services/company-deletion.ts index 538e9b50bd5..b668c7ad01e 100644 --- a/server/src/services/company-deletion.ts +++ b/server/src/services/company-deletion.ts @@ -100,8 +100,6 @@ export async function deleteCompany(db: Db, id: string) { await tx.delete(companyLogos).where(eq(companyLogos.companyId, id)); await tx.delete(assets).where(eq(assets.companyId, id)); await tx.delete(goals).where(eq(goals.companyId, id)); - // A retained workspace protects its source project from individual deletion. - // Whole-company deletion explicitly removes both in dependency order. await tx.delete(executionWorkspaces).where(eq(executionWorkspaces.companyId, id)); await tx.delete(projects).where(eq(projects.companyId, id)); await agentLifecycleCompanyDeletion.deleteCompanyData(tx, id); diff --git a/server/src/services/email-channels.ts b/server/src/services/email-channels.ts index aa55247106c..688aa2d6cda 100644 --- a/server/src/services/email-channels.ts +++ b/server/src/services/email-channels.ts @@ -1322,7 +1322,6 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { current.assignedAgentId, ); const defaults = resolveChatExecutionDefaults(current.executionDefaults, null); - // A channel default cannot widen the receiving agent's trust boundary. if ("projectId" in placement && defaults.projectId !== undefined && defaults.projectId !== placement.projectId) { throw forbidden("Default project conflicts with the assigned agent's trust boundary"); } diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index b815f18c5e6..9d315ed2671 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -477,7 +477,6 @@ export function buildExecutionWorkspaceAdapterConfig(input: { return { ...nextConfig, ...input.adapterConfigOverrides }; } -/** Persisted filesystem authority survives UI feature-flag changes. */ export function taskWorkspaceRuntimeSelectionEnabled(input: { legacyUiEnabled: boolean; hasTypedSelection: boolean; @@ -486,7 +485,6 @@ export function taskWorkspaceRuntimeSelectionEnabled(input: { return input.legacyUiEnabled || input.hasTypedSelection || input.hasBinding; } -/** Defaults and pending intent never rewrite an already admitted native run. */ export function canApplyTaskWorkspaceSelectionAtAdmission(input: { admittedInput: unknown; restarting: boolean; diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index e7254d955f3..8c030adb113 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -17,69 +17,21 @@ import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js" import { captureRunIdentity } from "./run-identity.js"; import { toolAccessService } from "./tool-access.js"; import { normalizeProjectRepositoryUrl } from "./project-repositories.js"; -import { localRepositoryPath } from "./local-repository-path.js"; import { buildGitAuthInvocation, createGitRemoteAuthProvider } from "./git-credentials.js"; import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "./managed-repository-checkout.js"; +import { canonicalRepositoryRoot, repositoryRootsOverlap, legacyRepositoryRootMayMatch, configuredWorkspaceRoot, verifiedIsolatedWorktree, unusedIsolatedCopyTarget, isolatedWorkspaceSourceRoots, type WorkspaceSourceCopyKind } from "./execution-workspace-source-roots.js"; +export { canonicalRepositoryRoot } from "./execution-workspace-source-roots.js"; + const execFile = promisify(execFileCallback); type RepositoryRow = typeof executionWorkspaceRepositories.$inferSelect; type DbTransaction = Parameters[0]>[0]; -/** Match physical aliases, including task directories not materialized yet. */ -export async function canonicalRepositoryRoot(cwd: string): Promise { - const absolute = path.resolve(cwd); - try { return await fs.realpath(absolute); } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - const parent = path.dirname(absolute); - if (parent === absolute) throw error; - return path.join(await canonicalRepositoryRoot(parent), path.basename(absolute)); - } -} - -/** Physical folder authority is symmetric: a parent captures its descendants, - * while a child remains inside its ancestor's retained file ownership. */ -function repositoryRootsOverlap(left: string, right: string): boolean { - const contains = (parent: string, child: string) => { - const relative = path.relative(parent, child); - return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); - }; - return contains(left, right) || contains(right, left); -} - -/** An unavailable legacy path must not block unrelated roots. Preserve the - * unresolved suffix and reject roots inside an inaccessible prefix, since a - * hidden symlink may still point at the candidate's admitted directory. */ -async function legacyRepositoryRootMayMatch(cwd: string, candidate: string): Promise { - const absolute = path.resolve(cwd); - let probe = absolute; - let suffix = ""; - let inaccessiblePrefix: string | null = null; - for (;;) { - try { - const canonical = await fs.realpath(probe); - if (repositoryRootsOverlap(path.join(canonical, suffix), candidate)) return true; - if (!inaccessiblePrefix) return false; - const unresolved = path.join(canonical, path.relative(probe, inaccessiblePrefix)); - return repositoryRootsOverlap(candidate, unresolved); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (!["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(code ?? "")) throw error; - if (code === "EACCES" || code === "EPERM") inaccessiblePrefix = probe; - const parent = path.dirname(probe); - if (parent === probe) return true; - suffix = path.join(path.basename(probe), suffix); - probe = parent; - } - } -} - const controllerAcquisitionMessage = "New controller repository acquisition requires a trusted task. Existing verified public checkouts remain available."; const lowTrustRepositoryMessage = "Low-trust repository reuse permits anonymous public URLs only; new acquisition is unavailable. Use an authorized configured source for private repositories."; function resolveAcquisitionSource(request: PrepareWorkspaceRepository["repository"], available: ProjectRepository[], anonymousOnly: boolean) { if (anonymousOnly && request.kind === "catalog") throw forbidden(lowTrustRepositoryMessage); - // A sponsor's catalog is credential discovery, not authority for untrusted work. return resolveTaskRepository(request, anonymousOnly ? [] : available); } @@ -87,7 +39,6 @@ function assertAcquisitionReceipt(row: Pick repo.id === request.id) : null; if (request.kind === "catalog" && !selected) throw forbidden("Repository access is no longer available; refresh the repository catalog"); @@ -106,35 +57,6 @@ export function assertRepositoryRetryMatches(row: Pick { - const resolveDirectory = async (cwd: string): Promise => { - try { - const entry = await fs.stat(cwd); - if (!entry.isDirectory()) { - if (scan) return null; - throw forbidden("Workspace files cannot be detached from unverifiable provenance"); - } - return await fs.realpath(cwd); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || (scan && ["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(code ?? ""))) return null; - throw error; - } - }; - if (input.cwd) { - const cwd = await resolveDirectory(input.cwd); - if (cwd) return cwd; - } - let repositoryPath: string | null; - try { repositoryPath = input.repoUrl ? localRepositoryPath(input.repoUrl) : null; } - catch (error) { if (scan) return null; throw error; } - return repositoryPath ? resolveDirectory(repositoryPath) : null; -} - -/** Resolve persisted and physical file provenance without registering readers or policies. */ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: { companyId: string; cwd: string; sourceWorkspaceId?: string; inspectCurrentAliases?: boolean; preparingCopyProjectIds?: Set; }) { @@ -148,9 +70,6 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: { || overlappingEntries.some(entry => createHash("sha256").update(entry.canonicalCwd).digest("hex") !== entry.rootKey)) { throw forbidden("Repository root identity conflicts with its retained access record"); } - // Only an exact target copy record proves that ancestral source owners were - // copied here. A copied child must never relax its parent or a new owner - // configured directly at/inside the target. const proofs = entries.filter(entry => entry.kind === "isolated_copy"); const copiedProjectIds = new Set(input.preparingCopyProjectIds ?? []); if (proofs.length && await verifiedIsolatedWorktree(canonicalCwd)) { @@ -170,8 +89,6 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: { const isolatedSourceIds = new Set(overlappingEntries.filter(entry => entry.kind === "isolated_source").map(entry => entry.scopeId)); const sourceIds = new Set([...sharedSourceIds, ...isolatedSourceIds]); if (input.sourceWorkspaceId) { sourceIds.add(input.sourceWorkspaceId); sharedSourceIds.add(input.sourceWorkspaceId); addSharedSource(input.sourceWorkspaceId, canonicalCwd); } - // Recheck current roots on every admission/publication. A later nested owner - // can introduce private files even after this root has retained readers. const workspaces = await tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces) .where(eq(executionWorkspaces.companyId, input.companyId)); for (const workspace of workspaces) { @@ -198,12 +115,8 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: { if (!namespaceEntry.isDirectory() || namespaceEntry.isSymbolicLink() || await fs.realpath(namespace) !== namespace) { throw forbidden("Repository namespace has no verifiable contained ownership"); } - // Configured project siblings have a separate source owner. This child - // aggregate owns only task-repo-* publications and their retained provenance. for (const name of await fs.readdir(namespace)) if (name.startsWith("task-repo-")) publishedNames.add(name); } - // Birth records distinguish new, fenced acquisitions from legacy receipts. - // Never infer origin for an old receipt merely because it is encountered now. const repositoryOrigins = await tx.select({ id: executionWorkspaceRepositoryAccess.scopeId }).from(executionWorkspaceRepositoryAccess) .where(and(eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.kind, "repository"))); const knownOrigins = new Set(repositoryOrigins.map(origin => origin.id)); @@ -215,10 +128,6 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: { if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null; throw error; }) : null; - // A basename identifies a repository, not its publisher. Independent - // copies in available unrelated roots do not contribute files here. - // Retained sources survive cwd changes; unavailable legacy aliases with - // matching publications remain ambiguous and therefore fail closed. const matches = sourceIds.has(peer.workspaceId) || (peerRoot !== null && repositoryRootsOverlap(peerRoot, canonicalCwd)) || (published && peerRoot === null && !knownOrigins.has(peer.id)) || (peerRoot === null && !entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd)); @@ -231,12 +140,8 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: { } } if ([...publishedNames].some(name => !recognizedNames.has(name))) throw forbidden("Published repository has no retained workspace provenance"); - // Configured folders can contain project files before any task has an - // execution workspace. Read access must include their current aliases too. const configured = await tx.select({ projectId: projectWorkspaces.projectId, cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl }) .from(projectWorkspaces).where(eq(projectWorkspaces.companyId, input.companyId)); - // Retain the privacy owner, not its disposable workspace configuration. - // Execution sources may later lose their project FK; retained files must not. const sourceProjectIds = new Set(overlappingEntries.filter(entry => (entry.kind === "project_source" || entry.kind === "isolated_project_source")).map(entry => entry.scopeId)); const sharedProjectIds = new Set(overlappingEntries.filter(entry => entry.kind === "shared_project_source" && !isCopiedAncestor(entry.scopeId, entry.canonicalCwd)).map(entry => entry.scopeId)); const isolatedProjectIds = new Set(overlappingEntries.filter(entry => entry.kind === "isolated_project_source").map(entry => entry.scopeId)); @@ -257,8 +162,6 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: { sourceProjectIds.add(source.projectId); if (!isCopiedAncestor(source.projectId, sourceRoot)) sharedProjectIds.add(source.projectId); } - // Older retained owners have no sharing classification. Require writes - // unless an authoritative retained workspace proves an isolated copy. for (const entry of overlappingEntries) if (entry.kind === "project_source" && !isolatedProjectIds.has(entry.scopeId)) { sharedProjectIds.add(entry.scopeId); } @@ -291,14 +194,9 @@ async function retainedRootReader(tx: DbTransaction, companyId: string, reader: } export async function lockExecutionWorkspaceRootProvenance(tx: DbTransaction, companyId: string) { - // Acquire before task/workspace row locks. Hold only for policy and receipt - // transactions, never Git, provider execution, or snapshot capture. await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`task-repository-roots:${companyId}`}, 0))`); } -/** A configured folder owns project files before its first task admission. - * Preserve that ownership before its configuration can move or disappear. - * Caller holds the company root fence and the fresh configured-workspace lock. */ export async function preserveConfiguredProjectWorkspaceRootProvenance(tx: DbTransaction, input: { companyId: string; projectId: string; cwd: string | null; repoUrl?: string | null; }): Promise { @@ -315,8 +213,6 @@ export async function preserveConfiguredProjectWorkspaceRootProvenance(tx: DbTra } } -/** Preserve deny-only owners before a workspace can detach from existing files. - * Caller holds the company root fence, followed by the workspace row lock. */ export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction, input: { companyId: string; workspaceId: string; cwd: string | null; }): Promise { @@ -339,7 +235,6 @@ export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction } } -/** File readers share admission's source closure, without changing root policy. */ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransaction, input: { companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string; }): Promise { @@ -348,104 +243,6 @@ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransact await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds); } -/** A retained copy intent protects privacy but proves no physical isolation. - * Only a real, registered linked worktree can activate completed-copy records. */ -async function verifiedIsolatedWorktree(cwd: string, allowedOwnerRoots?: Set): Promise { - try { - const directory = await fs.lstat(cwd), marker = await fs.lstat(path.join(cwd, ".git")); - if (!directory.isDirectory() || directory.isSymbolicLink() || !marker.isFile() || marker.isSymbolicLink() || marker.size > 4096) return false; - const git = async (args: string[]) => (await execFile("git", ["-C", cwd, ...args], { - timeout: 30_000, env: { ...process.env, GIT_NO_LAZY_FETCH: "1" }, - })).stdout.trim(); - const checkoutRoot = await fs.realpath(await git(["rev-parse", "--show-toplevel"])); - if (checkoutRoot !== cwd) return false; - const commonRoot = await fs.realpath(path.resolve(checkoutRoot, await git(["rev-parse", "--git-common-dir"]))); - const gitRoot = await fs.realpath(await git(["rev-parse", "--absolute-git-dir"])); - // An ordinary --separate-git-dir checkout also has a .git file. Only a - // linked worktree has distinct per-worktree and common metadata directories. - if (gitRoot === commonRoot || (allowedOwnerRoots && !allowedOwnerRoots.has(commonRoot))) return false; - const registrations = (await git(["worktree", "list", "--porcelain", "-z"])).split("\0"); - return registrations.includes(`worktree ${checkoutRoot}`); - } catch { return false; } -} - -async function unusedIsolatedCopyTarget(cwd: string): Promise { - const entry = await fs.lstat(cwd).catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return null; - throw error; - }); - if (!entry) return true; - return entry.isDirectory() && !entry.isSymbolicLink() && (await fs.readdir(cwd)).length === 0; -} - -/** Match realization's checkout and common repository roots. A configured cwd - * may be a subdirectory or linked worktree while Git copies a broader tree. */ -type WorkspaceSourceCopyKind = "linked_worktree" | "repository"; -async function isolatedWorkspaceSourceRoots(db: Db | DbTransaction, companyId: string, sourceCwd: string, copyKind: WorkspaceSourceCopyKind = "linked_worktree"): Promise<{ roots: string[]; commonRoot: string }> { - const git = async (cwd: string, ...args: string[]) => (await execFile("git", ["-C", cwd, ...args], { - timeout: 30_000, env: { ...process.env, GIT_NO_LAZY_FETCH: "1" }, - })).stdout.trim(); - const localRoot = await fs.realpath(sourceCwd); - if (copyKind === "repository" && await git(sourceCwd, "rev-parse", "--is-bare-repository") === "true") { - const gitRoot = await fs.realpath(path.resolve(sourceCwd, await git(sourceCwd, "rev-parse", "--absolute-git-dir"))); - return { roots: [...new Set([localRoot, gitRoot])], commonRoot: gitRoot }; - } - 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]); - // 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; - try { - const candidateGitRoot = await fs.realpath(await git(candidate, "rev-parse", "--absolute-git-dir")); - if (candidateGitRoot !== commonRoot) return false; - const candidateCheckout = await fs.realpath(await git(candidate, "rev-parse", "--show-toplevel")); - const candidateCommon = await fs.realpath(path.resolve(candidate, await git(candidate, "rev-parse", "--git-common-dir"))); - const relative = path.relative(candidateCheckout, candidate); - if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) - && candidateCommon === commonRoot) { - roots.add(candidateCheckout); - return true; - } - } catch { /* Unverified candidates cannot identify another checkout owner. */ } - return false; - }; - if (!primaryIdentified) { - const registrations = (await git(checkoutRoot, "worktree", "list", "--porcelain", "-z")).split("\0"); - for (const registration of registrations) { - if (registration.startsWith("worktree ") && await considerPrimary(registration.slice("worktree ".length))) { - primaryIdentified = true; - break; - } - } - } - if (!primaryIdentified) { - // Separate Git metadata has no backlink to its main checkout. Only this - // layout needs company-owned current/retained candidates; never infer an - // owner from the metadata directory's parent or probe unrelated history for - // already-identified ordinary repositories. - const candidates = new Set(); - const configured = await db.select({ cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl }).from(projectWorkspaces) - .where(eq(projectWorkspaces.companyId, companyId)); - for (const workspace of configured) { - const cwd = await configuredWorkspaceRoot(workspace, true); - if (cwd) candidates.add(cwd); - } - const workspaces = await db.select({ cwd: executionWorkspaces.cwd }).from(executionWorkspaces).where(eq(executionWorkspaces.companyId, companyId)); - for (const workspace of workspaces) if (workspace.cwd) candidates.add(workspace.cwd); - const retained = await db.select({ cwd: executionWorkspaceRepositoryAccess.canonicalCwd }).from(executionWorkspaceRepositoryAccess) - .where(eq(executionWorkspaceRepositoryAccess.companyId, companyId)); - for (const entry of retained) candidates.add(entry.cwd); - for (const candidate of candidates) await considerPrimary(candidate); - } - return { roots: [...roots], commonRoot }; -} - -/** Internal, deny-only snapshot captured before copying; never accepted from a model or API payload. */ export type IsolatedWorkspaceSourceAccess = { companyId: string; roots: string[]; @@ -453,8 +250,6 @@ export type IsolatedWorkspaceSourceAccess = { projectIds: string[]; }; -/** Shared Git objects outlive checkout pointers. Associate their verified - * storage with authorized owners before copying, without granting write access. */ async function retainGitMetadataOwners(tx: DbTransaction, input: { companyId: string; commonRoot: string; workspaceIds: Set; projectIds: Set; }): Promise { @@ -495,9 +290,6 @@ export async function assertIsolatedWorkspaceSourceReadAccess(db: Db, input: { }); } -/** Retain source privacy before copying and after realization, including a failed copy admission. - * Commit deny-only ownership before reporting a revoked/missing source, so - * already-copied private bytes never become an unowned readable target. */ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: { companyId: string; sourceCwd: string; targetCwd: string; actor: AuthorizationActor; captured: IsolatedWorkspaceSourceAccess; issueId?: string; phase?: "preparing" | "verified"; copyKind?: WorkspaceSourceCopyKind; @@ -536,8 +328,6 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: { await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind, scopeId }); } } - // Completed-copy authority is separate from the deny-only intent above. - // 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; @@ -547,25 +337,16 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: { throw error; }); if (targetEntry?.isSymbolicLink()) throw forbidden("Isolated copy target cannot be a symbolic link"); - // Upgrades and interrupted admissions can already have a registered - // worktree without the newer proof row. Verify the same physical evidence - // during preparation before adopting it; never infer isolation from files. const linkedWorktree = input.copyKind !== "repository"; 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"); - // 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. preparingCopyProjectIds = projectIds; } } catch (error) { failure ??= error; } - // The target may already contain independently owned files. Copy source - // 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 }); @@ -589,7 +370,6 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: { 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; @@ -611,13 +391,9 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: { 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; @@ -629,13 +405,10 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: { 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; } & Omit[4]>, "write">): Promise { @@ -645,7 +418,6 @@ export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransac await assertRootProjectTrust(db, { ...input, projectIds: sourceProjectIds }); } -/** Workspace aggregate child. It owns acquisition receipts, never task binding or provider sessions. */ export function executionWorkspaceRepositoryService(db: Db) { async function catalog(companyId: string, userId: string | null, localTrusted: boolean) { return (await toolAccessService(db).listProjectRepositories(companyId, userId, localTrusted)).repositories; @@ -688,8 +460,6 @@ export function executionWorkspaceRepositoryService(db: Db) { throw forbidden(lowTrustRepositoryMessage); } if (mode === "anonymous_only") { - // Legacy receipts predate root reservations. Even pending/staging work - // can publish after admission, so no managed intent in this root is safe. const managed = await tx.select({ cwd: executionWorkspaces.cwd, repositoryIdentity: executionWorkspaceRepositories.repositoryIdentity }).from(executionWorkspaceRepositories) .innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) .where(and(eq(executionWorkspaceRepositories.companyId, companyId), eq(executionWorkspaces.companyId, companyId), @@ -704,11 +474,6 @@ export function executionWorkspaceRepositoryService(db: Db) { if (!namespaceEntry.isDirectory() || namespaceEntry.isSymbolicLink() || await fs.realpath(namespace) !== namespace) { throw forbidden(lowTrustRepositoryMessage); } - // An unreadable/moved legacy alias cannot disprove published private - // bytes in this readable root. Markers are agent-writable, so none can - // authorize this collision: choose a fresh root even if these bytes - // were acquired anonymously. Check existing reservations too, since - // older versions may have admitted this root before fencing it. for (const identity of new Set(managed.map(peer => peer.repositoryIdentity))) { const entry = await fs.lstat(path.join(canonicalCwd, taskRepositoryRelativePath(identity))).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; @@ -736,9 +501,6 @@ export function executionWorkspaceRepositoryService(db: Db) { readers.push({ id: randomUUID(), companyId: input.companyId, rootKey, canonicalCwd, kind: "reader", scopeId: input.reader.runId, issueId: input.reader.issueId, agentId: input.reader.agentId, responsibleUserId: run?.responsibleUserId ?? null, createdAt: new Date(), updatedAt: new Date() }); } - // Heartbeat owns ordinary admission to this task's already-authorized - // folder. Avoid adding acquisition permissions when it is the only task - // and has no acquisition. Any other task/source requires the full closure. const ownAdmissionOnly = input.preserveOwnAdmission && input.reader && input.sourceWorkspaceId && sourceIds.size === 1 && sourceIds.has(input.sourceWorkspaceId) && readers.every(reader => reader.issueId === input.reader!.issueId); @@ -751,8 +513,6 @@ export function executionWorkspaceRepositoryService(db: Db) { if (ownAdmissionOnly) { const [ownSource] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and( eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.sourceWorkspaceId!), - // Check both privacy principals without repeating the membership - // admission gate that heartbeat already owns for this exact task. await executionWorkspaceReadSqlCondition(tx, { ...actor, onBehalfOfUserId: null }), actor.onBehalfOfUserId ? await executionWorkspaceReadSqlCondition(tx, { type: "board", userId: actor.onBehalfOfUserId, ignoreInstanceAdmin: true, @@ -763,8 +523,6 @@ export function executionWorkspaceRepositoryService(db: Db) { eq(executionWorkspaces.companyId, input.companyId), or(...[...sourceIds].map(id => eq(executionWorkspaces.id, id))))) : []; for (const sourceWorkspace of sourceWorkspaces.length ? sourceWorkspaces : [null]) { if (await requiresAnonymousAcquisition(tx, { issue, workspace: sourceWorkspace, agentId: reader.agentId!, runId: reader.scopeId })) { - // Retained readers cannot gain managed bytes after their task, agent, - // run, or any retained physical source becomes anonymous-only. await reserveRootPolicy(tx, input.companyId, canonicalCwd, "anonymous_only"); } } @@ -831,7 +589,6 @@ export function executionWorkspaceRepositoryService(db: Db) { if (input.actor.type === "agent" && !["standard", "skill_test"].includes(issue.workMode)) throw forbidden("Repository preparation is unavailable in Ask or Plan mode"); if (!issue.executionWorkspaceId) throw conflict("The task has no admitted workspace yet. Start it once before preparing a repository."); if (!(await canActorReadExecutionWorkspace(db, input.actor, issue.executionWorkspaceId))) throw notFound("Execution workspace not found"); - // Serialize receipts across tasks without blocking unchanged-ID foreign-key reads. const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, issue.executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId), taskWorkspaceSelectableCondition())).for("no key update"); if (!workspace) throw conflict("Execution workspace is unavailable"); await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId }); @@ -865,7 +622,6 @@ export function executionWorkspaceRepositoryService(db: Db) { await tx.insert(activityLog).values({ companyId: input.companyId, actorType: input.actor.type === "agent" ? "agent" : "user", actorId: input.actor.agentId ?? input.actor.userId ?? "local-board", action: "workspace.repository.requested", entityType: "execution_workspace", entityId: workspace.id, runId: input.actor.runId ?? null, responsibleUserId: userId, details: { repositoryId: row.id, issueId: input.issueId, applies: "next_normal_admission" } }); return row; }); - // No host-only checkout is represented as ready in an already admitted remote session. return { kind: "requires_next_admission" as const, applies: "next_normal_admission" as const, operationId: receipt.id, repository: receipt, reason: "immutable_admitted_workspace" }; } async function list(companyId: string, workspaceId: string) { @@ -886,7 +642,6 @@ export function executionWorkspaceRepositoryService(db: Db) { }); return { workspace, actor }; } - /** Configured-source publication shares this root, but does not acquire a repository. */ async function guardForPublication(input: PublicationInput) { await db.transaction(async tx => { await lockExecutionWorkspaceRootProvenance(tx, input.companyId); @@ -894,10 +649,6 @@ export function executionWorkspaceRepositoryService(db: Db) { await authorizeRootAccess(tx, { companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: workspace.id, reader: input }); }); } - /** Ordinary sibling clones inherit source privacy on their admitted task root. - * These records never prove worktree isolation or confer source WRITE rights. - * The callback rechecks before publication/reuse and keeps captured owners - * even if their configuration disappears while files are being copied. */ async function prepareConfiguredRepositorySource(input: PublicationInput & { sourceCwd: string }): Promise<() => Promise> { const roots = new Set(), workspaceIds = new Set(), projectIds = new Set(); const retain = async () => { @@ -953,17 +704,14 @@ export function executionWorkspaceRepositoryService(db: Db) { await retain(); return retain; } - /** Recovery keeps its admitted identity and folder; only fence future publication. */ async function reserveForRecovery(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string }) { const [issue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))); const [workspace] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId))); if (!issue) throw forbidden("Task repository recovery identity is unavailable"); if (!workspace) { - // Older projectless native admissions used the run ID as a transient - // workspace binding. Only the persisted exact native binding grants this exception. const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId))); let execution; - try { execution = parseNativeExecutionInput(run?.runnerProfileJson?.nativeExecutionInput); } catch { /* Fail closed below. */ } + try { execution = parseNativeExecutionInput(run?.runnerProfileJson?.nativeExecutionInput); } catch { } const binding = execution?.binding; if (input.workspaceId !== input.runId || run?.runtimeMode !== "native" || !binding || binding.companyId !== input.companyId || binding.runId !== input.runId @@ -981,8 +729,6 @@ export function executionWorkspaceRepositoryService(db: Db) { ...(workspace ? { sourceWorkspaceId: workspace.id } : {}), preserveOwnAdmission: !(await list(input.companyId, input.workspaceId)).length }); } - /** Logical rows may deliberately name one physical folder. Reuse bytes only - * after validating every published owner's retained privacy and source intent. */ async function sharedPublicationPin(input: { issueId: string; row: RepositoryRow; root: string; actor: AuthorizationActor; anonymousOnly: boolean; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) { const candidates = await db.select({ repository: executionWorkspaceRepositories, workspace: executionWorkspaces }) .from(executionWorkspaceRepositories).innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) @@ -992,7 +738,6 @@ export function executionWorkspaceRepositoryService(db: Db) { for (const { repository, workspace } of candidates) { if (repository.id === input.row.id || !workspace.cwd) continue; const candidatePin = repository.pinnedCommit ?? (input.ownership?.repositoryId === repository.id ? input.ownership.pinnedCommit : null); - // An intent that has not published anything cannot claim another task's checkout. if (!candidatePin || !/^[a-f0-9]{40,64}$/.test(candidatePin)) continue; if (await fs.realpath(workspace.cwd).catch(() => null) !== input.root) continue; assertAcquisitionReceipt(repository, input.anonymousOnly); @@ -1008,14 +753,11 @@ export function executionWorkspaceRepositoryService(db: Db) { const rows = await list(input.companyId, input.workspaceId); const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.companyId, agentId: input.agentId, runId: input.runId, - // local-board is a server identity, not a membership record. Preserve the - // agent's own authority rather than promoting it to a board actor. onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId }; const [issue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))); const [workspace] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId))); if (!issue || !workspace || issue.executionWorkspaceId !== workspace.id) throw forbidden("Task repository workspace binding is unavailable"); const anonymousOnly = await requiresAnonymousAcquisition(db, { issue, workspace: workspace ?? null, agentId: input.agentId, runId: input.runId }); - // Ordinary admission without acquisitions is owned by heartbeat, not this mutation service. if (!rows.length) { if (anonymousOnly) await reserveAdmissionRoot(input.companyId, input.cwd, "anonymous_only"); await reserveRootAccess({ companyId: input.companyId, cwd: input.cwd, reader: input, @@ -1030,7 +772,6 @@ export function executionWorkspaceRepositoryService(db: Db) { const root = await fs.realpath(input.cwd); const available = acquisitionRestricted ? [] : await catalog(input.companyId, input.responsibleUserId, input.responsibleUserId === "local-board"); const resolveAuth = createGitRemoteAuthProvider(db, input.companyId, { issueId: input.issueId, heartbeatRunId: input.runId, agentId: input.agentId, responsibleUserId: input.responsibleUserId }); - // Validate every receipt before reading or transferring any prepared bytes. const sources = rows.map(row => { assertAcquisitionReceipt(row, anonymousOnly); return resolveAcquisitionSource(row.catalogRepositoryId ? { kind: "catalog", id: row.catalogRepositoryId } : { kind: "url", url: row.repoUrl }, available, anonymousOnly); @@ -1043,8 +784,6 @@ export function executionWorkspaceRepositoryService(db: Db) { const repositoryRoot = path.join(root, ".paperclip-repositories"); if (!acquisitionRestricted) await fs.mkdir(repositoryRoot, { recursive: true }); if ((await fs.lstat(repositoryRoot)).isSymbolicLink() || await fs.realpath(repositoryRoot) !== repositoryRoot) throw conflict("Repository directory escapes the task workspace"); - // Incomplete clones belong to the runtime tree, which is excluded from - // transfer and recovery. The published namespace contains checkouts only. let stagingParent = root; if (!acquisitionRestricted) { for (const name of [".paperclip-runtime", "repository-staging"]) { @@ -1057,8 +796,6 @@ export function executionWorkspaceRepositoryService(db: Db) { throw conflict("Repository staging directory escapes the task workspace"); } } - // Self-ignore before any clone writes, including when the task directory - // is a subfolder of another checkout. Never follow a supplied ignore file. const ignorePath = path.join(stagingParent, ".gitignore"); const ignoreEntry = await fs.lstat(ignorePath).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; @@ -1088,7 +825,6 @@ export function executionWorkspaceRepositoryService(db: Db) { if (!metadata?.isDirectory() || metadata.isSymbolicLink() || await fs.realpath(gitDir) !== gitDir) throw conflict("Repository Git metadata escapes the managed checkout"); } if (acquisitionRestricted && !existing) throw forbidden(controllerAcquisitionMessage); - // Uncataloged URLs may use anonymous public Git only. Never borrow a user's/company's credential for an arbitrary URL. const anonymous = buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null }); anonymous.env = { ...anonymous.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }; if (!acquisitionRestricted) await db.update(executionWorkspaceRepositories).set({ state: "preparing", catalogRepositoryId: source.catalogRepositoryId, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id)); @@ -1131,7 +867,6 @@ export function executionWorkspaceRepositoryService(db: Db) { if (origin.url.toLowerCase() !== row.repositoryIdentity) throw conflict("Repository checkout belongs to a different origin"); const ownership = await readOwnership(); if (!authorizedPin && (ownership?.repositoryId !== row.id || !/^[a-f0-9]{40,64}$/.test(ownership.pinnedCommit))) throw conflict("Repository publication has no valid preparation receipt"); - // The pre-publication commit survives a crash before the database update. const pinnedCommit: string = authorizedPin ?? ownership.pinnedCommit; if (acquisitionRestricted) await git("cat-file", "-e", `${pinnedCommit}^{commit}`); const branchName = await git("symbolic-ref", "--quiet", "--short", "HEAD").catch(() => null); diff --git a/server/src/services/execution-workspace-source-roots.ts b/server/src/services/execution-workspace-source-roots.ts new file mode 100644 index 00000000000..36830eac7cf --- /dev/null +++ b/server/src/services/execution-workspace-source-roots.ts @@ -0,0 +1,164 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { eq } from "drizzle-orm"; +import { projectWorkspaces, executionWorkspaceRepositoryAccess, executionWorkspaces, type Db } from "@paperclipai/db"; +import { forbidden } from "../errors.js"; +import { localRepositoryPath } from "./local-repository-path.js"; + +const execFile = promisify(execFileCallback); +type DbTransaction = Parameters[0]>[0]; + +export async function canonicalRepositoryRoot(cwd: string): Promise { + const absolute = path.resolve(cwd); + try { return await fs.realpath(absolute); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = path.dirname(absolute); + if (parent === absolute) throw error; + return path.join(await canonicalRepositoryRoot(parent), path.basename(absolute)); + } +} + +export function repositoryRootsOverlap(left: string, right: string): boolean { + const contains = (parent: string, child: string) => { + const relative = path.relative(parent, child); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); + }; + return contains(left, right) || contains(right, left); +} + +export async function legacyRepositoryRootMayMatch(cwd: string, candidate: string): Promise { + const absolute = path.resolve(cwd); + let probe = absolute; + let suffix = ""; + let inaccessiblePrefix: string | null = null; + for (;;) { + try { + const canonical = await fs.realpath(probe); + if (repositoryRootsOverlap(path.join(canonical, suffix), candidate)) return true; + if (!inaccessiblePrefix) return false; + const unresolved = path.join(canonical, path.relative(probe, inaccessiblePrefix)); + return repositoryRootsOverlap(candidate, unresolved); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(code ?? "")) throw error; + if (code === "EACCES" || code === "EPERM") inaccessiblePrefix = probe; + const parent = path.dirname(probe); + if (parent === probe) return true; + suffix = path.join(path.basename(probe), suffix); + probe = parent; + } + } +} + +export async function configuredWorkspaceRoot(input: { cwd: string | null; repoUrl?: string | null }, scan: boolean): Promise { + const resolveDirectory = async (cwd: string): Promise => { + try { + const entry = await fs.stat(cwd); + if (!entry.isDirectory()) { + if (scan) return null; + throw forbidden("Workspace files cannot be detached from unverifiable provenance"); + } + return await fs.realpath(cwd); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || (scan && ["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(code ?? ""))) return null; + throw error; + } + }; + if (input.cwd) { + const cwd = await resolveDirectory(input.cwd); + if (cwd) return cwd; + } + let repositoryPath: string | null; + try { repositoryPath = input.repoUrl ? localRepositoryPath(input.repoUrl) : null; } + catch (error) { if (scan) return null; throw error; } + return repositoryPath ? resolveDirectory(repositoryPath) : null; +} + +export async function verifiedIsolatedWorktree(cwd: string, allowedOwnerRoots?: Set): Promise { + try { + const directory = await fs.lstat(cwd), marker = await fs.lstat(path.join(cwd, ".git")); + if (!directory.isDirectory() || directory.isSymbolicLink() || !marker.isFile() || marker.isSymbolicLink() || marker.size > 4096) return false; + const git = async (args: string[]) => (await execFile("git", ["-C", cwd, ...args], { + timeout: 30_000, env: { ...process.env, GIT_NO_LAZY_FETCH: "1" }, + })).stdout.trim(); + const checkoutRoot = await fs.realpath(await git(["rev-parse", "--show-toplevel"])); + if (checkoutRoot !== cwd) return false; + const commonRoot = await fs.realpath(path.resolve(checkoutRoot, await git(["rev-parse", "--git-common-dir"]))); + const gitRoot = await fs.realpath(await git(["rev-parse", "--absolute-git-dir"])); + if (gitRoot === commonRoot || (allowedOwnerRoots && !allowedOwnerRoots.has(commonRoot))) return false; + const registrations = (await git(["worktree", "list", "--porcelain", "-z"])).split("\0"); + return registrations.includes(`worktree ${checkoutRoot}`); + } catch { return false; } +} + +export async function unusedIsolatedCopyTarget(cwd: string): Promise { + const entry = await fs.lstat(cwd).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!entry) return true; + return entry.isDirectory() && !entry.isSymbolicLink() && (await fs.readdir(cwd)).length === 0; +} + +export type WorkspaceSourceCopyKind = "linked_worktree" | "repository"; + +export async function isolatedWorkspaceSourceRoots(db: Db | DbTransaction, companyId: string, sourceCwd: string, copyKind: WorkspaceSourceCopyKind = "linked_worktree"): Promise<{ roots: string[]; commonRoot: string }> { + const git = async (cwd: string, ...args: string[]) => (await execFile("git", ["-C", cwd, ...args], { + timeout: 30_000, env: { ...process.env, GIT_NO_LAZY_FETCH: "1" }, + })).stdout.trim(); + const localRoot = await fs.realpath(sourceCwd); + if (copyKind === "repository" && await git(sourceCwd, "rev-parse", "--is-bare-repository") === "true") { + const gitRoot = await fs.realpath(path.resolve(sourceCwd, await git(sourceCwd, "rev-parse", "--absolute-git-dir"))); + return { roots: [...new Set([localRoot, gitRoot])], commonRoot: gitRoot }; + } + 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]); + 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; + try { + const candidateGitRoot = await fs.realpath(await git(candidate, "rev-parse", "--absolute-git-dir")); + if (candidateGitRoot !== commonRoot) return false; + const candidateCheckout = await fs.realpath(await git(candidate, "rev-parse", "--show-toplevel")); + const candidateCommon = await fs.realpath(path.resolve(candidate, await git(candidate, "rev-parse", "--git-common-dir"))); + const relative = path.relative(candidateCheckout, candidate); + if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) + && candidateCommon === commonRoot) { + roots.add(candidateCheckout); + return true; + } + } catch { } + return false; + }; + if (!primaryIdentified) { + const registrations = (await git(checkoutRoot, "worktree", "list", "--porcelain", "-z")).split("\0"); + for (const registration of registrations) { + if (registration.startsWith("worktree ") && await considerPrimary(registration.slice("worktree ".length))) { + primaryIdentified = true; + break; + } + } + } + if (!primaryIdentified) { + const candidates = new Set(); + const configured = await db.select({ cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl }).from(projectWorkspaces) + .where(eq(projectWorkspaces.companyId, companyId)); + for (const workspace of configured) { + const cwd = await configuredWorkspaceRoot(workspace, true); + if (cwd) candidates.add(cwd); + } + const workspaces = await db.select({ cwd: executionWorkspaces.cwd }).from(executionWorkspaces).where(eq(executionWorkspaces.companyId, companyId)); + for (const workspace of workspaces) if (workspace.cwd) candidates.add(workspace.cwd); + const retained = await db.select({ cwd: executionWorkspaceRepositoryAccess.canonicalCwd }).from(executionWorkspaceRepositoryAccess) + .where(eq(executionWorkspaceRepositoryAccess.companyId, companyId)); + for (const entry of retained) candidates.add(entry.cwd); + for (const candidate of candidates) await considerPrimary(candidate); + } + return { roots: [...roots], commonRoot }; +} diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 704bcef7ef0..9c9711246a9 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -236,7 +236,6 @@ async function acquireExecutionWorkspaceLifecycleLock( const workspaceSourceKeys = ["cwd", "projectId", "projectWorkspaceId", "sourceIssueId"] as const; -/** Caller holds the company root fence and the current workspace row lock. */ async function preserveWorkspaceOwnershipForPatch( tx: DbTransaction, current: typeof executionWorkspaces.$inferSelect, patch: Partial, ) { @@ -1320,8 +1319,6 @@ type WorkspaceOverviewIssueRow = WorkspaceOverviewLinkedIssue & { const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseReadiness); -/** A failed response can be settled without a committed task result. Its durable - * output and resource obligations still fence root changes, including after Stop. */ async function hasPendingTaskWorkspaceRecovery(tx: DbTransaction, input: { companyId: string; issueId: string; excludeRunId?: string; }) { @@ -1338,7 +1335,6 @@ async function hasPendingTaskWorkspaceRecovery(tx: DbTransaction, input: { const [pendingLease] = await tx.select({ id: environmentLeases.id }).from(environmentLeases).where(and( eq(environmentLeases.companyId, input.companyId), inArray(environmentLeases.heartbeatRunId, runIds), or(and(isNull(environmentLeases.releasedAt), - // A successful warm release settles the old run while keeping its resource. sql`not coalesce(${environmentLeases.status} = 'retained' and ${environmentLeases.leasePolicy} = 'reuse_by_environment' and ${environmentLeases.cleanupStatus} = 'success', false)`), @@ -1350,7 +1346,6 @@ async function hasPendingTaskWorkspaceRecovery(tx: DbTransaction, input: { .from(workspaceOperations).where(and(eq(workspaceOperations.companyId, input.companyId), inArray(workspaceOperations.heartbeatRunId, runIds), eq(workspaceOperations.phase, "workspace_finalize"))) .orderBy(desc(workspaceOperations.createdAt), - // Equal timestamps cannot prove that success settled another attempt. asc(sql`case when ${workspaceOperations.status} = 'succeeded' then 1 else 0 end`)); const latest = new Map(); for (const operation of operations) { @@ -1359,14 +1354,11 @@ async function hasPendingTaskWorkspaceRecovery(tx: DbTransaction, input: { return rows.some(({ finalization, run }) => { const status = latest.get(run.id); if (status !== undefined) return status !== "succeeded"; - // Accepted output needs its persistence receipt, including terminal export - // repair. A successful unsafe-export omission is also a settled receipt. return finalization.phase === "terminal_failure" && finalization.resultId !== null; }); } export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) { - /** Resolve prospective source use from the same deterministic inputs as admission. */ async function resolveSourceWorkspaceStrategy(input: { companyId: string; issueId?: string | null; assigneeAgentId?: string | null; assigneeAdapterOverrides?: unknown; executionWorkspaceSettings?: unknown; @@ -1401,7 +1393,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic return resolveEffectiveWorkspaceStrategyType(mode, config); } - /** Validate intent without allocating files or changing a task binding. */ async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; assigneeAdapterOverrides?: unknown; projectId?: string | null; executionPolicy?: unknown }, reader: Db | DbTransaction = db) { const selection = taskWorkspaceSelectionSchema.parse(input.selection); if (selection.kind === "existing") { @@ -1480,12 +1471,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic if (task.workspaceBindingRevision !== input.expectedBindingRevision) throw conflict("Task workspace binding changed; inspect it before selecting again"); await validateSelection(input, tx); 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. - // 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, @@ -1518,16 +1503,12 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic async function bindTaskWorkspace(companyId: string, issueId: string, workspaceId: string, patch: TaskWorkspaceBindingPatch = {}) { return db.transaction(async tx => { - // Serialize binding and metadata writes without blocking unchanged-ID foreign-key reads. const [task] = await tx.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))).for("no key update"); const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId))).for("no key update"); if (!task || !workspace) throw notFound("Task workspace unavailable"); - // Retain confidentiality even after a task is moved to another root. await tx.update(executionWorkspaces).set({ metadata: { ...workspace.metadata, _issuePrivacySources: { ...(workspace.metadata?._issuePrivacySources as Record ?? {}), [task.id]: true } } }).where(eq(executionWorkspaces.id, workspaceId)); const nextRevision = task.workspaceBindingRevision + (task.executionWorkspaceId === workspaceId ? 0 : 1); - // Realizing the current run's first folder must not invalidate an already - // accepted choice for its next run. Keep the original request receipt for retries. const pending = task.workspacePendingSelection; const pendingAfterFirstBinding = !task.executionWorkspaceId && pending?.expectedBindingRevision === task.workspaceBindingRevision ? { ...pending, expectedBindingRevision: nextRevision } @@ -1539,7 +1520,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic }); } - /** Ordinary issue edits participate in the same binding revision transaction. */ async function assertTaskWorkspaceUpdateAccess(input: { task: typeof issues.$inferSelect; actor: AuthorizationActor; }, tx: DbTransaction) { @@ -3237,8 +3217,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .where(eq(executionWorkspaces.id, id)); if (!identity) return null; return db.transaction(async tx => { - // Repository publication takes the company fence before task/workspace - // row locks. Keep that order while retaining the old physical owner. await lockExecutionWorkspaceRootProvenance(tx, identity.companyId); const [current] = await tx.select().from(executionWorkspaces).where(and( eq(executionWorkspaces.id, id), eq(executionWorkspaces.companyId, identity.companyId), @@ -3253,10 +3231,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic // Reopen one closed isolated execution workspace so an authorized issue can // use it again. The caller must first authorize the request on the issue. - // Source privacy is retained before the lifecycle transaction, which rechecks - // the binding and rebuilds under its per-workspace fence. Read-only copy - // checks avoid taking the root fence in reverse order. Final provenance is - // committed after lifecycle release, including failed provisioning. reopenClosedIsolatedExecutionWorkspaceForIssue: async (input: { workspaceId: string; issue: { id: string; companyId: string; projectId: string | null }; @@ -3283,8 +3257,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic ] = await Promise.all([ import("./workspace-runtime.js"), import("./workspace-operations.js"), - // heartbeat.js imports this module, so a static import creates a - // cycle. Load ensureManagedProjectWorkspace dynamically instead. import("./heartbeat.js"), ]); const [projectWorkspace, projectPolicy] = await Promise.all([ @@ -3304,13 +3276,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .where(and(eq(projects.companyId, snapshot.companyId), snapshot.projectId ? eq(projects.id, snapshot.projectId) : sql`false`)) .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)), ]); - // Resolve the base checkout that the rebuild spawns git in. A - // local-folder project stores its base path in projectWorkspaces.cwd. - // A managed_checkout project stores null there, so resolve its live - // managed checkout instead. Never use snapshot.cwd for a git_worktree - // rebuild: snapshot.cwd is the archived worktree path, which the reaper - // already removed from disk. A spawn in that missing directory fails - // with "spawn git ENOENT" and hides the real cause. let resolvedBaseCwd = projectWorkspace?.cwd ?? null; if (isClosedExecutionWorkspaceStatus(snapshot.status) && resolvedBaseCwd == null && snapshot.strategyType === "git_worktree" && snapshot.projectId) { try { @@ -3336,9 +3301,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" }; } } - // This transaction commits deny-only ownership before any copy. Holding - // the lifecycle fence while acquiring the company root fence would invert - // archive's lock order and roll ownership back after failed Git writes. let capturedSource: IsolatedWorkspaceSourceAccess | null = null; let authorizedTarget: string | null = null; if (isClosedExecutionWorkspaceStatus(snapshot.status) && snapshot.strategyType === "git_worktree") { @@ -3361,8 +3323,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic } const result = await db.transaction(async (tx): Promise => { await acquireExecutionWorkspaceLifecycleLock(tx, input.workspaceId); - // Organizational project assignment does not identify the source of files. - // Reopen only the task's persisted binding, with current source access. const [boundTask] = await tx.select({ id: issues.id }).from(issues).where(and( eq(issues.id, issue.id), eq(issues.companyId, issue.companyId), eq(issues.executionWorkspaceId, input.workspaceId), @@ -3380,7 +3340,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic )) .then((rows) => rows[0] ?? null); if (!row) { - // Wrong company, inaccessible source, wrong mode, or missing. Fail closed and // disclose no workspace detail. return { ok: false, code: "not_reopenable", message: "Execution workspace is not reopenable" }; } @@ -3417,8 +3376,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic || await canonicalRepositoryRoot(targetCwd) !== authorizedTarget) { throw forbidden("Execution workspace copy identity changed during reopen"); } - // Read-only checks under the lifecycle fence cannot acquire the - // root mutation fence. The target already retains the captured owners. for (const cwd of [...capturedSource!.roots, authorizedTarget!]) await assertExecutionWorkspaceRootReadAccess(tx, { companyId: row.companyId, cwd, actor: input.authorizationActor, }); @@ -3598,8 +3555,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic actor: input.authorizationActor, captured: capturedSource, issueId: issue.id, phase: "verified", }); } catch { - // The route cannot consume a denied reopen. Release only the fence - // published by this attempt; a newer reopen keeps its own generation. if (result.ok && result.reopened) { await executionWorkspaceService(db).clearReopenPendingConsumptionForUnconsumedReopen({ workspaceId: snapshot.id, issue, actor, expectedGeneration: result.generation, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 75599415cf5..fa5f08bacf4 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2235,8 +2235,6 @@ export function heartbeatService( experimentalInstanceSettings.enableIsolatedWorkspacesByDefault; const [taskWorkspaceIntentRow] = issueId ? await db.select({ intent: issues.workspaceSelection }).from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) : []; - // Typed selections and durable bindings are authoritative even when the - // legacy isolated-workspace UI is disabled. Never downgrade isolation. const runtimeWorkspaceSelectionEnabled = taskWorkspaceRuntimeSelectionEnabled({ legacyUiEnabled: isolatedWorkspacesEnabled, hasTypedSelection: Boolean(taskWorkspaceIntentRow?.intent), hasBinding: Boolean(issueContext?.executionWorkspaceId) }); const parsedIssueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings( @@ -2285,8 +2283,6 @@ export function heartbeatService( : null; const taskProjectId = issueContext ? issueContext.projectId : contextProjectId; const taskProjectContext = await loadProjectContext(taskProjectId); - // Organizational placement supplies environment and trust, not an - // implicit filesystem source when that project has no configured files. const executionProjectId = boundSourceWorkspace ? boundSourceWorkspace.projectId : taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory" ? null : selectedWorkspaceSource?.projectId ?? (taskProjectContext?.hasWorkspace ? taskProjectId : null); @@ -2404,9 +2400,6 @@ export function heartbeatService( ); issueContext = { ...issueContext, responsibleUserId }; } - // Legacy unbound tasks still inherit explicit organizational workspace - // requirements even when that project has no files to use as a source. - // A typed choice or durable binding keeps its own source policy. const workspacePolicyContext = workspaceProjectContext ?? ( !boundSourceWorkspace && !taskWorkspaceIntentRow?.intent && !issueContext?.projectWorkspaceId ? taskProjectContext : null @@ -2437,7 +2430,6 @@ export function heartbeatService( project: taskProjectContext ? { companyId: agent.companyId, executionWorkspacePolicy: taskProjectContext.executionWorkspacePolicy } : null, - // Selecting files cannot erase the task's policy or relax the source's. workspaceSourceProject: workspaceProjectContext && workspaceProjectContext.id !== taskProjectContext?.id ? { companyId: agent.companyId, executionWorkspacePolicy: workspaceProjectContext.executionWorkspacePolicy } : null, @@ -2757,8 +2749,6 @@ export function heartbeatService( } const remoteExecution = realizationResult.remoteExecution; if (nativeChatWorkspaceScope && materializeNativeChatWorktree) { - // The fresh plan is not filesystem authority. Validate the realized, - // durable task-owned worktree before exposing it to the native runner. nativeChatExpectedCwd = nativeChatWorkspaceCwd(nativeChatWorkspaceScope, persistedExecutionWorkspace, true); } if ( diff --git a/server/src/services/heartbeat/environment-selection.ts b/server/src/services/heartbeat/environment-selection.ts index e205dd3ddaa..6af79911b67 100644 --- a/server/src/services/heartbeat/environment-selection.ts +++ b/server/src/services/heartbeat/environment-selection.ts @@ -134,7 +134,6 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron if (workspaceRequiringAccess) { await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true, issueId }); } else if (!isDotRun && executionProjectId && (hasSelectedWorkspaceSource || workspaceProjectContext?.hasWorkspace)) { - // Check source authority before resolution can clone or expose files. await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, sourceWorkspaceConfig) !== "git_worktree", issueId }); } } @@ -412,8 +411,6 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron ); } } - // Once bound, retained workspace authorization above is authoritative. - // The original source selection may outlive its configuration row. if (taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) { await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId, actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId }, diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 2f42681ef75..0fddf246d3d 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -291,8 +291,6 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", spec: { host: "fixture.invalid", port: 22, username: "fixture", remoteWorkspacePath: remoteCwd, privateKey: null, knownHosts: null, strictHostKeyChecking: true, remoteCwd } } : { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd }; - // Double the external transport boundary, while retaining real workspace, - // database, lease and descriptor handling. Driver hints alone cannot decide. const realize = input.services.envOrchestrator.realizeForRun; vi.spyOn(input.services.envOrchestrator, "realizeForRun").mockImplementation(async args => ({ ...await realize(args), executionTarget: target, @@ -329,7 +327,6 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow(descriptor === "absent" ? "native_workspace_sync_descriptor_binding_missing" : "native_workspace_sync_descriptor_digest_mismatch"); expect(reserve).not.toHaveBeenCalled(); - // The outer run owner retains its normal terminalization/lease cleanup path. expect(await leases(input.run.id)).toMatchObject([{ status: "active" }]); } expect(prepare).not.toHaveBeenCalled(); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index f681cd6d1d2..ed6aaa5984c 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -476,8 +476,6 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac ); const isolatedWorkspaceSourceActor = { type: "agent" as const, source: "agent_jwt" as const, companyId: agent.companyId, agentId: agent.id, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId }; - // Capture the source's ownership before Git can copy tracked bytes. Keep the - // server-owned snapshot so a concurrent deletion cannot erase copied privacy. const isolatedWorkspaceSourceAccess = !isDotRun && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId && (latestWorkspaceStrategyType === "git_worktree" || (workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree")) @@ -937,8 +935,6 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac const paths = new Map(repositories.map((repo) => [repo.workspaceId, repo.cwd])); projectRepositoryPaths.push(...repositories.map((repo) => path.relative(executionWorkspace.cwd, repo.cwd))); if (resolvedWorkspace.workspaceId) paths.set(resolvedWorkspace.workspaceId, executionWorkspace.cwd); - // Reused roots may have no transient hints. Rebuild from the currently - // authorized source rows and the paths actually prepared for this admission. resolvedWorkspace.workspaceHints = repositoryRows.map((source) => ({ workspaceId: source.id, cwd: paths.get(source.id) ?? readNonEmptyString(source.cwd), @@ -1108,8 +1104,6 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace); const workspaceRealization = realizationResult.workspaceRealization; const executionTarget = realizationResult.executionTarget; - // SSH uses its remote filesystem directly; only local and sandbox runs - // expose or transfer the host root protected by repository reservations. if (!isDotRun && issueId && (persistedNativeExecutionInput || nativeRecoveryExecutionWorkspaceId) && !(executionTarget?.kind === "remote" && executionTarget.transport === "ssh")) { const workspaceId = persistedNativeExecutionInput?.binding.executionWorkspaceId ?? nativeRecoveryExecutionWorkspaceId!; @@ -1117,8 +1111,6 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac if (!reference && executionTarget?.kind === "remote" && executionTarget.transport === "sandbox") { throw new Error("native_workspace_sync_descriptor_binding_missing"); } - // Recovery keeps admitted files and trust even when the task now selects - // another workspace. Reserve only; never acquire newly requested repositories. const cwd = reference ? await readNativeWorkspaceSyncLocalCwd({ companyId: agent.companyId, runId: run.id, workspaceId, reference }) : persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6" diff --git a/server/src/services/heartbeat/workspaces.ts b/server/src/services/heartbeat/workspaces.ts index 93656e3c346..ba396795a66 100644 --- a/server/src/services/heartbeat/workspaces.ts +++ b/server/src/services/heartbeat/workspaces.ts @@ -494,7 +494,6 @@ export async function ensureManagedProjectWorkspace(input: { }); managedCheckoutMaterializations.set(cwd, attempt); const result = await attempt; - // Covers the materializer's existing checkout and competing-publisher paths. await guardLocalSource?.(cwd); if (input.repoUrl) { // A different server process can publish a same-name checkout between the @@ -516,8 +515,6 @@ export async function prepareProjectRepositoryWorkspaces(input: { workspaces: Array & { cwd?: string | null }>; resolveGitAuth?: GitRemoteAuthProvider | null; beforePublish?: () => Promise; - /** Authorize and retain the configured local source before cloning or reuse. - * The returned guard rechecks that same source envelope before publication. */ prepareLocalSource?: (sourceCwd: string) => Promise<() => Promise>; }): Promise> { const identity = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, ""); @@ -546,11 +543,7 @@ export async function prepareProjectRepositoryWorkspaces(input: { const localSource = configuredLocalSource && await fs.stat(configuredLocalSource).then((entry) => entry.isDirectory()).catch(() => false) ? configuredLocalSource : null; - // A URL-only clone can still read this host. Keep its committed-only clone - // behavior, but authorize the physical file source before any Git read. const urlLocalSource = localRepositoryPath(repoUrl); - // Run before the materializer's existing-checkout shortcut too. A sibling - // added after the first admission still contributes private source bytes. const sourceToAuthorize = localSource ?? urlLocalSource; const recheckLocalSource = sourceToAuthorize ? await input.prepareLocalSource?.(sourceToAuthorize) : undefined; @@ -560,8 +553,6 @@ export async function prepareProjectRepositoryWorkspaces(input: { }; const result = await materializeManagedProjectWorkspace(cwd, { repoUrl, repoRef: workspace.repoRef, localSource, resolveGitAuth: input.resolveGitAuth, beforePublish }); if (result.warning) throw new Error(result.warning); - // Existing clones and a competing publisher can return without invoking - // beforePublish. Reauthorize before exposing either result to the run. await beforePublish(); results.push({ workspaceId: workspace.id, cwd, repoUrl, repoRef: workspace.repoRef }); } @@ -2809,10 +2800,6 @@ function buildSessionConfigCategoryValues(input: { // boundary; the reusable row and its evolving generation are state. delete workspaceConfig.existingExecutionWorkspace; delete workspaceConfig.reusableExecutionWorkspaceConfig; - // Binding the first realized workspace persists its already-effective mode - // on the issue. That receipt must not rotate the session on the next turn. - // requestedMode/effectiveMode still fingerprint mode changes, and all other - // issue settings remain part of the compatibility boundary. const issueSettings = { ...parseObject(workspaceConfig.issueSettings) }; if (issueSettings.mode === workspaceConfig.requestedMode) delete issueSettings.mode; workspaceConfig.issueSettings = Object.keys(issueSettings).length ? issueSettings : null; @@ -3464,8 +3451,6 @@ export function createHeartbeatWorkspaceResolver(db: Db) { unorderedProjectWorkspaceRows, preferredProjectWorkspaceId, ); - // A durable source choice names the filesystem authority, not a preference - // among the project's repositories. Never label another checkout with it. const projectWorkspaceRows = configuredSourceId ? prioritizedWorkspaceRows.filter((workspace) => workspace.id === configuredSourceId) : prioritizedWorkspaceRows; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 9447ed16e61..793207036c0 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10129,8 +10129,6 @@ export function issueService(db: Db) { projectWorkspaceId = workspaceSource.projectWorkspaceId; inheritedWorkspaceSelection = true; } - // Sharing an existing task binding preserves parent files even when - // the optional isolated-worktree controls are disabled. if ( inheritsSourceProject && !hasExplicitExecutionWorkspaceOverride && @@ -10179,8 +10177,6 @@ export function issueService(db: Db) { workspaceIntent = { version: 1, selection: workspaceSelection, source: workspaceSelectionSource }; } if (executionWorkspaceId) { - // A durable binding owns its source, including a deliberately absent - // source. Organizational defaults and legacy hints cannot replace it. const workspace = await assertValidExecutionWorkspace(companyId, null, executionWorkspaceId, tx); projectWorkspaceId = workspace.projectWorkspaceId; } @@ -10414,7 +10410,6 @@ export function issueService(db: Db) { projectGoalId, defaultGoalId: defaultCompanyGoal?.id ?? null, }), - // Nulls from canonical selection must clear conflicting legacy input. projectWorkspaceId, executionWorkspaceId, executionWorkspacePreference, @@ -11284,9 +11279,6 @@ export function issueService(db: Db) { .then((rows: Array) => rows[0] ?? null); if (!updated) return null; if (workspaceSelectionActor && updated.workspaceBindingRevision !== receiptExisting.workspaceBindingRevision) { - // Authorize the persisted target inside this transaction so a combined - // assignment/workspace edit checks the actual new assignee. A denial - // rolls back the binding, revision and retained-privacy trigger too. await executionWorkspaceService(db).assertTaskWorkspaceUpdateAccess({ task: updated, actor: workspaceSelectionActor, }, tx); diff --git a/server/src/services/local-repository-path.ts b/server/src/services/local-repository-path.ts index 93fb8043c5f..90aeb37231a 100644 --- a/server/src/services/local-repository-path.ts +++ b/server/src/services/local-repository-path.ts @@ -1,7 +1,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -/** Resolve only Git transports that read the local host filesystem. */ export function localRepositoryPath(repoUrl: string): string | null { if (/^file:/i.test(repoUrl.trim())) { const localUrl = new URL(repoUrl); diff --git a/server/src/services/managed-repository-checkout.ts b/server/src/services/managed-repository-checkout.ts index 3353405281b..f8810406f06 100644 --- a/server/src/services/managed-repository-checkout.ts +++ b/server/src/services/managed-repository-checkout.ts @@ -12,7 +12,6 @@ import { isWorkspaceGitScanError, WorkspaceGitScanError } from "./workspace-git- const execFile = promisify(execFileCallback); const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; -/** Atomically publish a complete managed checkout; never delete a competing checkout. */ export async function materializeManagedProjectWorkspace( cwd: string, input: { @@ -20,11 +19,8 @@ export async function materializeManagedProjectWorkspace( repoRef?: string | null; localSource?: string | null; resolveGitAuth?: GitRemoteAuthProvider | null; - /** Protect the fresh staging directory before Git reads any local source bytes. */ beforeCopy?: (cloneCwd: string) => Promise; - /** Internal preparation receipt is committed inside the clone before atomic publication. */ beforePublish?: (cloneCwd: string) => Promise; - /** Existing, validated private staging parent outside published repositories. */ stagingParent?: string; }, ): Promise<{ cwd: string; warning: string | null }> { @@ -58,10 +54,6 @@ export async function materializeManagedProjectWorkspace( await fs.rm(cwd, { recursive: true, force: true }); } - // Clone into a private temp directory (a sibling by default), then publish atomically. The target - // is never created in a partial state and never removed on failure, so a concurrent - // materialization (another process, or a run racing this one) can neither adopt a broken - // checkout nor lose its own completed one. const auth = input.resolveGitAuth && !input.localSource ? await input.resolveGitAuth(input.repoUrl) : null; @@ -76,10 +68,6 @@ export async function materializeManagedProjectWorkspace( [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", "--", input.localSource ?? input.repoUrl, cloneTmpDir], { env: { - // Spread order matters: the sanitizer strips PAPERCLIP_*, which would remove the - // credential-helper token env if it came first. GIT_TERMINAL_PROMPT=0 fails a - // credential-less private clone immediately instead of hanging on a prompt until - // the clone timeout. ...sanitizeRuntimeServiceBaseEnv(process.env), GIT_TERMINAL_PROMPT: "0", ...(auth?.env ?? {}), @@ -122,8 +110,6 @@ export async function materializeManagedProjectWorkspace( const message = scrubGitCredentialText( `Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`, ); - // Preserve the closed failure code without copying subprocess output or - // credentials into the durable run. Setup recovery needs the actual cause. if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message); const connectionFailure = readGitConnectionFailure(error); if (connectionFailure) throw new GitConnectionFailureError(message, connectionFailure); @@ -136,8 +122,6 @@ export async function materializeManagedProjectWorkspace( await fs .rm(cloneTmpDir, { recursive: true, force: true }) .catch(() => undefined); - // The target appearing between the emptiness check and the rename means another - // materialization won the race; adopt its checkout instead of failing the run. if (await hasAdoptableGitDir()) { return { cwd, warning: null }; } @@ -151,8 +135,6 @@ export async function materializeManagedProjectWorkspace( } -/** The container is independently captured, never an accidental Git submodule. - * A plain task root must not discover and mutate an ancestor repository. */ export async function ensureManagedRepositoriesIgnored(cwd: string): Promise { const gitEntry = await fs.lstat(path.join(cwd, ".git")).catch(() => null); if (!gitEntry) return; diff --git a/server/src/services/native-runtime/native-chat-workspace.ts b/server/src/services/native-runtime/native-chat-workspace.ts index 24d0f23f86c..e9432818589 100644 --- a/server/src/services/native-runtime/native-chat-workspace.ts +++ b/server/src/services/native-runtime/native-chat-workspace.ts @@ -30,7 +30,6 @@ export async function findNativeChatWorkspaceScope( agentId: string; issueId: string | null; instanceRoot?: string; - /** Immutable admitted input only; never a wake or prompt path. */ admittedCwd?: string | null; }, ): Promise { diff --git a/server/src/services/native-runtime/native-workspace-sync.ts b/server/src/services/native-runtime/native-workspace-sync.ts index acad5392af6..6841f9f132f 100644 --- a/server/src/services/native-runtime/native-workspace-sync.ts +++ b/server/src/services/native-runtime/native-workspace-sync.ts @@ -1091,7 +1091,6 @@ export async function prepareNativeWorkspaceSync(input: { } } -/** Read the admitted host root without reacquiring or transferring workspace files. */ export async function readNativeWorkspaceSyncLocalCwd(input: { companyId: string; runId: string; diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 786c42b4f44..cdd88c948c1 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -41,7 +41,6 @@ import { lockExecutionWorkspaceRootProvenance, preserveConfiguredProjectWorkspac type ProjectTransaction = Parameters[0]>[0]; -/** Root provenance fences precede project/workspace locks in every caller. */ async function lockProjectRootMutation(tx: ProjectTransaction, projectId: string) { const [identity] = await tx.select({ companyId: projects.companyId }).from(projects).where(eq(projects.id, projectId)); if (!identity) return null; diff --git a/server/src/services/runtime-service-env.ts b/server/src/services/runtime-service-env.ts index 306e21ae544..2deb8ee7645 100644 --- a/server/src/services/runtime-service-env.ts +++ b/server/src/services/runtime-service-env.ts @@ -5,9 +5,6 @@ export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJ delete env[key]; } } - // These origin settings belong to the parent instance. Letting them leak into a - // managed worktree runtime can send auth cookies and OAuth callbacks to the wrong - // Paperclip instance. Runtime/service overrides are merged back after sanitizing. delete env.BETTER_AUTH_URL; delete env.BETTER_AUTH_BASE_URL; delete env.DATABASE_URL; diff --git a/server/src/services/task-workspace-selection.ts b/server/src/services/task-workspace-selection.ts index f3f8f7e8b3f..c10e2f7e783 100644 --- a/server/src/services/task-workspace-selection.ts +++ b/server/src/services/task-workspace-selection.ts @@ -1,7 +1,6 @@ import { and, inArray, isNull } from "drizzle-orm"; import { executionWorkspaces } from "@paperclipai/db"; -/** Lifecycle eligibility shared by discovery, selection and repository requests; callers add scope and privacy. */ export function taskWorkspaceSelectableCondition() { return and( inArray(executionWorkspaces.status, ["active", "idle"]), diff --git a/server/src/services/task-workspace-source-access.ts b/server/src/services/task-workspace-source-access.ts index 68cfa9f8e4e..cf226305110 100644 --- a/server/src/services/task-workspace-source-access.ts +++ b/server/src/services/task-workspace-source-access.ts @@ -7,7 +7,6 @@ import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./a type DbTransaction = Parameters[0]>[0]; type WorkspaceAccessContext = { write?: boolean; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null }; -/** Filesystem source scope is independent of a task's organizational project. */ export async function assertTaskWorkspaceSourceProjectAccess( db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: WorkspaceAccessContext = {}, ) { @@ -17,8 +16,6 @@ export async function assertTaskWorkspaceSourceProjectAccess( }); if (!decision.allowed) throw forbidden(decision.explanation); if (options.write) { - // Sharing a configured root gives the task write access to project files. - // Reuse project-scoped grants without reassigning this task or its agent. const [task] = actor.type === "board" && options.issueId ? await db.select({ assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues) .where(and(eq(issues.id, options.issueId), eq(issues.companyId, companyId))) @@ -29,15 +26,12 @@ export async function assertTaskWorkspaceSourceProjectAccess( actor, action: "project:write_workspace", resource: { type: "issue", companyId, projectId, assigneeAgentId, assigneeUserId, issueId: options.issueId, parentIssueId: options.parentIssueId }, - // Lineage proves low-trust containment; grants must still cover the source - // project independently of the task's organizational project. scope: { projectId, assigneeAgentId, assigneeUserId }, }); if (!assignment.allowed) throw forbidden(assignment.explanation); } } -/** Retained files keep every task's authorization boundary after rebinding. */ export async function assertTaskWorkspaceAccess( db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, options: WorkspaceAccessContext = {}, ) { diff --git a/server/src/services/task-workspace-tools.ts b/server/src/services/task-workspace-tools.ts index 5a97a0991d4..d2dfa72e463 100644 --- a/server/src/services/task-workspace-tools.ts +++ b/server/src/services/task-workspace-tools.ts @@ -6,7 +6,6 @@ function repositoryView(repo: Record) { state: repo.state, pinnedCommit: repo.pinnedCommit, branchName: repo.branchName }; } -/** Transport only: authorization, receipts, and workspace decisions remain in ordinary APIs. */ export async function callTaskWorkspaceTool(input: { name: string; arguments: Record; apiUrl: string; token: string; companyId: string; issueId: string; workspaceRoot?: string }) { let route = `/issues/${input.issueId}/workspace`; let method = "GET"; @@ -23,7 +22,6 @@ export async function callTaskWorkspaceTool(input: { name: string; arguments: Re if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Workspace tool failed (${response.status})`); if (input.name === "prepare_repository") { const { operationId, ...rest } = result; - // PRP reserves operationId for the semantic operation name. return { ...rest, ...(result.repository ? { repository: repositoryView(result.repository) } : {}), preparationId: operationId }; } if (input.name === "get_workspace") { diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index 5f73992bdc1..d7ec8c44dbd 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -1056,7 +1056,6 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor) if (!rows.length) continue; if (candidate.rootPath && (!candidate.remote || candidate.provider === "local_fs" || LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES.has(candidate.provider))) { try { - // Pin the physical root used by every subsequent file operation. const canonicalCwd = await fs.realpath(candidate.rootPath); await assertExecutionWorkspaceRootReadAccess(db, { companyId: candidate.companyId, cwd: canonicalCwd, actor, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index a354e18d94e..b4b32280af2 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -2706,16 +2706,11 @@ async function resolveGitOwnerRepoRoot(cwd: string): Promise { try { const commonDir = await fs.realpath(path.resolve(checkoutRoot, await runGit(["rev-parse", "--git-common-dir"], checkoutRoot))); const candidate = path.dirname(commonDir); - // A conventional .git directory belongs to its parent checkout. Separate - // Git storage can live inside an unrelated repository, so verify both the - // checkout boundary and metadata identity before using that parent. const candidateRoot = await fs.realpath(path.resolve(await runGit(["rev-parse", "--show-toplevel"], candidate))); const candidateGitDir = await fs.realpath(path.resolve(candidate, await runGit(["rev-parse", "--absolute-git-dir"], candidate))); const candidateCommonDir = await fs.realpath(path.resolve(candidate, await runGit(["rev-parse", "--git-common-dir"], candidate))); if (candidateRoot === candidate && candidateGitDir === commonDir && candidateCommonDir === commonDir) return candidateRoot; } catch { - // A main or linked checkout is itself a valid Git command anchor, even - // when separate metadata does not identify a primary checkout directory. } return checkoutRoot; } @@ -3337,7 +3332,6 @@ 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; @@ -3799,7 +3793,6 @@ 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; diff --git a/tests/runner-e2e/task-workspaces-cases.ts b/tests/runner-e2e/task-workspaces-cases.ts index a1ab5438f3c..ab8a80f4b1d 100644 --- a/tests/runner-e2e/task-workspaces-cases.ts +++ b/tests/runner-e2e/task-workspaces-cases.ts @@ -10,7 +10,6 @@ export function taskWorkspaceFiles(nonce: string) { noteBytes: (turn: number) => `task-files-turn-${turn}-${nonce}\n`, repositoryBytes: `repository-dirty-${nonce}\n` }; } -/** Remove the legacy operator cwd so this suite actually exercises default admission. */ export function taskWorkspaceProfile(profile: RunnerProfileFixture): RunnerProfileFixture { return { ...profile, buildAgent(input) { const agent = profile.buildAgent(input); diff --git a/tests/runner-e2e/task-workspaces-flow.ts b/tests/runner-e2e/task-workspaces-flow.ts index b736579130f..b7add6b59f6 100644 --- a/tests/runner-e2e/task-workspaces-flow.ts +++ b/tests/runner-e2e/task-workspaces-flow.ts @@ -40,7 +40,6 @@ export async function runTaskWorkspacesFlow(input: { issue = await api.get(`/api/issues/${issue.id}`); const listed = await api.get(`${companyPath}/heartbeat-runs?limit=100`); runs = await Promise.all(listed.map(run => api.get(`/api/heartbeat-runs/${run.id}`))); - // A synthetic company contains only this task. Extra/background work is counted, never hidden. runs.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)) || a.id.localeCompare(b.id)); evidence.runCount = runs.length; } @@ -150,7 +149,6 @@ export async function runTaskWorkspacesFlow(input: { evidence.initial = { workspaceId: before.workspace.id, cwd: await realpath(before.workspace.cwd), repositories: before.repositories.length }; await save("initial-admission"); await settled(1); await checkpoint(1); - // Controller lifecycle only: the original provider turn is already settled. const issueUrl = page.url(); await restartChatServer(page, async () => { await input.restart(); }); evidence.restarted = true; diff --git a/tests/runner-e2e/task-workspaces-scoring.ts b/tests/runner-e2e/task-workspaces-scoring.ts index 87737f63eec..205ec6f80d8 100644 --- a/tests/runner-e2e/task-workspaces-scoring.ts +++ b/tests/runner-e2e/task-workspaces-scoring.ts @@ -25,7 +25,6 @@ export interface TaskWorkspaceObservation { remoteLeaseRunIds: string[]; } -/** Every check uses independent persisted/API/filesystem evidence, never model assertions. */ export function gradeTaskWorkspaces(value: TaskWorkspaceObservation) { const checks: Array<{ id: string; passed: boolean; detail: string }> = []; const check = (id: string, passed: boolean, detail: string) => checks.push({ id, passed, detail }); @@ -37,8 +36,6 @@ export function gradeTaskWorkspaces(value: TaskWorkspaceObservation) { check("projectless-task-owned-root", complete && value.projectCount === 0 && value.checkpoints.every(p => p.issue.projectId === null && p.workspace.projectId === null && p.confinedTaskRoot && p.issue.executionWorkspaceId === p.workspace.id), "Task and execution workspace remain projectless, with a confined company/task directory rather than agent home."); - // The provider may enqueue its receipt before the first browser observation. - // Deferred acquisition is checked at the settled first-turn checkpoint below. check("immutable-root-and-binding", complete && value.initial !== null && value.checkpoints.every(p => p.workspace.id === value.initial!.workspaceId && p.workspace.cwd === value.initial!.cwd), "The independently observed initial root and binding survive all three admissions."); @@ -75,7 +72,6 @@ export function gradeTaskWorkspaces(value: TaskWorkspaceObservation) { return checks; } -/** Count durable runner receipts, including the provider's normalized tool name. */ export function isRepositoryPreparationReceipt(event: { eventType?: string; payload?: { prpEvent?: { sourceKind?: string; runId?: string; payload?: { name?: string; status?: string } } }; diff --git a/ui/src/pages/apps/chat/ChatExecutionDefaults.test.tsx b/ui/src/pages/apps/chat/ChatExecutionDefaults.test.tsx index 2d6f2048117..1418456bb3d 100644 --- a/ui/src/pages/apps/chat/ChatExecutionDefaults.test.tsx +++ b/ui/src/pages/apps/chat/ChatExecutionDefaults.test.tsx @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx b/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx index 4af5d358e69..cc60f61571c 100644 --- a/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx +++ b/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx @@ -15,7 +15,6 @@ function selectionKey(value: TaskWorkspaceSelection | null | undefined): string return `source:${value.projectWorkspaceId}:${value.mode}`; } -/** The same optional editor is used by the connection and individual destinations. */ export function ChatExecutionDefaults({ companyId, value, resource = false, onSave }: { companyId: string; value: Defaults | null | undefined; diff --git a/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx b/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx index b6246aaba58..ef3706234d8 100644 --- a/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx +++ b/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx @@ -9,7 +9,7 @@ import { PluginLauncherProvider } from "@/plugins/launchers"; import { ChatEndpointDetail } from "@/pages/apps/chat/ChatEndpointDetail"; import { useCompany } from "@/context/CompanyContext"; import type { ChatEndpoint, ChatEndpointResource, ChatIdentityLink } from "@/api/chatEndpoints"; -import { storybookAgents } from "../../../fixtures/paperclipData"; +import { storybookAgents, storybookExecutionWorkspaces } from "../../../fixtures/paperclipData"; type Scenario = "populated" | "empty" | "error" | "channel-added" | "unlinked" | "invitation-expired"; function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?: string; scenario?: Scenario }) { @@ -45,6 +45,10 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab? const json = (data: unknown) => Response.json(data); if (path === `/api/agents/${agent.id}`) return json(agent); if (path === "/api/companies/company-storybook/agents") return json([agent, ...storybookAgents.slice(1)]); + if (path === "/api/companies/company-storybook/execution-workspaces" && url.searchParams.get("selectableForTask") === "true") { + return json(storybookExecutionWorkspaces.filter(workspace => + workspace.closedAt == null && ["active", "idle"].includes(workspace.status))); + } if (path === "/api/chat-endpoints/chat-management") { if (init?.method === "PATCH") endpoint = { ...endpoint, ...JSON.parse(String(init.body)) }; return json(endpoint); diff --git a/ui/storybook/support/github-bot-management/fixture-api.test.tsx b/ui/storybook/support/github-bot-management/fixture-api.test.tsx index 65a2fc2dc2a..881f4644a2b 100644 --- a/ui/storybook/support/github-bot-management/fixture-api.test.tsx +++ b/ui/storybook/support/github-bot-management/fixture-api.test.tsx @@ -30,3 +30,26 @@ it("serves a review detail to the routed preview and rejects unknown review IDs" flushSync(() => root.unmount()); } }); + +it("persists connection defaults, preserves omitted values and clears them explicitly", async () => { + vi.spyOn(window, "fetch").mockResolvedValue(Response.json({})); + let root = createRoot(document.createElement("div")); + const url = `/api/chat-endpoints/${endpoint.id}`; + const patch = async (body: unknown) => (await window.fetch(url, { + method: "PATCH", body: JSON.stringify(body), + })).json(); + try { + flushSync(() => root.render(Ready)); + const executionDefaults = { projectId: null, workspace: { kind: "task_directory" } }; + expect(await patch({ executionDefaults })).toMatchObject({ executionDefaults }); + expect(await (await window.fetch(url)).json()).toMatchObject({ executionDefaults }); + expect(await patch({ communicationInstructions: "Fixture instructions" })).toMatchObject({ executionDefaults }); + expect(await patch({ executionDefaults: null })).toMatchObject({ executionDefaults: null }); + flushSync(() => root.unmount()); + root = createRoot(document.createElement("div")); + flushSync(() => root.render(Ready)); + expect((await (await window.fetch(url)).json()).executionDefaults).toEqual(endpoint.executionDefaults); + } finally { + flushSync(() => root.unmount()); + } +}); diff --git a/ui/storybook/support/github-bot-management/fixture-api.tsx b/ui/storybook/support/github-bot-management/fixture-api.tsx index 3ef1b23d042..b826997e060 100644 --- a/ui/storybook/support/github-bot-management/fixture-api.tsx +++ b/ui/storybook/support/github-bot-management/fixture-api.tsx @@ -53,6 +53,9 @@ export function FixtureApi({ const [ready, setReady] = useState(false); useLayoutEffect(() => { const original = window.fetch; + let currentEndpoint = state === "setup" + ? { ...endpoint, status: "draft", setup: { github: { stage: "setup" } } } + : structuredClone(endpoint); let saved = { revision: 2, configuration: structuredClone(configuration) }; if (state === "skills") saved.configuration.defaults = { ...saved.configuration.defaults, @@ -92,6 +95,9 @@ export function FixtureApi({ ); } const body = init?.body ? JSON.parse(String(init.body)) : {}; + if (path === `/api/chat-endpoints/${endpoint.id}` && init?.method === "PATCH") { + currentEndpoint = { ...currentEndpoint, ...body }; + } if (path.endsWith("/github/setup")) return Response.json({ endpointId: endpoint.id, state: state === "setup" ? "create" : "connected" }); if (path.endsWith("/repositories/access")) { repos = repos.map((r) => (!body.enabled || r.availability === "available") ? { ...r, enabled: body.enabled } : r); @@ -172,7 +178,7 @@ export function FixtureApi({ } if (path.endsWith("/people/lookup")) return Response.json({ githubUserId: "44", login: body.login }); - return Response.json(state === "setup" ? { ...endpoint, status: "draft", setup: { github: { stage: "setup" } } } : endpoint); + return Response.json(currentEndpoint); }; setReady(true); return () => {