refactor(workspaces): isolate source identity and align review fixtures

Extract source-root identity helpers and split policy regression suites.
Preserve behavior while applying current contributor source rules and
reconcile Storybook execution-default fixtures and catalog dependencies.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 17:53:55 -05:00
1 parent 4817df3e82
commit 3452c67d86
82 files changed
+1337 -1523

No files matched your search

+26 -26
View File
@@ -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
+42
View File
@@ -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.
+66 -54
View File
@@ -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"
}
@@ -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<void>;
workspaceSeedCacheDirectory?: string;
workspaceSeedCacheCompanyDirectory?: string;
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
/** Without a Git snapshot, undefined discovers managed task repositories; [] keeps plain directories uninterpreted. */
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
workspaceExclude?: string[];
/** Includes ordinary ignored/cache files; plain directories also set workspaceGitSnapshot: null and workspaceRepositories: []. */
workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: CommandManagedRuntimeAsset[];
@@ -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<void>;
workspaceSeedCacheDirectory?: string;
workspaceSeedCacheCompanyDirectory?: string;
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
/** Without a Git snapshot, undefined discovers managed task repositories; [] keeps plain directories uninterpreted. */
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
workspaceExclude?: string[];
/** Includes ordinary ignored/cache files; plain directories also set workspaceGitSnapshot: null and workspaceRepositories: []. */
workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: AdapterManagedRuntimeAsset[];
@@ -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;
@@ -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<void> {
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<void> {
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<void>;
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<GitWorkspaceSnapshot["repositories"]>;
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) {
@@ -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,
@@ -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")) {
@@ -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<void> {
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<unknown>).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<WorkspaceCheckpointMetrics> {
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);
@@ -507,8 +507,6 @@ export async function withDirectoryMergeLock<T>(
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<T>(
targetDir: string,
fn: (canonicalTargetDir: string) => Promise<T>,
@@ -20,8 +20,6 @@ async function digest(file: string): Promise<string> {
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<WorkspaceDurableSeedPaths | null> {
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<SeedCacheLimits>; verify?: () => Promise<boolean> } = {},
@@ -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 });
@@ -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" }),
@@ -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" }),
@@ -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" }),
@@ -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",
@@ -1,4 +1,3 @@
/** Canonical task workspace operation. */
export const getWorkspaceAction = {
"id": "get_workspace",
"canonical": {
@@ -1,4 +1,3 @@
/** Canonical task workspace operation. */
export const listWorkspacesAction = {
"id": "list_workspaces",
"canonical": {
@@ -1,4 +1,3 @@
/** Canonical task workspace operation. */
export const prepareRepositoryAction = {
"id": "prepare_repository",
"canonical": {
@@ -1,4 +1,3 @@
/** Canonical task workspace operation. */
export const selectWorkspaceAction = {
"id": "select_workspace",
"canonical": {
-1
View File
@@ -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;
@@ -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;
-1
View File
@@ -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;
@@ -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;
};
-2
View File
@@ -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";
@@ -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();
@@ -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,
@@ -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;
@@ -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[] = [];
@@ -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,
@@ -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();
@@ -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(
@@ -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",
@@ -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));
@@ -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");
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let root: string;
let heartbeat: ReturnType<typeof heartbeatService>;
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" });
}
});
});
@@ -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); }
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
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<ReturnType<typeof fixture>>, 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<ReturnType<typeof fixture>>) {
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<ReturnType<typeof fixture>>) {
await db.update(issues).set({ visibility: "private", privacyRootIssueId: f.issueId, responsibleUserId: f.userId }).where(eq(issues.id, f.issueId));
}
async function grantPeer(f: Awaited<ReturnType<typeof fixture>>, peer: Awaited<ReturnType<typeof standardPeer>>) {
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<ReturnType<typeof fixture>>, beforePublish?: () => Promise<void>) {
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<typeof import("../../services/managed-repository-checkout.js")>("../../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 };
}
@@ -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/);
-3
View File
@@ -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";
}
-1
View File
@@ -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);
@@ -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-"));
@@ -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" });
@@ -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<typeof pending>;
let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -103,8 +99,6 @@ const support = await getEmbeddedPostgresTestSupport();
new Promise<never>((_, 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");
});
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const context = createRepositoryTrustFixture(acquisition);
let db: ReturnType<typeof createDb>;
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<ReturnType<typeof fixture>>, 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<ReturnType<typeof fixture>>) {
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<ReturnType<typeof fixture>>) {
await db.update(issues).set({ visibility: "private", privacyRootIssueId: f.issueId, responsibleUserId: f.userId }).where(eq(issues.id, f.issueId));
}
async function grantPeer(f: Awaited<ReturnType<typeof fixture>>, peer: Awaited<ReturnType<typeof standardPeer>>) {
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<ReturnType<typeof fixture>>, beforePublish?: () => Promise<void>) {
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<typeof import("../services/managed-repository-checkout.js")>("../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<unknown>;
let detach: () => Promise<unknown>;
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"] });
@@ -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<typeof import("../services/git-credentials.js")>(),
createGitRemoteAuthProvider: () => acquisition.managedAuth,
}));
vi.mock("../services/managed-repository-checkout.js", async (original) => ({
...await original<typeof import("../services/managed-repository-checkout.js")>(),
materializeManagedProjectWorkspace: acquisition.materialize,
}));
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("task workspace source copy privacy", () => {
const context = createRepositoryTrustFixture(acquisition);
let db: ReturnType<typeof createDb>;
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<unknown>;
let detach: () => Promise<unknown>;
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");
});
});
@@ -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");
});
-2
View File
@@ -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,
-1
View File
@@ -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);
-2
View File
@@ -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);
@@ -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,
-2
View File
@@ -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);
-1
View File
@@ -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");
}
@@ -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;
@@ -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<Parameters<Db["transaction"]>[0]>[0];
/** Match physical aliases, including task directories not materialized yet. */
export async function canonicalRepositoryRoot(cwd: string): Promise<string> {
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<boolean> {
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<RepositoryRow, "catalogRepositoryId"
if (anonymousOnly && row.catalogRepositoryId) throw forbidden(lowTrustRepositoryMessage);
}
/** The catalog is refreshed on request and admission; saved IDs are not grants. */
export function resolveTaskRepository(request: PrepareWorkspaceRepository["repository"], available: ProjectRepository[]) {
const selected = request.kind === "catalog" ? available.find(repo => 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<RepositoryRow, "repositor
}
}
/** A configured source can be an existing checkout or a local repository URL
* before its checkout exists. Use the same physical owner for reads and detach
* preservation; unrelated unavailable paths cannot block company-wide scans. */
async function configuredWorkspaceRoot(input: { cwd: string | null; repoUrl?: string | null }, scan: boolean): Promise<string | null> {
const resolveDirectory = async (cwd: string): Promise<string | null> => {
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<string>;
}) {
@@ -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<void> {
@@ -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<void> {
@@ -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<void> {
@@ -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<string>): Promise<boolean> {
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<boolean> {
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<boolean> => {
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<string>();
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<string>; projectIds: Set<string>;
}): Promise<void> {
@@ -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<string> | undefined;
let verifiedCopyProjectIds: Set<string> | 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<string>; 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<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">): Promise<void> {
@@ -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<void>> {
const roots = new Set<string>(), workspaceIds = new Set<string>(), projectIds = new Set<string>();
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);
@@ -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<Parameters<Db["transaction"]>[0]>[0];
export async function canonicalRepositoryRoot(cwd: string): Promise<string> {
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<boolean> {
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<string | null> {
const resolveDirectory = async (cwd: string): Promise<string | null> => {
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<string>): Promise<boolean> {
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<boolean> {
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<boolean> => {
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<string>();
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 };
}
@@ -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<typeof executionWorkspaces.$inferInsert>,
) {
@@ -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<string, string>();
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<string, unknown> ?? {}), [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<ReopenClosedIsolatedExecutionWorkspaceResult> => {
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,
-10
View File
@@ -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 (
@@ -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 },
@@ -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();
@@ -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"
@@ -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<Pick<typeof projectWorkspaces.$inferSelect, "id" | "repoUrl" | "repoRef"> & { cwd?: string | null }>;
resolveGitAuth?: GitRemoteAuthProvider | null;
beforePublish?: () => Promise<void>;
/** 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<void>>;
}): Promise<Array<{ workspaceId: string; cwd: string; repoUrl: string; repoRef: string | null }>> {
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;
-8
View File
@@ -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<typeof issues.$inferSelect>) => 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);
@@ -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);
@@ -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<void>;
/** Internal preparation receipt is committed inside the clone before atomic publication. */
beforePublish?: (cloneCwd: string) => Promise<void>;
/** 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<void> {
const gitEntry = await fs.lstat(path.join(cwd, ".git")).catch(() => null);
if (!gitEntry) return;
@@ -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<NativeChatWorkspaceScope | null> {
@@ -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;
-1
View File
@@ -41,7 +41,6 @@ import { lockExecutionWorkspaceRootProvenance, preserveConfiguredProjectWorkspac
type ProjectTransaction = Parameters<Parameters<Db["transaction"]>[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;
@@ -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;
@@ -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"]),
@@ -7,7 +7,6 @@ import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./a
type DbTransaction = Parameters<Parameters<Db["transaction"]>[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 = {},
) {
@@ -6,7 +6,6 @@ function repositoryView(repo: Record<string, unknown>) {
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<string, unknown>; 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") {
@@ -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,
-7
View File
@@ -2706,16 +2706,11 @@ async function resolveGitOwnerRepoRoot(cwd: string): Promise<string> {
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<void>;
afterSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
@@ -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<void>;
afterSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
@@ -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);
-2
View File
@@ -40,7 +40,6 @@ export async function runTaskWorkspacesFlow(input: {
issue = await api.get<Row>(`/api/issues/${issue.id}`);
const listed = await api.get<Row[]>(`${companyPath}/heartbeat-runs?limit=100`);
runs = await Promise.all(listed.map(run => api.get<Row>(`/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;
@@ -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 } } };
@@ -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";
@@ -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;
@@ -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);
@@ -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(<FixtureApi><span>Ready</span></FixtureApi>));
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(<FixtureApi><span>Ready</span></FixtureApi>));
expect((await (await window.fetch(url)).json()).executionDefaults).toEqual(endpoint.executionDefaults);
} finally {
flushSync(() => root.unmount());
}
});
@@ -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 () => {