mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
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:
1 parent
4817df3e82
commit
3452c67d86
82 files changed
+1337
-1523
No files matched your search
+26
-26
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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/);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user