mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
Remove filesystem history authorization from workspace preparation (#16963)
Remove physical-root source/reader authorization and sticky directory classifications from workspace preparation. Preserve current task/run authority, app-record privacy, credential authorization, containment, and lifecycle/publication integrity.
Validate replacement selections against persisted new inputs and refresh verified checkout ownership so bounded reuse survives earlier workspace relocation. Add real PostgreSQL/Git regressions, a reproducible history-scaling benchmark, and documentation.
Full CI passed all 48 jobs and Greptile scored 5/5 at 3d5da6c259. Five preparation-guard medians improved from 20,512.99 ms to 52.22 ms on the 8,500-workspace fixture; these are not end-to-end startup timings.
Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
1 parent
d84061b923
commit
813e808af4
60 files changed
+2332
-3480
No files matched your search
+37
-44
@@ -43,12 +43,10 @@ launch context does not travel with each policy or parent-report check. JSON
|
||||
types, task-key precedence, and company and agent identity checks are preserved.
|
||||
Each decision still reads current data; this is not an authorization cache.
|
||||
|
||||
Repository-root checks load retained run, agent and task identities in batches
|
||||
of at most 500 IDs. They keep each run's task binding, responsible user and saved
|
||||
policy separate, even when several runs use the same agent. Project settings and
|
||||
saved policies for batched read authorization are also loaded once per operation.
|
||||
The next admission or publication reloads these records and permission grants.
|
||||
The queries exclude run results and unused launch context.
|
||||
Filesystem preparation uses current task/run and selected-configuration checks.
|
||||
Historical root-reader authorization has been retired; no retained-reader batch is
|
||||
loaded at admission or publication. Workspace-record disclosure still uses task
|
||||
privacy checks, independently of filesystem access.
|
||||
|
||||
The initial agent JWT run lookup reads only the cancellation state and
|
||||
conversation flag from saved run JSON. It does not transfer the full context or
|
||||
@@ -79,9 +77,8 @@ the same 200 complete rows. This benchmark excludes HTTP and redaction latency.
|
||||
Workspace reads also compare retained task IDs as guarded, canonical UUIDs.
|
||||
This lets PostgreSQL check each source task by primary key before it evaluates
|
||||
task privacy. Casting the indexed task ID to text can instead evaluate privacy
|
||||
across company history for each workspace. Repository admission repeats these
|
||||
reads while holding its company provenance lock, so that plan can delay other
|
||||
agent launches. Missing, malformed, noncanonical, and foreign-company source
|
||||
across company history for each workspace. These predicates protect workspace-record disclosure and operation metadata;
|
||||
repository admission does not use them to authorize filesystem contents. Missing, malformed, noncanonical, and foreign-company source
|
||||
IDs still deny access.
|
||||
|
||||
The subsequent source-task enumeration also keeps `issues.id` as a UUID. Its
|
||||
@@ -946,45 +943,41 @@ Migration [0336_big_crystal.sql](../packages/db/src/migrations/0336_big_crystal.
|
||||
|
||||
`execution_workspace_repositories` is company-scoped inventory beneath an execution workspace. It records repository identity, contained relative path, requested ref, pinned commit, preparation state and idempotency receipts. Credentials are resolved at use and are never stored in this inventory. Connection/destination `execution_defaults` JSON contains optional typed project/workspace choices, never arbitrary host paths or commands.
|
||||
|
||||
Migration [0337_exotic_proemial_gods.sql](../packages/db/src/migrations/0337_exotic_proemial_gods.sql) adds internal `execution_workspace_repository_roots` records. A company and hash of its canonical physical path uniquely identify a sticky `anonymous_only` or `managed` acquisition reservation. Short transactions serialize reservations with repository requests and credential promotion, preventing private publication after low-trust admission checks. Records have no workspace foreign key: mutable workspace metadata, relocation, archival or deletion cannot release a physical folder still accessible to an admitted run. There is no automatic mode change or reservation deletion.
|
||||
The physical-root tables introduced by migrations
|
||||
[0337_exotic_proemial_gods.sql](../packages/db/src/migrations/0337_exotic_proemial_gods.sql)
|
||||
and [0338_absurd_vengeance.sql](../packages/db/src/migrations/0338_absurd_vengeance.sql)
|
||||
are legacy: `execution_workspace_repository_roots` and
|
||||
`execution_workspace_repository_access` remain in the schema with their existing
|
||||
data, but runtime services neither read nor write them. There is no backfill,
|
||||
cleanup job, startup DDL, or schema migration for this retirement. Historical
|
||||
`anonymous_only` / `managed` classifications and source/reader identities have no
|
||||
effect on new operations.
|
||||
|
||||
Migration [0338_absurd_vengeance.sql](../packages/db/src/migrations/0338_absurd_vengeance.sql) adds internal `execution_workspace_repository_access` provenance. Company/canonical-root source entries retain execution-workspace and source-project identities; reader entries retain the exact admitted run, task, agent and responsible user. A retained reader's task must match the run's immutable task binding or its current context task; a wake coalesced into a running run can replace the context task without changing that binding. New repository receipts also record their root at creation; legacy receipts receive no inferred origin. Shared project-source entries retain which physical owners require workspace write authority. Current admission, selection, requests and publication check the current task’s writer against those owners; retained past readers and file APIs still require only read authority. Genuine isolated copies retain source read access without requiring shared-folder write access. An existing linked worktree can recover a missing isolated-copy proof only after Git registration and its captured source owner are verified. Configured local sibling copies retain deny-only task/project source ownership on the bound task root before materialization; ordinary repository clones create no isolation proof. The shared root fence checks every reader against every source before admission and future publication, including tasks without repository requests. File APIs apply the same source privacy without writing provenance. These entries grant no authority and have no workspace or run deletion cascade; unavailable retained identity fails closed. Automatic provenance release is deferred, so a newly incompatible permission scope requires a separate physical root.
|
||||
`execution_workspace_repositories` remains active and records actual repository
|
||||
requests, pins, preparation state, and idempotency. Shared publication recovery
|
||||
uses the current receipt and the exact receipt named by the checkout's bounded
|
||||
ownership marker, with company, origin, ref, path, and pin integrity checks.
|
||||
|
||||
Repository admission loads retained workspace and task sources in bounded batches. Readers share source rows and privacy evaluation for identical principals, but each retained run receives its own trust and responsible-user decision. The snapshot lasts only for one read batch; later admission and publication reload current authority. Keep source-loading query counts bounded as both workspace history and retained reader counts grow.
|
||||
The disposable preparation benchmark compares a pinned pre-removal commit with
|
||||
the checked-out implementation using 8,500 workspaces, 4,800 shared-root records,
|
||||
5,300 retained sources, and 50 readers. It reports two warmups and five measured
|
||||
samples, phase medians/ranges, SQL fingerprints, and canonicalization counts.
|
||||
Both retired-table query counts must remain zero, and increasing peer history
|
||||
must not increase preparation authorization or path checks. Record the exact
|
||||
treatment commit and measured improvement in the change's verification report.
|
||||
These service measurements exclude network acquisition, copying, environment
|
||||
acquisition, and provider startup. Reproduce with:
|
||||
|
||||
Each physical-root scan resolves duplicate workspace paths once, with bounded concurrency for distinct paths. Alias results are discarded after that scan so later admission and publication recheck symlink targets. Provenance identities select overlapping root records through the company/root index; source retention writes use bounded batches under the existing company lock, including deny-only ownership retained after a failed copy authorization.
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-preparation-history.ts --baseline-ref=df80616d98119cbf5abd40549794d9980a5fabf6
|
||||
```
|
||||
|
||||
Git-source capture checks the actual common Git storage directory and verified
|
||||
checkout owners. Separately stored metadata does not make its parent a checkout.
|
||||
When a linked worktree cannot identify its primary checkout, the service verifies
|
||||
company-known configured, execution, and retained roots against that metadata
|
||||
identity. A primary checkout identifies itself when its Git directory equals its
|
||||
common directory, including separate metadata layouts. Verified primary sources
|
||||
and linked worktrees with a verified registered primary avoid the fallback scan.
|
||||
|
||||
Writable selection, source-copy preparation, admission, and publication check the trust policy of
|
||||
current and retained physical source projects. A projectless alias cannot bypass
|
||||
a source's sandbox requirement. An incompatible preset or boundary is rejected;
|
||||
the check never silently changes the selected execution environment. Admitted
|
||||
and retained readers use their persisted effective run policy; absence of a
|
||||
retained low-trust policy counts as standard. Live policy edits cannot relabel
|
||||
a standard local run as sandboxed. Read-only file access
|
||||
keeps its existing authorization rules.
|
||||
|
||||
Source-copy checks retain deny-only ownership on failure but create a new
|
||||
isolated-copy proof only after physical identity, access, and trust checks pass.
|
||||
For low-trust sandbox runs, the harness also disables host worktree provisioning
|
||||
on fresh realization, reuse, and rebuild. This covers configured commands and
|
||||
the repository's built-in provision script; an empty configured command alone
|
||||
would still allow that fallback. The capability comes from the server's selected
|
||||
trust policy and environment, never repository configuration.
|
||||
|
||||
After source authorization and retained-reader checks pass, the existing
|
||||
`source` and `project_source` kinds retain ownership on shared Git storage.
|
||||
Removing the original checkout cannot release private committed files in surviving
|
||||
worktrees. These records grant no authority and create no isolation proof. Denied
|
||||
source captures leave no new records. Worktree realization uses a verified checkout
|
||||
as its Git command directory, including when metadata sits inside another repository.
|
||||
Workspace-record and workspace-operation `_issuePrivacySources` /
|
||||
`_runPrivacySources` metadata and their migration 0313 triggers remain app-data
|
||||
privacy controls. They do not authorize filesystem access. Current run identity,
|
||||
selected project policy, acquisition/credential authority, concrete copy identity,
|
||||
path containment, and lifecycle serialization remain enforced. Low-trust execution
|
||||
still suppresses controller provisioning according to the selected environment.
|
||||
|
||||
No migration scans or copies existing agent home directories. Keep admitted native descriptors and recovery references until compatible controllers finish their persistence obligations. See [the architecture rollout contract](plans/2026-10-09-task-workspaces.md).
|
||||
|
||||
|
||||
@@ -1158,6 +1158,21 @@ If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs f
|
||||
|
||||
Local adapters require their corresponding CLI/session setup on the machine running Paperclip. External adapters are installed through the adapter/plugin flow and should not require hardcoded imports in `server/` or `ui/`.
|
||||
|
||||
### Filesystem access boundary (2026-10-20)
|
||||
|
||||
Filesystem isolation belongs to the OS, execution environment, and storage layout.
|
||||
Paperclip authorizes the current task/run, company, explicitly selected configuration,
|
||||
execution policy, and repository credentials. It does not infer file permissions from
|
||||
other tasks or agents that previously used the same directory, overlapping roots,
|
||||
symlink aliases, or shared Git storage. Existing files under an authorized task root
|
||||
may be read subject to OS access and API path containment.
|
||||
|
||||
Private task records, comments, attachments, run logs, full workspace metadata, and
|
||||
operation logs retain their app authorization. Workspace `_issuePrivacySources` and
|
||||
operation privacy metadata protect app-record disclosure; they do not grant or deny
|
||||
filesystem execution or task-bound file browsing. Current trust and sandbox policy,
|
||||
publication receipts, binding checks, and lifecycle locks remain authoritative.
|
||||
|
||||
## Project Repository Checkouts
|
||||
|
||||
Tasks use every distinct repository attached to their project, including repository-only sources with no local folder. Paperclip creates a managed checkout when no local folder is configured. The selected repository remains at the task workspace root. Other project repositories have editable, independent Git checkouts under `.paperclip-repositories/<name>-<key>`. Workspace hints expose each checkout path to the agent.
|
||||
@@ -2934,3 +2949,21 @@ Storybook: **Skills → Project repository skills**. Its production UI journeys
|
||||
in-memory provider and execution fixtures. Live GitHub refresh and execution need
|
||||
separate acceptance evidence. Run the focused browser suite against built Storybook,
|
||||
or set `STORYBOOK_TEST_BASE_URL` to an owned Storybook dev server.
|
||||
|
||||
Workspace preparation no longer reads or writes physical-root source/reader ledgers
|
||||
or persistent `anonymous_only` / `managed` directory classifications. Explicit
|
||||
workspace selection checks record access; admission checks the persisted current
|
||||
selection without requiring disclosure of historical workspace metadata. Repository
|
||||
copy/publication callbacks recheck concrete source/destination identities and the
|
||||
current task binding. They do not inventory peer workspaces. Existing
|
||||
`isolatedRemoteSource` receipts remain restorable, but new local sources are not
|
||||
replaced with remote clones because of historical file ownership.
|
||||
|
||||
The disposable performance harness is:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-preparation-history.ts --baseline-ref=df80616d98119cbf5abd40549794d9980a5fabf6
|
||||
```
|
||||
|
||||
It compares actual preparation guards with synthetic history. Local timings are
|
||||
separate from Git materialization, environment acquisition, and provider startup.
|
||||
@@ -307,3 +307,18 @@ and sticky headers help operators scan exports. Rendered and raw view icons
|
||||
remain next to download; raw view preserves the original source. The table
|
||||
shows up to 500 data rows and 100 columns, with a notice when the preview is
|
||||
limited. Download retains the complete file.
|
||||
|
||||
### Filesystem access boundary (2026-10-20)
|
||||
|
||||
Filesystem isolation belongs to the OS, execution environment, and storage layout.
|
||||
Paperclip authorizes the current task/run, company, explicitly selected configuration,
|
||||
execution policy, and repository credentials. It does not infer file permissions from
|
||||
other tasks or agents that previously used the same directory, overlapping roots,
|
||||
symlink aliases, or shared Git storage. Existing files under an authorized task root
|
||||
may be read subject to OS access and API path containment.
|
||||
|
||||
Private task records, comments, attachments, run logs, full workspace metadata, and
|
||||
operation logs retain their app authorization. Workspace `_issuePrivacySources` and
|
||||
operation privacy metadata protect app-record disclosure; they do not grant or deny
|
||||
filesystem execution or task-bound file browsing. Current trust and sandbox policy,
|
||||
publication receipts, binding checks, and lifecycle locks remain authoritative.
|
||||
@@ -2277,6 +2277,21 @@ The internal service provides local-only availability and bounded, reauthorized
|
||||
|
||||
### Task workspace ownership (2026-10-09)
|
||||
|
||||
### Filesystem access boundary (2026-10-20)
|
||||
|
||||
Filesystem isolation belongs to the OS, execution environment, and storage layout.
|
||||
Paperclip authorizes the current task/run, company, explicitly selected configuration,
|
||||
execution policy, and repository credentials. It does not infer file permissions from
|
||||
other tasks or agents that previously used the same directory, overlapping roots,
|
||||
symlink aliases, or shared Git storage. Existing files under an authorized task root
|
||||
may be read subject to OS access and API path containment.
|
||||
|
||||
Private task records, comments, attachments, run logs, full workspace metadata, and
|
||||
operation logs retain their app authorization. Workspace `_issuePrivacySources` and
|
||||
operation privacy metadata protect app-record disclosure; they do not grant or deny
|
||||
filesystem execution or task-bound file browsing. Current trust and sandbox policy,
|
||||
publication receipts, binding checks, and lifecycle locks remain authoritative.
|
||||
|
||||
A task's organizational project and filesystem workspace are independent. New tasks that need a filesystem and have no selected/configured workspace get a company/task directory, not the agent home. Plain tasks need neither Git nor a project. Existing bindings and admitted roots remain authoritative; changing project does not move files. Explicit workspace selections apply at a subsequent normal admission after prior execution and persistence obligations settle.
|
||||
|
||||
Chat connection and destination settings may supply project/workspace defaults. Destination fields override connection fields; omitted values inherit and null clears the inherited choice. Resolve and authorize these defaults once for a new conversation, then retain the resulting task intent. Existing conversations do not change when settings change. Trust confinement still takes precedence.
|
||||
|
||||
+15
@@ -812,3 +812,18 @@ Recent-task navigation refreshes a bounded, authorized summary projection for
|
||||
its saved task IDs. The projection avoids full task/workspace enrichment and
|
||||
preserves company/task access rules. The HTTP contract is defined in
|
||||
`SPEC-implementation.md` section 10.4.
|
||||
|
||||
### Filesystem access boundary (2026-10-20)
|
||||
|
||||
Filesystem isolation belongs to the OS, execution environment, and storage layout.
|
||||
Paperclip authorizes the current task/run, company, explicitly selected configuration,
|
||||
execution policy, and repository credentials. It does not infer file permissions from
|
||||
other tasks or agents that previously used the same directory, overlapping roots,
|
||||
symlink aliases, or shared Git storage. Existing files under an authorized task root
|
||||
may be read subject to OS access and API path containment.
|
||||
|
||||
Private task records, comments, attachments, run logs, full workspace metadata, and
|
||||
operation logs retain their app authorization. Workspace `_issuePrivacySources` and
|
||||
operation privacy metadata protect app-record disclosure; they do not grant or deny
|
||||
filesystem execution or task-bound file browsing. Current trust and sandbox policy,
|
||||
publication receipts, binding checks, and lifecycle locks remain authoritative.
|
||||
@@ -1,5 +1,12 @@
|
||||
# Selected architecture — 2026-10-09
|
||||
|
||||
> **Policy superseded 2026-10-20:** Physical-root provenance authorization, retained
|
||||
> source/reader intersections for files, source filesystem write grants, and sticky
|
||||
> directory acquisition classifications below describe the original design. Runtime
|
||||
> filesystem access now follows current task/configuration authority and OS isolation.
|
||||
> App-record privacy, current trust/credentials, lifecycle, and copy integrity remain.
|
||||
|
||||
|
||||
This design addresses PAP-171: a short agent turn paid minutes of workspace hydration and copyback because a projectless chat task inherited a large agent fallback directory. It covers app/API and chat tasks, including optional channel defaults, independent project and filesystem choices, explicit physical sharing, and deterministic harness preparation. Two architecture workers received the same full plan and grounding. Both candidates and an independent review used the inherited parent model; this is not cross-model consensus.
|
||||
|
||||
## Caller contract first
|
||||
|
||||
@@ -52,22 +52,12 @@ revision; superseded retries fail while current-choice retries remain idempotent
|
||||
Binding revisions and request keys fence retries;
|
||||
selection applies at the next normal admission without interrupting a live run.
|
||||
|
||||
Fresh standard-trust tasks with an implicit repository default can use an owned
|
||||
remote clone when the shared default source fails the restricted-task workspace
|
||||
guard. This does not grant access to the private local checkout or its Git metadata.
|
||||
The normal admitted Git identity must already authorize the configured remote.
|
||||
Creation of an automatic fallback source is unavailable for explicit, parent/channel, reused
|
||||
or low-trust selections, or branch templates without a task-derived placeholder;
|
||||
an existing verified owned source remains reusable. Literal branch selections
|
||||
retain the original denial without cloning or replacing their unpublished commits.
|
||||
New implicit Git defaults record project-source provenance. Untyped explicit,
|
||||
inherited and older ambiguous checkout bindings retain the denial; admission reads
|
||||
the current company-scoped binding before considering the fallback.
|
||||
A retained denial blocks the task as `configuration_incomplete`
|
||||
(`workspace_source_restricted`) before dispatch; it grants no access and offers Retry.
|
||||
The same blocker applies when shared project workspace repository admission finds
|
||||
the restricted source. The existing binding and files stay intact; no provider
|
||||
starts and no automatic failure retry is queued.
|
||||
Admission checks current task/run authority and the persisted selected configuration.
|
||||
It does not inventory physical aliases or authorize historical sources/readers.
|
||||
Explicit workspace-record selection and full workspace metadata disclosure retain
|
||||
app-record privacy. Existing isolated remote-source receipts remain restorable;
|
||||
new preparation does not create a remote fallback due to historical file ownership.
|
||||
Historical `workspace_source_restricted` failures retain existing recovery support.
|
||||
An authorized board recovery through `POST /api/issues/{id}/recovery-actions/resolve`
|
||||
can schedule a server-owned `recovery` selection for the latest restricted-source
|
||||
failure only when its saved evidence proves provider work never started. The
|
||||
@@ -91,14 +81,13 @@ choose a newer commit or grant access to another company's source. Shared projec
|
||||
bases and unresolved recovery remain protected. Local lifecycle evidence is in
|
||||
[expiration tests](../../server/src/__tests__/git-workspace-expiration.test.ts).
|
||||
|
||||
Writable aliases must also be compatible with current and retained physical
|
||||
source projects' trust boundaries. Incompatible choices are rejected before
|
||||
dispatch; a projectless alias cannot bypass required sandbox containment.
|
||||
Source-copy checks apply the same trust boundary before provisioning. The
|
||||
Only directly selected configuration policies participate in current execution trust.
|
||||
Physical aliases and historical source projects do not contribute filesystem policy.
|
||||
The
|
||||
authoritative sandbox or SSH selection suppresses configured and built-in
|
||||
controller worktree and runtime provisioning regardless of task trust, source
|
||||
controller worktree and runtime provisioning regardless of task trust, app configuration
|
||||
write grants or board sponsorship. Genuine isolated worktrees still permit
|
||||
authorized read-only source copying. Source write permission does not authorize
|
||||
authorized read-only source copying. Configuration write permission does not authorize
|
||||
controller execution. Explicit, channel, inherited, foreign or nondefault isolated
|
||||
source choices remain copy-only for controller provisioning even on local runs.
|
||||
Their deny-only workspace marker survives metadata and binding changes; a board
|
||||
@@ -115,7 +104,7 @@ access. The merge still holds its canonical writer lock and runs both authority
|
||||
and publication hooks. Denials still propagate, and checkpoint/archive validation
|
||||
is unchanged. [Regression coverage](../../packages/adapter-utils/src/workspace-restore-unchanged.test.ts)
|
||||
is source-level evidence only.
|
||||
Child creation by an admitted agent checks an explicit prospective-task context before the child is saved. The creator run must have an authoritative saved policy and a verified company/agent/parent lineage. Current parent, prospective child and physical source policies must fit that boundary. This creation path does not relax exact task/run checks for existing-task writes. The [child trust regression](../../server/src/__tests__/task-workspace-child-trust.test.ts) is automated source-level evidence, not live agent qualification.
|
||||
Child creation by an admitted agent checks an explicit prospective-task context before the child is saved. The creator run must have an authoritative saved policy and a verified company/agent/parent lineage. Current parent, prospective child and selected source configuration policies must fit that boundary. This creation path does not relax exact task/run checks for existing-task writes. The [child trust regression](../../server/src/__tests__/task-workspace-child-trust.test.ts) is automated source-level evidence, not live agent qualification.
|
||||
Source review for these additions: PR #15749 and
|
||||
[task workspace routes](../../server/src/routes/task-workspaces.ts),
|
||||
[native authority](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts),
|
||||
@@ -156,9 +145,9 @@ server provisioning.
|
||||
Native `prepare_repository` and admitted legacy REST callers can request a
|
||||
catalog repository or validated repository URL for their current task.
|
||||
Preparation occurs at normal admission; the agent cannot supply a host path or
|
||||
controller identity. Current source permissions, responsible-user intersection,
|
||||
trust restrictions, immutable receipts, and retained physical-root ownership are
|
||||
checked before acquisition and publication. Low-trust callers cannot trigger a
|
||||
controller identity. Current selected-configuration permissions, responsible-user authority, trust
|
||||
restrictions, immutable receipts, and concrete binding/path integrity are checked
|
||||
before acquisition and publication. Low-trust callers cannot trigger a
|
||||
new controller-side Git acquisition. Evidence remains source-only here; focused
|
||||
[repository tests](../../server/src/__tests__/task-workspace-repositories-trust.test.ts)
|
||||
are separate from live agent qualification.
|
||||
@@ -232,14 +221,11 @@ remaining operations before qualifying the whole capability.
|
||||
Gaps: Files panel HTTP requires board. Agents can use their admitted workspace file
|
||||
tools; remote/Dot scope and Git command availability differ.
|
||||
|
||||
File reads also enforce retained source privacy after local copy, configuration
|
||||
move/removal, reopen, and project deletion. A task assignment or repository
|
||||
receipt does not grant access to private files from another physical owner.
|
||||
|
||||
Authorized Git-source capture also retains ownership on the actual shared Git
|
||||
storage. Source checks use verified checkout identities, including company-known
|
||||
owners when separate metadata has no primary-checkout registration. Removing that
|
||||
checkout does not release its private committed files in surviving worktrees.
|
||||
Task-bound file reads authorize the current task, company, and concrete binding;
|
||||
selected project roots require selected configuration access. Historical task,
|
||||
reader, physical-root, and directory acquisition classifications have no effect.
|
||||
Root containment and symlink escape checks remain, and attachments, task records,
|
||||
workspace metadata, and operation logs retain their app-data privacy.
|
||||
|
||||
## `closure`
|
||||
|
||||
|
||||
@@ -61,38 +61,17 @@ verify it retains its source lock, rejects competing preparation, and leaves
|
||||
ordinary reads available. The [source-lock regression](../../server/src/services/isolated-remote-workspace-source-lock.test.ts)
|
||||
uses real PostgreSQL; it does not prove provider startup or deployed recovery.
|
||||
|
||||
For fresh standard-trust tasks using an implicit repository default, create a
|
||||
disposable project checkout whose shared Git storage overlaps a private task
|
||||
workspace. Verify that source admission creates a separate checkout from the
|
||||
configured remote, with no private local branches, commits or shared Git storage.
|
||||
Reusing, closing and reopening that task must retain the company/task-bound source;
|
||||
a failed workspace save must clean up only its owned checkout. Explicit workspace,
|
||||
parent/channel selections, literal or task-independent branch templates, low-trust
|
||||
tasks, and local or credential-bearing remote
|
||||
URLs must not use this fallback. A selected checkout requires recorded default
|
||||
provenance. Create tasks
|
||||
through the task service with an explicit checkout ID or an inherited parent
|
||||
source but no typed selection; verify denial, no clone or environment acquisition,
|
||||
and preservation of unpublished branch commits. Older ambiguous bindings and
|
||||
source choices changed during preparation must retain the denial. The
|
||||
[remote-source tests](../../server/src/services/isolated-remote-workspace-source.test.ts)
|
||||
and [preparation tests](../../server/src/services/heartbeat/workspace-preparation.test.ts)
|
||||
exercise the real Git isolation and lifecycle guards. They do not qualify live
|
||||
remote credentials or a provider deployment.
|
||||
|
||||
For a task reusing the shared project checkout, add a private task worktree that
|
||||
the assigned agent cannot read. Continue the shared task and verify the
|
||||
task offers **Retry**, without a workspace picker or repair checklist. Before the
|
||||
click, no agent turn or automatic failure retry may start. Click Retry and verify
|
||||
that normal admission selects an isolated checkout from the configured remote,
|
||||
preserves the agent and environment, and leaves both tasks' old files intact.
|
||||
Private local branches and commits must not enter the new checkout. Repeated
|
||||
clicks request one retry; a paused project or pending approval must still block it.
|
||||
Repeat with fixed project branches, an existing task branch, and adapter branch
|
||||
overrides; Retry must choose a fresh branch while preserving setup and model settings.
|
||||
Add enough later comments to page the original notice out of view, reload, and
|
||||
verify Retry remains above the composer. This control does
|
||||
not depend on the workspace picker's visibility setting.
|
||||
Create an authorized task whose selected checkout is also used by private historical
|
||||
tasks and many old workspace records. Start and reuse it without a historical-source
|
||||
permission failure or remote-clone fallback. Verify task-bound file browsing works,
|
||||
while private historical task content and operation logs remain denied through app
|
||||
APIs. Root traversal and symlink escapes must remain rejected. The OS and execution
|
||||
environment own isolation between agents; Paperclip does not classify existing bytes
|
||||
by the task that wrote them. Existing `workspace_source_restricted` failures remain
|
||||
inspectable and recoverable, but new preparation does not produce that failure from
|
||||
filesystem history. Current trust, credential, binding, and publication checks still
|
||||
apply. The preparation-history benchmark supplies local service measurements, not
|
||||
provider deployment qualification.
|
||||
|
||||
For reconstructable Git expiration, finish a disposable task tree, run the
|
||||
internal lifecycle sweep after its 24-hour grace, and inspect candidate/reclaimed
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
/** Legacy physical-root policy ledger; retained for schema compatibility, unused by runtime. */
|
||||
export const executionWorkspaceRepositoryAccess = pgTable("execution_workspace_repository_access", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
/** Legacy physical-root policy ledger; retained for schema compatibility, unused by runtime. */
|
||||
export const executionWorkspaceRepositoryRoots = pgTable("execution_workspace_repository_roots", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Disposable synthetic PostgreSQL + real Git proof. Never accepts DATABASE_URL.
|
||||
* pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-preparation-history.ts \
|
||||
* --baseline-ref=df80616d98119cbf5abd40549794d9980a5fabf6 --output=/tmp/workspace-history.json
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createDb, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
|
||||
import {
|
||||
addReadyWorkspaceHistoryRepository, createWorkspacePreparationHistoryFixture,
|
||||
} from "../server/src/__tests__/helpers/workspace-preparation-history-fixture.js";
|
||||
import * as treatment from "../server/src/services/execution-workspace-repositories.js";
|
||||
|
||||
const require = createRequire(new URL("../server/package.json", import.meta.url));
|
||||
const { drizzle } = require("drizzle-orm/postgres-js");
|
||||
const argument = (name: string) => process.argv.find(value => value.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const baselineRef = argument("baseline-ref") ?? "df80616d98119cbf5abd40549794d9980a5fabf6";
|
||||
const baselineRevision = execFileSync("git", ["rev-parse", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim();
|
||||
const treatmentRevision = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
const samples = Number(argument("samples") ?? 5), warmups = Number(argument("warmups") ?? 2);
|
||||
assert.ok(Number.isInteger(samples) && samples >= 5, "At least five measured samples are required");
|
||||
assert.ok(Number.isInteger(warmups) && warmups >= 2, "At least two warmups are required");
|
||||
const selectedSizes = (argument("sizes") ?? "small,stress").split(",");
|
||||
assert.ok(selectedSizes.length && selectedSizes.every(size => size === "small" || size === "stress"));
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
const repositoryRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const originalSourceRoot = path.join(repositoryRoot, "server", "src");
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-history-"));
|
||||
const baselineDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-history-baseline-"));
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: Db | undefined;
|
||||
|
||||
type Fixture = Awaited<ReturnType<typeof createWorkspacePreparationHistoryFixture>>;
|
||||
type Observation = {
|
||||
ms: number; queries: string[]; queryFingerprints: string[]; realpaths: string[];
|
||||
retiredTableQueries: number; historicalReaderQueries: number; companyInventories: number;
|
||||
};
|
||||
|
||||
async function sourceFiles(directory: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
return (await Promise.all(entries.map(entry => entry.isDirectory()
|
||||
? sourceFiles(path.join(directory, entry.name))
|
||||
: Promise.resolve([path.join(directory, entry.name)])))).flat();
|
||||
}
|
||||
|
||||
async function loadBaseline() {
|
||||
// Snapshot the complete relative server dependency graph. Holding just the top-level old
|
||||
// service while importing a changed authorization helper would invalidate the comparison.
|
||||
const archive = execFileSync("git", ["archive", "--format=tar", baselineRevision, "server/src", "server/package.json"], {
|
||||
cwd: repositoryRoot, maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
execFileSync("tar", ["-xf", "-", "-C", baselineDirectory], { input: archive });
|
||||
// Resolve unchanged workspace dependencies without writing a checkout or package-manager files.
|
||||
await fs.symlink(path.join(repositoryRoot, "server", "node_modules"), path.join(baselineDirectory, "node_modules"), "dir");
|
||||
const snapshotSource = path.join(baselineDirectory, "server", "src");
|
||||
for (const file of await sourceFiles(snapshotSource)) {
|
||||
if (!/\.[cm]?[jt]s$/.test(file)) continue;
|
||||
const original = path.join(originalSourceRoot, path.relative(snapshotSource, file));
|
||||
const source = await fs.readFile(file, "utf8");
|
||||
const rewritten = source.replace(/((?:from\s*|import\s*\(|import\s*|new\s+URL\s*\(\s*)["'])(\.{1,2}\/[^"']+)(["'])/g,
|
||||
(match, prefix: string, specifier: string, suffix: string) => {
|
||||
const destination = path.resolve(path.dirname(original), specifier);
|
||||
return destination.startsWith(`${originalSourceRoot}${path.sep}`) ? match : `${prefix}${destination}${suffix}`;
|
||||
});
|
||||
if (source !== rewritten) await fs.writeFile(file, rewritten);
|
||||
}
|
||||
return import(pathToFileURL(path.join(snapshotSource, "services", "execution-workspace-repositories.ts")).href);
|
||||
}
|
||||
|
||||
async function observe(action: (recorded: Db) => Promise<unknown>,
|
||||
prepare?: (recorded: Db) => Promise<() => Promise<unknown>>): Promise<Observation> {
|
||||
assert.ok(db);
|
||||
const queries: string[] = [], realpaths: string[] = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema,
|
||||
logger: { logQuery: (query: string) => queries.push(query) } }) as Db;
|
||||
const actualRealpath = fs.realpath;
|
||||
fs.realpath = (async (...args: Parameters<typeof fs.realpath>) => {
|
||||
realpaths.push(String(args[0]));
|
||||
return actualRealpath(...args);
|
||||
}) as typeof fs.realpath;
|
||||
let start = performance.now();
|
||||
try {
|
||||
const prepared = prepare ? await prepare(recorded) : undefined;
|
||||
queries.length = 0; realpaths.length = 0;
|
||||
start = performance.now();
|
||||
if (prepared) await prepared(); else await action(recorded);
|
||||
}
|
||||
finally { fs.realpath = actualRealpath; }
|
||||
return { ms: performance.now() - start, queries, realpaths,
|
||||
queryFingerprints: [...new Set(queries.map(query => createHash("sha256").update(query).digest("hex")))],
|
||||
retiredTableQueries: queries.filter(query => /execution_workspace_repository_(?:access|roots)/.test(query)).length,
|
||||
historicalReaderQueries: queries.filter(query => /from "heartbeat_runs"/.test(query) && /\bin\s*\(/i.test(query)).length,
|
||||
companyInventories: queries.filter(query => /from "(?:execution_workspaces|project_workspaces)"/.test(query)
|
||||
&& !/"(?:execution_workspaces|project_workspaces)"\."id"\s*=/.test(query)).length,
|
||||
};
|
||||
}
|
||||
|
||||
function stats(observations: Observation[]) {
|
||||
const ordered = observations.map(item => item.ms).sort((a, b) => a - b);
|
||||
return { samplesMs: observations.map(item => item.ms), medianMs: ordered[Math.floor(ordered.length / 2)],
|
||||
minMs: ordered[0], maxMs: ordered.at(-1), queryCounts: observations.map(item => item.queries.length),
|
||||
realpathCounts: observations.map(item => item.realpaths.length),
|
||||
retiredTableQueries: observations.map(item => item.retiredTableQueries),
|
||||
historicalReaderQueries: observations.map(item => item.historicalReaderQueries),
|
||||
companyInventories: observations.map(item => item.companyInventories),
|
||||
realpaths: [...new Set(observations.flatMap(item => item.realpaths))],
|
||||
sql: [...new Set(observations.flatMap(item => item.queries))],
|
||||
queryFingerprints: [...new Set(observations.flatMap(item => item.queryFingerprints))] };
|
||||
}
|
||||
|
||||
try {
|
||||
const baseline = await loadBaseline();
|
||||
database = await startEmbeddedPostgresTestDatabase("workspace-preparation-history-benchmark-");
|
||||
db = createDb(database.connectionString, { maxConnections: 5 });
|
||||
await db.$client.unsafe("set statement_timeout = '120s'");
|
||||
const results: Array<{ size: string; phase: string; baseline: ReturnType<typeof stats>; treatment: ReturnType<typeof stats> }> = [];
|
||||
const fixtureSummary = [];
|
||||
const sqlPlans: Array<{ size: string; phase: string; sql: string; plan: unknown }> = [];
|
||||
for (const size of selectedSizes) {
|
||||
const config = size === "stress" ? { workspaces: 8500, sharedRoots: 4800, sources: 5300, readers: 50 }
|
||||
: { workspaces: 8, sharedRoots: 5, sources: 6, readers: 2 };
|
||||
const fixtures: Fixture[] = [];
|
||||
for (const variant of ["baseline", "treatment"]) {
|
||||
console.error(`Seeding ${size} ${variant}: ${config.workspaces} historical workspaces`);
|
||||
fixtures.push(await createWorkspacePreparationHistoryFixture(db, path.join(root, `${size}-${variant}`), config));
|
||||
}
|
||||
fixtureSummary.push({ size, ...config, principals: 8, crossCompanyDecoys: 2 });
|
||||
const modules = [baseline, treatment];
|
||||
const phase = async (name: string, build: (module: any, recorded: Db, fixture: Fixture) => Promise<unknown>,
|
||||
prepare?: (module: any, recorded: Db, fixture: Fixture) => Promise<() => Promise<unknown>>) => {
|
||||
const observations: Observation[][] = [[], []];
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
for (const variant of iteration % 2 ? [1, 0] : [0, 1]) {
|
||||
const fixture = fixtures[variant];
|
||||
const observation = await observe(recorded => build(modules[variant], recorded, fixture),
|
||||
prepare ? recorded => prepare(modules[variant], recorded, fixture) : undefined);
|
||||
if (variant === 1) {
|
||||
assert.equal(observation.retiredTableQueries, 0, `${name} queried retired root tables`);
|
||||
assert.equal(observation.historicalReaderQueries, 0, `${name} loaded historical readers`);
|
||||
assert.equal(observation.companyInventories, 0, `${name} inventoried company workspaces`);
|
||||
assert.ok(observation.realpaths.every(cwd => cwd === fixture.cwd || cwd.startsWith(`${fixture.cwd}${path.sep}`)),
|
||||
`${name} canonicalized an unrelated path`);
|
||||
}
|
||||
if (iteration >= warmups) observations[variant].push(observation);
|
||||
}
|
||||
console.error(`${size} ${name}: ${iteration + 1}/${warmups + samples}`);
|
||||
}
|
||||
results.push({ size, phase: name, baseline: stats(observations[0]), treatment: stats(observations[1]) });
|
||||
};
|
||||
await phase("initial-local-source-guard", async (module, recorded, fixture) => {
|
||||
const service = module.executionWorkspaceRepositoryService(recorded);
|
||||
const method = service.prepareConfiguredRepositoryCopy ?? service.prepareConfiguredRepositorySource;
|
||||
assert.equal(typeof await method({ ...fixture.input, sourceCwd: fixture.cwd }), "function");
|
||||
});
|
||||
await phase("before-copy-revalidation", async () => {}, async (module, recorded, fixture) => {
|
||||
const service = module.executionWorkspaceRepositoryService(recorded);
|
||||
return (service.prepareConfiguredRepositoryCopy ?? service.prepareConfiguredRepositorySource)
|
||||
({ ...fixture.input, sourceCwd: fixture.cwd });
|
||||
});
|
||||
await phase("publication-guard", async (module, recorded, fixture) => {
|
||||
const service = module.executionWorkspaceRepositoryService(recorded);
|
||||
await (service.assertPublicationAllowed ?? service.guardForPublication)(fixture.input);
|
||||
});
|
||||
await phase("empty-repository-admission", async (module, recorded, fixture) => {
|
||||
assert.deepEqual(await module.executionWorkspaceRepositoryService(recorded).prepareForAdmission(fixture.input), []);
|
||||
});
|
||||
await phase("recovery-binding", async (module, recorded, fixture) => {
|
||||
const service = module.executionWorkspaceRepositoryService(recorded);
|
||||
await (service.assertRecoveryBinding ?? service.reserveForRecovery)(fixture.input);
|
||||
});
|
||||
const repositories = [];
|
||||
for (const fixture of fixtures) repositories.push(await addReadyWorkspaceHistoryRepository(db, fixture, { matchingHistoricalReceipts: true }));
|
||||
await db.$client.unsafe("ANALYZE");
|
||||
await phase("ready-public-repository-admission-and-warm-reuse", async (module, recorded, fixture) => {
|
||||
const rows = await module.executionWorkspaceRepositoryService(recorded).prepareForAdmission(fixture.input);
|
||||
const expected = repositories[fixtures.indexOf(fixture)];
|
||||
assert.deepEqual(rows, [{ id: expected.row.id, cwd: expected.checkout, repoUrl: expected.row.repoUrl,
|
||||
relativePath: expected.row.relativePath, pinnedCommit: fixture.pin, branchName: "main" }]);
|
||||
assert.equal(await fs.readFile(path.join(rows[0].cwd, "result.txt"), "utf8"), "Synthetic task workspace\n");
|
||||
});
|
||||
// Capture actual baseline permission SQL with its original parameters outside timing samples.
|
||||
const captures: Array<{ query: string; params: unknown[] }> = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema,
|
||||
logger: { logQuery: (query: string, params: unknown[]) => captures.push({ query, params }) } }) as Db;
|
||||
await baseline.executionWorkspaceRepositoryService(recorded).guardForPublication(fixtures[0].input);
|
||||
const expensive = captures.find(item => item.query.includes('from "execution_workspaces"') && item.query.includes("jsonb_object_keys"));
|
||||
if (expensive) {
|
||||
const [plan] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${expensive.query}`, expensive.params as never[]);
|
||||
sqlPlans.push({ size, phase: "baseline-workspace-disclosure-query", sql: expensive.query, plan: plan["QUERY PLAN"] });
|
||||
}
|
||||
}
|
||||
if (selectedSizes.includes("small") && selectedSizes.includes("stress")) {
|
||||
for (const small of results.filter(result => result.size === "small")) {
|
||||
const stress = results.find(result => result.size === "stress" && result.phase === small.phase)!;
|
||||
assert.deepEqual(stress.treatment.queryCounts, small.treatment.queryCounts, `${small.phase}: SQL count grew with peer history`);
|
||||
assert.deepEqual(stress.treatment.realpathCounts, small.treatment.realpathCounts, `${small.phase}: canonicalization count grew with peer history`);
|
||||
}
|
||||
}
|
||||
const guardPhases = results.filter(result => result.size === "stress" && result.phase !== "ready-public-repository-admission-and-warm-reuse");
|
||||
const baselineGuardMs = guardPhases.reduce((sum, result) => sum + result.baseline.medianMs, 0);
|
||||
const treatmentGuardMs = guardPhases.reduce((sum, result) => sum + result.treatment.medianMs, 0);
|
||||
const improvementPercent = guardPhases.length ? (1 - treatmentGuardMs / baselineGuardMs) * 100 : null;
|
||||
if (improvementPercent !== null) assert.ok(improvementPercent >= 90, `Guard improvement ${improvementPercent.toFixed(2)}% missed 90% target`);
|
||||
const report = { baselineRevision, treatmentRevision,
|
||||
treatmentWorkingDiff: execFileSync("git", ["diff", "--stat"], { encoding: "utf8" }),
|
||||
node: process.version, platform: process.platform, arch: process.arch,
|
||||
postgres: (await db.$client.unsafe("select version()"))[0].version,
|
||||
databaseSettings: await db.$client.unsafe("select current_setting('shared_buffers') as shared_buffers, current_setting('work_mem') as work_mem"),
|
||||
warmups, samples, fixtureSummary,
|
||||
baselineIsolation: "Complete pinned server/src snapshot; current workspace package dependencies and schema held constant",
|
||||
contract: "Both versions succeed on granted synthetic history; privacy-denial behavior changes are covered by the integration suite",
|
||||
timingScope: "Actual repository service guards/admission with ready local Git receipts; excludes network acquisition, provider startup, and copying",
|
||||
performance: { baselineGuardMs, treatmentGuardMs, improvementPercent },
|
||||
results, sqlPlans };
|
||||
const output = JSON.stringify(report, null, 2);
|
||||
if (argument("output")) await fs.writeFile(path.resolve(argument("output")!), `${output}\n`);
|
||||
console.log(output);
|
||||
} finally {
|
||||
try {
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await database?.cleanup();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rm(baselineDirectory, { recursive: true, force: true });
|
||||
} finally { clearInterval(keepAlive); }
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agents, companies, companyMemberships, createDb, closeRegisteredClients, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeRegisteredClients, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { type AiConnectionBinding, type AiConnectionPoolMember, type PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
@@ -63,6 +63,8 @@ async function fixture(provider: "anthropic" | "openai", method: "api_key" | "su
|
||||
const userId = `owner-${companyId}`;
|
||||
const binding = { provider, method, mode: "responsible_user" } as const;
|
||||
const adapterType = provider === "anthropic" ? "claude_local" : "codex_local";
|
||||
await db.insert(authUsers).values({ id: userId, name: "Connection owner", email: `${userId}@example.test`,
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companies).values({ id: companyId, name: "Hiring connection test", issuePrefix: `H${companyId.slice(0, 7)}`, defaultResponsibleUserId: userId });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:create" });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdir } from "node:fs/promises";
|
||||
import { createIsolatedTestHome } from "./helpers/isolated-test-home.js";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { createDb, closeRegisteredClients, companies, agents, companyMemberships, connectionGrants, plugins, aiConnectionRouterCursors, aiConnectionTaskPins, toolConnections, companySecrets, heartbeatRuns, issues, dotAgentBindings, grokBotRoutines } from "@paperclipai/db";
|
||||
import { createDb, closeRegisteredClients, companies, agents, authUsers, companyMemberships, connectionGrants, plugins, aiConnectionRouterCursors, aiConnectionTaskPins, toolConnections, companySecrets, heartbeatRuns, issues, dotAgentBindings, grokBotRoutines } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
|
||||
import type { AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { aiConnectionRouterService, AiConnectionPoolExhausted, poolMemberRuntimeConfig, applyAiConnectionRouterTaskSettings } from "../services/ai-connection-router.js";
|
||||
@@ -62,6 +62,7 @@ beforeAll(async () => {
|
||||
else { database = await startEmbeddedPostgresTestDatabase("paperclip-router-db-"); db = createDb(database.connectionString); }
|
||||
await db.insert(companies).values([{ id: companyId, name: "Router fixture", issuePrefix: "RTF" }, { id: otherCompanyId, name: "Other", issuePrefix: "RTO" }]);
|
||||
await db.insert(agents).values([agentId, secondAgentId].map(id => ({ id, companyId, name: "Router agent", adapterType: "paperclip_runner" })));
|
||||
await db.insert(authUsers).values(["alice", "bob"].map(id => ({ id, name: id, email: `${id}@router.example.test`, createdAt: new Date(), updatedAt: new Date() }))).onConflictDoNothing();
|
||||
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: pluginKey, version: "0.1.0", manifestJson: manifest, status: "ready" });
|
||||
const accounts = aiConnectionService(db);
|
||||
@@ -461,7 +462,10 @@ describe("durable, authorized connection routing", () => {
|
||||
recoveryRequiredAt: new Date(), recoveryReason: "work_session" });
|
||||
expect(await dotRunnerBroker(db).canStartNewWork(companyId, agent.id, bindingId)).toBe(false);
|
||||
const readiness = vi.spyOn(dotRunnerBroker(db), "canStartNewWork");
|
||||
const beforeNative = vi.fn(async () => { throw new Error("fixture stopped before provider start"); });
|
||||
const beforeNative = vi.fn(async () => {
|
||||
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agent.id));
|
||||
throw new Error("fixture stopped before provider start");
|
||||
});
|
||||
const heartbeat = heartbeatService(db, { pluginWorkerManager: worker, runtimeEnv: {},
|
||||
beforeNativeRuntimeSelection: beforeNative,
|
||||
nativeSessionBackendFactory: () => { throw new Error("Unexpected provider start"); } });
|
||||
|
||||
@@ -9171,15 +9171,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: fixture.companyId, projectId: project.id,
|
||||
name: "Source", cwd: `/tmp/chat-source-${fixture.companyId}` }).returning();
|
||||
const defaults = { workspace: { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "shared" as const } };
|
||||
await expect(service.update(endpoint.id, { executionDefaults: defaults }, "owner-user")).rejects.toMatchObject({ status: 403 });
|
||||
await db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent",
|
||||
principalId: fixture.assignedAgentId, status: "active", membershipRole: "member" });
|
||||
await db.insert(principalPermissionGrants).values([
|
||||
{ companyId: fixture.companyId, principalType: "user", principalId: "owner-user", permissionKey: "tasks:assign_scope",
|
||||
scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } },
|
||||
{ companyId: fixture.companyId, principalType: "agent", principalId: fixture.assignedAgentId, permissionKey: "tasks:assign_scope",
|
||||
scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } },
|
||||
]);
|
||||
// Selecting readable workspace configuration does not assign the task to its source project
|
||||
// or require filesystem-write grants from that project.
|
||||
await service.update(endpoint.id, { executionDefaults: defaults }, "owner-user");
|
||||
const thread = makeThread({ channelId: "C-PROTECTED", id: "slack:C-PROTECTED:8300.1", name: "protected" });
|
||||
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { assertExecutionWorkspaceRootReadAccess } from "../services/execution-workspace-repositories.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -501,7 +500,27 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true, "revoked_during_provision"] as const)("guards a private local managed reopen before Git and retains privacy after provisioning failure=%s", async scenario => {
|
||||
it("reopens from a configured repository subdirectory using its concrete Git root", async () => {
|
||||
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
|
||||
const directory = await makeExistingDir();
|
||||
const source = join(directory, "source");
|
||||
const nested = join(source, "nested");
|
||||
const target = join(directory, "restored-worktree");
|
||||
initManagedGitRepo(source, "reopen-nested");
|
||||
mkdirSync(nested);
|
||||
await db.update(projectWorkspaces).set({ cwd: nested }).where(eq(projectWorkspaces.id, projectWorkspaceId));
|
||||
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId,
|
||||
cwd: target, repoUrl: pathToFileURL(source).href, branchName: "reopen-nested" });
|
||||
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4900 });
|
||||
const reopened = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId,
|
||||
issue: { id: issueId, companyId, projectId }, actor: { agentId: null, actorType: "user" },
|
||||
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
|
||||
});
|
||||
expect(reopened).toMatchObject({ ok: true, reopened: true });
|
||||
expect((await stat(target)).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true, "revoked_during_provision"] as const)("reopens a current task using a private historical local source while preserving provisioning failure=%s", async scenario => {
|
||||
const provisionFails = scenario === true;
|
||||
const revokeDuringProvision = scenario === "revoked_during_provision";
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
@@ -527,16 +546,14 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
|
||||
await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private source", visibility: "private", status: "todo", responsibleUserId: "another-owner" });
|
||||
await db.insert(executionWorkspaces).values({ companyId, projectId: null, sourceIssueId: privateIssueId, name: "Private owner", mode: "shared_workspace", strategyType: "project_primary", cwd: source, providerType: "local_fs", status: "active" });
|
||||
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId, cwd: target, repoUrl, branchName: "reopen-feature" });
|
||||
if (!provisionFails && !revokeDuringProvision) {
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: privateIssueId }).where(eq(executionWorkspaces.id, workspaceId));
|
||||
}
|
||||
const waitingProvision = `node -e 'const fs = require("node:fs"); fs.writeFileSync("dirty.txt", "DIRTY_REOPEN_BYTES"); fs.writeFileSync("provision-started", "ready"); const deadline = Date.now() + 15000; const timer = setInterval(() => { if (fs.existsSync("provision-continue")) { clearInterval(timer); process.exit(0); } if (Date.now() > deadline) process.exit(2); }, 20);'`;
|
||||
if (provisionFails || revokeDuringProvision) await db.update(executionWorkspaces).set({ metadata: { [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: provisionFails ? "exit 42" : waitingProvision } } }).where(eq(executionWorkspaces.id, workspaceId));
|
||||
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4901 });
|
||||
if (revokeDuringProvision) await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
||||
const reopen = () => executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId, issue: { id: issueId, companyId, projectId }, actor: { actorType: "user", agentId: null }, authorizationActor });
|
||||
expect(await reopen()).toMatchObject({ ok: false, code: "rebuild_failed" });
|
||||
await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const managed = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "private-source" });
|
||||
await expect(stat(managed)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await db.insert(issueAccessGrants).values({ issueId: privateIssueId, subjectType: "user", subjectId: readerId, source: "explicit" });
|
||||
const attempt = revokeDuringProvision ? reopen() : null;
|
||||
if (attempt) {
|
||||
try {
|
||||
@@ -547,27 +564,17 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
|
||||
}
|
||||
}
|
||||
const results = attempt ? [await attempt] : provisionFails ? [await reopen()] : await Promise.all([reopen(), reopen()]);
|
||||
expect(results.every(result => result.ok)).toBe(!provisionFails && !revokeDuringProvision);
|
||||
if (!provisionFails) expect(results).toEqual(results.map(() => expect.objectContaining({ ok: true })));
|
||||
else expect(results.every(result => !result.ok)).toBe(true);
|
||||
if (!provisionFails && !revokeDuringProvision) expect(results.map(result => result.ok && result.reopened).sort()).toEqual([false, true]);
|
||||
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
|
||||
if (revokeDuringProvision) {
|
||||
expect(results[0]).toMatchObject({ ok: false, code: "rebuild_failed" });
|
||||
const reopened = await readWorkspace(workspaceId);
|
||||
expect(reopened?.status).toBe("active");
|
||||
expect(metadataHasReopenPendingConsumption(reopened?.metadata)).toBe(false);
|
||||
expect(results[0]).toMatchObject({ ok: true });
|
||||
expect(await readFile(join(target, "dirty.txt"), "utf8")).toBe("DIRTY_REOPEN_BYTES");
|
||||
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, workspaceId));
|
||||
expect(activity.map(row => row.action)).toContain("execution_workspace.reopen_unconsumed");
|
||||
const archive = await executionWorkspaceService(db).archiveWorkspaceUnderLifecycleLock({ id: workspaceId, patch: {}, closedAt: new Date() });
|
||||
expect(archive?.outcome).toBe("archived");
|
||||
expect(await readFile(join(target, "dirty.txt"), "utf8")).toBe("DIRTY_REOPEN_BYTES");
|
||||
} else {
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: target, actor: authorizationActor })).resolves.toBeUndefined();
|
||||
}
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, projectWorkspaceId));
|
||||
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
|
||||
await rm(source, { recursive: true, force: true });
|
||||
for (const cwd of [managed, target]) await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd, actor: authorizationActor })).rejects.toMatchObject({ status: 403 });
|
||||
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { activityLog, agents, companies, companyMemberships, createDb, execution
|
||||
import { eq } from "drizzle-orm";
|
||||
import { taskRepositoryRelativePath } from "../services/execution-workspace-repositories.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { canActorReadExecutionWorkspace } from "../services/authorization.js";
|
||||
import {
|
||||
createFileResourceAvailabilityLimiter,
|
||||
createFileResourceLimiter,
|
||||
@@ -344,10 +345,10 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
expect(download.text).toBe("CURRENT_TASK_REPORT");
|
||||
const explicit = { projectId: graph.projectId, workspaceId: graph.projectWorkspaceId };
|
||||
for (const target of [explicit, { workspace: "project" }]) {
|
||||
await request(app).get(`${base}/list`).query(target).expect(404);
|
||||
await request(app).get(`${base}/resolve`).query({ ...target, path: "report.txt" }).expect(404);
|
||||
await request(app).get(`${base}/content`).query({ ...target, path: "report.txt" }).expect(404);
|
||||
await request(app).get(`${base}/content`).query({ ...target, path: "report.txt", download: true }).expect(404);
|
||||
await request(app).get(`${base}/list`).query(target).expect(alternative === "denied" ? 200 : 404);
|
||||
await request(app).get(`${base}/resolve`).query({ ...target, path: "report.txt" }).expect(alternative === "denied" ? 200 : 404);
|
||||
await request(app).get(`${base}/content`).query({ ...target, path: "report.txt" }).expect(alternative === "denied" ? 200 : 404);
|
||||
await request(app).get(`${base}/content`).query({ ...target, path: "report.txt", download: true }).expect(alternative === "denied" ? 200 : 404);
|
||||
}
|
||||
const batch = await request(app).post(`${base}/availability`).send({ queries: [
|
||||
{ path: "report.txt" }, { workspace: "execution", path: "report.txt" },
|
||||
@@ -356,8 +357,8 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
expect(batch.body.results).toEqual([
|
||||
expect.objectContaining({ openable: true, resource: expect.objectContaining({ workspaceKind: "execution_workspace" }) }),
|
||||
expect.objectContaining({ openable: true, resource: expect.objectContaining({ workspaceKind: "execution_workspace" }) }),
|
||||
expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null }),
|
||||
expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null }),
|
||||
alternative === "denied" ? expect.objectContaining({ openable: true }) : expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null }),
|
||||
alternative === "denied" ? expect.objectContaining({ openable: true }) : expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null }),
|
||||
]);
|
||||
expect(JSON.stringify(batch.body)).not.toContain(projectRoot);
|
||||
expect(JSON.stringify(batch.body)).not.toContain("PRIVATE_ALTERNATIVE_BYTES");
|
||||
@@ -379,7 +380,7 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("checks shared physical-root privacy before every file read and retains moved or deleted source provenance", async () => {
|
||||
it("reads task-bound files regardless of historical private sources and leaves legacy ledgers unchanged", async () => {
|
||||
const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
|
||||
try {
|
||||
const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot });
|
||||
@@ -393,6 +394,11 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
const [source] = await db.insert(executionWorkspaces).values({ companyId: graph.companyId, sourceIssueId: privateIssueId,
|
||||
name: "Private source workspace", cwd: executionRoot, mode: "shared_workspace", strategyType: "task_directory", providerType: "local_fs" }).returning();
|
||||
await fs.writeFile(path.join(executionRoot, "secret.txt"), "PRIVATE_ROOT_BYTES");
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, graph.issueId));
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: privateIssueId, name: "Private original workspace metadata" })
|
||||
.where(eq(executionWorkspaces.id, task.executionWorkspaceId!));
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: null }).where(eq(executionWorkspaces.id, task.executionWorkspaceId!));
|
||||
expect(await canActorReadExecutionWorkspace(db, { type: "board", userId: "board-user", source: "session", companyIds: [graph.companyId] }, task.executionWorkspaceId!)).toBe(false);
|
||||
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
|
||||
const targets = [{ workspace: "execution" }, { projectId: graph.projectId, workspaceId: graph.projectWorkspaceId }];
|
||||
const reads = () => targets.flatMap(target => [
|
||||
@@ -402,20 +408,17 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ ...target, path: "secret.txt", download: true }),
|
||||
request(app).post(`/api/issues/${graph.issueId}/file-resources/availability`).send({ queries: [{ ...target, path: "secret.txt" }] }),
|
||||
]);
|
||||
const deny = async () => {
|
||||
const allow = async () => {
|
||||
for (const pending of reads()) {
|
||||
const response = await pending;
|
||||
expect(response.status).toBe(200);
|
||||
if (response.body.kind === "workspace_file_availability") {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.results).toEqual([expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null })]);
|
||||
} else expect(response.status).toBe(404);
|
||||
for (const hidden of ["PRIVATE_ROOT_BYTES", source.id, privateIssueId, executionRoot, "Private source workspace"]) expect(JSON.stringify(response.body)).not.toContain(hidden);
|
||||
expect(response.body.results).toEqual([expect.objectContaining({ openable: true })]);
|
||||
}
|
||||
for (const hidden of [source.id, privateIssueId, "Private source workspace", "Private original workspace metadata"]) expect(JSON.stringify(response.body)).not.toContain(hidden);
|
||||
}
|
||||
};
|
||||
await deny();
|
||||
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));
|
||||
await allow();
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId))).toEqual([]);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId))).toEqual([]);
|
||||
const [grant] = await db.insert(issueAccessGrants).values({ issueId: privateIssueId, subjectType: "user", subjectId: "board-user", source: "explicit" }).returning();
|
||||
@@ -426,17 +429,17 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
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));
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
await deny();
|
||||
await allow();
|
||||
await db.update(issueAccessGrants).set({ revokedAt: null }).where(eq(issueAccessGrants.id, grant.id));
|
||||
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "secret.txt" }).expect(200);
|
||||
await db.delete(executionWorkspaces).where(eq(executionWorkspaces.id, source.id));
|
||||
await deny();
|
||||
await allow();
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId))).toEqual(provenance);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId))).toEqual([]);
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("protects a private configured-project alias even without an execution workspace", async () => {
|
||||
it("ignores an unrelated private project alias while reading the current task root", async () => {
|
||||
const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
|
||||
try {
|
||||
const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot });
|
||||
@@ -446,7 +449,7 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
await db.update(projectWorkspaces).set({ cwd: executionRoot }).where(eq(projectWorkspaces.id, graph.targetProjectWorkspaceId));
|
||||
await fs.writeFile(path.join(executionRoot, "private-project.txt"), "PRIVATE_CONFIGURED_PROJECT");
|
||||
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
|
||||
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "private-project.txt" }).expect(404);
|
||||
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "private-project.txt" }).expect(200);
|
||||
await db.insert(projectAccessMembers).values({ companyId: graph.companyId, projectId: graph.targetProjectId, subjectType: "user", subjectId: "board-user" });
|
||||
const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "private-project.txt" }).expect(200);
|
||||
expect(response.body.content.data).toBe("PRIVATE_CONFIGURED_PROJECT");
|
||||
@@ -456,7 +459,7 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
it.each([
|
||||
["configured", "ancestor"], ["configured", "descendant"],
|
||||
["execution", "ancestor"], ["execution", "descendant"],
|
||||
] as const)("protects %s private roots from a public %s file browser, including retained moved ownership", async (ownerKind, relation) => {
|
||||
] as const)("allows files in authorized roots despite %s private %s aliases and retained history", async (ownerKind, relation) => {
|
||||
const { root, projectRoot, targetProjectRoot } = await makeWorkspace();
|
||||
try {
|
||||
const ancestor = path.join(root, "work"), descendant = path.join(ancestor, "private");
|
||||
@@ -489,22 +492,22 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
access: await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId)),
|
||||
policies: await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId)),
|
||||
});
|
||||
const deny = async () => {
|
||||
const allow = async () => {
|
||||
const before = await readState();
|
||||
for (const target of targets) {
|
||||
for (const [endpoint, query] of [
|
||||
["list", {}], ["resolve", { path: relativeFile }], ["content", { path: relativeFile }], ["content", { path: relativeFile, download: true }],
|
||||
] as const) {
|
||||
const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/${endpoint}`).query({ ...target, ...query }).expect(404);
|
||||
for (const hidden of ["PRIVATE_OVERLAPPING_ROOT_BYTES", ownerId, privateRoot, "Private overlapping"]) expect(JSON.stringify(response.body)).not.toContain(hidden);
|
||||
const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/${endpoint}`).query({ ...target, ...query }).expect(200);
|
||||
for (const hidden of [ownerId, "Private overlapping"]) expect(JSON.stringify(response.body)).not.toContain(hidden);
|
||||
}
|
||||
const availability = await request(app).post(`/api/issues/${graph.issueId}/file-resources/availability`)
|
||||
.send({ queries: [{ ...target, path: relativeFile }] }).expect(200);
|
||||
expect(availability.body.results).toEqual([expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null })]);
|
||||
expect(availability.body.results).toEqual([expect.objectContaining({ openable: true })]);
|
||||
}
|
||||
expect(await readState()).toEqual(before);
|
||||
};
|
||||
await deny();
|
||||
await allow();
|
||||
const grant = ownerKind === "configured"
|
||||
? (await db.insert(projectAccessMembers).values({ companyId: graph.companyId, projectId: ownerId, subjectType: "user", subjectId: "board-user" }).returning())[0]!
|
||||
: (await db.insert(issueAccessGrants).values({ issueId: privateIssueId!, subjectType: "user", subjectId: "board-user", source: "explicit" }).returning())[0]!;
|
||||
@@ -525,7 +528,7 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
await db.update(executionWorkspaces).set({ cwd: targetProjectRoot }).where(eq(executionWorkspaces.id, ownerId));
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
}
|
||||
await deny();
|
||||
await allow();
|
||||
expect(await fs.readFile(path.join(publicRoot, relativeFile), "utf8")).toBe("PRIVATE_OVERLAPPING_ROOT_BYTES");
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
@@ -737,6 +737,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
|
||||
try {
|
||||
await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix));
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]);
|
||||
await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway));
|
||||
await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Discard deferred follow-up", agentId, issuePrefix));
|
||||
const firstComment = await db
|
||||
@@ -852,6 +853,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
|
||||
try {
|
||||
await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix));
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]);
|
||||
|
||||
await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway));
|
||||
|
||||
@@ -1631,13 +1633,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
paperclipUserId: userId,
|
||||
status: "linked",
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", userId]);
|
||||
await db.insert(chatDeliveries).values({
|
||||
id: deliveryId,
|
||||
companyId,
|
||||
@@ -2169,6 +2165,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
|
||||
try {
|
||||
await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix));
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]);
|
||||
|
||||
await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway));
|
||||
|
||||
@@ -2307,6 +2304,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
|
||||
try {
|
||||
await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix));
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user"]);
|
||||
|
||||
await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Local CLI Agent", gateway));
|
||||
|
||||
@@ -2998,6 +2996,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
|
||||
try {
|
||||
await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix));
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user"]);
|
||||
|
||||
await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, companyMemberships, companySkills, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, companySkills, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { withSkillVersionCacheLock } from "../services/skill-version-cache.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
@@ -105,6 +105,8 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), projectId = randomUUID();
|
||||
const userId = `configuration-user-${companyId}`;
|
||||
const credential = `fixture-api-key-${companyId}`;
|
||||
await db.insert(authUsers).values({ id: userId, name: "Configuration user", email: `${userId}@example.test`,
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companies).values({ id: companyId, name: "Execution configuration fixture", issuePrefix: `C${companyId.slice(0, 6)}` });
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
||||
const account = managed ? await aiConnectionService(db).save(companyId, userId, {
|
||||
@@ -187,7 +189,11 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
}
|
||||
skillFailures.set(f.companyId, "Preconfigured mode must not resolve assigned skills");
|
||||
const prepare = vi.spyOn(aiRuntime, "prepareManagedAiRuntime");
|
||||
const beforeNative = vi.fn(async () => { throw new Error("fixture stopped before provider start"); });
|
||||
const beforeNative = vi.fn(async () => {
|
||||
// The fixture intentionally stops preparation; do not schedule a retry.
|
||||
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
||||
throw new Error("fixture stopped before provider start");
|
||||
});
|
||||
const nativeHeartbeat = heartbeatService(db, { beforeNativeRuntimeSelection: beforeNative });
|
||||
try {
|
||||
const run = await nativeHeartbeat.wakeup(f.agentId, { source: "on_demand", triggerDetail: "manual", reason: "manual",
|
||||
|
||||
@@ -130,7 +130,7 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers).where(eq(authUsers.id, "responsible-user"));
|
||||
await db.delete(authUsers);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -247,6 +247,10 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
// The responsible-user intersection requires both the account and its
|
||||
// active membership; a membership pointing at a nonexistent user is denied.
|
||||
await db.insert(authUsers).values({ id: ownerUserId, name: "Fixture owner", email: `${ownerUserId}@example.test`,
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
@@ -550,6 +554,10 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
|
||||
const issueId = randomUUID();
|
||||
const wakeId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Review Co", issuePrefix: `R${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" });
|
||||
await db.insert(authUsers).values({ id: "responsible-user", name: "Fixture user", email: "[email protected]",
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user",
|
||||
status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Reviewer", role: "engineer", adapterType: "codex_local", runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Repair finalized before restart", status: "in_review", assigneeAgentId: agentId, responsibleUserId: "responsible-user" });
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
|
||||
@@ -500,17 +500,26 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const childProcesses = new Set<ChildProcess>();
|
||||
const cleanupPids = new Set<number>();
|
||||
|
||||
function recoveryCompanyRow(
|
||||
rowId: (typeof companies.$inferInsert)["id"],
|
||||
async function seedRecoveryCompany(
|
||||
companyId: string,
|
||||
issuePrefix: (typeof companies.$inferInsert)["issuePrefix"],
|
||||
): typeof companies.$inferInsert {
|
||||
return {
|
||||
id: rowId,
|
||||
) {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
};
|
||||
});
|
||||
// The account created in beforeAll also needs an active membership for
|
||||
// admission's current responsible-user authorization intersection.
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: "responsible-user",
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
}
|
||||
|
||||
function recoveryAgentRow(
|
||||
@@ -849,7 +858,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const now = new Date("2026-03-19T00:00:00.000Z");
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix));
|
||||
await seedRecoveryCompany(companyId, issuePrefix);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
@@ -1081,7 +1090,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const now = new Date("2026-03-19T00:00:00.000Z");
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix));
|
||||
await seedRecoveryCompany(companyId, issuePrefix);
|
||||
|
||||
await db.insert(agents).values(recoveryAgentRow(agentId, companyId, "CodexCoder", "engineer", "idle"));
|
||||
|
||||
@@ -1263,9 +1272,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const wakeReason = input?.wakeReason ?? "execution_review_requested";
|
||||
|
||||
await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix));
|
||||
await seedRecoveryCompany(companyId, issuePrefix);
|
||||
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values(recoveryAgentRow(agentId, companyId, "CodexReviewer", "engineer", "idle"));
|
||||
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
@@ -1341,7 +1349,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix));
|
||||
await seedRecoveryCompany(companyId, issuePrefix);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
@@ -1528,9 +1536,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const now = new Date("2026-03-19T00:00:00.000Z");
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix));
|
||||
await seedRecoveryCompany(companyId, issuePrefix);
|
||||
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
@@ -10833,7 +10840,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), interactionId = randomUUID();
|
||||
const resolvedAt = new Date("2026-03-19T00:05:00.000Z");
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix));
|
||||
await seedRecoveryCompany(companyId, issuePrefix);
|
||||
await db.insert(agents).values({
|
||||
id: agentId, companyId, name: input.agentName ?? "CodexCoder", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {},
|
||||
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } }, permissions: {},
|
||||
@@ -11490,7 +11497,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const blockerIssueId = randomUUID();
|
||||
const blockedIssueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix));
|
||||
await seedRecoveryCompany(companyId, issuePrefix);
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: creatorAgentId,
|
||||
@@ -11617,7 +11624,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix));
|
||||
await seedRecoveryCompany(companyId, issuePrefix);
|
||||
await db.insert(agents).values(recoveryAgentRow(agentId, companyId, "CodexCoder", "engineer", "idle"));
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
|
||||
@@ -20,7 +20,6 @@ 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";
|
||||
@@ -344,7 +343,7 @@ 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(["shared_workspace", "isolated_workspace"] as const)("blocks shared source adapter admission after grant revocation with retained mode %s", async (retainedMode) => {
|
||||
it.each(["shared_workspace", "isolated_workspace"] as const)("continues authorized source execution after filesystem assignment grants are revoked 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");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
@@ -387,10 +386,9 @@ suite("task project repository provisioning", () => {
|
||||
expect(await accessService(db).decide({ actor, action: "tasks:assign", resource: { type: "issue", companyId, projectId, assigneeAgentId: agentId },
|
||||
scope: { projectId, assigneeAgentId: agentId } })).toMatchObject({ allowed: false, reason: "deny_policy_restricted" });
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
const denied = await admit();
|
||||
expect(denied.status).toBe("failed");
|
||||
expect(denied.error).toContain("Target project is protected and requires an explicit assignment grant");
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === denied.id)).toHaveLength(0);
|
||||
const continued = await admit();
|
||||
expect(continued).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === continued.id)).toHaveLength(1);
|
||||
const [retained] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(retained.executionWorkspaceId).toBe(bound.executionWorkspaceId);
|
||||
expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Protected project files");
|
||||
@@ -425,7 +423,7 @@ suite("task project repository provisioning", () => {
|
||||
expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Existing assignment files");
|
||||
}, 30_000);
|
||||
|
||||
it.each(["project_primary", "adapter_managed", "cloud_sandbox", "git_worktree"] as const)("authorizes isolated source admission by its actual strategy: %s", async (strategy) => {
|
||||
it.each(["project_primary", "adapter_managed", "cloud_sandbox", "git_worktree"] as const)("authorizes current selected source without requiring filesystem assignment grants: %s", async (strategy) => {
|
||||
const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const cwd = path.join(root, companyId, "protected-source");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
@@ -454,22 +452,18 @@ suite("task project repository provisioning", () => {
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
const result = await heartbeat.getRun(run!.id);
|
||||
const calls = execute.mock.calls.filter(([input]) => input.runId === run!.id);
|
||||
if (strategy !== "git_worktree") {
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.error).toContain("Target project is protected and requires an explicit assignment grant");
|
||||
expect(calls).toHaveLength(0);
|
||||
} else {
|
||||
expect(result).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(calls).toHaveLength(1);
|
||||
const workspace = calls[0]![0].context.paperclipWorkspace;
|
||||
expect(result).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(calls).toHaveLength(1);
|
||||
const workspace = calls[0]![0].context.paperclipWorkspace;
|
||||
if (strategy === "git_worktree") {
|
||||
expect(workspace.strategy).toBe("git_worktree");
|
||||
expect(await realpath(workspace.cwd)).not.toBe(await realpath(cwd));
|
||||
expect(await readFile(path.join(workspace.cwd, "work.txt"), "utf8")).toBe("Protected source content");
|
||||
}
|
||||
expect(await readFile(path.join(workspace.cwd, "work.txt"), "utf8")).toBe("Protected source content");
|
||||
expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Protected source content");
|
||||
}, 30_000);
|
||||
|
||||
it.each(["success", "provision_failure", "copy_only_provision", "occupied_target"] as const)("checks nested private source access before worktree copy and retains it after read-grant revocation (%s)", async (scenario) => {
|
||||
it.each(["success", "provision_failure", "copy_only_provision", "occupied_target"] as const)("copies selected Git source regardless of nested private filesystem history while preserving provisioning integrity (%s)", async (scenario) => {
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
try {
|
||||
@@ -521,17 +515,6 @@ suite("task project repository provisioning", () => {
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
return (await heartbeat.getRun(run!.id))!;
|
||||
};
|
||||
const denied = await admit();
|
||||
expect(denied.status).toBe("failed");
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === denied.id)).toHaveLength(0);
|
||||
expect(git("worktree", "list", "--porcelain")).toBe(initialWorktrees);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId).toBeNull();
|
||||
await db.insert(projectAccessMembers).values([
|
||||
{ companyId, projectId: privateProjectId, subjectType: "agent", subjectId: agentId },
|
||||
{ companyId, projectId: privateProjectId, subjectType: "user", subjectId: "responsible-user" },
|
||||
]);
|
||||
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: scenario === "provision_failure" });
|
||||
const allowed = await admit();
|
||||
const calls = execute.mock.calls.filter(([input]) => input.runId === allowed.id);
|
||||
let copiedCwd: string;
|
||||
@@ -544,7 +527,7 @@ suite("task project repository provisioning", () => {
|
||||
expect(paths).toHaveLength(2);
|
||||
copiedCwd = paths.find(entry => entry !== paths[0])!;
|
||||
} else {
|
||||
expect(allowed.error).toMatch(/protected|assignment grant/);
|
||||
expect(allowed.error).toMatch(/exists|occupied|worktree|checkout|provision|protected|assignment grant/i);
|
||||
expect(git("worktree", "list", "--porcelain")).toBe(initialWorktrees);
|
||||
copiedCwd = occupiedTarget;
|
||||
await expect(readFile(path.join(copiedCwd, ".git"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -556,10 +539,6 @@ suite("task project repository provisioning", () => {
|
||||
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") {
|
||||
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/);
|
||||
}
|
||||
} else {
|
||||
expect(allowed).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(calls).toHaveLength(1);
|
||||
@@ -587,8 +566,7 @@ suite("task project repository provisioning", () => {
|
||||
expect((await request(app).get(fileUrl).query({ workspace: "execution", path: "private/secret.txt" }).expect(200)).body.content.data).toBe("NESTED_PRIVATE_TRACKED_BYTES");
|
||||
await db.delete(projectAccessMembers).where(and(eq(projectAccessMembers.projectId, privateProjectId), eq(projectAccessMembers.subjectId, "responsible-user")));
|
||||
for (const download of [false, true]) {
|
||||
const response = await request(app).get(fileUrl).query({ workspace: "execution", path: "private/secret.txt", download }).expect(404);
|
||||
expect(JSON.stringify(response.body)).not.toContain("NESTED_PRIVATE_TRACKED_BYTES");
|
||||
await request(app).get(fileUrl).query({ workspace: "execution", path: "private/secret.txt", download }).expect(200);
|
||||
}
|
||||
expect(await readFile(path.join(workspace.cwd, "private/secret.txt"), "utf8")).toBe("NESTED_PRIVATE_TRACKED_BYTES");
|
||||
} finally {
|
||||
@@ -644,7 +622,7 @@ suite("task project repository provisioning", () => {
|
||||
expect(execute.mock.calls).toHaveLength(callsBeforeRevocation);
|
||||
} else {
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error).toContain("Workspace source is unavailable or inaccessible");
|
||||
expect(result.error).toMatch(/Workspace source is unavailable or inaccessible|Task workspace source is unavailable/);
|
||||
expect(calls).toHaveLength(0);
|
||||
}
|
||||
}, 40_000);
|
||||
@@ -755,7 +733,7 @@ suite("task project repository provisioning", () => {
|
||||
expect(await readFile(path.join(source, "README.md"), "utf8")).toBe("preserved dirty work");
|
||||
}, 40_000);
|
||||
|
||||
it.each(["fresh", "existing", "occupied", "file_url", "absolute_path"] as const)("authorizes a late private configured sibling before %s copy and retains its privacy", async (scenario) => {
|
||||
it.each(["fresh", "existing", "occupied", "file_url", "absolute_path"] as const)("prepares an authorized configured sibling independently of private task filesystem history (%s)", async (scenario) => {
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
try {
|
||||
@@ -787,7 +765,8 @@ suite("task project repository provisioning", () => {
|
||||
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "managed_isolated" } } });
|
||||
const admit = async () => {
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } });
|
||||
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", manualUserWake: true,
|
||||
requestedByActorType: "user", requestedByActorId: "responsible-user", contextSnapshot: { issueId } });
|
||||
expect(run).not.toBeNull();
|
||||
await vi.waitFor(async () => expect((await heartbeat.getRun(run!.id))?.status).toMatch(/^(succeeded|failed)$/), { timeout: 15_000 });
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
@@ -811,15 +790,6 @@ suite("task project repository provisioning", () => {
|
||||
else await mkdir(copy);
|
||||
await writeFile(path.join(copy, "private.txt"), "PRESERVED_EXISTING_SIBLING_BYTES");
|
||||
}
|
||||
const denied = await admit();
|
||||
expect(denied.status).toBe("failed");
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === denied.id)).toHaveLength(0);
|
||||
if (scenario === "fresh" || urlOnly) expect(await readdir(path.join(taskCwd, ".paperclip-repositories")).catch(() => [])).toEqual([]);
|
||||
else expect(await readFile(path.join(copy, "private.txt"), "utf8")).toBe("PRESERVED_EXISTING_SIBLING_BYTES");
|
||||
await db.insert(issueAccessGrants).values([
|
||||
{ issueId: privateIssueId, subjectType: "agent", subjectId: agentId, source: "explicit" },
|
||||
{ issueId: privateIssueId, subjectType: "user", subjectId: "responsible-user", source: "explicit" },
|
||||
]);
|
||||
const authorized = await admit();
|
||||
expect(authorized.status).toBe(scenario === "occupied" ? "failed" : "succeeded");
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === authorized.id)).toHaveLength(scenario === "occupied" ? 0 : 1);
|
||||
@@ -839,19 +809,26 @@ suite("task project repository provisioning", () => {
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, siblingId));
|
||||
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
|
||||
for (const download of [false, true]) {
|
||||
const response = await request(app).get(fileUrl).query({ workspace: "execution", path: relativeFile, download }).expect(404);
|
||||
expect(JSON.stringify(response.body)).not.toContain(expectedBytes);
|
||||
await request(app).get(fileUrl).query({ workspace: "execution", path: relativeFile, download }).expect(200);
|
||||
}
|
||||
const deniedContinuation = await admit();
|
||||
expect(deniedContinuation.status).toBe("failed");
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === deniedContinuation.id)).toHaveLength(0);
|
||||
expect(await readFile(path.join(copy, "private.txt"), "utf8")).toBe(expectedBytes);
|
||||
// The fixture adapter reports no progress; exercise reuse outside its unrelated wake cooldown.
|
||||
await db.update(heartbeatRuns).set({ startedAt: new Date(Date.now() - 181_000), finishedAt: new Date(Date.now() - 180_000) })
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
const continued = await admit();
|
||||
expect(continued.status).toBe("succeeded");
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === continued.id)).toHaveLength(1);
|
||||
// Removing a configured repository detaches its files under the same task root.
|
||||
const detachedRoot = path.join(taskCwd, ".paperclip-runtime", "detached-repositories");
|
||||
const detached = await readdir(detachedRoot);
|
||||
expect(detached.length).toBeGreaterThan(0);
|
||||
const retained = await Promise.all(detached.map(name => readFile(path.join(detachedRoot, name, "private.txt"), "utf8").catch(() => null)));
|
||||
expect(retained).toContain(expectedBytes);
|
||||
} finally {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", previousPrivacyMode);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("guards an unbound private file-URL anchor before cloning and retains copied source privacy", async () => {
|
||||
it("checks current run identity for an unbound file-URL anchor without inheriting private filesystem history", async () => {
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
try {
|
||||
@@ -884,8 +861,9 @@ 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();
|
||||
// The resolver derives the sponsor from the authoritative run, ignoring a stale context hint.
|
||||
await expect(resolver.resolveWorkspaceForRun(actualAgent, { issueId, executionIdentityRunId: ownedRun.id, responsibleUserId: "private-owner" }, null))
|
||||
.rejects.toThrow(/access|source/i);
|
||||
.resolves.toMatchObject({ source: "project_primary" });
|
||||
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
|
||||
const admit = async () => {
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
@@ -895,15 +873,7 @@ suite("task project repository provisioning", () => {
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
return (await heartbeat.getRun(run!.id))!;
|
||||
};
|
||||
const denied = await admit();
|
||||
expect(denied.status).toBe("failed");
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === denied.id)).toHaveLength(0);
|
||||
const managedCwd = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "private-anchor" });
|
||||
await expect(readdir(managedCwd)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await db.insert(issueAccessGrants).values([
|
||||
{ issueId: privateIssueId, subjectType: "agent", subjectId: agentId, source: "explicit" },
|
||||
{ issueId: privateIssueId, subjectType: "user", subjectId: "responsible-user", source: "explicit" },
|
||||
]);
|
||||
const authorized = await admit();
|
||||
expect(authorized).toMatchObject({ status: "succeeded", error: null });
|
||||
const copiedCwd = execute.mock.calls.find(([input]) => input.runId === authorized.id)![0].context.paperclipWorkspace.cwd;
|
||||
@@ -918,11 +888,11 @@ suite("task project repository provisioning", () => {
|
||||
expect((await request(app).get(fileUrl).query({ workspace: "execution", path: "secret.txt" }).expect(200)).body.content.data).toBe("PRIVATE_ANCHOR_COMMITTED_BYTES");
|
||||
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
|
||||
await expect(resolver.resolveWorkspaceForRun(actualAgent, { issueId, executionIdentityRunId: authorized.id, responsibleUserId: "private-owner" }, null))
|
||||
.rejects.toThrow(/access|source/i);
|
||||
.resolves.toMatchObject({ source: "project_primary" });
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, sourceId));
|
||||
await rename(source, source + "-removed");
|
||||
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
|
||||
for (const download of [false, true]) await request(app).get(fileUrl).query({ workspace: "execution", path: "secret.txt", download }).expect(404);
|
||||
for (const download of [false, true]) await request(app).get(fileUrl).query({ workspace: "execution", path: "secret.txt", download }).expect(200);
|
||||
expect(await readFile(path.join(copiedCwd, "secret.txt"), "utf8")).toBe("PRIVATE_ANCHOR_COMMITTED_BYTES");
|
||||
} finally {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", previousPrivacyMode);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { getRunLogStore } from "../services/run-log-store.js";
|
||||
@@ -22,6 +22,8 @@ describePostgres("heartbeat output through adapter dispatch and finalization", (
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-run-output-");
|
||||
db = createDb(temporary.connectionString);
|
||||
await db.insert(authUsers).values({ id: "output-user", name: "Output fixture sponsor", email: "[email protected]",
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
heartbeat = heartbeatService(db);
|
||||
registerServerAdapter({
|
||||
type: adapterType, supportsLocalAgentJwt: false,
|
||||
@@ -44,6 +46,8 @@ describePostgres("heartbeat output through adapter dispatch and finalization", (
|
||||
async function seed(execute: (input: AdapterExecutionContext) => Promise<void>) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Output fixture", issuePrefix: `O${companyId.slice(0, 6)}` });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "output-user",
|
||||
membershipRole: "operator", status: "active" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Output writer", adapterType, status: "idle" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Output fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: "output-user", issueNumber: 1 });
|
||||
executeByAgent.set(agentId, execute);
|
||||
|
||||
@@ -3,9 +3,11 @@ import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
agentWakeupRequests,
|
||||
agentRuntimeState,
|
||||
companies,
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
@@ -180,6 +182,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
// the TRUNCATE below runs.
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
await cleanupHeartbeatInvalidationFixture(db);
|
||||
await db.delete(authUsers).where(eq(authUsers.id, "responsible-user"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -196,6 +199,22 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
// Admission intersects agent authority with an existing responsible user
|
||||
// and that user's active company membership.
|
||||
await db.insert(authUsers).values({
|
||||
id: "responsible-user",
|
||||
name: "Fixture user",
|
||||
email: "[email protected]",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoNothing();
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: "responsible-user",
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
|
||||
import { FAST_RESPONSE_AGENT_GUIDANCE } from "@paperclipai/shared";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
@@ -26,6 +26,8 @@ describePostgres("heartbeat task context delivered to an adapter", () => {
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-task-context-");
|
||||
db = createDb(temporary.connectionString);
|
||||
await db.insert(authUsers).values({ id: "context-user", name: "Context user", email: "[email protected]",
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
heartbeat = heartbeatService(db);
|
||||
registerServerAdapter({
|
||||
type: adapterType,
|
||||
@@ -48,6 +50,8 @@ describePostgres("heartbeat task context delivered to an adapter", () => {
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Task context fixture", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "context-user",
|
||||
membershipRole: "member", status: "active" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Context reader", adapterType, status: "idle" });
|
||||
await db.insert(issues).values({
|
||||
id: issueId, companyId, title: "Task context fixture", description: "Assignment description canary",
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
costEvents,
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySkills,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
@@ -37,6 +39,8 @@ describeEmbeddedPostgres("heartbeat task-drain admission release", () => {
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-task-drain-admission-release-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
await db.insert(authUsers).values({ id: "responsible-user", name: "Drain fixture sponsor",
|
||||
email: "[email protected]", createdAt: new Date(), updatedAt: new Date() });
|
||||
}, 60_000);
|
||||
|
||||
function isHeartbeatRunDependentFkError(error: unknown) {
|
||||
@@ -70,6 +74,7 @@ describeEmbeddedPostgres("heartbeat task-drain admission release", () => {
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -88,6 +93,8 @@ describeEmbeddedPostgres("heartbeat task-drain admission release", () => {
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user",
|
||||
membershipRole: "operator", status: "active" });
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
|
||||
@@ -1075,7 +1075,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["git_worktree", "project_primary"] as const)("blocks a reused %s workspace with the owner action once a restricted task worktree shares it", async (strategyType) => {
|
||||
it.each(["git_worktree", "project_primary"] as const)("reuses a %s workspace when a private historical task worktree shares its Git root", async (strategyType) => {
|
||||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
|
||||
const seeded = await seedRetainedSourceRun("deleted");
|
||||
if (strategyType === "project_primary") {
|
||||
@@ -1095,25 +1095,30 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
providerType: "git_worktree", cwd: privateCwd, providerRef: privateCwd, branchName: "restricted-task", status: "idle" }).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: privateWorkspace.id }).where(eq(issues.id, privateIssue.id));
|
||||
|
||||
adapterExecute.mockImplementationOnce(async () => {
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, seeded.issueId));
|
||||
return { exitCode: 0, signal: null, timedOut: false, summary: "Completed shared-root work.", provider: "test", model: "test-model" };
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const run = await waitForRunToFinish(heartbeat, seeded.runId);
|
||||
await heartbeat.waitForRunExecutionDrain(seeded.runId);
|
||||
await waitForPendingRunFailureReports();
|
||||
|
||||
expect(run).toMatchObject({ status: "failed", errorCode: "configuration_incomplete", resultJson: {
|
||||
configurationIncomplete: { reason: "workspace_source_restricted", issueId: seeded.issueId },
|
||||
executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } });
|
||||
expect(adapterExecute).not.toHaveBeenCalled();
|
||||
expect(run).toMatchObject({ status: "succeeded", errorCode: null });
|
||||
expect(adapterExecute).toHaveBeenCalledTimes(1);
|
||||
expect(adapterExecute.mock.calls[0]?.[0]).toMatchObject({ context: {
|
||||
paperclipWorkspace: { cwd: strategyType === "project_primary" ? seeded.owner : seeded.worktree },
|
||||
executionWorkspaceId: seeded.executionWorkspaceId,
|
||||
} });
|
||||
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(issue).toMatchObject({ status: "blocked", executionRunId: null, executionWorkspaceId: seeded.executionWorkspaceId });
|
||||
expect(issue).toMatchObject({ status: "done", executionWorkspaceId: seeded.executionWorkspaceId });
|
||||
const comments = await db.select({ body: issueComments.body }).from(issueComments).where(eq(issueComments.issueId, seeded.issueId));
|
||||
expect(comments.map(comment => comment.body).join("\n")).toContain("The task couldn’t start.");
|
||||
const actions = await db.select({ nextAction: issueRecoveryActions.nextAction }).from(issueRecoveryActions)
|
||||
expect(comments.map(comment => comment.body).join("\n")).not.toContain("The task couldn’t start.");
|
||||
const actions = await db.select({ cause: issueRecoveryActions.cause }).from(issueRecoveryActions)
|
||||
.where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId));
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions.map(action => action.nextAction).join("\n")).toContain("Retry the task.");
|
||||
expect(actions).toHaveLength(0);
|
||||
expect(await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(eq(heartbeatRuns.companyId, seeded.companyId))).toHaveLength(1);
|
||||
expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n");
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ import { createHeartbeatWorkspaceResolver } from "../services/heartbeat/workspac
|
||||
import { resolveManagedProjectWorkspaceDir, resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { issueService } from "../services/issues.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";
|
||||
@@ -72,7 +71,7 @@ suite("heartbeat physical workspace source trust", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["current", "retained", "sandbox", "provision_current", "provision_retained"] as const)("enforces physical source containment before dispatch (%s)", async scenario => {
|
||||
it.each(["current", "retained", "sandbox", "provision_current", "provision_retained"] as const)("uses current selected trust without inferring policy from unrelated physical aliases (%s)", async scenario => {
|
||||
const sandbox = scenario === "sandbox";
|
||||
const provision = scenario.startsWith("provision_");
|
||||
const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
@@ -135,9 +134,8 @@ suite("heartbeat physical workspace source trust", () => {
|
||||
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" });
|
||||
expect(await driver.getRun(run!.id)).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -195,7 +193,7 @@ suite("heartbeat physical workspace source trust", () => {
|
||||
expect(await accessService(db).decide({ actor, action: "project:write_workspace", resource: { type: "issue", companyId, projectId, issueId, assigneeAgentId: agentId }, scope: { projectId, assigneeAgentId: agentId } })).toMatchObject({ allowed: grants === "both" });
|
||||
if (placement === "local_effective_strategy") {
|
||||
const selection = { kind: "configured_source" as const, projectWorkspaceId: sourceId, mode: "managed_isolated" as const };
|
||||
await expect(executionWorkspaceService(db).validateSelection({ companyId, actor, issueId, selection })).rejects.toThrow("project:write_workspace");
|
||||
await expect(executionWorkspaceService(db).validateSelection({ companyId, actor, issueId, selection })).resolves.toMatchObject({ projectWorkspaceId: sourceId });
|
||||
await expect(executionWorkspaceService(db).validateSelection({ companyId, actor, issueId,
|
||||
selection: { ...selection, mode: "shared" }, preserveImplicitDefaultSettings: true })).rejects.toThrow("Task workspace default changed");
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
costEvents,
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
agentWakeupRequests,
|
||||
agentRuntimeState,
|
||||
companySkills,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
@@ -66,6 +68,8 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-worktree-suppression-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
await db.insert(authUsers).values(["responsible-user", "operator"].map(id => ({ id, name: `Worktree fixture ${id}`,
|
||||
email: `worktree-${id}@example.test`, createdAt: new Date(), updatedAt: new Date() })));
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -88,6 +92,7 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
await db.delete(instanceSettings);
|
||||
});
|
||||
@@ -109,6 +114,8 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await db.insert(companyMemberships).values(["responsible-user", "operator"].map(principalId => ({ companyId,
|
||||
principalType: "user", principalId, membershipRole: "operator", status: "active" })));
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents, authUsers, companies, companyMemberships, executionWorkspaceRepositories,
|
||||
executionWorkspaceRepositoryAccess, executionWorkspaceRepositoryRoots, executionWorkspaces,
|
||||
heartbeatRuns, issueAccessGrants, issues, type Db,
|
||||
} from "@paperclipai/db";
|
||||
import type { AuthorizationActor } from "../../services/authorization.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/** Synthetic data only. The caller owns the disposable database and directory. */
|
||||
export async function createWorkspacePreparationHistoryFixture(db: Db, directory: string, options: {
|
||||
workspaces: number; sharedRoots: number; sources: number; readers: number;
|
||||
privateHistory?: boolean;
|
||||
}) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID();
|
||||
const issueId = randomUUID(), workspaceId = randomUUID(), runId = randomUUID();
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const cwd = await fs.realpath(directory);
|
||||
await exec("git", ["init", "--initial-branch=main", cwd]);
|
||||
await fs.writeFile(path.join(cwd, "result.txt"), "Synthetic task workspace\n");
|
||||
await exec("git", ["-C", cwd, "add", "."]);
|
||||
await exec("git", ["-C", cwd, "-c", "user.name=Fixture", "-c", "[email protected]", "commit", "-m", "Fixture"]);
|
||||
const pin = (await exec("git", ["-C", cwd, "rev-parse", "HEAD"])).stdout.trim();
|
||||
await db.insert(companies).values({ id: companyId, name: "Synthetic workspace preparation", issuePrefix: `H${companyId.slice(0, 6)}` });
|
||||
const principals = Array.from({ length: 8 }, (_, index) => ({
|
||||
userId: index === 0 ? userId : randomUUID(), agentId: index === 0 ? agentId : randomUUID(),
|
||||
}));
|
||||
await db.insert(authUsers).values(principals.map(({ userId: id }) => ({ id, name: "Synthetic sponsor", email: `${id}@example.test`,
|
||||
createdAt: new Date(), updatedAt: new Date(), emailVerified: true })));
|
||||
await db.insert(agents).values(principals.map(({ agentId: id }) => ({ id, companyId, name: "Synthetic worker", adapterType: "process" })));
|
||||
await db.insert(companyMemberships).values(principals.flatMap(principal => [
|
||||
{ companyId, principalType: "user", principalId: principal.userId, membershipRole: "operator", status: "active" },
|
||||
{ companyId, principalType: "agent", principalId: principal.agentId, membershipRole: "member", status: "active" },
|
||||
]));
|
||||
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, cwd, name: "Current task workspace",
|
||||
mode: "shared_workspace", strategyType: "operator_directory" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Current task", responsibleUserId: userId,
|
||||
assigneeAgentId: agentId, executionWorkspaceId: workspaceId });
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: issueId }).where(eq(executionWorkspaces.id, workspaceId));
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId: userId, status: "running",
|
||||
issueId, contextSnapshot: { issueId } });
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
|
||||
const privateRoots = Array.from({ length: 8 }, () => randomUUID());
|
||||
await db.insert(issues).values(privateRoots.map((id, index) => ({ id, companyId, title: `Private historical context ${index}`,
|
||||
visibility: "private", privacyRootIssueId: id, responsibleUserId: principals[1].userId, assigneeAgentId: principals[1].agentId })));
|
||||
if (!options.privateHistory) {
|
||||
await db.insert(issueAccessGrants).values(privateRoots.flatMap(root => principals.flatMap(principal => [
|
||||
{ issueId: root, subjectType: "user", subjectId: principal.userId, source: "explicit" },
|
||||
{ issueId: root, subjectType: "agent", subjectId: principal.agentId, source: "explicit" },
|
||||
])));
|
||||
}
|
||||
const historyWorkspaceIds = Array.from({ length: options.workspaces }, () => randomUUID());
|
||||
const historyTaskIds = Array.from({ length: options.workspaces }, () => randomUUID());
|
||||
const otherRoot = path.join(path.dirname(cwd), `${path.basename(cwd)}-unrelated`);
|
||||
await fs.mkdir(otherRoot, { recursive: true });
|
||||
for (let offset = 0; offset < historyWorkspaceIds.length; offset += 400) {
|
||||
await db.insert(executionWorkspaces).values(historyWorkspaceIds.slice(offset, offset + 400).map((id, batchIndex) => ({
|
||||
id, companyId, name: "Historical workspace", cwd: offset + batchIndex < options.sharedRoots ? cwd : otherRoot,
|
||||
mode: "shared_workspace", strategyType: "operator_directory",
|
||||
})));
|
||||
await db.insert(issues).values(historyTaskIds.slice(offset, offset + 400).map((id, batchIndex) => ({
|
||||
id, companyId, title: "Historical task", visibility: "private", parentId: privateRoots[(offset + batchIndex) % privateRoots.length],
|
||||
privacyRootIssueId: privateRoots[(offset + batchIndex) % privateRoots.length],
|
||||
privacyParentIssueId: privateRoots[(offset + batchIndex) % privateRoots.length], responsibleUserId: principals[1].userId,
|
||||
assigneeAgentId: principals[1].agentId, executionWorkspaceId: historyWorkspaceIds[offset + batchIndex],
|
||||
})));
|
||||
}
|
||||
// The trigger retains task disclosure sources on each workspace. It intentionally remains active.
|
||||
const rootKey = createHash("sha256").update(cwd).digest("hex");
|
||||
for (let offset = 0; offset < options.sources; offset += 400) {
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values(historyWorkspaceIds.slice(offset, Math.min(offset + 400, options.sources))
|
||||
.map(scopeId => ({ companyId, canonicalCwd: cwd, rootKey, kind: "source" as const, scopeId })));
|
||||
}
|
||||
const readerRunIds = Array.from({ length: options.readers }, () => randomUUID());
|
||||
if (readerRunIds.length) {
|
||||
await db.insert(heartbeatRuns).values(readerRunIds.map((id, index) => ({ id, companyId,
|
||||
agentId: principals[index % principals.length].agentId, responsibleUserId: principals[index % principals.length].userId,
|
||||
issueId, status: "succeeded", contextSnapshot: { issueId } })));
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values(readerRunIds.map((scopeId, index) => ({ companyId, canonicalCwd: cwd,
|
||||
rootKey, kind: "reader" as const, scopeId, issueId, agentId: principals[index % principals.length].agentId,
|
||||
responsibleUserId: principals[index % principals.length].userId })));
|
||||
}
|
||||
const foreignCompanyId = randomUUID(), foreignWorkspaceId = randomUUID();
|
||||
await db.insert(companies).values({ id: foreignCompanyId, name: "Cross-company decoy", issuePrefix: `D${foreignCompanyId.slice(0, 6)}` });
|
||||
await db.insert(executionWorkspaces).values({ id: foreignWorkspaceId, companyId: foreignCompanyId, cwd, name: "Foreign workspace",
|
||||
mode: "shared_workspace", strategyType: "operator_directory" });
|
||||
await db.$client.unsafe("ANALYZE");
|
||||
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId, agentId, runId, onBehalfOfUserId: userId };
|
||||
const input = { companyId, issueId, workspaceId, cwd, agentId, runId, responsibleUserId: userId };
|
||||
return { ...input, userId, input, actor, pin, rootKey, principals, privateRoots, historyWorkspaceIds, historyTaskIds, readerRunIds,
|
||||
foreignCompanyId, foreignWorkspaceId, options };
|
||||
}
|
||||
|
||||
export async function addReadyWorkspaceHistoryRepository(db: Db,
|
||||
fixture: Awaited<ReturnType<typeof createWorkspacePreparationHistoryFixture>>,
|
||||
options: { matchingHistoricalReceipts?: boolean } = {},
|
||||
) {
|
||||
const repositoryIdentity = "https://github.com/paperclip-synthetic/history-fixture";
|
||||
const relativePath = `.paperclip-repositories/task-repo-${createHash("sha256").update(repositoryIdentity).digest("hex").slice(0, 24)}`;
|
||||
const checkout = path.join(fixture.cwd, relativePath);
|
||||
await fs.mkdir(path.dirname(checkout), { recursive: true });
|
||||
await exec("git", ["clone", "--no-hardlinks", fixture.cwd, checkout]);
|
||||
await exec("git", ["-C", checkout, "remote", "set-url", "origin", repositoryIdentity]);
|
||||
const [row] = await db.insert(executionWorkspaceRepositories).values({ companyId: fixture.companyId,
|
||||
executionWorkspaceId: fixture.workspaceId, requestedByIssueId: fixture.issueId, repositoryIdentity, repoUrl: repositoryIdentity,
|
||||
relativePath, requestedRef: "HEAD", pinnedCommit: fixture.pin, state: "ready", requestKey: randomUUID() }).returning();
|
||||
let ownerId = row.id;
|
||||
if (options.matchingHistoricalReceipts) {
|
||||
for (let offset = 0; offset < fixture.historyWorkspaceIds.length; offset += 400) {
|
||||
const peers = await db.insert(executionWorkspaceRepositories).values(fixture.historyWorkspaceIds.slice(offset, offset + 400)
|
||||
.map((executionWorkspaceId, index) => ({ companyId: fixture.companyId, executionWorkspaceId,
|
||||
requestedByIssueId: fixture.historyTaskIds[offset + index], repositoryIdentity, repoUrl: repositoryIdentity,
|
||||
relativePath, requestedRef: "HEAD", pinnedCommit: offset === 0 && index === 0 ? fixture.pin : null,
|
||||
state: offset === 0 && index === 0 ? "ready" : "pending", requestKey: randomUUID() })))
|
||||
.returning({ id: executionWorkspaceRepositories.id });
|
||||
if (offset === 0 && peers[0]) ownerId = peers[0].id;
|
||||
}
|
||||
}
|
||||
await fs.writeFile(path.join(checkout, ".git", "paperclip-workspace-owner.json"), JSON.stringify({ version: 1,
|
||||
repositoryId: ownerId, pinnedCommit: fixture.pin }));
|
||||
return { row, checkout };
|
||||
}
|
||||
|
||||
export async function addPoisonedWorkspaceHistory(db: Db, fixture: Awaited<ReturnType<typeof createWorkspacePreparationHistoryFixture>>) {
|
||||
await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: fixture.companyId, canonicalCwd: fixture.cwd,
|
||||
rootKey: fixture.rootKey, mode: "anonymous_only" });
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: fixture.companyId, canonicalCwd: fixture.cwd,
|
||||
rootKey: fixture.rootKey, kind: "reader", scopeId: randomUUID(), issueId: randomUUID(), agentId: randomUUID(),
|
||||
responsibleUserId: "deleted-historical-user" });
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: fixture.companyId, canonicalCwd: fixture.cwd,
|
||||
rootKey: fixture.rootKey, kind: "source", scopeId: randomUUID() });
|
||||
// Exercise the actual durable app-disclosure trigger: NEW metadata cannot forge its retained set.
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: fixture.privateRoots[0] })
|
||||
.where(eq(executionWorkspaces.id, fixture.workspaceId));
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: fixture.issueId })
|
||||
.where(eq(executionWorkspaces.id, fixture.workspaceId));
|
||||
}
|
||||
@@ -436,6 +436,8 @@ async function seedLowTrustFixture(db: Db) {
|
||||
defaultResponsibleUserId: "board-user",
|
||||
})
|
||||
.returning();
|
||||
await db.insert(authUsers).values({ id: "board-user", name: "Owner", email: "[email protected]", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: "board-user", membershipRole: "owner", status: "active" });
|
||||
const [allowedProject] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
@@ -930,8 +932,6 @@ describeEmbeddedPostgres(
|
||||
|
||||
it("allows the human-requested run through HTTP while rejecting attribution-only authority", async () => {
|
||||
const fixture = await seedLowTrustFixture(db);
|
||||
await db.insert(authUsers).values({ id: "board-user", name: "Owner", email: "[email protected]", createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: fixture.company.id, principalType: "user", principalId: "board-user", membershipRole: "owner", status: "active" });
|
||||
// The fixture already occupies the agent's sole execution slot. Let the
|
||||
// real board route enqueue work, then bind its queued run for HTTP checks.
|
||||
await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } })
|
||||
@@ -995,8 +995,6 @@ describeEmbeddedPostgres(
|
||||
|
||||
it("retains a board backlog assignment and rejects plugin-attributed wakes with forged board payloads", async () => {
|
||||
const fixture = await seedLowTrustFixture(db);
|
||||
await db.insert(authUsers).values({ id: "board-user", name: "Owner", email: "[email protected]", createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: fixture.company.id, principalType: "user", principalId: "board-user", membershipRole: "owner", status: "active" });
|
||||
await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } })
|
||||
.where(eq(agents.id, fixture.agents.lowTrust.id));
|
||||
const board = createApp(db, boardActor(fixture));
|
||||
|
||||
@@ -9,11 +9,11 @@ import { agents, authUsers, companies, companyMemberships, createDb, executionWo
|
||||
executionWorkspaces, issues, projects, projectWorkspaces, projectAccessMembers, principalPermissionGrants,
|
||||
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { projectService } from "../services/projects.js";
|
||||
import { assertExecutionWorkspaceRootReadAccess, assertExecutionWorkspaceRootWriteAccess } from "../services/execution-workspace-repositories.js";
|
||||
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("configured project root provenance", () => {
|
||||
(support.supported ? describe : describe.skip)("configured project mutations without filesystem provenance", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let root: string;
|
||||
@@ -31,7 +31,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
|
||||
it.each((["cwd", "file_url", "absolute_path"] as const).flatMap(sourceKind =>
|
||||
(["move", "remove", "remove_project", "replace_repositories"] as const).map(mutation => ({ sourceKind, mutation }))))(
|
||||
"retains nested private files before first admission ($sourceKind / $mutation)", async ({ sourceKind, mutation }) => {
|
||||
"does not retain filesystem ownership after $sourceKind / $mutation", async ({ sourceKind, mutation }) => {
|
||||
const companyId = randomUUID(), userId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const cwd = path.join(root, companyId), privateCwd = path.join(cwd, "private"), unrelatedCwd = `${cwd}-unrelated`;
|
||||
await mkdir(privateCwd, { recursive: true });
|
||||
@@ -59,25 +59,13 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
else if (mutation === "remove") await service.removeWorkspace(source.id, configured.id);
|
||||
else if (mutation === "remove_project") await service.remove(source.id);
|
||||
else await service.replaceRepositories(source.id, []);
|
||||
const retained = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId));
|
||||
expect(retained).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "project_source", scopeId: source.id, canonicalCwd: await realpath(privateCwd) }),
|
||||
expect.objectContaining({ kind: "shared_project_source", scopeId: source.id, canonicalCwd: await realpath(privateCwd) }),
|
||||
]));
|
||||
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/);
|
||||
if (mutation !== "move") await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: unrelatedCwd, actor })).resolves.toBeUndefined();
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId))).toEqual([]);
|
||||
await expect(assertTaskWorkspaceExecutionAccess(db, actor, companyId, { issueId, workspaceId: workspace.id })).resolves.toBeDefined();
|
||||
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/);
|
||||
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId: source.id, assigneeAgentId: agentId } });
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).resolves.toBeUndefined();
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, readGrant.id));
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, access)).rejects.toThrow(/source project/);
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/source project/);
|
||||
// The selected app configuration remains private even though neighboring files are not classified.
|
||||
await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, source.id)).rejects.toThrow();
|
||||
await db.insert(projectAccessMembers).values({ companyId, projectId: source.id, subjectType: "user", subjectId: userId });
|
||||
await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, source.id)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("can remove a configured folder that never existed without inventing provenance", async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryAccess,
|
||||
executionWorkspaces, heartbeatRuns, issues, projects, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { loadRepositoryRootReaders } from "../services/execution-workspace-root-readers.js";
|
||||
import { runRetainsIssueIdentity } from "../services/task-repository-acquisition.js";
|
||||
import { authorizationService, type AuthorizationActor } from "../services/authorization.js";
|
||||
import { mergeCoalescedContextSnapshot } from "../services/heartbeat/run-preparation.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
@@ -53,115 +53,58 @@ afterAll(async () => {
|
||||
|
||||
const input = () => ({ companyId, issueId, workspaceId, cwd, agentId, runId: runIds[0] });
|
||||
|
||||
it("bounds repeated-principal queries without loading retained run payloads", async () => {
|
||||
it("does no historical reader work and keeps query/path counts constant as peer history grows", async () => {
|
||||
const queries: string[] = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema, logger: { logQuery: query => queries.push(query) } }) as unknown as typeof db;
|
||||
await executionWorkspaceRepositoryService(recorded).guardForPublication(input());
|
||||
expect(queries.length).toBeLessThan(100);
|
||||
const runs = queries.filter(query => query.includes('from "heartbeat_runs"'));
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
expect(runs.length).toBeLessThan(10);
|
||||
expect(runs.every(query => !query.includes('"result_json"') && !query.includes('"runner_profile_json"'))).toBe(true);
|
||||
const service = executionWorkspaceRepositoryService(recorded);
|
||||
const paths = vi.spyOn(fs, "realpath");
|
||||
try {
|
||||
await service.assertPublicationAllowed(input());
|
||||
const smallQueries = queries.length, smallPaths = paths.mock.calls.length;
|
||||
queries.length = 0; paths.mockClear();
|
||||
const sourceIds = Array.from({ length: 1200 }, () => randomUUID());
|
||||
await db.insert(executionWorkspaces).values(sourceIds.map(id => ({ id, companyId, projectId, cwd,
|
||||
sourceIssueId: issueId, name: "Historical source", mode: "shared_workspace", strategyType: "operator_directory" })));
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values(sourceIds.map(scopeId => ({ companyId,
|
||||
canonicalCwd: cwd, rootKey: createHash("sha256").update(cwd).digest("hex"), kind: "reader", scopeId,
|
||||
issueId: randomUUID(), agentId: randomUUID(), responsibleUserId: "deleted-user" })));
|
||||
await service.assertPublicationAllowed(input());
|
||||
expect(queries).toHaveLength(smallQueries);
|
||||
expect(paths.mock.calls).toHaveLength(smallPaths);
|
||||
expect(queries.every(query => !query.includes('"execution_workspace_repository_access"')
|
||||
&& !query.includes('"execution_workspace_repository_roots"'))).toBe(true);
|
||||
const runs = queries.filter(query => query.includes('from "heartbeat_runs"'));
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
expect(runs.every(query => !query.includes('"result_json"') && !query.includes('"runner_profile_json"'))).toBe(true);
|
||||
} finally { paths.mockRestore(); }
|
||||
});
|
||||
|
||||
it("rejects missing, foreign-company and mismatched retained run identities", async () => {
|
||||
const invalidId = runIds[31];
|
||||
const otherCompanyId = randomUUID(), otherIssueId = randomUUID();
|
||||
await db.insert(companies).values({ id: otherCompanyId, name: "Foreign readers", issuePrefix: "FRQ" });
|
||||
await db.insert(issues).values({ id: otherIssueId, companyId, projectId, title: "Other task", assigneeAgentId: agentId });
|
||||
it("rechecks the current run identity while ignoring broken historical readers", async () => {
|
||||
const service = executionWorkspaceRepositoryService(db);
|
||||
for (const change of [
|
||||
{ companyId: otherCompanyId },
|
||||
{ responsibleUserId: randomUUID() },
|
||||
]) {
|
||||
try {
|
||||
await db.update(heartbeatRuns).set(change).where(eq(heartbeatRuns.id, invalidId));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow("identity");
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ companyId, nativeIssueId: null, contextSnapshot: { issueId }, responsibleUserId: userId })
|
||||
.where(eq(heartbeatRuns.id, invalidId));
|
||||
}
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: randomUUID() } }).where(eq(heartbeatRuns.id, runIds[31]));
|
||||
await expect(service.assertPublicationAllowed(input())).resolves.toBeUndefined();
|
||||
await expect(service.assertPublicationAllowed({ ...input(), runId: randomUUID() })).rejects.toThrow("identity");
|
||||
const [foreign] = await db.insert(companies).values({ name: "Foreign current run", issuePrefix: "FCR" }).returning();
|
||||
try {
|
||||
await db.update(heartbeatRuns).set({ companyId: foreign.id }).where(eq(heartbeatRuns.id, runIds[0]));
|
||||
await expect(service.assertPublicationAllowed(input())).rejects.toThrow("identity");
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ companyId, contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runIds[0]));
|
||||
}
|
||||
for (const change of [{ scopeId: randomUUID() }, { agentId: randomUUID() }, { issueId: randomUUID() }, { issueId: otherIssueId }]) {
|
||||
try {
|
||||
await db.update(executionWorkspaceRepositoryAccess).set(change).where(eq(executionWorkspaceRepositoryAccess.scopeId, invalidId));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow("identity");
|
||||
} finally {
|
||||
await db.update(executionWorkspaceRepositoryAccess).set({ scopeId: invalidId, agentId, issueId })
|
||||
.where(eq(executionWorkspaceRepositoryAccess.scopeId, "scopeId" in change ? change.scopeId : invalidId));
|
||||
}
|
||||
}
|
||||
await db.delete(issues).where(eq(issues.id, otherIssueId));
|
||||
await expect(service.guardForPublication(input())).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a nested retained reader valid after another task's wake coalesces into its run", async () => {
|
||||
const runId = randomUUID(), otherRunId = randomUUID(), otherIssueId = randomUUID();
|
||||
const nested = path.join(cwd, ".paperclip", "worktrees", "earlier-task");
|
||||
await fs.mkdir(nested, { recursive: true });
|
||||
const admitted = { issueId, taskId: issueId, taskKey: issueId, wakeReason: "issue_assigned" };
|
||||
const coalesced = mergeCoalescedContextSnapshot(admitted, { issueId: otherIssueId, taskId: otherIssueId,
|
||||
taskKey: otherIssueId, wakeReason: "issue_monitor_due", source: "issue.monitor" });
|
||||
expect(coalesced.issueId).toBe(otherIssueId);
|
||||
await db.insert(issues).values({ id: otherIssueId, companyId, projectId, title: "Monitored task", assigneeAgentId: agentId });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId: userId, issueId,
|
||||
status: "succeeded", contextSnapshot: admitted });
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values({ companyId, rootKey: createHash("sha256").update(nested).digest("hex"),
|
||||
canonicalCwd: nested, kind: "reader", scopeId: runId, issueId, agentId, responsibleUserId: userId });
|
||||
it("preserves immutable task identity after another wake coalesces into the current run", async () => {
|
||||
const otherIssueId = randomUUID();
|
||||
const context = mergeCoalescedContextSnapshot({ issueId, taskId: issueId }, { issueId: otherIssueId, taskId: otherIssueId });
|
||||
expect(runRetainsIssueIdentity({ nativeIssueId: null, issueId, contextSnapshot: context }, issueId)).toBe(true);
|
||||
expect(runRetainsIssueIdentity({ nativeIssueId: issueId, issueId: otherIssueId, contextSnapshot: context }, issueId)).toBe(true);
|
||||
expect(runRetainsIssueIdentity({ nativeIssueId: null, issueId: otherIssueId, contextSnapshot: context }, issueId)).toBe(false);
|
||||
const service = executionWorkspaceRepositoryService(db);
|
||||
try {
|
||||
await expect(service.guardForPublication(input())).resolves.toBeUndefined();
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: coalesced }).where(eq(heartbeatRuns.id, runId));
|
||||
await expect(service.guardForPublication(input())).resolves.toBeUndefined();
|
||||
// A reader whose task matches neither the run's binding nor its context stays unavailable.
|
||||
await db.insert(heartbeatRuns).values({ id: otherRunId, companyId, agentId, responsibleUserId: userId,
|
||||
status: "succeeded", contextSnapshot: { issueId: otherIssueId } });
|
||||
await db.update(executionWorkspaceRepositoryAccess).set({ scopeId: otherRunId }).where(eq(executionWorkspaceRepositoryAccess.scopeId, runId));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow("identity");
|
||||
await db.update(heartbeatRuns).set({ issueId, contextSnapshot: context }).where(eq(heartbeatRuns.id, runIds[0]));
|
||||
await expect(service.assertPublicationAllowed(input())).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await db.delete(executionWorkspaceRepositoryAccess).where(inArray(executionWorkspaceRepositoryAccess.scopeId, [runId, otherRunId]));
|
||||
await db.delete(heartbeatRuns).where(inArray(heartbeatRuns.id, [runId, otherRunId]));
|
||||
await db.delete(issues).where(eq(issues.id, otherIssueId));
|
||||
await rm(path.join(cwd, ".paperclip"), { recursive: true, force: true });
|
||||
}
|
||||
await expect(service.guardForPublication(input())).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps each retained run identity and policy distinct for the same principals", async () => {
|
||||
const service = executionWorkspaceRepositoryService(db);
|
||||
const invalidId = runIds[31];
|
||||
try {
|
||||
await db.update(executionWorkspaceRepositoryAccess).set({ responsibleUserId: randomUUID() }).where(eq(executionWorkspaceRepositoryAccess.scopeId, invalidId));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow("identity");
|
||||
await db.update(executionWorkspaceRepositoryAccess).set({ responsibleUserId: userId }).where(eq(executionWorkspaceRepositoryAccess.scopeId, invalidId));
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionPolicy: { trustPreset: "unsupported" } } }).where(eq(heartbeatRuns.id, invalidId));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow();
|
||||
} finally {
|
||||
await db.update(executionWorkspaceRepositoryAccess).set({ responsibleUserId: userId }).where(eq(executionWorkspaceRepositoryAccess.scopeId, invalidId));
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, invalidId));
|
||||
}
|
||||
await expect(service.guardForPublication(input())).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks identities after the first query batch and reloads them on the next operation", async () => {
|
||||
const ids = Array.from({ length: 501 }, () => randomUUID());
|
||||
await db.insert(heartbeatRuns).values(ids.map(id => ({ id, companyId, agentId, responsibleUserId: userId,
|
||||
status: "succeeded", contextSnapshot: { taskId: issueId } })));
|
||||
const entries = ids.map(scopeId => ({ scopeId, issueId, agentId, responsibleUserId: userId }));
|
||||
try {
|
||||
const queries: string[] = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema, logger: { logQuery: query => queries.push(query) } }) as unknown as typeof db;
|
||||
const readers = await loadRepositoryRootReaders(recorded, companyId, entries);
|
||||
expect(readers.map(reader => reader.actor.runId)).toEqual(ids);
|
||||
expect(readers.every(reader => reader.issue.id === issueId && reader.actor.onBehalfOfUserId === userId)).toBe(true);
|
||||
const agentQueries = queries.filter(query => query.includes('from "agents"'));
|
||||
expect(agentQueries).toHaveLength(1);
|
||||
expect(agentQueries.every(query =>
|
||||
!query.includes('"adapter_config"') && !query.includes('"runtime_config"') && !query.includes('"metadata"'))).toBe(true);
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: randomUUID() }).where(eq(heartbeatRuns.id, ids.at(-1)!));
|
||||
await expect(loadRepositoryRootReaders(db, companyId, entries)).rejects.toThrow("identity");
|
||||
} finally {
|
||||
await db.delete(heartbeatRuns).where(inArray(heartbeatRuns.id, ids));
|
||||
await db.update(heartbeatRuns).set({ issueId: null, contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runIds[0]));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -199,9 +142,9 @@ it("reloads project grants on every publication attempt", async () => {
|
||||
{ companyId, projectId, subjectType: "user", subjectId: userId },
|
||||
]).returning();
|
||||
try {
|
||||
await expect(service.guardForPublication(input())).resolves.toBeUndefined();
|
||||
await expect(service.assertPublicationAllowed(input())).resolves.toBeUndefined();
|
||||
await db.delete(projectAccessMembers).where(and(eq(projectAccessMembers.projectId, projectId), eq(projectAccessMembers.subjectType, "user")));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow();
|
||||
await expect(service.assertPublicationAllowed(input())).rejects.toThrow();
|
||||
} finally {
|
||||
await db.delete(projectAccessMembers).where(inArray(projectAccessMembers.id, grants.map(grant => grant.id)));
|
||||
await db.update(projects).set({ visibility: "open" }).where(eq(projects.id, projectId));
|
||||
@@ -229,23 +172,14 @@ it("keeps supplied membership snapshots separate from fresh database snapshots",
|
||||
}
|
||||
});
|
||||
|
||||
it("batches source retention and duplicate path reads without losing owners", async () => {
|
||||
const sourceIds = Array.from({ length: 501 }, () => randomUUID());
|
||||
await db.insert(executionWorkspaces).values(sourceIds.map(id => ({ id, companyId, projectId, cwd,
|
||||
sourceIssueId: issueId, name: "Shared source", mode: "shared_workspace", strategyType: "operator_directory" })));
|
||||
it("does not canonicalize filesystem paths or mutate a ledger for empty-repository admission", async () => {
|
||||
const queries: string[] = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema, logger: { logQuery: query => queries.push(query) } }) as unknown as typeof db;
|
||||
const paths = vi.spyOn(fs, "realpath");
|
||||
try {
|
||||
await executionWorkspaceRepositoryService(recorded).guardForPublication(input());
|
||||
expect(queries.filter(query => query.startsWith('insert into "execution_workspace_repository_access"')).length).toBeLessThanOrEqual(2);
|
||||
expect(paths.mock.calls.length).toBeLessThanOrEqual(8);
|
||||
} finally {
|
||||
paths.mockRestore();
|
||||
}
|
||||
const retained = await db.select({ scopeId: executionWorkspaceRepositoryAccess.scopeId }).from(executionWorkspaceRepositoryAccess)
|
||||
.where(and(eq(executionWorkspaceRepositoryAccess.companyId, companyId),
|
||||
eq(executionWorkspaceRepositoryAccess.rootKey, createHash("sha256").update(cwd).digest("hex")),
|
||||
eq(executionWorkspaceRepositoryAccess.kind, "source")));
|
||||
expect(new Set(retained.map(row => row.scopeId))).toEqual(new Set([workspaceId, ...sourceIds]));
|
||||
await expect(executionWorkspaceRepositoryService(recorded).prepareForAdmission({ ...input(), responsibleUserId: userId })).resolves.toEqual([]);
|
||||
expect(paths).not.toHaveBeenCalled();
|
||||
expect(queries.every(query => !query.includes('"execution_workspace_repository_access"')
|
||||
&& !query.includes('"execution_workspace_repository_roots"'))).toBe(true);
|
||||
} finally { paths.mockRestore(); }
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces, principalPermissionGrants, companyMemberships } from "@paperclipai/db";
|
||||
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
||||
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
||||
@@ -58,7 +58,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]).toMatchObject({ id: f.runId, status: "running" });
|
||||
});
|
||||
it("keeps projectless protected-folder aliases readable while enforcing scoped write grants", async () => {
|
||||
it("allows a projectless task to use a directory also configured by an unrelated protected project", async () => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
const [project] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Protected physical source",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
@@ -70,14 +70,6 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const selection = { selection: { kind: "existing", workspaceId: workspace.id }, expectedBindingRevision: 0, requestKey: "protected-alias" };
|
||||
const repository = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "protected-root-write" };
|
||||
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: workspace.id, projectId: null } });
|
||||
await expect(call("select_workspace", selection)).rejects.toThrow(/protected|not authorized/);
|
||||
await expect(call("prepare_repository", repository)).rejects.toThrow(/protected|not authorized/);
|
||||
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId,
|
||||
membershipRole: "member", status: "active" });
|
||||
await server.db.insert(principalPermissionGrants).values([
|
||||
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
{ companyId: f.companyId, principalType: "user", principalId: f.userId!, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
]);
|
||||
await expect(call("select_workspace", selection)).resolves.toMatchObject({ kind: "scheduled" });
|
||||
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
|
||||
});
|
||||
@@ -155,13 +147,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
|
||||
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);
|
||||
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-"));
|
||||
await server.db.update(executionWorkspaces).set({ cwd: unrelatedCwd }).where(eq(executionWorkspaces.id, unrelated.id));
|
||||
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
|
||||
await expect(call("prepare_repository", repository)).rejects.toThrow(/trusted task/);
|
||||
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
|
||||
expect((await server.db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, unrelated.id)))[0]?.cwd).toBe(f.workspace);
|
||||
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.projectId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,11 @@ import request from "supertest";
|
||||
import { taskWorkspaceRoutes } from "../routes/task-workspaces.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { agents, authUsers, companies, companyMemberships, completionContracts, createDb, environmentLeases, executionWorkspaceRepositories, executionWorkspaceRepositoryAccess, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, workspaceOperations, principalPermissionGrants, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, completionContracts, createDb, environmentLeases, executionWorkspaceRepositories, executionWorkspaceRepositoryAccess, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, workspaceOperations, principalPermissionGrants, projectAccessMembers, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { authorizationService, canActorReadExecutionWorkspace } from "../services/authorization.js";
|
||||
import { assertChatExecutionDefaultsAccess } from "../services/chat-execution-defaults.js";
|
||||
import { assertTaskWorkspaceAccess } from "../services/task-workspace-source-access.js";
|
||||
import { assertExecutionWorkspaceRecordAccess } from "../services/task-workspace-access.js";
|
||||
import { deleteCompany } from "../services/company-deletion.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
@@ -57,7 +57,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(overview.items).toEqual(expect.arrayContaining([expect.objectContaining({ workspaceId: f.workspaceId, projectId: null })]));
|
||||
});
|
||||
|
||||
it.each(["replace", "clear"] as const)("retains unbound task privacy and old root ownership when an update %ss its source", async action => {
|
||||
it.each(["replace", "clear"] as const)("retains app-record privacy without physical-root history when an update %ss its source", async action => {
|
||||
const f = await fixture(), service = executionWorkspaceService(db);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-update-provenance-"));
|
||||
try {
|
||||
@@ -83,12 +83,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
} });
|
||||
const retained = await db.select().from(executionWorkspaceRepositoryAccess)
|
||||
.where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
expect(retained.map(entry => ({ kind: entry.kind, scopeId: entry.scopeId, cwd: entry.canonicalCwd })))
|
||||
.toEqual(expect.arrayContaining([
|
||||
{ kind: "source", scopeId: f.workspaceId, cwd: await fs.realpath(oldCwd) },
|
||||
{ kind: "project_source", scopeId: project.id, cwd: await fs.realpath(oldCwd) },
|
||||
]));
|
||||
expect(retained.every(entry => ["source", "project_source", "shared_project_source"].includes(entry.kind))).toBe(true);
|
||||
expect(retained).toEqual([]);
|
||||
expect(await fs.readFile(path.join(oldCwd, "private.txt"), "utf8")).toBe("Retained old files");
|
||||
expect(await service.update(f.workspaceId, { metadata: null })).toMatchObject({
|
||||
metadata: { _issuePrivacySources: { [ancestor.id]: true, [f.issueId]: true } },
|
||||
@@ -98,7 +93,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back a source update when existing root provenance cannot be verified", async () => {
|
||||
it("updates source metadata independently of the former filesystem root", async () => {
|
||||
const f = await fixture(), service = executionWorkspaceService(db);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-update-invalid-root-"));
|
||||
try {
|
||||
@@ -108,9 +103,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
.where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
const [before] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
await expect(service.update(f.workspaceId, { cwd: path.join(root, "new"), sourceIssueId: null, metadata: null }))
|
||||
.rejects.toThrow("Workspace files cannot be detached from unverifiable provenance");
|
||||
const [after] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
expect(after).toEqual(before);
|
||||
.resolves.toMatchObject({ cwd: path.join(root, "new"), sourceIssueId: null });
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess)
|
||||
.where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual([]);
|
||||
} finally {
|
||||
@@ -491,15 +484,15 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id }).where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
const selection = { selection: { kind: "existing" as const, workspaceId: f.workspaceId }, expectedBindingRevision: 1, requestKey: "member-selection" };
|
||||
const repository = { repository: { kind: "url" as const, url: "https://github.com/public/example" }, requestKey: "member-repository" };
|
||||
expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection)).status).toBe(403);
|
||||
expect((await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(repository)).status).toBe(403);
|
||||
expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection)).status).toBe(200);
|
||||
expect((await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(repository)).status).toBe(200);
|
||||
const channelDefaults = { companyId: f.companyId, actor, assigneeAgentId: assignee.id,
|
||||
defaults: { workspace: { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "shared" as const } } };
|
||||
await expect(assertChatExecutionDefaultsAccess(db, channelDefaults)).rejects.toThrow(/protected/);
|
||||
await expect(assertChatExecutionDefaultsAccess(db, channelDefaults)).resolves.toBeUndefined();
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: userId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: assignee.id } });
|
||||
await expect(assertChatExecutionDefaultsAccess(db, channelDefaults)).resolves.toBeUndefined();
|
||||
await expect(assertChatExecutionDefaultsAccess(db, { ...channelDefaults, assigneeAgentId: null })).rejects.toThrow(/protected/);
|
||||
await expect(assertChatExecutionDefaultsAccess(db, { ...channelDefaults, assigneeAgentId: null })).resolves.toBeUndefined();
|
||||
expect(await issueService(db).create(f.companyId, { title: "Board-assigned shared work", createdByUserId: userId,
|
||||
assigneeAgentId: assignee.id, workspaceSelectionActor: actor,
|
||||
workspaceSelection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" } }))
|
||||
@@ -525,52 +518,32 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, f.workspaceId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["binding", "configured_source"] as const)("enforces source authority on legacy PATCH %s and rolls back denied edits", async kind => {
|
||||
const f = await fixture(), userId = randomUUID(), settings = instanceSettingsService(db);
|
||||
const previous = await settings.getExperimental();
|
||||
it.each(["binding", "configured_source"] as const)("preserves selected app configuration privacy on legacy PATCH %s", async kind => {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
const f = await fixture(), userId = randomUUID(), settings = instanceSettingsService(db), previous = await settings.getExperimental();
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
try {
|
||||
await db.insert(authUsers).values({ id: userId, name: "Workspace selector", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
|
||||
const [assignee, nextAssignee] = await db.insert(agents).values(["Current worker", "Next worker"].map(name => ({ companyId: f.companyId, name, status: "idle", runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }))).returning();
|
||||
await db.update(issues).set({ assigneeAgentId: assignee.id }).where(eq(issues.id, f.issueId));
|
||||
const [project, organization] = await db.insert(projects).values([
|
||||
{ companyId: f.companyId, name: "Protected source", executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } },
|
||||
{ companyId: f.companyId, name: "Organization" },
|
||||
]).returning();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Reader", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" });
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private config", visibility: "private" }).returning();
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: `/tmp/source-${f.issueId}` }).returning();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id }).where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
const actor = { type: "board" as const, source: "session" as const, userId, companyIds: [f.companyId], isInstanceAdmin: false };
|
||||
const beforeMetadata = (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0].metadata;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", taskWorkspaceRoutes(db));
|
||||
app.use("/api", issueRoutes(db, {} as never));
|
||||
app.use(errorHandler);
|
||||
const patch = kind === "binding" ? { executionWorkspaceId: f.workspaceId } : { projectWorkspaceId: source.id, executionWorkspaceSettings: { mode: "shared_workspace" } };
|
||||
const selection = kind === "binding" ? { kind: "existing", workspaceId: f.workspaceId } : { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" };
|
||||
expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send({ selection, expectedBindingRevision: 0, requestKey: "denied" })).status).toBe(403);
|
||||
const denied = await request(app).patch(`/api/issues/${f.issueId}`).send(patch);
|
||||
expect(denied.status, JSON.stringify(denied.body)).toBe(403);
|
||||
const readTask = () => db.select().from(issues).where(eq(issues.id, f.issueId)).then(rows => rows[0]);
|
||||
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();
|
||||
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);
|
||||
expect(allowed.status, JSON.stringify(allowed.body)).toBe(200);
|
||||
const bound = await readTask();
|
||||
expect(bound).toMatchObject({ ...patch, projectWorkspaceId: source.id, workspaceBindingRevision: 1 });
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
const organized = await request(app).patch(`/api/issues/${f.issueId}`).send({ projectId: organization.id });
|
||||
expect(organized.status, JSON.stringify(organized.body)).toBe(200);
|
||||
expect(await readTask()).toMatchObject({ projectId: organization.id, executionWorkspaceId: bound.executionWorkspaceId, projectWorkspaceId: source.id, workspaceBindingRevision: 1 });
|
||||
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); }
|
||||
const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", issueRoutes(db, {} as never)); app.use(errorHandler);
|
||||
const patch = kind === "binding" ? { executionWorkspaceId: f.workspaceId } : { projectWorkspaceId: source.id };
|
||||
await request(app).patch(`/api/issues/${f.issueId}`).send(patch).expect(403);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ executionWorkspaceId: null, projectWorkspaceId: null });
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: userId });
|
||||
await request(app).patch(`/api/issues/${f.issueId}`).send(patch).expect(200);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ projectWorkspaceId: source.id, workspaceBindingRevision: 1 });
|
||||
} finally {
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["create", "patch", "selection"] as const)("uses the assigned agent's effective source strategy for %s authorization", async operation => {
|
||||
it.each(["create", "patch", "selection"] as const)("allows either assigned agent source strategy for %s when the selected config is readable", async operation => {
|
||||
const f = await fixture(), userId = randomUUID(), settings = instanceSettingsService(db);
|
||||
const previous = await settings.getExperimental();
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: false });
|
||||
@@ -590,10 +563,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
if (operation === "create") {
|
||||
const input = { title: "Read source in separate checkout", assigneeAgentId: isolatedAgent.id,
|
||||
projectWorkspaceId: source.id, workspaceSelectionActor: actor, createdByUserId: userId };
|
||||
await expect(tasks.create(f.companyId, { ...input, assigneeAdapterOverrides: sharedOverrides })).rejects.toThrow(/protected/);
|
||||
await expect(tasks.create(f.companyId, { ...input, assigneeAdapterOverrides: sharedOverrides })).resolves.toBeDefined();
|
||||
await expect(tasks.create(f.companyId, input)).resolves.toMatchObject({ assigneeAgentId: isolatedAgent.id, projectWorkspaceId: source.id });
|
||||
await expect(tasks.create(f.companyId, { ...input, workspaceSelection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "managed_isolated" },
|
||||
assigneeAdapterOverrides: sharedOverrides })).rejects.toThrow(/protected/);
|
||||
assigneeAdapterOverrides: sharedOverrides })).resolves.toBeDefined();
|
||||
} else if (operation === "patch") {
|
||||
await db.update(issues).set({ assigneeAgentId: sharedAgent.id }).where(eq(issues.id, f.issueId));
|
||||
const app = express();
|
||||
@@ -602,8 +575,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
app.use("/api", issueRoutes(db, {} as never)); app.use(errorHandler);
|
||||
const patch = { projectWorkspaceId: source.id, assigneeAgentId: isolatedAgent.id };
|
||||
const denied = await request(app).patch(`/api/issues/${f.issueId}`).send({ ...patch, assigneeAdapterOverrides: sharedOverrides });
|
||||
expect(denied.status, JSON.stringify(denied.body)).toBe(403);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ assigneeAgentId: sharedAgent.id, projectWorkspaceId: null });
|
||||
expect(denied.status, JSON.stringify(denied.body)).toBe(200);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ assigneeAgentId: isolatedAgent.id, projectWorkspaceId: source.id });
|
||||
const allowed = await request(app).patch(`/api/issues/${f.issueId}`).send(patch);
|
||||
expect(allowed.status, JSON.stringify(allowed.body)).toBe(200);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ assigneeAgentId: isolatedAgent.id, projectWorkspaceId: source.id });
|
||||
@@ -611,94 +584,43 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await db.update(issues).set({ assigneeAgentId: isolatedAgent.id, assigneeAdapterOverrides: sharedOverrides }).where(eq(issues.id, f.issueId));
|
||||
const input = { companyId: f.companyId, issueId: f.issueId, actor,
|
||||
selection: { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "managed_isolated" as const } };
|
||||
await expect(workspaces.validateSelection(input)).rejects.toThrow(/protected/);
|
||||
await expect(workspaces.validateSelection(input)).resolves.toBeDefined();
|
||||
await db.update(issues).set({ assigneeAdapterOverrides: null }).where(eq(issues.id, f.issueId));
|
||||
await expect(workspaces.validateSelection(input)).resolves.toMatchObject({ projectWorkspaceId: source.id });
|
||||
await db.update(issues).set({ assigneeAgentId: sharedAgent.id }).where(eq(issues.id, f.issueId));
|
||||
await expect(workspaces.validateSelection(input)).rejects.toThrow(/protected/);
|
||||
await expect(workspaces.validateSelection(input)).resolves.toBeDefined();
|
||||
}
|
||||
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces, enableIsolatedWorkspacesByDefault: previous.enableIsolatedWorkspacesByDefault }); }
|
||||
});
|
||||
|
||||
it("requires source project assignment authority for shared files while preserving isolated reads", async () => {
|
||||
it("uses selected project read access without interpreting assignment grants as filesystem permissions", async () => {
|
||||
const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db);
|
||||
const settings = instanceSettingsService(db), previous = await settings.getExperimental();
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
try {
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Read-only source user", adapterType: "process", status: "idle" }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Source reader", adapterType: "process", status: "idle" }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", principalId: agent.id, status: "active", membershipRole: "member" });
|
||||
const actor = { type: "agent" as const, agentId: agent.id, companyId: f.companyId, source: "agent_key" as const };
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected source",
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected config",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id,
|
||||
name: "Shared source", cwd: `/tmp/protected-${f.issueId}` }).returning();
|
||||
const [shared] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id,
|
||||
projectWorkspaceId: source.id, name: "Shared files", cwd: source.cwd, mode: "shared_workspace", strategyType: "project_primary" }).returning();
|
||||
const [isolated] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id,
|
||||
projectWorkspaceId: source.id, name: "Isolated files", cwd: `/tmp/isolated-${f.issueId}`, mode: "isolated_workspace", strategyType: "git_worktree" }).returning();
|
||||
expect(await authorizationService(db).decide({ actor, action: "project:read",
|
||||
resource: { type: "project", companyId: f.companyId, projectId: project.id } })).toMatchObject({ allowed: true });
|
||||
const sharedSelection = { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "shared" as const };
|
||||
const isolatedSelection = { ...sharedSelection, mode: "managed_isolated" as const };
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: sharedSelection })).rejects.toThrow(/protected/);
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: isolatedSelection })).resolves.toMatchObject({ executionWorkspaceSettings: { mode: "isolated_workspace" } });
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: shared.id } })).rejects.toThrow(/protected/);
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: isolated.id } })).resolves.toMatchObject({ executionWorkspaceId: isolated.id });
|
||||
for (const sourceKind of ["explicit", "channel"] as const) {
|
||||
await expect(tasks.create(f.companyId, { title: `Denied ${sourceKind}`, createdByAgentId: agent.id,
|
||||
workspaceSelection: sharedSelection, workspaceSelectionSource: sourceKind, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
|
||||
expect(await tasks.create(f.companyId, { title: `Isolated ${sourceKind}`, createdByAgentId: agent.id,
|
||||
workspaceSelection: isolatedSelection, workspaceSelectionSource: sourceKind, workspaceSelectionActor: actor })).toMatchObject({ projectId: null, projectWorkspaceId: source.id });
|
||||
for (const mode of ["shared", "managed_isolated"] as const) {
|
||||
const selection = { kind: "configured_source" as const, projectWorkspaceId: source.id, mode };
|
||||
await expect(svc.validateSelection({ ...f, actor, selection })).resolves.toMatchObject({ projectWorkspaceId: source.id });
|
||||
for (const sourceKind of ["explicit", "channel"] as const) {
|
||||
expect(await tasks.create(f.companyId, { title: `${sourceKind} ${mode}`, createdByAgentId: agent.id,
|
||||
workspaceSelection: selection, workspaceSelectionSource: sourceKind, workspaceSelectionActor: actor })).toMatchObject({ projectWorkspaceId: source.id });
|
||||
}
|
||||
}
|
||||
await expect(tasks.create(f.companyId, { title: "Denied legacy source", createdByAgentId: agent.id,
|
||||
projectWorkspaceId: source.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
|
||||
await expect(tasks.create(f.companyId, { title: "Denied legacy binding", createdByAgentId: agent.id,
|
||||
executionWorkspaceId: shared.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
|
||||
|
||||
await db.update(executionWorkspaces).set({ mode: "isolated_workspace" }).where(eq(executionWorkspaces.id, shared.id));
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: shared.id } })).rejects.toThrow(/protected/);
|
||||
await expect(tasks.create(f.companyId, { title: "Mislabelled isolated source", createdByAgentId: agent.id,
|
||||
projectWorkspaceId: source.id, workspaceSelectionActor: actor,
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "project_primary" } } })).rejects.toThrow(/protected/);
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace",
|
||||
workspaceStrategy: { type: "project_primary" }, authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(projects.id, project.id));
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: isolatedSelection })).rejects.toThrow(/protected/);
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(projects.id, project.id));
|
||||
|
||||
const [organization] = await db.insert(projects).values({ companyId: f.companyId, name: "Task organization" }).returning();
|
||||
await db.update(issues).set({ projectId: organization.id }).where(eq(issues.id, f.issueId));
|
||||
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" };
|
||||
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));
|
||||
await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: otherAgent.id } })
|
||||
.where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(svc.selectTaskWorkspace(selectionRequest)).rejects.toThrow(/protected/);
|
||||
await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: agent.id } })
|
||||
.where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(svc.selectTaskWorkspace(selectionRequest)).resolves.toMatchObject({ kind: "scheduled" });
|
||||
const responsibleUserId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: responsibleUserId, name: "Source owner", email: `${responsibleUserId}@example.test`,
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: responsibleUserId,
|
||||
status: "active", membershipRole: "member" });
|
||||
const delegated = { ...actor, onBehalfOfUserId: responsibleUserId };
|
||||
await expect(svc.validateSelection({ ...f, actor: delegated, selection: sharedSelection })).rejects.toThrow(/Responsible user.*not authorized/);
|
||||
const [userGrant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user",
|
||||
principalId: responsibleUserId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: agent.id } }).returning();
|
||||
await expect(svc.validateSelection({ ...f, actor: delegated, selection: sharedSelection })).resolves.toMatchObject({ projectWorkspaceId: source.id });
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, userGrant.id));
|
||||
await expect(svc.validateSelection({ ...f, actor: delegated, selection: sharedSelection })).rejects.toThrow(/Responsible user.*not authorized/);
|
||||
expect(await tasks.create(f.companyId, { title: "Authorized shared source", createdByAgentId: agent.id,
|
||||
workspaceSelection: sharedSelection, workspaceSelectionActor: actor })).toMatchObject({ projectId: null, projectWorkspaceId: source.id });
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(svc.applyPendingTaskWorkspaceSelection({ ...f, actor, runId: randomUUID() })).rejects.toThrow(/protected/);
|
||||
await expect(assertTaskWorkspaceAccess(db, actor, f.companyId, shared.id)).resolves.toBeUndefined();
|
||||
await expect(assertTaskWorkspaceAccess(db, actor, f.companyId, shared.id, { write: true })).rejects.toThrow(/protected/);
|
||||
await expect(assertTaskWorkspaceAccess(db, actor, f.companyId, isolated.id, { write: true })).resolves.toBeUndefined();
|
||||
expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).workspace).toBeNull();
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id,
|
||||
projectWorkspaceId: source.id, name: "Shared files", cwd: source.cwd, mode: "shared_workspace", strategyType: "project_primary" }).returning();
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: workspace.id } })).resolves.toMatchObject({ executionWorkspaceId: workspace.id });
|
||||
await expect(assertExecutionWorkspaceRecordAccess(db, actor, f.companyId, workspace.id)).resolves.toBeUndefined();
|
||||
// Changing the app configuration still requires its normal write grant.
|
||||
expect(await authorizationService(db).decide({ actor, action: "project:write_workspace", resource: {
|
||||
type: "issue", companyId: f.companyId, projectId: project.id, assigneeAgentId: agent.id,
|
||||
} })).toMatchObject({ allowed: false });
|
||||
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { assertTaskWorkspaceExecutionAccess } from "../services/task-workspace-access.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -41,6 +47,37 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
parentId: f.input.parentId, executionWorkspaceId: f.workspace.id,
|
||||
});
|
||||
});
|
||||
it("allows a parent run to patch an authorized child's existing workspace settings", async () => {
|
||||
const f = await fixture();
|
||||
const child = await issueService(db).create(f.companyId, { ...f.input, status: "backlog", responsibleUserId: f.userId });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { ...f.actor, onBehalfOfMemberships: [{ companyId: f.companyId, status: "active", membershipRole: "operator" }] };
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const settings = instanceSettingsService(db);
|
||||
const previous = await settings.getExperimental();
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
try {
|
||||
const response = await request(app).patch(`/api/issues/${child.id}`).set("X-Paperclip-Run-Id", f.runId)
|
||||
.send({ executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "main" } } });
|
||||
expect({ status: response.status, body: response.body }).toMatchObject({ status: 200, body: {
|
||||
id: child.id, executionWorkspaceId: f.workspace.id,
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "main" } },
|
||||
} });
|
||||
} finally {
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
|
||||
}
|
||||
const [parentRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
expect(parentRun.contextSnapshot?.issueId).toBe(f.issueId);
|
||||
await expect(assertTaskWorkspaceExecutionAccess(db, f.actor, f.companyId, {
|
||||
issueId: child.id, workspaceId: f.workspace.id,
|
||||
})).rejects.toThrow(/run identity/);
|
||||
});
|
||||
|
||||
it.each(["child_policy", "unrelated_parent", "missing_parent", "missing_run", "cross_company_run", "sponsor", "live_parent_policy", "absent_saved_policy", "child_agent_policy"] as const)(
|
||||
"rejects incompatible child creation (%s)", async scenario => {
|
||||
const f = await fixture();
|
||||
@@ -76,6 +113,6 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const f = await fixture();
|
||||
await expect(executionWorkspaceService(db).validateSelection({ companyId: f.companyId, actor: f.actor,
|
||||
selection: f.input.workspaceSelection, parentIssueId: f.issueId, executionPolicy: f.policy,
|
||||
assigneeAgentId: f.agentId })).rejects.toThrow(/Physical workspace source trust/);
|
||||
assigneeAgentId: f.agentId })).rejects.toThrow(/Selected workspace source trust/);
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await readFile(path.join(checkout, "source.txt"), "utf8")).toBe("uncommitted work\n");
|
||||
if (process.platform !== "win32") {
|
||||
const receiptPath = path.join(checkout, ".git", "paperclip-workspace-owner.json");
|
||||
await rm(receiptPath, { force: true });
|
||||
await promisify(execFileCallback)("mkfifo", [receiptPath]);
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
@@ -210,7 +211,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
}
|
||||
}
|
||||
});
|
||||
it("reuses the same physical checkout across logical workspaces and rechecks retained privacy", async () => {
|
||||
it("reuses the exact published checkout across logical workspaces without applying historical task privacy", async () => {
|
||||
const [secondAgent] = await db.insert(agents).values({ companyId, name: "Second agent", adapterType: "process" }).returning();
|
||||
const [secondWorkspace] = await db.insert(executionWorkspaces).values({ companyId, projectId: null, name: "Same configured folder", cwd, mode: "shared_workspace", strategyType: "operator_directory" }).returning();
|
||||
const [secondIssue] = await db.insert(issues).values({ companyId, title: "Second task", assigneeAgentId: secondAgent.id, executionWorkspaceId: secondWorkspace.id }).returning();
|
||||
@@ -218,10 +219,13 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const intent = await service.request({ companyId, issueId: secondIssue.id, actor, request: { repository: { kind: "catalog", id: "123" }, requestKey: "shared-second" } });
|
||||
const admission = { companyId, issueId: secondIssue.id, workspaceId: secondWorkspace.id, cwd, agentId: secondAgent.id, runId: randomUUID(), responsibleUserId: null };
|
||||
const [original] = await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, workspaceId));
|
||||
await writeFile(path.join(cwd, original.relativePath, ".git", "paperclip-workspace-owner.json"),
|
||||
JSON.stringify({ version: 1, repositoryId: original.id, pinnedCommit: original.pinnedCommit }));
|
||||
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");
|
||||
expect(await readFile(path.join(prepared[0].cwd, ".git", "paperclip-workspace-owner.json"), "utf8").catch(() => null)).toBeNull();
|
||||
expect(JSON.parse(await readFile(path.join(prepared[0].cwd, ".git", "paperclip-workspace-owner.json"), "utf8")))
|
||||
.toMatchObject({ repositoryId: intent.operationId, pinnedCommit: original.pinnedCommit });
|
||||
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();
|
||||
await service.request({ companyId, issueId: differentRefIssue.id, actor, request: { repository: { kind: "catalog", id: "123" }, ref: "release", requestKey: "conflicting-ref" } });
|
||||
@@ -233,7 +237,7 @@ 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 });
|
||||
await expect(prepareForAdmission(admission)).rejects.toThrow(/Task workspace access is no longer available/);
|
||||
await expect(prepareForAdmission(admission)).resolves.toHaveLength(1);
|
||||
expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n");
|
||||
});
|
||||
it("stages a root change without replacing active files or adding a wake", async () => {
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
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 { 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 { agents, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces,
|
||||
heartbeatRuns, issues, projects, projectWorkspaces, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.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 { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
||||
|
||||
import { createRepositoryTrustFixture } from "./helpers/repository-trust-fixture.js";
|
||||
|
||||
@@ -38,367 +35,205 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
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;
|
||||
const { fixture, lowTrustPeer, standardPeer, privateTask, 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 => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
if (scenario === "different_identity") await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "url", url: "https://github.com/public/other" }, requestKey: randomUUID() } });
|
||||
await privateTask(f);
|
||||
const intent = await f.request(scenario === "anonymous_private_edits" ? { kind: "url", url: "https://github.com/public/source" } : { kind: "catalog", id: "private-repo" });
|
||||
await localPrivateClone(f);
|
||||
const [prepared] = await f.service.prepareForAdmission(f.admission);
|
||||
expect(await readFile(path.join(prepared.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
if (scenario === "unreadable_alias" || scenario === "loop_alias") {
|
||||
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));
|
||||
await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
const actual = fs.realpath.bind(fs);
|
||||
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
|
||||
if (scenario === "unreadable_alias" && String(pathname) === alias) throw Object.assign(new Error("Private alias unavailable"), { code: "EACCES" });
|
||||
return actual(pathname);
|
||||
});
|
||||
}
|
||||
if (scenario === "authorized_peer") {
|
||||
await grantPeer(f, peer);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
} else await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
expect((await f.service.list(f.companyId, f.workspace.id))[0].id).toBe(intent.operationId);
|
||||
expect(acquisition.materialize).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["retained", "legacy", "known_origin_removed"] as const)("keeps independent copies of the same repository separate (%s provenance)", async provenance => {
|
||||
it.each(["same_root", "ancestor", "descendant", "symlink"] as const)("ignores private historical filesystem owners at a %s path", async relation => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
const peerCwd = path.join(root, peer.issueId);
|
||||
await mkdir(peerCwd);
|
||||
await db.update(executionWorkspaces).set({ cwd: peerCwd }).where(eq(executionWorkspaces.id, peer.workspace.id));
|
||||
peer.admission.cwd = peerCwd;
|
||||
await privateTask(f);
|
||||
const repository = { kind: "url" as const, url: "https://github.com/public/source" };
|
||||
await f.request(repository);
|
||||
await localPrivateClone(f);
|
||||
const [privateCheckout] = await f.service.prepareForAdmission(f.admission);
|
||||
await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository, requestKey: randomUUID() } });
|
||||
await localPrivateClone({ ...f, issueId: peer.issueId });
|
||||
const [peerCheckout] = await f.service.prepareForAdmission(peer.admission);
|
||||
expect(peerCheckout.cwd).not.toBe(privateCheckout.cwd);
|
||||
expect(await readFile(path.join(peerCheckout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
if (provenance === "legacy") await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
if (provenance === "known_origin_removed") await rm(f.admission.cwd, { recursive: true });
|
||||
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
|
||||
acquisition.materialize.mockImplementationOnce(actual.materializeManagedProjectWorkspace);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toHaveLength(1);
|
||||
const beforeRead = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: peerCwd,
|
||||
sourceWorkspaceId: peer.workspace.id, actor: peer.actor })).resolves.toBeUndefined();
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: peer.actor })).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual(beforeRead);
|
||||
let historicalRoot = f.admission.cwd;
|
||||
if (relation === "ancestor") historicalRoot = root;
|
||||
if (relation === "descendant") { historicalRoot = path.join(f.admission.cwd, "old-task"); await mkdir(historicalRoot); }
|
||||
if (relation === "symlink") { historicalRoot = path.join(root, `${f.issueId}-alias`); await symlink(f.admission.cwd, historicalRoot); }
|
||||
await db.update(executionWorkspaces).set({ cwd: historicalRoot }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
const canonicalCwd = await realpath(f.admission.cwd);
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values([
|
||||
{ companyId: f.companyId, canonicalCwd, rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), kind: "source", scopeId: f.workspace.id },
|
||||
{ companyId: f.companyId, canonicalCwd, rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), kind: "reader", scopeId: randomUUID(), issueId: randomUUID(), agentId: randomUUID(), responsibleUserId: "deleted-user" },
|
||||
]);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.assertPublicationAllowed(peer.admission)).resolves.toBeUndefined();
|
||||
const { authorizationService, canActorReadExecutionWorkspace } = await import("../services/authorization.js");
|
||||
expect((await authorizationService(db).decide({ actor: peer.actor, action: "issue:read",
|
||||
resource: { type: "issue", companyId: f.companyId, issueId: f.issueId } })).allowed).toBe(false);
|
||||
expect(await canActorReadExecutionWorkspace(db, peer.actor, f.workspace.id)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["update", "archive"] as const)("preserves legacy private publishers before %s moves their workspace away from shared files", async operation => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
const repository = { kind: "url" as const, url: "https://github.com/public/legacy-shared" };
|
||||
await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository, requestKey: randomUUID() } });
|
||||
await privateTask(f);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private legacy source", visibility: "private" }).returning();
|
||||
it.each(["anonymous_only", "managed"] as const)("ignores a legacy %s directory reservation while preserving current acquisition restrictions", async mode => {
|
||||
const f = await fixture(), peer = await lowTrustPeer(f);
|
||||
const canonicalCwd = await realpath(f.admission.cwd);
|
||||
await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, canonicalCwd,
|
||||
rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), mode });
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
await expect(f.request({ kind: "catalog", id: "private-repo" })).resolves.toMatchObject({ kind: "requires_next_admission" });
|
||||
await expect(f.service.request({ companyId: f.companyId, issueId: peer.admission.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "url", url: "https://github.com/public/new" }, requestKey: randomUUID() } })).rejects.toThrow(/trusted task|Selected workspace source trust/);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId)))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores unselected private/protected project aliases but rechecks the directly selected project", async () => {
|
||||
const f = await fixture();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private source", visibility: "private",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Other configuration", cwd: f.admission.cwd });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.assertPublicationAllowed(f.admission)).resolves.toBeUndefined();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow();
|
||||
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 },
|
||||
]);
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id, sourceIssueId: f.issueId }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await f.request(repository);
|
||||
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");
|
||||
await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
const moved = path.join(root, `${f.issueId}-legacy-moved`);
|
||||
await mkdir(moved);
|
||||
const patch = { cwd: moved, projectId: null, sourceIssueId: null, metadata: {} };
|
||||
const svc = executionWorkspaceService(db);
|
||||
if (operation === "archive") {
|
||||
const result = await svc.archiveWorkspaceUnderLifecycleLock({ id: f.workspace.id, patch, closedAt: new Date() });
|
||||
expect(result?.outcome).toBe("archived");
|
||||
if (result?.outcome === "archived") {
|
||||
expect(result.capturedGeneration).toBeGreaterThan(0);
|
||||
expect(result.workspace.status).toBe("archived");
|
||||
expect(result.workspace.metadata?._issuePrivacySources).toHaveProperty(f.issueId);
|
||||
}
|
||||
} else await svc.update(f.workspace.id, patch);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.subjectId, f.userId));
|
||||
await expect(f.service.assertPublicationAllowed(f.admission)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects current identity and binding changes before publication", async () => {
|
||||
const f = await fixture();
|
||||
await expect(f.service.assertPublicationAllowed(f.admission)).resolves.toBeUndefined();
|
||||
await expect(f.service.assertPublicationAllowed({ ...f.admission, runId: randomUUID() })).rejects.toThrow(/identity/);
|
||||
await expect(f.service.prepareForAdmission({ ...f.admission, responsibleUserId: randomUUID() })).rejects.toThrow(/responsible user/);
|
||||
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, f.issueId));
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: peer.admission.cwd,
|
||||
sourceWorkspaceId: peer.workspace.id, actor: peer.actor })).rejects.toThrow(/workspace access|authorization boundary|source project/);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary|source project/);
|
||||
const retained = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
expect(retained.some(entry => entry.kind === "source" && entry.scopeId === f.workspace.id)).toBe(true);
|
||||
expect(retained.some(entry => entry.kind === "project_source" && entry.scopeId === project.id)).toBe(true);
|
||||
expect(retained.some(entry => entry.kind === "repository" || entry.kind === "reader")).toBe(false);
|
||||
expect(await readFile(path.join(checkout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
await expect(f.service.assertPublicationAllowed(f.admission)).rejects.toThrow(/binding/);
|
||||
});
|
||||
|
||||
it("requires the admitted actor's write grant for a projectless alias of a protected configured root", async () => {
|
||||
it("rechecks source, destination, configuration, and current identity when copying", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(companyMemberships).set({ membershipRole: "member" }).where(eq(companyMemberships.principalId, f.userId));
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected shared root",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
const alias = path.join(root, `${f.issueId}-protected-alias`);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected source", cwd: alias }).returning();
|
||||
const read = () => assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor });
|
||||
await expect(read()).resolves.toBeUndefined();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
|
||||
await expect(f.request({ kind: "url", url: "https://github.com/public/writable" })).rejects.toThrow(/protected|not authorized/);
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
|
||||
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } }).returning();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, source.id));
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(read()).resolves.toBeUndefined();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
|
||||
const source = path.join(root, `${f.issueId}-source`), moved = path.join(root, `${f.issueId}-moved`);
|
||||
await mkdir(source); await mkdir(moved);
|
||||
const alias = path.join(root, `${f.issueId}-source-alias`);
|
||||
await symlink(source, alias);
|
||||
const verify = await f.service.prepareConfiguredRepositoryCopy({ ...f.admission, sourceCwd: alias });
|
||||
await expect(verify()).resolves.toBeUndefined();
|
||||
await fs.unlink(alias); await symlink(moved, alias);
|
||||
await expect(verify()).rejects.toThrow(/source or destination changed/);
|
||||
const verifyIdentity = await f.service.prepareConfiguredRepositoryCopy({ ...f.admission, sourceCwd: source });
|
||||
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(verifyIdentity()).rejects.toThrow(/identity/);
|
||||
});
|
||||
|
||||
it("keeps genuine isolated Git copies read-only against their protected source project", async () => {
|
||||
it("captures the bound workspace's source configuration even when the task has no source pointer", async () => {
|
||||
const f = await fixture();
|
||||
const sourceRoot = path.join(root, `${f.issueId}-isolated-source`);
|
||||
await mkdir(sourceRoot);
|
||||
const exec = promisify(execFileCallback);
|
||||
await exec("git", ["init", "--initial-branch=main", sourceRoot]);
|
||||
await writeFile(path.join(sourceRoot, "README.md"), "Protected source\n");
|
||||
await exec("git", ["-C", sourceRoot, "add", "."]);
|
||||
await exec("git", ["-C", sourceRoot, "-c", "user.name=Fixture", "-c", "[email protected]", "commit", "-m", "source"]);
|
||||
await exec("git", ["-C", sourceRoot, "worktree", "add", "-b", "isolated-task", f.admission.cwd]);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected copy source",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Original", cwd: sourceRoot }).returning();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id, strategyType: "git_worktree" }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
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();
|
||||
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/);
|
||||
const source = path.join(root, `${f.issueId}-configured-source`);
|
||||
await mkdir(source);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Selected configuration" }).returning();
|
||||
const [configuration] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id,
|
||||
name: "Source", cwd: source }).returning();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: configuration.id }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
const verify = await f.service.prepareConfiguredRepositoryCopy({ ...f.admission, sourceCwd: source });
|
||||
await expect(verify()).resolves.toBeUndefined();
|
||||
await db.update(projectWorkspaces).set({ cwd: f.admission.cwd }).where(eq(projectWorkspaces.id, configuration.id));
|
||||
await expect(verify()).rejects.toThrow(/configuration changed/);
|
||||
});
|
||||
|
||||
it.each(["move", "delete"] as const)("retains protected shared execution-source write scope after %s", async change => {
|
||||
const f = await fixture(), source = await standardPeer(f);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected execution owner",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, source.workspace.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
|
||||
const grants = await db.insert(principalPermissionGrants).values([
|
||||
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
{ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
]).returning();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
if (change === "move") {
|
||||
const moved = path.join(root, `${source.issueId}-shared-moved`);
|
||||
await mkdir(moved);
|
||||
await executionWorkspaceService(db).update(source.workspace.id, { cwd: moved, projectId: null });
|
||||
} else await db.delete(executionWorkspaces).where(eq(executionWorkspaces.id, source.workspace.id));
|
||||
for (const grant of grants) await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized|workspace access/);
|
||||
it("validates a prebinding configured copy without requiring a destination workspace row", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, f.issueId));
|
||||
const verify = await f.service.prepareConfiguredRepositoryCopy({ ...f.admission, workspaceId: randomUUID(),
|
||||
cwd: path.join(root, `${f.issueId}-not-created`), sourceCwd: f.admission.cwd });
|
||||
await expect(verify()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not require historical readers to retain write grants for another authorized writer", async () => {
|
||||
it("reuses the marker's exact private-origin receipt without requiring its task's app access", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Shared source writers",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Shared", cwd: f.admission.cwd });
|
||||
const prior = await db.insert(principalPermissionGrants).values([
|
||||
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
{ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
]).returning();
|
||||
await f.service.prepareForAdmission(f.admission);
|
||||
await db.insert(principalPermissionGrants).values([
|
||||
{ companyId: f.companyId, principalType: "agent", principalId: peer.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: peer.agentId } },
|
||||
{ companyId: f.companyId, principalType: "user", principalId: peer.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: peer.agentId } },
|
||||
await privateTask(f);
|
||||
const first = await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await localPrivateClone(f);
|
||||
const [published] = await f.service.prepareForAdmission(f.admission);
|
||||
const next = await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } });
|
||||
const decoys = await db.insert(executionWorkspaces).values(Array.from({ length: 500 }, () => ({
|
||||
companyId: f.companyId, name: "Unrelated matching receipt", cwd: f.admission.cwd,
|
||||
mode: "shared_workspace", strategyType: "operator_directory" }))).returning({ id: executionWorkspaces.id });
|
||||
await db.insert(executionWorkspaceRepositories).values(decoys.map(workspace => ({ companyId: f.companyId,
|
||||
executionWorkspaceId: workspace.id, requestedByIssueId: f.issueId, repositoryIdentity: first.repository.repositoryIdentity,
|
||||
repoUrl: first.repository.repoUrl, catalogRepositoryId: "private-repo", relativePath: first.repository.relativePath,
|
||||
requestedRef: first.repository.requestedRef, requestKey: randomUUID(), state: "ready", pinnedCommit: "b".repeat(40) })));
|
||||
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
|
||||
acquisition.materialize.mockImplementationOnce(actual.materializeManagedProjectWorkspace);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([
|
||||
expect.objectContaining({ id: next.operationId, cwd: published.cwd, pinnedCommit: published.pinnedCommit }),
|
||||
]);
|
||||
for (const grant of prior) await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.guardForPublication(peer.admission)).resolves.toBeUndefined();
|
||||
expect(JSON.parse(await readFile(path.join(published.cwd, ".git", "paperclip-workspace-owner.json"), "utf8")))
|
||||
.toMatchObject({ repositoryId: next.operationId, pinnedCommit: published.pinnedCommit });
|
||||
const { authorizationService } = await import("../services/authorization.js");
|
||||
expect((await authorizationService(db).decide({ actor: peer.actor, action: "issue:read",
|
||||
resource: { type: "issue", companyId: f.companyId, issueId: f.issueId } })).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("checks configured private aliases against both admitted agent and responsible user before capture and publication", async () => {
|
||||
const f = await fixture();
|
||||
const alias = path.join(root, `${f.issueId}-configured-alias`);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private configured files", visibility: "private" }).returning();
|
||||
const [configured] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Configured alias", cwd: alias }).returning();
|
||||
await writeFile(path.join(f.admission.cwd, "private-configured.txt"), "Private configured project bytes\n");
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId });
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual([]);
|
||||
const [grant] = await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id,
|
||||
subjectType: "user", subjectId: f.userId }).returning();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
|
||||
const moved = path.join(root, `${f.issueId}-configured-moved`);
|
||||
it("keeps shared checkout ownership current when an earlier workspace moves", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await localPrivateClone(f);
|
||||
const [published] = await f.service.prepareForAdmission(f.admission);
|
||||
const next = await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } });
|
||||
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
|
||||
acquisition.materialize.mockImplementationOnce(actual.materializeManagedProjectWorkspace);
|
||||
await f.service.prepareForAdmission(peer.admission);
|
||||
const moved = path.join(root, `${f.issueId}-moved-after-reuse`);
|
||||
await mkdir(moved);
|
||||
await db.update(projectWorkspaces).set({ cwd: moved }).where(eq(projectWorkspaces.id, configured.id));
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, grant.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, configured.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).resolves.toBeUndefined();
|
||||
await db.delete(projects).where(eq(projects.id, project.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains execution-source project privacy after its workspace project pointer is cleared", async () => {
|
||||
const f = await fixture();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Execution source owner", visibility: "private" }).returning();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId });
|
||||
const [grant] = await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }).returning();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await db.update(executionWorkspaces).set({ projectId: null }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, grant.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await db.delete(projects).where(eq(projects.id, project.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project/);
|
||||
});
|
||||
|
||||
it.each(["configured", "execution"] as const)("ignores an unrelated %s workspace symlink loop", async kind => {
|
||||
const f = await fixture();
|
||||
const loop = path.join(root, `${f.issueId}-unrelated-loop`);
|
||||
await symlink(loop, loop);
|
||||
if (kind === "configured") {
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unavailable unrelated project", visibility: "private" }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Loop", cwd: loop });
|
||||
} else {
|
||||
await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Unrelated loop", cwd: loop, mode: "isolated_workspace", strategyType: "task_directory" });
|
||||
}
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an unusable selected root without releasing a retained private source", async () => {
|
||||
const f = await fixture();
|
||||
await privateTask(f);
|
||||
await f.service.prepareForAdmission(f.admission);
|
||||
const peer = await standardPeer(f);
|
||||
const loop = path.join(root, `${f.issueId}-selected-loop`);
|
||||
await symlink(loop, loop);
|
||||
await db.update(executionWorkspaces).set({ cwd: loop }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await expect(f.service.prepareForAdmission({ ...f.admission, cwd: loop })).rejects.toThrow(/ELOOP/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: loop,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/ELOOP/);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: peer.admission.cwd,
|
||||
sourceWorkspaceId: peer.workspace.id, actor: peer.actor })).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
});
|
||||
|
||||
it("rechecks retained own-workspace privacy without adding an empty-admission membership gate", async () => {
|
||||
const f = await fixture();
|
||||
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, f.companyId));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
const [privateSource] = await db.insert(issues).values({ companyId: f.companyId, title: "Other private writer", visibility: "private",
|
||||
executionWorkspaceId: f.workspace.id }).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, privateSource.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/workspace access/);
|
||||
});
|
||||
|
||||
it("does not publish managed bytes to a retained low-trust reader even with private source grants", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId,
|
||||
parentId: f.issueId }).where(eq(issues.id, peer.issueId));
|
||||
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 },
|
||||
await db.update(executionWorkspaces).set({ cwd: moved }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
const latest = await standardPeer(f);
|
||||
const last = await f.service.request({ companyId: f.companyId, issueId: latest.issueId, actor: latest.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } });
|
||||
acquisition.materialize.mockImplementationOnce(actual.materializeManagedProjectWorkspace);
|
||||
await expect(f.service.prepareForAdmission(latest.admission)).resolves.toEqual([
|
||||
expect.objectContaining({ id: last.operationId, cwd: published.cwd, pinnedCommit: published.pinnedCommit }),
|
||||
]);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } })).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
expect(await f.service.list(f.companyId, peer.workspace.id)).toEqual([]);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(await readFile(path.join(published.cwd, ".git", "paperclip-workspace-owner.json"), "utf8")))
|
||||
.toMatchObject({ repositoryId: last.operationId, pinnedCommit: published.pinnedCommit });
|
||||
expect((await f.service.list(f.companyId, peer.workspace.id))[0])
|
||||
.toMatchObject({ id: next.operationId, state: "ready", pinnedCommit: published.pinnedCommit });
|
||||
expect(await readFile(path.join(published.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains private direct files from an admitted workspace with no repository inventory", async () => {
|
||||
it("does not transfer shared ownership after the current task binding changes", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
await privateTask(f);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await writeFile(path.join(f.admission.cwd, "private-direct.txt"), "Private direct task output");
|
||||
await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-direct-source"), metadata: {} }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
await grantPeer(f, peer);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
expect(await readFile(path.join(f.admission.cwd, "private-direct.txt"), "utf8")).toBe("Private direct task output");
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
const first = await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await localPrivateClone(f);
|
||||
const [published] = await f.service.prepareForAdmission(f.admission);
|
||||
await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } });
|
||||
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
|
||||
acquisition.materialize.mockImplementationOnce(async (cwd, input) => {
|
||||
const result = await actual.materializeManagedProjectWorkspace(cwd, input);
|
||||
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, peer.issueId));
|
||||
return result;
|
||||
});
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/publication binding/);
|
||||
expect(JSON.parse(await readFile(path.join(published.cwd, ".git", "paperclip-workspace-owner.json"), "utf8")))
|
||||
.toMatchObject({ repositoryId: first.operationId, pinnedCommit: published.pinnedCommit });
|
||||
});
|
||||
|
||||
it("blocks a future private source after an empty standard reader was admitted and moved", async () => {
|
||||
it.each(["missing", "malformed", "symlink", "foreign_company", "conflicting_pin"] as const)("rejects unverified shared publication ownership (%s) without overwriting files", async marker => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-standard-reader"), metadata: {}, status: "archived" }).where(eq(executionWorkspaces.id, peer.workspace.id));
|
||||
await privateTask(f);
|
||||
await expect(executionWorkspaceRepositoryService(db).request({ companyId: f.companyId, issueId: f.issueId, actor: f.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } })).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks configured-source publication against retained readers after grant revocation", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
await privateTask(f);
|
||||
const grants = await grantPeer(f, peer);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grants[0].id));
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
await expect(f.service.guardForPublication({ ...f.admission, workspaceId: peer.workspace.id })).rejects.toThrow(/publication binding/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(acquisition.catalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fences pending private clones and rechecks granted readers before actual publication", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
await privateTask(f);
|
||||
const intent = await f.request({ kind: "catalog", id: "private-repo" });
|
||||
let release!: () => void, started!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const staged = new Promise<void>(resolve => { started = resolve; });
|
||||
await localPrivateClone(f, async () => { started(); await gate; });
|
||||
const preparation = f.service.prepareForAdmission(f.admission);
|
||||
const outcome = preparation.then(value => ({ value, error: null }), error => ({ value: null, error }));
|
||||
try {
|
||||
await Promise.race([staged, outcome.then(() => { throw new Error("Clone ended before publication barrier"); })]);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
await grantPeer(f, peer);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.issueId, f.issueId));
|
||||
} finally { release(); }
|
||||
expect((await outcome).error).toBeTruthy();
|
||||
expect(await fs.lstat(path.join(f.admission.cwd, intent.repository.relativePath)).catch(() => null)).toBeNull();
|
||||
const first = await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await localPrivateClone(f);
|
||||
const [published] = await f.service.prepareForAdmission(f.admission);
|
||||
const next = await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } });
|
||||
const ownerPath = path.join(published.cwd, ".git", "paperclip-workspace-owner.json");
|
||||
await rm(ownerPath);
|
||||
if (marker === "malformed") await writeFile(ownerPath, "{not-json");
|
||||
if (marker === "symlink") {
|
||||
const outside = path.join(root, `${f.issueId}-owner.json`);
|
||||
await writeFile(outside, JSON.stringify({ repositoryId: first.operationId, pinnedCommit: published.pinnedCommit }));
|
||||
await symlink(outside, ownerPath);
|
||||
}
|
||||
if (marker === "foreign_company") {
|
||||
const foreign = await fixture();
|
||||
const foreignReceipt = await foreign.request({ kind: "catalog", id: "private-repo" });
|
||||
await writeFile(ownerPath, JSON.stringify({ repositoryId: foreignReceipt.operationId, pinnedCommit: published.pinnedCommit }));
|
||||
}
|
||||
if (marker === "conflicting_pin") {
|
||||
await writeFile(ownerPath, JSON.stringify({ repositoryId: first.operationId, pinnedCommit: "b".repeat(40) }));
|
||||
}
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/matching preparation receipt|receipts disagree/);
|
||||
expect((await f.service.list(f.companyId, peer.workspace.id))[0]).toMatchObject({ id: next.operationId, state: "needs_repair" });
|
||||
expect(await readFile(path.join(published.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
});
|
||||
|
||||
it.each(["agent", "issue", "project", "workspace_source_project", "run"] as const)(
|
||||
@@ -416,7 +251,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id, strategyType: "git_worktree" }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
}
|
||||
}
|
||||
await expect(f.request({ kind: "catalog", id: "private-repo" })).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
await expect(f.request({ kind: "catalog", id: "private-repo" })).rejects.toThrow(/anonymous public URLs only|trusted task|Selected workspace source trust/);
|
||||
expect(await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
},
|
||||
@@ -444,7 +279,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
}
|
||||
}
|
||||
for (const url of ["https://github.com/public/example", "https://github.com/sponsor/private"]) {
|
||||
await expect(f.request({ kind: "url", url })).rejects.toThrow(/trusted task/);
|
||||
await expect(f.request({ kind: "url", url })).rejects.toThrow(/trusted task|Selected workspace source trust/);
|
||||
}
|
||||
expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
@@ -456,7 +291,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const receipt = await f.request({ kind: "url", url: "https://github.com/public/example" });
|
||||
await db.update(executionWorkspaceRepositories).set({ state }).where(eq(executionWorkspaceRepositories.id, receipt.operationId));
|
||||
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(/trusted task/);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/trusted task|Selected workspace source trust/);
|
||||
expect((await f.service.list(f.companyId, f.workspace.id))[0].state).toBe(state);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
@@ -479,8 +314,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const checkout = path.join(f.admission.cwd, receipt.repository.relativePath);
|
||||
if (state === "ready") { await mkdir(checkout, { recursive: true }); await writeFile(path.join(checkout, "private.txt"), "retained private bytes"); }
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(f.request({ kind: "url", url: receipt.repository.repoUrl })).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
await expect(f.request({ kind: "url", url: receipt.repository.repoUrl })).rejects.toThrow(/anonymous public URLs only|trusted task|Selected workspace source trust/);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/anonymous public URLs only|trusted task|Selected workspace source trust/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
if (state === "ready") expect(await readFile(path.join(checkout, "private.txt"), "utf8")).toBe("retained private bytes");
|
||||
@@ -503,7 +338,9 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const f = await fixture();
|
||||
await db.update(executionWorkspaces).set({ status: "idle" }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
if (principal === "local_board") await db.update(heartbeatRuns).set({ responsibleUserId: "local-board" }).where(eq(heartbeatRuns.id, f.runId));
|
||||
const receipt = await f.request({ kind: "url", url: "https://github.com/public/example" });
|
||||
const receipt = await f.service.request({ companyId: f.companyId, issueId: f.issueId,
|
||||
actor: principal === "local_board" ? { ...f.actor, onBehalfOfUserId: null } : f.actor,
|
||||
request: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: randomUUID() } });
|
||||
const responsibleUserId = principal === "local_board" ? "local-board" : principal === "routine" ? null : f.userId;
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId }).where(eq(heartbeatRuns.id, f.runId));
|
||||
const admission = { ...f.admission, responsibleUserId };
|
||||
@@ -536,13 +373,13 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
acquisition.materialize.mockClear();
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(f.service.prepareForAdmission(admission)).resolves.toEqual([prepared]);
|
||||
await expect(f.service.reserveForRecovery(admission)).resolves.toBeUndefined();
|
||||
await expect(f.service.assertRecoveryBinding(admission)).resolves.toBeUndefined();
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(await readFile(path.join(prepared.cwd, "public.txt"), "utf8")).toBe("Public fixture bytes\n");
|
||||
expect((await exec("git", ["-C", prepared.cwd, "rev-parse", "HEAD"])).stdout.trim()).toBe(editedHead);
|
||||
expect(await readFile(path.join(prepared.cwd, "dirty.txt"), "utf8")).toBe("Uncommitted task bytes\n");
|
||||
await rm(prepared.cwd, { recursive: true });
|
||||
await expect(f.service.prepareForAdmission(admission)).rejects.toThrow(/trusted task/);
|
||||
await expect(f.service.prepareForAdmission(admission)).rejects.toThrow(/trusted task|Selected workspace source trust/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -552,91 +389,24 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await localPrivateClone(f, async () => {
|
||||
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(/trusted task/);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/trusted task|Selected workspace source trust/);
|
||||
await expect(fs.lstat(path.join(f.admission.cwd, receipt.repository.relativePath))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect((await f.service.list(f.companyId, f.workspace.id))[0]).toMatchObject({ pinnedCommit: null, state: "needs_repair" });
|
||||
expect(acquisition.materialize).toHaveBeenCalledOnce();
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["same_repository", "different_repository", "empty_inventory", "unpinned_publication", "pending_intent"] as const)(
|
||||
"rejects managed peer bytes in the same physical root with %s", async scenario => {
|
||||
const f = await fixture();
|
||||
const managed = await f.request({ kind: "catalog", id: "private-repo" });
|
||||
const checkout = path.join(f.admission.cwd, managed.repository.relativePath);
|
||||
if (scenario !== "pending_intent") {
|
||||
await mkdir(path.join(checkout, ".git"), { recursive: true });
|
||||
await writeFile(path.join(checkout, "private.txt"), "Private peer bytes");
|
||||
} else {
|
||||
await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
}
|
||||
if (scenario !== "unpinned_publication" && scenario !== "pending_intent") await db.update(executionWorkspaceRepositories)
|
||||
.set({ state: "ready", pinnedCommit: "b".repeat(40) }).where(eq(executionWorkspaceRepositories.id, managed.operationId));
|
||||
const [peer] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Same physical folder",
|
||||
cwd: f.admission.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: peer.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 } });
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
||||
if (scenario !== "empty_inventory") await expect(f.service.request({ companyId: f.companyId, issueId, actor: { ...f.actor, runId },
|
||||
request: { requestKey: randomUUID(), repository: { kind: "url", url: scenario === "same_repository" ? managed.repository.repoUrl : "https://github.com/public/other" } } }))
|
||||
.rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
await expect(f.service.prepareForAdmission({ ...f.admission, issueId, runId, workspaceId: peer.id })).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
if (scenario !== "pending_intent") expect(await readFile(path.join(checkout, "private.txt"), "utf8")).toBe("Private peer bytes");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ marker: "valid", reserved: false }, { marker: "malformed", reserved: false }, { marker: "symlink", reserved: false },
|
||||
{ marker: "valid", reserved: true }, { marker: "malformed", reserved: true }, { marker: "symlink", reserved: true },
|
||||
])("rejects readable private publication behind an unreadable alias ($marker marker, reserved=$reserved)", async ({ marker, reserved }) => {
|
||||
it.each(["catalog_revocation", "binding_change", "responsible_user_change"] as const)("rejects a staged clone before publication after %s", async change => {
|
||||
const f = await fixture();
|
||||
const receipt = await f.request({ kind: "catalog", id: "private-repo" });
|
||||
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 });
|
||||
const exec = promisify(execFileCallback);
|
||||
await exec("git", ["init", "--initial-branch=main", checkout]);
|
||||
await writeFile(path.join(checkout, "private.txt"), "Private published bytes must never enter low-trust capture\n");
|
||||
await exec("git", ["-C", checkout, "add", "."]);
|
||||
await exec("git", ["-C", checkout, "-c", "user.name=Fixture", "-c", "[email protected]", "commit", "-m", "private source"]);
|
||||
const pin = (await exec("git", ["-C", checkout, "rev-parse", "HEAD"])).stdout.trim();
|
||||
const owner = path.join(checkout, ".git", "paperclip-workspace-owner.json");
|
||||
const ownerJson = JSON.stringify({ version: 1, repositoryId: receipt.operationId, pinnedCommit: pin });
|
||||
if (marker === "symlink") {
|
||||
const target = path.join(root, `${f.issueId}-untrusted-owner`);
|
||||
await writeFile(target, ownerJson);
|
||||
await symlink(target, owner);
|
||||
} else await writeFile(owner, marker === "valid" ? ownerJson : "{unverifiable owner");
|
||||
await db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit: pin }).where(eq(executionWorkspaceRepositories.id, receipt.operationId));
|
||||
const aliasParent = path.join(root, `${f.issueId}-unreadable`);
|
||||
await mkdir(aliasParent);
|
||||
const alias = path.join(aliasParent, "workspace");
|
||||
await symlink(f.admission.cwd, alias);
|
||||
await db.update(executionWorkspaces).set({ cwd: alias }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
const peer = await lowTrustPeer(f);
|
||||
const canonicalCwd = await realpath(f.admission.cwd);
|
||||
if (reserved) await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, canonicalCwd,
|
||||
rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), mode: "anonymous_only" });
|
||||
const actual = fs.realpath.bind(fs);
|
||||
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
|
||||
if (String(pathname).startsWith(aliasParent)) throw Object.assign(new Error("Unreadable legacy alias"), { code: "EACCES" });
|
||||
return actual(pathname);
|
||||
await localPrivateClone(f, async () => {
|
||||
if (change === "catalog_revocation") acquisition.catalog.mockResolvedValue({ repositories: [] });
|
||||
if (change === "binding_change") await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, f.issueId));
|
||||
if (change === "responsible_user_change") await db.update(heartbeatRuns).set({ responsibleUserId: randomUUID() }).where(eq(heartbeatRuns.id, f.runId));
|
||||
});
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
await expect(f.service.request({ companyId: f.companyId, issueId: peer.admission.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "url", url: "https://github.com/public/unrelated" }, requestKey: randomUUID() } }))
|
||||
.rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
expect(await f.service.list(f.companyId, peer.workspace.id)).toEqual([]);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
expect(await readFile(path.join(checkout, "private.txt"), "utf8")).toContain("Private published bytes");
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/no longer available|binding|responsible user/);
|
||||
await expect(fs.lstat(path.join(f.admission.cwd, receipt.repository.relativePath))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect((await f.service.list(f.companyId, f.workspace.id))[0]).toMatchObject({ state: "needs_repair", pinnedCommit: null });
|
||||
});
|
||||
|
||||
it.each(["standard", "retained_low_trust", "current_low_trust"] as const)("recovers an exact legacy run-ID binding with %s trust", async trust => {
|
||||
@@ -652,163 +422,16 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: execution },
|
||||
contextSnapshot: { issueId: f.issueId, ...(trust === "retained_low_trust" ? { executionPolicy: f.policy } : {}) } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
if (trust === "current_low_trust") await db.update(agents).set({ permissions: f.policy }).where(eq(agents.id, f.agentId));
|
||||
await expect(f.service.reserveForRecovery({ ...f.admission, workspaceId: f.runId })).resolves.toBeUndefined();
|
||||
await expect(f.service.assertRecoveryBinding({ ...f.admission, workspaceId: f.runId })).resolves.toBeUndefined();
|
||||
const reservations = await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
expect(reservations).toHaveLength(trust === "standard" ? 0 : 1);
|
||||
if (trust !== "standard") expect(reservations[0].mode).toBe("anonymous_only");
|
||||
await expect(f.service.reserveForRecovery({ ...f.admission, workspaceId: randomUUID() })).rejects.toThrow(/recovery identity/);
|
||||
expect(reservations).toHaveLength(0);
|
||||
await expect(f.service.assertRecoveryBinding({ ...f.admission, workspaceId: randomUUID() })).rejects.toThrow(/recovery identity/);
|
||||
execution.binding.agentId = randomUUID();
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: execution } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(f.service.reserveForRecovery({ ...f.admission, workspaceId: f.runId })).rejects.toThrow(/recovery identity/);
|
||||
await expect(f.service.assertRecoveryBinding({ ...f.admission, workspaceId: f.runId })).rejects.toThrow(/recovery identity/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["ENOTDIR", "EACCES"] as const)("ignores unrelated unavailable legacy managed paths (%s) while preserving anonymous roots", async code => {
|
||||
const f = await fixture();
|
||||
await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
const peerRoot = path.join(root, `${f.issueId}-anonymous`);
|
||||
await mkdir(peerRoot);
|
||||
const peer = await lowTrustPeer(f, peerRoot);
|
||||
const unavailableParent = path.join(root, `${f.issueId}-unavailable`);
|
||||
await writeFile(unavailableParent, "Archived directory replaced by a file");
|
||||
const unavailableCwd = path.join(unavailableParent, "checkout");
|
||||
await db.update(executionWorkspaces).set({ cwd: unavailableCwd, status: "archived" }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
if (code === "EACCES") {
|
||||
const actual = fs.realpath.bind(fs);
|
||||
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
|
||||
if (String(pathname).startsWith(unavailableParent)) throw Object.assign(new Error("fixture access denied"), { code: "EACCES" });
|
||||
return actual(pathname);
|
||||
});
|
||||
}
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
vi.restoreAllMocks();
|
||||
const actual = fs.realpath.bind(fs);
|
||||
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
|
||||
if (String(pathname) === unavailableCwd) throw Object.assign(new Error("Historical path remains unavailable"), { code: "EACCES" });
|
||||
return actual(pathname);
|
||||
});
|
||||
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("still rejects a legacy pending managed receipt through a canonical path alias", async () => {
|
||||
const f = await fixture();
|
||||
await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
const alias = path.join(root, `${f.issueId}-legacy-alias`);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
await db.update(executionWorkspaces).set({ cwd: alias }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
const peer = await lowTrustPeer(f);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
});
|
||||
|
||||
it("reserves a legacy low-trust recovery folder after its task binding moves", async () => {
|
||||
const f = await fixture();
|
||||
const peer = await lowTrustPeer(f);
|
||||
await db.update(issues).set({ parentId: peer.admission.issueId }).where(eq(issues.id, f.issueId));
|
||||
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, peer.admission.issueId));
|
||||
await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-recovery"), metadata: {}, status: "archived" })
|
||||
.where(eq(executionWorkspaces.id, peer.workspace.id));
|
||||
await f.service.reserveForRecovery(peer.admission);
|
||||
await expect(executionWorkspaceRepositoryService(db).request({ companyId: f.companyId, issueId: f.issueId, actor: f.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } }))
|
||||
.rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
const [reservation] = await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
expect(reservation).toMatchObject({ canonicalCwd: await realpath(f.admission.cwd), mode: "anonymous_only" });
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reserves a legacy managed recovery folder before a low-trust peer resumes", async () => {
|
||||
const f = await fixture();
|
||||
await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
const peer = await lowTrustPeer(f);
|
||||
await f.service.reserveForRecovery(f.admission);
|
||||
await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-managed-recovery") }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await expect(executionWorkspaceRepositoryService(db).reserveForRecovery(peer.admission)).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes a managed request against anonymous admission through physical aliases", async () => {
|
||||
const f = await fixture();
|
||||
const alias = path.join(root, `${f.issueId}-alias`);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
const peer = await lowTrustPeer(f, alias);
|
||||
await db.update(issues).set({ parentId: peer.admission.issueId }).where(eq(issues.id, f.issueId));
|
||||
const outcomes = await Promise.allSettled([
|
||||
f.service.prepareForAdmission(peer.admission),
|
||||
f.request({ kind: "catalog", id: "private-repo" }),
|
||||
]);
|
||||
expect(outcomes.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(outcomes.filter(result => result.status === "rejected")).toHaveLength(1);
|
||||
const [reservation] = await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
expect(reservation.mode).toBe(outcomes[0].status === "fulfilled" ? "anonymous_only" : "managed");
|
||||
const restarted = executionWorkspaceRepositoryService(db);
|
||||
if (reservation.mode === "anonymous_only") {
|
||||
await expect(f.request({ kind: "catalog", id: "private-repo" })).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
await expect(restarted.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
} else await expect(restarted.prepareForAdmission(peer.admission)).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps anonymous root ownership across workspace moves and blocks future catalog promotion", async () => {
|
||||
const f = await fixture();
|
||||
const pending = await f.request({ kind: "url", url: "https://github.com/public/later-catalog" });
|
||||
const peer = await lowTrustPeer(f);
|
||||
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" })
|
||||
.where(eq(executionWorkspaces.id, peer.workspace.id));
|
||||
const restarted = executionWorkspaceRepositoryService(db);
|
||||
await expect(restarted.request({ companyId: f.companyId, issueId: f.issueId, actor: f.actor,
|
||||
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } })).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
acquisition.catalog.mockResolvedValue({ repositories: [{ id: "later", fullName: "public/later-catalog", private: true,
|
||||
url: pending.repository.repoUrl, connections: ["sponsor-github"] }] });
|
||||
await expect(restarted.prepareForAdmission(f.admission)).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
expect(acquisition.managedAuth).not.toHaveBeenCalled();
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect((await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, pending.operationId)))[0].catalogRepositoryId).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects anonymous admission while a managed clone is waiting to publish", async () => {
|
||||
const f = await fixture();
|
||||
const receipt = await f.request({ kind: "catalog", id: "private-repo" });
|
||||
const peer = await lowTrustPeer(f);
|
||||
const source = path.join(root, `${f.issueId}-private-source`);
|
||||
await mkdir(source);
|
||||
const exec = promisify(execFileCallback);
|
||||
await exec("git", ["init", "--initial-branch=main", source]);
|
||||
await writeFile(path.join(source, "private.txt"), "Private fixture bytes\n");
|
||||
await exec("git", ["-C", source, "add", "."]);
|
||||
await exec("git", ["-C", source, "-c", "user.name=Fixture", "-c", "[email protected]", "commit", "-m", "private source"]);
|
||||
let release!: () => void, started!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const staged = new Promise<void>(resolve => { started = resolve; });
|
||||
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
|
||||
acquisition.materialize.mockImplementationOnce(async (cwd, input) => {
|
||||
const result = await actual.materializeManagedProjectWorkspace(cwd, { ...input, localSource: source,
|
||||
beforePublish: async cloneCwd => {
|
||||
await input.beforePublish(cloneCwd);
|
||||
started();
|
||||
await gate;
|
||||
} });
|
||||
await exec("git", ["-C", cwd, "remote", "set-url", "origin", input.repoUrl]);
|
||||
return result;
|
||||
});
|
||||
const preparation = f.service.prepareForAdmission(f.admission);
|
||||
try {
|
||||
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 });
|
||||
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(); }
|
||||
const [prepared] = await preparation;
|
||||
expect(await readFile(path.join(prepared.cwd, "private.txt"), "utf8")).toBe("Private fixture bytes\n");
|
||||
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(peer.admission)).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
});
|
||||
|
||||
it("retains previous run trust without confusing a board request's reassigned agent", async () => {
|
||||
const f = await fixture();
|
||||
const [replacement] = await db.insert(agents).values({ companyId: f.companyId, name: "Replacement", adapterType: "process" }).returning();
|
||||
@@ -819,12 +442,11 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
request: { requestKey: randomUUID(), repository: { kind: "catalog", id: "private-repo" } } });
|
||||
await expect(request()).resolves.toMatchObject({ kind: "requires_next_admission" });
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { taskId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(request()).rejects.toThrow(/anonymous public URLs only|trusted task/);
|
||||
await expect(request()).rejects.toThrow(/anonymous public URLs only|trusted task|Selected workspace source trust/);
|
||||
});
|
||||
|
||||
it("preserves standard empty-inventory admission without repository mutation authority", async () => {
|
||||
it("keeps standard empty-inventory admission free of repository acquisition work", async () => {
|
||||
const f = await fixture();
|
||||
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, f.companyId));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
expect(acquisition.catalog).not.toHaveBeenCalled();
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
@@ -835,10 +457,9 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps low-trust empty-inventory admission free of acquisition-only filesystem and membership requirements", async () => {
|
||||
it("keeps low-trust empty-inventory admission free of acquisition-only filesystem requirements", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, f.companyId));
|
||||
await expect(f.service.prepareForAdmission({ ...f.admission, cwd: path.join(root, "not-materialized") })).resolves.toEqual([]);
|
||||
expect(acquisition.catalog).not.toHaveBeenCalled();
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,363 +1,142 @@
|
||||
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 { mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises";
|
||||
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 { createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaces,
|
||||
heartbeatRuns, issues, projects, projectWorkspaces, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { assertTaskWorkspaceExecutionAccess, assertExecutionWorkspaceRecordAccess } from "../services/task-workspace-access.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 { authorizationService } from "../services/authorization.js";
|
||||
import { getEmbeddedPostgresTestSupport } 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) => ({
|
||||
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) => ({
|
||||
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", () => {
|
||||
(support.supported ? describe : describe.skip)("task workspace filesystem history independence", () => {
|
||||
const context = createRepositoryTrustFixture(acquisition);
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let root: string;
|
||||
const exec = promisify(execFileCallback);
|
||||
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;
|
||||
const { fixture, standardPeer, privateTask } = 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 => {
|
||||
it.each(["same_root", "ancestor", "descendant", "symlink"] as const)("admits a current task despite private historical files at %s", async relation => {
|
||||
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));
|
||||
const peer = await standardPeer(f);
|
||||
await privateTask(f);
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId,
|
||||
responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId));
|
||||
let historicalCwd = f.admission.cwd;
|
||||
if (relation === "ancestor") historicalCwd = root;
|
||||
if (relation === "descendant") {
|
||||
historicalCwd = path.join(f.admission.cwd, "private-child");
|
||||
await mkdir(historicalCwd);
|
||||
}
|
||||
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));
|
||||
if (relation === "symlink") {
|
||||
historicalCwd = path.join(root, `${peer.issueId}-alias`);
|
||||
await symlink(f.admission.cwd, historicalCwd);
|
||||
}
|
||||
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 db.update(executionWorkspaces).set({ cwd: historicalCwd, sourceIssueId: peer.issueId }).where(eq(executionWorkspaces.id, peer.workspace.id));
|
||||
await writeFile(path.join(f.admission.cwd, "shared.txt"), "Bytes accessible to this OS identity\n");
|
||||
const decision = await authorizationService(db).decide({ actor: f.actor, action: "issue:read",
|
||||
resource: { type: "issue", companyId: f.companyId, issueId: peer.issueId } });
|
||||
expect(decision.allowed).toBe(false);
|
||||
await expect(assertExecutionWorkspaceRecordAccess(db, f.actor, f.companyId, peer.workspace.id)).rejects.toThrow();
|
||||
await expect(assertTaskWorkspaceExecutionAccess(db, f.actor, f.companyId, {
|
||||
issueId: f.issueId, workspaceId: f.workspace.id,
|
||||
})).resolves.toMatchObject({ task: { id: f.issueId } });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.assertPublicationAllowed(f.admission)).resolves.toBeUndefined();
|
||||
expect(await readFile(path.join(f.admission.cwd, "shared.txt"), "utf8")).toContain("OS identity");
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual([]);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
expect(acquisition.managedAuth).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`);
|
||||
it.each(["anonymous_only", "managed"] as const)("ignores legacy %s reservations and deleted historical readers", async mode => {
|
||||
const f = await fixture();
|
||||
const rootKey = createHash("sha256").update(f.admission.cwd).digest("hex");
|
||||
await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, rootKey, canonicalCwd: f.admission.cwd, mode });
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values([
|
||||
{ companyId: f.companyId, rootKey, canonicalCwd: f.admission.cwd, kind: "source", scopeId: randomUUID() },
|
||||
{ companyId: f.companyId, rootKey, canonicalCwd: f.admission.cwd, kind: "reader", scopeId: randomUUID(), agentId: randomUUID(), responsibleUserId: randomUUID() },
|
||||
]);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.assertPublicationAllowed(f.admission)).resolves.toBeUndefined();
|
||||
const rows = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not infer trust or source-project access from another configuration at the same path", async () => {
|
||||
const f = await fixture();
|
||||
const [privateProject] = await db.insert(projects).values({ companyId: f.companyId,
|
||||
name: "Private unrelated project", visibility: "private", executionWorkspacePolicy: f.policy }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: privateProject.id,
|
||||
name: "Unrelated physical alias", cwd: f.admission.cwd });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.assertPublicationAllowed(f.admission)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses stored current-task selection without disclosing private full workspace metadata", async () => {
|
||||
const f = await fixture();
|
||||
const peer = await standardPeer(f);
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId,
|
||||
responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId));
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: peer.issueId }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: null }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
const selection = { kind: "existing" as const, workspaceId: f.workspace.id };
|
||||
await db.update(issues).set({ workspaceSelection: { version: 1, source: "explicit", selection } }).where(eq(issues.id, f.issueId));
|
||||
await expect(executionWorkspaceService(db).validateSelection({ companyId: f.companyId,
|
||||
actor: f.actor, issueId: f.issueId, selection })).rejects.toThrow();
|
||||
await expect(executionWorkspaceService(db).validateStoredSelectionForRun({ companyId: f.companyId,
|
||||
actor: f.actor, issueId: f.issueId, selection })).resolves.toMatchObject({ executionWorkspaceId: f.workspace.id });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("checks concrete local copy identity again before publication", async () => {
|
||||
const f = await fixture();
|
||||
const sourceCwd = path.join(root, `${f.issueId}-source`);
|
||||
await mkdir(sourceCwd);
|
||||
await exec("git", ["init", "--initial-branch=main", sourceCwd]);
|
||||
await writeFile(path.join(sourceCwd, "source.txt"), "Low-trust source\n");
|
||||
await writeFile(path.join(sourceCwd, "source.txt"), "source bytes\n");
|
||||
await exec("git", ["-C", sourceCwd, "add", "."]);
|
||||
await exec("git", ["-C", sourceCwd, "-c", "user.name=Fixture", "-c", "[email protected]", "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");
|
||||
const beforePublish = await f.service.prepareConfiguredRepositoryCopy({ ...f.admission, sourceCwd });
|
||||
await expect(beforePublish()).resolves.toBeUndefined();
|
||||
await rename(sourceCwd, `${sourceCwd}-moved`);
|
||||
await expect(beforePublish()).rejects.toThrow();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
it("rejects a stale current task binding and an invalid current run", async () => {
|
||||
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/);
|
||||
const peer = await standardPeer(f);
|
||||
await expect(f.service.prepareForAdmission({ ...f.admission, runId: peer.runId })).rejects.toThrow();
|
||||
await db.update(issues).set({ executionWorkspaceId: peer.workspace.id }).where(eq(issues.id, f.issueId));
|
||||
await expect(f.service.assertPublicationAllowed(f.admission)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not confuse sibling component prefixes with overlapping roots", async () => {
|
||||
it("still rejects cross-company execution bindings", 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" });
|
||||
const other = await fixture();
|
||||
await expect(f.service.prepareForAdmission({ ...f.admission, workspaceId: other.workspace.id })).rejects.toThrow();
|
||||
});
|
||||
|
||||
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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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 => {
|
||||
it.each(["url", "catalog"] as const)("rejects unsupported independent local-file acquisition through %s", 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"] }] });
|
||||
@@ -365,395 +144,4 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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", "[email protected]", "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("rechecks retargeted execution-workspace aliases at admission and publication", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
const unrelated = path.join(root, `${f.issueId}-unrelated`); await mkdir(unrelated);
|
||||
const alias = path.join(root, `${f.issueId}-alias`); await symlink(unrelated, alias);
|
||||
await db.update(executionWorkspaces).set({ cwd: alias }).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));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await rm(alias);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, actor: f.actor }))
|
||||
.rejects.toThrow();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow();
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each(["conflicting_path", "overlapping_invalid_key"] as const)("rejects retained root identity corruption (%s)", async scenario => {
|
||||
const f = await fixture();
|
||||
const canonicalCwd = await realpath(f.admission.cwd);
|
||||
const unrelated = path.join(root, `${f.issueId}-unrelated`); await mkdir(unrelated);
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: f.companyId, kind: "source", scopeId: f.workspace.id,
|
||||
rootKey: createHash("sha256").update(scenario === "conflicting_path" ? canonicalCwd : await realpath(unrelated)).digest("hex"),
|
||||
canonicalCwd: scenario === "conflicting_path" ? await realpath(unrelated) : path.join(canonicalCwd, "child") });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow("Repository root identity conflicts");
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow("Repository root identity conflicts");
|
||||
});
|
||||
|
||||
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", "[email protected]", "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");
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
@@ -8,8 +8,8 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaces, executionWorkspaceRepositoryAccess, heartbeatRuns, issueAccessGrants,
|
||||
issues, projects, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { authorizationService, executionWorkspaceReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
|
||||
import { executionWorkspaceRepositoryService, assertExecutionWorkspaceRootReadAccess } from "../services/execution-workspace-repositories.js";
|
||||
import { assertTaskWorkspaceAccess } from "../services/task-workspace-source-access.js";
|
||||
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { assertExecutionWorkspaceRecordAccess } from "../services/task-workspace-access.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
@@ -83,7 +83,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
schema: db._.fullSchema,
|
||||
logger: { logQuery: (query, params) => statements.push({ sql: query, params }) },
|
||||
}) as unknown as typeof db;
|
||||
await assertTaskWorkspaceAccess(recorded, { type: "board", source: "local_implicit", userId: "local-board" }, companyId, workspaceId);
|
||||
await assertExecutionWorkspaceRecordAccess(recorded, { type: "board", source: "local_implicit", userId: "local-board" }, companyId, workspaceId);
|
||||
const query = statements.find(statement => statement.sql.startsWith('select "id" from "issues"')
|
||||
&& statement.sql.includes('"execution_workspace_id"'));
|
||||
expect(query).toBeDefined();
|
||||
@@ -107,31 +107,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
}
|
||||
});
|
||||
|
||||
it("authorizes a retained repository in bounded database round trips as its workspace history grows", async () => {
|
||||
const cwd = await mkdtemp(path.join(tmpdir(), "root-admission-query-"));
|
||||
const workspaceIds = Array.from({ length: 40 }, () => randomUUID());
|
||||
const sourceIds = workspaceIds.map(() => randomUUID());
|
||||
await db.insert(executionWorkspaces).values(workspaceIds.map(id => ({
|
||||
id, companyId, cwd, name: "Historical root", mode: "shared_workspace", strategyType: "operator_directory",
|
||||
metadata: { _issuePrivacySources: { [sourceId]: true } },
|
||||
})));
|
||||
await db.insert(issues).values(sourceIds.map((id, i) => ({ id, companyId, title: "Source", executionWorkspaceId: workspaceIds[i] })));
|
||||
try {
|
||||
const statements: string[] = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema,
|
||||
logger: { logQuery: query => statements.push(query) } }) as unknown as typeof db;
|
||||
await assertExecutionWorkspaceRootReadAccess(recorded, { companyId, cwd, actor: { ...actor } });
|
||||
expect(statements.length).toBeLessThan(60);
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: sourceIds[39] }).where(eq(issues.id, sourceIds[39]));
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd, actor: { ...actor } })).rejects.toThrow();
|
||||
} finally {
|
||||
await db.delete(issues).where(inArray(issues.id, sourceIds));
|
||||
await db.delete(executionWorkspaces).where(inArray(executionWorkspaces.id, workspaceIds));
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("shares source loading across retained readers during actual repository admission", async () => {
|
||||
it("ignores retained readers during actual repository admission", async () => {
|
||||
const cwd = await realpath(await mkdtemp(path.join(tmpdir(), "multi-reader-admission-")));
|
||||
const rootKey = createHash("sha256").update(cwd).digest("hex");
|
||||
const workspaceIds = Array.from({ length: 40 }, () => randomUUID());
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaces, issues,
|
||||
projects, projectWorkspaces, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { fileResourceRoutes } from "../routes/file-resources.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("workspace history real process journey", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let root: string;
|
||||
let heartbeat: ReturnType<typeof heartbeatService>;
|
||||
const exec = promisify(execFileCallback);
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "paperclip-workspace-history-journey-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "home"));
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "false");
|
||||
database = await startEmbeddedPostgresTestDatabase("workspace-history-journey");
|
||||
db = createDb(database.connectionString);
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
heartbeat = heartbeatService(db);
|
||||
}, 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();
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
it("dispatches a real child process, serves its file, and reuses files despite private source history", async () => {
|
||||
const companyId = randomUUID(), userId = randomUUID(), privateUserId = randomUUID(), agentId = randomUUID();
|
||||
const projectId = randomUUID(), sourceId = randomUUID(), issueId = randomUUID(), privateIssueId = randomUUID();
|
||||
const sourceCwd = path.join(root, "selected-checkout");
|
||||
await mkdir(sourceCwd);
|
||||
await exec("git", ["init", "--initial-branch=main", sourceCwd]);
|
||||
await writeFile(path.join(sourceCwd, "README.md"), "Selected repository\n");
|
||||
await exec("git", ["-C", sourceCwd, "add", "."]);
|
||||
await exec("git", ["-C", sourceCwd, "-c", "user.name=Fixture", "-c", "[email protected]", "commit", "-m", "source"]);
|
||||
await db.insert(authUsers).values([userId, privateUserId].map(id => ({ id, name: "Journey user", email: `${id}@example.test`,
|
||||
createdAt: new Date(), updatedAt: new Date() })));
|
||||
await db.insert(companies).values({ id: companyId, name: "Workspace history journey", issuePrefix: "JNY", defaultResponsibleUserId: userId });
|
||||
await db.insert(companyMemberships).values([userId, privateUserId].map(principalId => ({ companyId,
|
||||
principalType: "user" as const, principalId, membershipRole: "member" as const, status: "active" as const })));
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Selected repository" });
|
||||
await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId, name: "Selected source", cwd: sourceCwd, isPrimary: true });
|
||||
const childScript = 'const startedAtUnixMs = Date.now(); const fs = require("node:fs"); fs.appendFileSync("journey-result.txt", process.env.PAPERCLIP_RUN_ID + "\\n"); console.log(JSON.stringify({ written: "journey-result.txt", cwd: process.cwd(), startedAtUnixMs, finishedAtUnixMs: Date.now() })); const release = ".journey-release-" + process.env.PAPERCLIP_RUN_ID; const timer = setInterval(() => { if (fs.existsSync(release)) { fs.unlinkSync(release); clearInterval(timer); } }, 10);';
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Real process worker", adapterType: "process", status: "idle",
|
||||
adapterConfig: { command: process.execPath, args: ["-e", childScript], cwd: sourceCwd, timeoutSec: 20 } });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId,
|
||||
membershipRole: "member", status: "active" });
|
||||
await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private historical source task",
|
||||
description: "PRIVATE_HISTORY_DESCRIPTION", visibility: "private", privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: privateUserId, status: "done" });
|
||||
await db.insert(executionWorkspaces).values({ companyId, sourceIssueId: privateIssueId,
|
||||
name: "PRIVATE_HISTORY_WORKSPACE", cwd: sourceCwd, mode: "shared_workspace", strategyType: "project_primary" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Write a result using selected source", status: "todo",
|
||||
assigneeAgentId: agentId, responsibleUserId: userId, projectId, projectWorkspaceId: sourceId,
|
||||
executionWorkspaceSettings: { mode: "shared_workspace" }, workspaceSelection: { version: 1, source: "explicit", selection: {
|
||||
kind: "configured_source", projectWorkspaceId: sourceId, mode: "shared",
|
||||
} } });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", source: "session", userId, companyIds: [companyId], isInstanceAdmin: false };
|
||||
next();
|
||||
});
|
||||
app.use("/api", fileResourceRoutes(db));
|
||||
app.use("/api", issueRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const measurements: Array<{ beforeChildMs: number; childWorkMs: number }> = [];
|
||||
const runTask = async () => {
|
||||
const requestedAtUnixMs = Date.now();
|
||||
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } });
|
||||
expect(run).not.toBeNull();
|
||||
// Hold the real child until the task is settled, avoiding an automatic
|
||||
// continuation for a process adapter that has no task-completion tool call.
|
||||
let processCwd: string | null = null;
|
||||
await vi.waitFor(async () => {
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const [workspace] = task.executionWorkspaceId ? await db.select().from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, task.executionWorkspaceId)) : [];
|
||||
processCwd = workspace?.cwd ?? null;
|
||||
expect(processCwd).not.toBeNull();
|
||||
expect(await readFile(path.join(processCwd!, "journey-result.txt"), "utf8")).toContain(run!.id);
|
||||
}, { timeout: 25_000 });
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
||||
await writeFile(path.join(processCwd!, `.journey-release-${run!.id}`), "settled");
|
||||
await vi.waitFor(async () => {
|
||||
const completed = await heartbeat.getRun(run!.id);
|
||||
expect(completed).toMatchObject({ status: "succeeded", error: null });
|
||||
}, { timeout: 25_000 });
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
const completed = await heartbeat.getRun(run!.id);
|
||||
expect(completed?.resultJson).toMatchObject({ stdout: expect.stringContaining("journey-result.txt") });
|
||||
const child = JSON.parse(String(completed!.resultJson!.stdout).trim());
|
||||
measurements.push({ beforeChildMs: child.startedAtUnixMs - requestedAtUnixMs,
|
||||
childWorkMs: child.finishedAtUnixMs - child.startedAtUnixMs });
|
||||
return run!;
|
||||
};
|
||||
const first = await runTask();
|
||||
const [bound] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, bound.executionWorkspaceId!));
|
||||
expect(workspace.cwd).toBe(sourceCwd);
|
||||
expect(workspace.metadata?.isolatedRemoteSource).toBeUndefined();
|
||||
expect(await readFile(path.join(workspace.cwd!, "journey-result.txt"), "utf8")).toBe(`${first.id}\n`);
|
||||
const content = await request(app).get(`/api/issues/${issueId}/file-resources/content`).query({ path: "journey-result.txt", workspace: "execution" });
|
||||
expect(content.status).toBe(200);
|
||||
expect(JSON.stringify(content.body)).toContain(first.id);
|
||||
const denied = await request(app).get(`/api/issues/${privateIssueId}`);
|
||||
expect([403, 404]).toContain(denied.status);
|
||||
expect(JSON.stringify(denied.body)).not.toContain("PRIVATE_HISTORY_DESCRIPTION");
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
const second = await runTask();
|
||||
const [reused] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(reused.executionWorkspaceId).toBe(workspace.id);
|
||||
expect(await readFile(path.join(workspace.cwd!, "journey-result.txt"), "utf8")).toBe(`${first.id}\n${second.id}\n`);
|
||||
const secondContent = await request(app).get(`/api/issues/${issueId}/file-resources/content`).query({ path: "journey-result.txt", workspace: "execution" });
|
||||
expect(secondContent.status).toBe(200);
|
||||
expect(JSON.stringify(secondContent.body)).toContain(second.id);
|
||||
console.info("workspace-history-journey timings", JSON.stringify(measurements));
|
||||
const evidencePath = process.env.PAPERCLIP_JOURNEY_EVIDENCE_PATH;
|
||||
if (evidencePath) await writeFile(evidencePath, JSON.stringify({
|
||||
result: "passed", processAdapter: "real", workspaceReused: true, privateTaskDenied: true, measurements,
|
||||
}, null, 2));
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, executionWorkspaceRepositories, executionWorkspaceRepositoryRoots, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { authorizationService, canActorReadExecutionWorkspace } from "../services/authorization.js";
|
||||
import { assertExecutionWorkspaceRecordAccess, assertTaskWorkspaceExecutionAccess } from "../services/task-workspace-access.js";
|
||||
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { taskWorkspaceRoutes } from "../routes/task-workspaces.js";
|
||||
import { startEmbeddedPostgresTestDatabase, getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
||||
import { addPoisonedWorkspaceHistory, addReadyWorkspaceHistoryRepository, createWorkspacePreparationHistoryFixture } from "./helpers/workspace-preparation-history-fixture.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("filesystem history is not execution authority", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let directory: string;
|
||||
let fixture: Awaited<ReturnType<typeof createWorkspacePreparationHistoryFixture>>;
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
database = await startEmbeddedPostgresTestDatabase("workspace-preparation-history-regression-");
|
||||
db = createDb(database.connectionString);
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-preparation-history-regression-"));
|
||||
fixture = await createWorkspacePreparationHistoryFixture(db, path.join(directory, "private-shared-source"), {
|
||||
workspaces: 40, sharedRoots: 20, sources: 30, readers: 8, privateHistory: true,
|
||||
});
|
||||
await addPoisonedWorkspaceHistory(db, fixture);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
afterAll(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await database?.cleanup();
|
||||
if (directory) await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function record() {
|
||||
const queries: string[] = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema, logger: { logQuery: query => queries.push(query) } }) as unknown as typeof db;
|
||||
return { queries, recorded, service: executionWorkspaceRepositoryService(recorded) };
|
||||
}
|
||||
|
||||
function expectNoHistory(queries: string[]) {
|
||||
expect(queries.filter(query => /execution_workspace_repository_(?:access|roots)/.test(query))).toEqual([]);
|
||||
expect(queries.filter(query => /from "heartbeat_runs"/.test(query) && /\bin\s*\(/i.test(query))).toEqual([]);
|
||||
expect(queries.filter(query => /from "(?:execution_workspaces|project_workspaces)"/.test(query)
|
||||
&& !/"(?:execution_workspaces|project_workspaces)"\."id"\s*=/.test(query))).toEqual([]);
|
||||
}
|
||||
|
||||
it("admits the current task despite private sources, deleted readers and a sticky root classification", async () => {
|
||||
const { queries, recorded, service } = record();
|
||||
await expect(assertTaskWorkspaceExecutionAccess(recorded, fixture.actor, fixture.companyId, {
|
||||
issueId: fixture.issueId, workspaceId: fixture.workspaceId,
|
||||
})).resolves.toMatchObject({ task: { id: fixture.issueId }, workspace: { id: fixture.workspaceId } });
|
||||
await expect(service.prepareForAdmission(fixture.input)).resolves.toEqual([]);
|
||||
await expect(service.assertPublicationAllowed(fixture.input)).resolves.toBeUndefined();
|
||||
await expect(service.assertRecoveryBinding(fixture.input)).resolves.toBeUndefined();
|
||||
const recheck = await service.prepareConfiguredRepositoryCopy({ ...fixture.input, sourceCwd: fixture.cwd });
|
||||
await expect(recheck()).resolves.toBeUndefined();
|
||||
expectNoHistory(queries);
|
||||
expect(await fs.readFile(path.join(fixture.cwd, "result.txt"), "utf8")).toBe("Synthetic task workspace\n");
|
||||
});
|
||||
|
||||
it("keeps historical task content and full workspace records private", async () => {
|
||||
const authorization = authorizationService(db);
|
||||
const decision = await authorization.decide({ actor: fixture.actor, action: "issue:read",
|
||||
resource: { type: "issue", companyId: fixture.companyId, issueId: fixture.historyTaskIds[0] } });
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(await canActorReadExecutionWorkspace(db, fixture.actor, fixture.workspaceId)).toBe(false);
|
||||
await expect(assertExecutionWorkspaceRecordAccess(db, fixture.actor, fixture.companyId, fixture.workspaceId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each(["task_directory", "configured_source", "existing"] as const)("applies a saved %s selection after the old workspace source becomes private", async kind => {
|
||||
const current = await createWorkspacePreparationHistoryFixture(db, path.join(directory, `escape-${kind}`), {
|
||||
workspaces: 0, sharedRoots: 0, sources: 0, readers: 0,
|
||||
});
|
||||
const [oldProject, nextProject] = await db.insert(projects).values([
|
||||
{ companyId: current.companyId, name: "Old source" }, { companyId: current.companyId, name: "New source" },
|
||||
]).returning();
|
||||
const [oldSource, nextSource] = await db.insert(projectWorkspaces).values([
|
||||
{ companyId: current.companyId, projectId: oldProject.id, name: "Old source", cwd: current.cwd },
|
||||
{ companyId: current.companyId, projectId: nextProject.id, name: "New source", cwd: current.cwd },
|
||||
]).returning();
|
||||
await db.update(issues).set({ projectWorkspaceId: oldSource.id }).where(eq(issues.id, current.issueId));
|
||||
await db.update(executionWorkspaces).set({ projectId: oldProject.id, projectWorkspaceId: oldSource.id })
|
||||
.where(eq(executionWorkspaces.id, current.workspaceId));
|
||||
const [nextWorkspace] = await db.insert(executionWorkspaces).values({ companyId: current.companyId, name: "New workspace",
|
||||
sourceIssueId: current.issueId, cwd: current.cwd, mode: "shared_workspace", strategyType: "task_directory" }).returning();
|
||||
const selection = kind === "task_directory" ? { kind } : kind === "existing" ? { kind, workspaceId: nextWorkspace.id }
|
||||
: { kind, projectWorkspaceId: nextSource.id, mode: "shared" as const };
|
||||
const service = executionWorkspaceService(db);
|
||||
await service.selectTaskWorkspace({ companyId: current.companyId, issueId: current.issueId, actor: current.actor,
|
||||
selection, expectedBindingRevision: 0, requestKey: randomUUID() });
|
||||
await db.update(projects).set({ visibility: "private", privacyOwnerUserId: current.principals[1].userId })
|
||||
.where(eq(projects.id, oldProject.id));
|
||||
await expect(assertTaskWorkspaceExecutionAccess(db, current.actor, current.companyId, {
|
||||
issueId: current.issueId, projectWorkspaceId: null,
|
||||
})).rejects.toThrow();
|
||||
await expect(service.applyPendingTaskWorkspaceSelection({ ...current.input, actor: current.actor })).resolves.toBe(true);
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, current.issueId));
|
||||
expect(task).toMatchObject({ projectId: null, workspacePendingSelection: null, workspaceSelection: { selection },
|
||||
executionWorkspaceId: kind === "existing" ? nextWorkspace.id : null,
|
||||
projectWorkspaceId: kind === "configured_source" ? nextSource.id : null });
|
||||
});
|
||||
|
||||
it("accepts only persisted selection context and keeps current-task and new-source authorization", async () => {
|
||||
const current = await createWorkspacePreparationHistoryFixture(db, path.join(directory, "selection-authority"), {
|
||||
workspaces: 0, sharedRoots: 0, sources: 0, readers: 0,
|
||||
});
|
||||
const taskDirectory = { kind: "task_directory" as const };
|
||||
const context = { issueId: current.issueId, persistedSelection: taskDirectory };
|
||||
await expect(assertTaskWorkspaceExecutionAccess(db, current.actor, current.companyId, context))
|
||||
.rejects.toThrow("not a current persisted intent");
|
||||
await db.update(issues).set({ workspacePendingSelection: { version: 1, requestKey: "stale", expectedBindingRevision: 1,
|
||||
intent: { version: 1, source: "explicit", selection: taskDirectory } } }).where(eq(issues.id, current.issueId));
|
||||
await expect(assertTaskWorkspaceExecutionAccess(db, current.actor, current.companyId, context))
|
||||
.rejects.toThrow("not a current persisted intent");
|
||||
await db.update(issues).set({ workspacePendingSelection: null }).where(eq(issues.id, current.issueId));
|
||||
const service = executionWorkspaceService(db);
|
||||
await service.selectTaskWorkspace({ companyId: current.companyId, issueId: current.issueId, actor: current.actor,
|
||||
selection: taskDirectory, expectedBindingRevision: 0, requestKey: randomUUID() });
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: current.issueId,
|
||||
responsibleUserId: current.principals[1].userId }).where(eq(issues.id, current.issueId));
|
||||
await expect(service.applyPendingTaskWorkspaceSelection({ ...current.input, actor: current.actor })).rejects.toThrow();
|
||||
await db.update(issues).set({ visibility: "open", privacyRootIssueId: null, responsibleUserId: current.userId })
|
||||
.where(eq(issues.id, current.issueId));
|
||||
const [project] = await db.insert(projects).values({ companyId: current.companyId, name: "Next source" }).returning();
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: current.companyId, projectId: project.id,
|
||||
name: "Next source", cwd: current.cwd }).returning();
|
||||
await service.selectTaskWorkspace({ companyId: current.companyId, issueId: current.issueId, actor: current.actor,
|
||||
selection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" },
|
||||
expectedBindingRevision: 1, requestKey: randomUUID() });
|
||||
await db.update(projects).set({ visibility: "private", privacyOwnerUserId: current.principals[1].userId })
|
||||
.where(eq(projects.id, project.id));
|
||||
await expect(service.applyPendingTaskWorkspaceSelection({ ...current.input, actor: current.actor })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("routes current-task repository requests and new selections independently from full workspace disclosure", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", source: "session", userId: fixture.userId, companyIds: [fixture.companyId] };
|
||||
next();
|
||||
});
|
||||
app.use("/api", taskWorkspaceRoutes(db));
|
||||
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(error.status ?? 500).json({ error: error.message });
|
||||
});
|
||||
const endpoint = `/api/issues/${fixture.issueId}/workspace`;
|
||||
const hidden = await request(app).get(endpoint);
|
||||
expect({ status: hidden.status, body: hidden.body }).toMatchObject({ status: 404 });
|
||||
let repositoryId: string | undefined;
|
||||
try {
|
||||
const repository = await request(app).post(`${endpoint}/repositories`).send({
|
||||
repository: { kind: "url", url: "https://github.com/paperclip-synthetic/route-request" }, requestKey: randomUUID(),
|
||||
}).expect(200);
|
||||
repositoryId = repository.body.operationId;
|
||||
expect(repository.body.kind).toBe("requires_next_admission");
|
||||
await request(app).put(endpoint).send({ selection: { kind: "existing", workspaceId: fixture.workspaceId },
|
||||
expectedBindingRevision: 0, requestKey: randomUUID() }).expect(404);
|
||||
const selection = await request(app).put(endpoint).send({ selection: { kind: "task_directory" },
|
||||
expectedBindingRevision: 0, requestKey: randomUUID() }).expect(200);
|
||||
expect(selection.body).toMatchObject({ kind: "scheduled", applies: "next_normal_admission" });
|
||||
await request(app).get(endpoint).expect(404);
|
||||
} finally {
|
||||
if (repositoryId) await db.delete(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, repositoryId));
|
||||
await db.update(issues).set({ workspacePendingSelection: null, workspaceBindingRevision: 0 })
|
||||
.where(eq(issues.id, fixture.issueId));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid current run, company and workspace bindings without consulting history", async () => {
|
||||
for (const change of [
|
||||
{ runId: randomUUID() },
|
||||
{ workspaceId: fixture.foreignWorkspaceId },
|
||||
{ companyId: fixture.foreignCompanyId },
|
||||
{ responsibleUserId: fixture.principals[1].userId },
|
||||
{ responsibleUserId: null },
|
||||
]) {
|
||||
const { queries, service } = record();
|
||||
await expect(service.prepareForAdmission({ ...fixture.input, ...change })).rejects.toThrow();
|
||||
expectNoHistory(queries);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires access to the directly selected private project", async () => {
|
||||
const [project] = await db.insert(projects).values({ companyId: fixture.companyId, name: "Private selected configuration",
|
||||
visibility: "private", privacyOwnerUserId: fixture.principals[1].userId }).returning();
|
||||
await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, fixture.issueId));
|
||||
try {
|
||||
const { queries, service } = record();
|
||||
await expect(service.prepareForAdmission(fixture.input)).rejects.toThrow();
|
||||
expectNoHistory(queries);
|
||||
} finally {
|
||||
await db.update(issues).set({ projectId: null }).where(eq(issues.id, fixture.issueId));
|
||||
}
|
||||
});
|
||||
|
||||
it("rechecks the current task's private app access", async () => {
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: fixture.issueId,
|
||||
responsibleUserId: fixture.principals[1].userId }).where(eq(issues.id, fixture.issueId));
|
||||
try {
|
||||
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(fixture.input)).rejects.toThrow();
|
||||
} finally {
|
||||
await db.update(issues).set({ visibility: "open", privacyRootIssueId: null, responsibleUserId: fixture.userId })
|
||||
.where(eq(issues.id, fixture.issueId));
|
||||
}
|
||||
});
|
||||
|
||||
it("permits a current trusted repository request despite a historical anonymous-only root", async () => {
|
||||
const { queries, service } = record();
|
||||
const intent = await service.request({ companyId: fixture.companyId, issueId: fixture.issueId, actor: fixture.actor,
|
||||
request: { repository: { kind: "url", url: "https://github.com/paperclip-synthetic/new-current-request" }, requestKey: randomUUID() } });
|
||||
expect(intent.kind).toBe("requires_next_admission");
|
||||
expectNoHistory(queries);
|
||||
await db.delete(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, intent.operationId));
|
||||
});
|
||||
|
||||
it("keeps current low-trust acquisition restrictions even though directory policy is retired", async () => {
|
||||
const intent = await executionWorkspaceRepositoryService(db).request({ companyId: fixture.companyId, issueId: fixture.issueId,
|
||||
actor: fixture.actor, request: { repository: { kind: "url", url: "https://github.com/paperclip-synthetic/low-trust-request" },
|
||||
requestKey: randomUUID() } });
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId, executionPolicy: {
|
||||
trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review", trustBoundary: {
|
||||
mode: "low_trust_review", companyId: fixture.companyId, rootIssueId: fixture.issueId,
|
||||
} },
|
||||
} } }).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
try {
|
||||
const { queries, service } = record();
|
||||
await expect(service.prepareForAdmission(fixture.input)).rejects.toThrow(/trusted task|acquisition|Low-trust/);
|
||||
expectNoHistory(queries);
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId } }).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
await db.delete(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, intent.operationId));
|
||||
}
|
||||
});
|
||||
|
||||
it("admits current low-trust execution with no acquisition despite a historical managed directory", async () => {
|
||||
await db.update(executionWorkspaceRepositoryRoots).set({ mode: "managed" })
|
||||
.where(eq(executionWorkspaceRepositoryRoots.companyId, fixture.companyId));
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId, executionPolicy: {
|
||||
trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review", trustBoundary: {
|
||||
mode: "low_trust_review", companyId: fixture.companyId, rootIssueId: fixture.issueId,
|
||||
} },
|
||||
} } }).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
try {
|
||||
const { queries, service } = record();
|
||||
await expect(service.prepareForAdmission(fixture.input)).resolves.toEqual([]);
|
||||
expectNoHistory(queries);
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId } }).where(eq(heartbeatRuns.id, fixture.runId));
|
||||
await db.update(executionWorkspaceRepositoryRoots).set({ mode: "anonymous_only" })
|
||||
.where(eq(executionWorkspaceRepositoryRoots.companyId, fixture.companyId));
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces an actual source OS failure without a historical-privacy fallback", async () => {
|
||||
const actual = fs.realpath;
|
||||
vi.spyOn(fs, "realpath").mockImplementation((async pathname => {
|
||||
if (String(pathname) === fixture.cwd) throw Object.assign(new Error("Synthetic OS denied selected source"), { code: "EACCES" });
|
||||
return actual(pathname);
|
||||
}) as typeof fs.realpath);
|
||||
await expect(executionWorkspaceRepositoryService(db).prepareConfiguredRepositoryCopy({ ...fixture.input, sourceCwd: fixture.cwd }))
|
||||
.rejects.toMatchObject({ code: "EACCES" });
|
||||
});
|
||||
|
||||
it("reuses a real public checkout whose exact ownership receipt belongs to an unreadable historical task", async () => {
|
||||
const ready = await addReadyWorkspaceHistoryRepository(db, fixture, { matchingHistoricalReceipts: true });
|
||||
try {
|
||||
const { queries, service } = record();
|
||||
await expect(service.prepareForAdmission(fixture.input)).resolves.toEqual([
|
||||
expect.objectContaining({ id: ready.row.id, cwd: ready.checkout, pinnedCommit: fixture.pin }),
|
||||
]);
|
||||
expectNoHistory(queries);
|
||||
} finally { await db.delete(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, ready.row.id)); }
|
||||
});
|
||||
|
||||
it("keeps SQL and path-check counts fixed when unrelated history grows to thousands of workspaces", async () => {
|
||||
const cases = [
|
||||
{ workspaces: 8, sharedRoots: 5, sources: 6, readers: 2 },
|
||||
{ workspaces: 2500, sharedRoots: 1500, sources: 1700, readers: 50 },
|
||||
];
|
||||
const observations = [];
|
||||
for (const [index, config] of cases.entries()) {
|
||||
const current = await createWorkspacePreparationHistoryFixture(db, path.join(directory, `query-count-${index}`), config);
|
||||
const { queries, service } = record();
|
||||
const paths = vi.spyOn(fs, "realpath");
|
||||
await service.prepareForAdmission(current.input);
|
||||
await service.assertPublicationAllowed(current.input);
|
||||
const guard = await service.prepareConfiguredRepositoryCopy({ ...current.input, sourceCwd: current.cwd });
|
||||
await guard();
|
||||
await service.assertRecoveryBinding(current.input);
|
||||
observations.push({ queries: queries.length, paths: paths.mock.calls.length });
|
||||
expectNoHistory(queries);
|
||||
expect(paths.mock.calls.map(call => String(call[0])).every(value => value === current.cwd || value.startsWith(`${current.cwd}${path.sep}`))).toBe(true);
|
||||
paths.mockRestore();
|
||||
}
|
||||
expect(observations[1]).toEqual(observations[0]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { Router, type Request } from "express";
|
||||
import { prepareWorkspaceRepositorySchema, selectTaskWorkspaceSchema } from "@paperclipai/shared";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { canActorReadExecutionWorkspace, issueReadSqlCondition } from "../services/authorization.js";
|
||||
import { issueReadSqlCondition } from "../services/authorization.js";
|
||||
import { projectToolContext } from "../services/project-tool-context.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { forbidden, notFound } from "../errors.js";
|
||||
@@ -24,7 +24,6 @@ export function taskWorkspaceRoutes(db: Db) {
|
||||
status: issue.status, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId,
|
||||
} });
|
||||
if (!decision.allowed) throw forbidden("Task workspace is outside this actor's authorization boundary");
|
||||
if (issue.executionWorkspaceId && !(await canActorReadExecutionWorkspace(db, req.actor, issue.executionWorkspaceId))) throw notFound("Execution workspace not found");
|
||||
if (write && req.actor.type === "agent") {
|
||||
const context = await projectToolContext(db, req.actor, true, "Workspace");
|
||||
if (context.issue.id !== issue.id) throw forbidden("Workspace selection is scoped to the current task");
|
||||
|
||||
@@ -2903,8 +2903,8 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
|
||||
};
|
||||
}
|
||||
|
||||
/** A workspace can contain several tasks. Reading files/operations requires
|
||||
* access to every task bound to it, not just the task that originally made it. */
|
||||
/** Protect disclosure of workspace records and their task-derived app metadata.
|
||||
* Filesystem execution and task-bound file roots use current task access instead. */
|
||||
export async function executionWorkspaceReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
||||
const issueCondition = await issueReadSqlCondition(db, actor);
|
||||
const projectCondition = await projectReadSqlCondition(db, actor);
|
||||
|
||||
@@ -10,8 +10,7 @@ import { withRunExecutionTiming } from "./execution-preparation-timing.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { lockExecutionWorkspaceRootProvenance } from "./execution-workspace-repositories.js";
|
||||
import { assertTaskWorkspaceAccess } from "./task-workspace-source-access.js";
|
||||
import { assertExecutionWorkspaceRecordAccess } from "./task-workspace-access.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("execution timing with real database waits", () => {
|
||||
@@ -42,13 +41,13 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
});
|
||||
afterAll(async () => { await temporary?.cleanup(); });
|
||||
|
||||
it.each(["advisory", "row"] as const)("attributes a controlled %s lock wait to its leaf and commits the original update", async kind => {
|
||||
it("attributes a controlled row lock wait to its leaf and commits the original update", async () => {
|
||||
const kind = "row";
|
||||
let release!: () => void, acquired!: () => void;
|
||||
const released = new Promise<void>(resolve => { release = resolve; });
|
||||
const ready = new Promise<void>(resolve => { acquired = resolve; });
|
||||
const blocker = db.transaction(async tx => {
|
||||
if (kind === "advisory") await lockExecutionWorkspaceRootProvenance(tx, companyId);
|
||||
else await tx.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)).for("update");
|
||||
await tx.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)).for("update");
|
||||
acquired();
|
||||
await released;
|
||||
});
|
||||
@@ -56,10 +55,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const update = withRunExecutionTiming(`wait-${kind}`, () => executionWorkspaceService(db).update(workspaceId, { metadata: { verified: kind } }));
|
||||
try {
|
||||
await vi.waitFor(async () => {
|
||||
const pending = await db.execute(sql`select count(*)::int as count from pg_locks where not granted and locktype = ${kind === "advisory" ? "advisory" : "transactionid"}`);
|
||||
const pending = await db.execute(sql`select count(*)::int as count from pg_locks where not granted and locktype = ${"transactionid"}`);
|
||||
expect(pending[0]?.count).toBeGreaterThan(0);
|
||||
});
|
||||
expect(exporter.getFinishedSpans().some(span => span.name === (kind === "advisory" ? "workspace.provenance_lock" : "workspace.row_lock"))).toBe(false);
|
||||
expect(exporter.getFinishedSpans().some(span => span.name === "workspace.row_lock")).toBe(false);
|
||||
} finally {
|
||||
release();
|
||||
await blocker;
|
||||
@@ -69,7 +68,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(saved.metadata).toMatchObject({ verified: kind });
|
||||
const spans = exporter.getFinishedSpans();
|
||||
const transaction = spans.find(span => span.name === "workspace.transaction")!;
|
||||
const wait = spans.find(span => span.name === (kind === "advisory" ? "workspace.provenance_lock" : "workspace.row_lock"))!;
|
||||
const wait = spans.find(span => span.name === "workspace.row_lock")!;
|
||||
expect(wait.parentSpanContext?.spanId).toBe(transaction.spanContext().spanId);
|
||||
expect(wait.duration[0] * 1e9 + wait.duration[1]).toBeGreaterThan(0);
|
||||
expect(spans.map(span => span.name)).toEqual(expect.arrayContaining(["workspace.transaction.entry", "workspace.transaction.commit", "workspace.notification", "workspace.write"]));
|
||||
@@ -117,7 +116,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
});
|
||||
|
||||
it("preserves a real access denial and exports only failed status", async () => {
|
||||
await expect(withRunExecutionTiming("denied", () => assertTaskWorkspaceAccess(db,
|
||||
await expect(withRunExecutionTiming("denied", () => assertExecutionWorkspaceRecordAccess(db,
|
||||
{ type: "board", userId: "not-a-company-member" }, companyId, workspaceId,
|
||||
))).rejects.toMatchObject({ status: 403 });
|
||||
const spans = exporter.getFinishedSpans();
|
||||
|
||||
@@ -2,56 +2,32 @@ import { measureRunTransaction } from "./execution-transaction-timing.js";
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { isDeepStrictEqual, promisify } from "node:util";
|
||||
import { promisify } from "node:util";
|
||||
import fs from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, inArray, eq, isNotNull, or, sql } from "drizzle-orm";
|
||||
import { activityLog, agents, heartbeatRuns, projects, projectWorkspaces, executionWorkspaceRepositoryAccess, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { activityLog, agents, heartbeatRuns, projects, projectWorkspaces, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db";
|
||||
import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared";
|
||||
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js";
|
||||
import { assertTaskWorkspacesReadAccessForActors, assertTaskWorkspacesReadAccess, assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js";
|
||||
import { authorizationService, canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition, issueReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-access.js";
|
||||
import { authorizationService, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
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 { buildGitAuthInvocation, createGitRemoteAuthProvider } from "./git-credentials.js";
|
||||
import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "./managed-repository-checkout.js";
|
||||
import { loadRepositoryRootReaders, requiresAnonymousRepositoryAcquisition, rootReaderRunColumns, runRetainsIssueIdentity } from "./execution-workspace-root-readers.js";
|
||||
import { requiresAnonymousRepositoryAcquisition, taskRepositoryRunColumns } from "./task-repository-acquisition.js";
|
||||
|
||||
import { canonicalRepositoryRoot, resolveRepositoryRoots, repositoryRootsOverlap, legacyRepositoryRootMayMatch, configuredWorkspaceRoot, verifiedIsolatedWorktree, unusedIsolatedCopyTarget, isolatedWorkspaceSourceRoots, type WorkspaceSourceCopyKind } from "./execution-workspace-source-roots.js";
|
||||
import { canonicalRepositoryRoot } 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];
|
||||
type RootProject = Pick<typeof projects.$inferSelect, "id" | "companyId" | "executionWorkspacePolicy">;
|
||||
|
||||
function repositoryAccessKeys(entries: Array<Pick<typeof executionWorkspaceRepositoryAccess.$inferSelect, "kind" | "scopeId">>) {
|
||||
return new Set(entries.map(entry => `${entry.kind}:${entry.scopeId}`));
|
||||
}
|
||||
|
||||
async function retainRepositorySources(tx: DbTransaction,
|
||||
root: { companyId: string; canonicalCwd: string; rootKey: string },
|
||||
existingKeys: Set<string>,
|
||||
groups: ReadonlyArray<readonly [typeof executionWorkspaceRepositoryAccess.$inferSelect["kind"], Iterable<string>]>,
|
||||
): Promise<void> {
|
||||
const additions: Array<typeof executionWorkspaceRepositoryAccess.$inferInsert> = [];
|
||||
for (const [kind, ids] of groups) for (const scopeId of ids) {
|
||||
const key = `${kind}:${scopeId}`;
|
||||
if (existingKeys.has(key)) continue;
|
||||
additions.push({ ...root, kind, scopeId });
|
||||
existingKeys.add(key);
|
||||
}
|
||||
for (let offset = 0; offset < additions.length; offset += 500) {
|
||||
await measureRunPhase("workspace.write", () => tx.insert(executionWorkspaceRepositoryAccess).values(additions.slice(offset, offset + 500)));
|
||||
}
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
@@ -82,403 +58,6 @@ export function assertRepositoryRetryMatches(row: Pick<RepositoryRow, "repositor
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
companyId: string; cwd: string; sourceWorkspaceId?: string; inspectCurrentAliases?: boolean; preparingCopyProjectIds?: Set<string>;
|
||||
}) {
|
||||
const canonicalCwd = await measureRunPhase("workspace.sources.canonicalize", () => canonicalRepositoryRoot(input.cwd));
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const companyEntries = await measureRunPhase("workspace.sources.inventory", async () => {
|
||||
const identities = await tx.selectDistinct({ rootKey: executionWorkspaceRepositoryAccess.rootKey,
|
||||
canonicalCwd: executionWorkspaceRepositoryAccess.canonicalCwd }).from(executionWorkspaceRepositoryAccess)
|
||||
.where(eq(executionWorkspaceRepositoryAccess.companyId, input.companyId));
|
||||
const rootKeys = [...new Set([rootKey, ...identities.filter(identity => repositoryRootsOverlap(identity.canonicalCwd, canonicalCwd))
|
||||
.map(identity => identity.rootKey)])];
|
||||
const retained: Array<typeof executionWorkspaceRepositoryAccess.$inferSelect> = [];
|
||||
for (let offset = 0; offset < rootKeys.length; offset += 500) {
|
||||
retained.push(...await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
|
||||
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId),
|
||||
inArray(executionWorkspaceRepositoryAccess.rootKey, rootKeys.slice(offset, offset + 500)))));
|
||||
}
|
||||
return retained;
|
||||
});
|
||||
const entries = companyEntries.filter(entry => entry.rootKey === rootKey);
|
||||
const entryKeys = repositoryAccessKeys(entries);
|
||||
const overlappingEntries = companyEntries.filter(entry => repositoryRootsOverlap(entry.canonicalCwd, canonicalCwd));
|
||||
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)
|
||||
|| overlappingEntries.some(entry => createHash("sha256").update(entry.canonicalCwd).digest("hex") !== entry.rootKey)) {
|
||||
throw forbidden("Repository root identity conflicts with its retained access record");
|
||||
}
|
||||
const proofs = entries.filter(entry => entry.kind === "isolated_copy");
|
||||
const copiedProjectIds = new Set(input.preparingCopyProjectIds ?? []);
|
||||
if (proofs.length && await verifiedIsolatedWorktree(canonicalCwd)) {
|
||||
for (const proof of proofs) copiedProjectIds.add(proof.scopeId);
|
||||
}
|
||||
const isCopiedAncestor = (projectId: string, sourceRoot: string) => copiedProjectIds.has(projectId)
|
||||
&& sourceRoot !== canonicalCwd && repositoryRootsOverlap(sourceRoot, canonicalCwd)
|
||||
&& !path.relative(sourceRoot, canonicalCwd).startsWith(`..${path.sep}`)
|
||||
&& path.relative(sourceRoot, canonicalCwd) !== "..";
|
||||
const sharedSourceRoots = new Map<string, Set<string>>();
|
||||
const addSharedSource = (id: string, cwd: string) => {
|
||||
const roots = sharedSourceRoots.get(id) ?? new Set<string>();
|
||||
roots.add(cwd); sharedSourceRoots.set(id, roots);
|
||||
};
|
||||
for (const entry of overlappingEntries) if (entry.kind === "source") addSharedSource(entry.scopeId, entry.canonicalCwd);
|
||||
const sharedSourceIds = new Set(sharedSourceRoots.keys());
|
||||
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); }
|
||||
const workspaces = await measureRunPhase("workspace.sources.inventory", async () => tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.companyId, input.companyId)));
|
||||
await measureRunPhase("workspace.sources.alias_scan", async () => {
|
||||
const workspaceRoots = await resolveRepositoryRoots(workspaces.flatMap(workspace => workspace.cwd ? [workspace.cwd] : []));
|
||||
for (const workspace of workspaces) {
|
||||
if (!workspace.cwd) continue;
|
||||
const peerRoot = workspaceRoots.get(workspace.cwd);
|
||||
if (peerRoot && repositoryRootsOverlap(peerRoot, canonicalCwd)) {
|
||||
sourceIds.add(workspace.id); sharedSourceIds.add(workspace.id); addSharedSource(workspace.id, peerRoot);
|
||||
}
|
||||
}
|
||||
});
|
||||
const inventory = await measureRunPhase("workspace.sources.inventory", async () => tx.select({ id: executionWorkspaceRepositories.id, workspaceId: executionWorkspaces.id, cwd: executionWorkspaces.cwd,
|
||||
identity: executionWorkspaceRepositories.repositoryIdentity }).from(executionWorkspaceRepositories)
|
||||
.innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId))
|
||||
.where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaces.companyId, input.companyId))));
|
||||
const namespace = path.join(canonicalCwd, ".paperclip-repositories");
|
||||
const namespaceEntry = await fs.lstat(namespace).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
const publishedNames = new Set<string>();
|
||||
if (namespaceEntry) {
|
||||
if (!namespaceEntry.isDirectory() || namespaceEntry.isSymbolicLink() || await fs.realpath(namespace) !== namespace) {
|
||||
throw forbidden("Repository namespace has no verifiable contained ownership");
|
||||
}
|
||||
for (const name of await fs.readdir(namespace)) if (name.startsWith("task-repo-")) publishedNames.add(name);
|
||||
}
|
||||
const repositoryOrigins = inventory.length ? await measureRunPhase("workspace.sources.inventory", async () => 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));
|
||||
const recognizedNames = new Set<string>();
|
||||
await measureRunPhase("workspace.sources.repository_scan", async () => {
|
||||
const inventoryRoots = await resolveRepositoryRoots(inventory.flatMap(peer => peer.cwd ? [peer.cwd] : []), true);
|
||||
const hasReader = entries.some(entry => entry.kind === "reader");
|
||||
for (const peer of inventory) {
|
||||
const name = path.basename(taskRepositoryRelativePath(peer.identity));
|
||||
const published = publishedNames.has(name);
|
||||
const peerRoot = peer.cwd ? inventoryRoots.get(peer.cwd)! : null;
|
||||
const matches = sourceIds.has(peer.workspaceId) || (peerRoot !== null && repositoryRootsOverlap(peerRoot, canonicalCwd))
|
||||
|| (published && peerRoot === null && !knownOrigins.has(peer.id))
|
||||
|| (peerRoot === null && !hasReader && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd));
|
||||
if (matches) {
|
||||
sourceIds.add(peer.workspaceId);
|
||||
if (!isolatedSourceIds.has(peer.workspaceId) || (peerRoot && repositoryRootsOverlap(peerRoot, canonicalCwd))) {
|
||||
sharedSourceIds.add(peer.workspaceId); addSharedSource(peer.workspaceId, peerRoot ?? canonicalCwd);
|
||||
}
|
||||
if (published) recognizedNames.add(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
if ([...publishedNames].some(name => !recognizedNames.has(name))) throw forbidden("Published repository has no retained workspace provenance");
|
||||
const configured = await measureRunPhase("workspace.sources.inventory", async () => tx.select({ projectId: projectWorkspaces.projectId, cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl })
|
||||
.from(projectWorkspaces).where(eq(projectWorkspaces.companyId, input.companyId)));
|
||||
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));
|
||||
if (sourceIds.size) {
|
||||
const owners = await measureRunPhase("workspace.sources.inventory", async () => tx.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId, strategyType: executionWorkspaces.strategyType }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), inArray(executionWorkspaces.id, [...sourceIds]))));
|
||||
for (const owner of owners) if (owner.projectId) {
|
||||
sourceProjectIds.add(owner.projectId);
|
||||
if (owner.strategyType === "git_worktree" || !sharedSourceIds.has(owner.id)
|
||||
|| [...sharedSourceRoots.get(owner.id) ?? []].every(cwd => isCopiedAncestor(owner.projectId!, cwd))) isolatedProjectIds.add(owner.projectId);
|
||||
else sharedProjectIds.add(owner.projectId);
|
||||
}
|
||||
}
|
||||
await measureRunPhase("workspace.sources.project_scan", async () => {
|
||||
for (const source of configured) {
|
||||
if (sharedProjectIds.has(source.projectId)) continue;
|
||||
const sourceRoot = await configuredWorkspaceRoot(source, true);
|
||||
if (!sourceRoot || !repositoryRootsOverlap(sourceRoot, canonicalCwd)) continue;
|
||||
sourceProjectIds.add(source.projectId);
|
||||
if (!isCopiedAncestor(source.projectId, sourceRoot)) sharedProjectIds.add(source.projectId);
|
||||
}
|
||||
});
|
||||
for (const entry of overlappingEntries) if (entry.kind === "project_source" && !isolatedProjectIds.has(entry.scopeId)) {
|
||||
sharedProjectIds.add(entry.scopeId);
|
||||
}
|
||||
return { canonicalCwd, rootKey, entries, entryKeys, overlappingEntries, sourceIds, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds };
|
||||
}
|
||||
|
||||
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>,
|
||||
write?: { sharedProjectIds: Set<string> } & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">) {
|
||||
for (const projectId of projectIds) {
|
||||
const [project] = await db.select({ id: projects.id }).from(projects).where(and(
|
||||
eq(projects.id, projectId), eq(projects.companyId, companyId), await projectReadSqlCondition(db, actor)));
|
||||
if (!project) throw forbidden("Workspace source project is no longer accessible");
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId, {
|
||||
...write, write: write?.sharedProjectIds.has(projectId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRootProjectsReadAccessForActors(db: Db | DbTransaction, actors: AuthorizationActor[], companyId: string, projectIds: Set<string>) {
|
||||
if (!actors.length || !projectIds.size) return;
|
||||
const decisions = await authorizationService(db).decideReadsForActors(actors,
|
||||
[...projectIds].map(projectId => ({ type: "project" as const, companyId, projectId })));
|
||||
if (decisions.some(batch => batch.some(decision => !decision.allowed))) {
|
||||
throw forbidden("Workspace source project is no longer accessible");
|
||||
}
|
||||
}
|
||||
|
||||
export async function lockExecutionWorkspaceRootProvenance(tx: DbTransaction, companyId: string) {
|
||||
await measureRunPhase("workspace.provenance_lock", () => tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`task-repository-roots:${companyId}`}, 0))`));
|
||||
}
|
||||
|
||||
export async function preserveConfiguredProjectWorkspaceRootProvenance(tx: DbTransaction, input: {
|
||||
companyId: string; projectId: string; cwd: string | null; repoUrl?: string | null;
|
||||
}): Promise<void> {
|
||||
const canonicalCwd = await configuredWorkspaceRoot(input, false);
|
||||
if (!canonicalCwd) return;
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const entries = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaceRepositoryAccess).where(and(
|
||||
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey))));
|
||||
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
|
||||
for (const kind of ["project_source", "shared_project_source"] as const) {
|
||||
if (!entries.some(entry => entry.kind === kind && entry.scopeId === input.projectId)) {
|
||||
await measureRunPhase("workspace.write", () => tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, canonicalCwd, rootKey, kind, scopeId: input.projectId }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction, input: {
|
||||
companyId: string; workspaceId: string; cwd: string | null;
|
||||
}): Promise<void> {
|
||||
if (!input.cwd) return;
|
||||
const directory = await fs.stat(input.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!directory) return;
|
||||
if (!directory.isDirectory()) throw forbidden("Workspace files cannot be detached from unverifiable provenance");
|
||||
const { canonicalCwd, rootKey, entryKeys, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds } = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(tx, {
|
||||
companyId: input.companyId, cwd: input.cwd!, sourceWorkspaceId: input.workspaceId, inspectCurrentAliases: true,
|
||||
}));
|
||||
await retainRepositorySources(tx, { companyId: input.companyId, canonicalCwd, rootKey }, entryKeys,
|
||||
[["source", sharedSourceIds], ["isolated_source", isolatedSourceIds], ["project_source", sourceProjectIds], ["shared_project_source", sharedProjectIds], ["isolated_project_source", isolatedProjectIds]]);
|
||||
}
|
||||
|
||||
export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransaction, input: {
|
||||
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string;
|
||||
}): Promise<void> {
|
||||
const { sourceIds, sourceProjectIds } = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true }));
|
||||
await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccess(db, input.actor, input.companyId, sourceIds));
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds));
|
||||
}
|
||||
|
||||
export type IsolatedWorkspaceSourceAccess = {
|
||||
companyId: string;
|
||||
roots: string[];
|
||||
workspaceIds: string[];
|
||||
projectIds: string[];
|
||||
};
|
||||
|
||||
async function retainGitMetadataOwners(tx: DbTransaction, input: {
|
||||
companyId: string; commonRoot: string; workspaceIds: Set<string>; projectIds: Set<string>;
|
||||
}): Promise<void> {
|
||||
const closure = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd: input.commonRoot, inspectCurrentAliases: true }));
|
||||
const workspaceIds = new Set([...closure.sourceIds, ...input.workspaceIds]);
|
||||
const projectIds = new Set([...closure.sourceProjectIds, ...input.projectIds]);
|
||||
const readerActors: AuthorizationActor[] = [];
|
||||
for (const { actor } of await loadRepositoryRootReaders(tx, input.companyId, closure.overlappingEntries.filter(entry => entry.kind === "reader"))) {
|
||||
readerActors.push(actor);
|
||||
}
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectsReadAccessForActors(tx, readerActors, input.companyId, projectIds));
|
||||
await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccessForActors(tx, readerActors, input.companyId, workspaceIds));
|
||||
await retainRepositorySources(tx, { companyId: input.companyId, canonicalCwd: closure.canonicalCwd, rootKey: closure.rootKey }, closure.entryKeys,
|
||||
[["source", workspaceIds], ["project_source", projectIds]]);
|
||||
}
|
||||
|
||||
export async function assertIsolatedWorkspaceSourceReadAccess(db: Db, input: {
|
||||
companyId: string; sourceCwd: string; actor: AuthorizationActor; copyKind?: WorkspaceSourceCopyKind;
|
||||
}): Promise<IsolatedWorkspaceSourceAccess> {
|
||||
const { roots, commonRoot } = await isolatedWorkspaceSourceRoots(db, input.companyId, input.sourceCwd, input.copyKind);
|
||||
return measureRunTransaction(db, "workspace", async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const workspaceIds = new Set<string>(), projectIds = new Set<string>();
|
||||
for (const cwd of roots) {
|
||||
const closure = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd, inspectCurrentAliases: true }));
|
||||
await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccess(tx, input.actor, input.companyId, closure.sourceIds));
|
||||
for (const id of closure.sourceIds) workspaceIds.add(id);
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectAccess(tx, input.actor!, input.companyId, closure.sourceProjectIds));
|
||||
for (const id of closure.sourceProjectIds) projectIds.add(id);
|
||||
}
|
||||
await retainGitMetadataOwners(tx, { companyId: input.companyId, commonRoot, workspaceIds, projectIds });
|
||||
return { companyId: input.companyId, roots, workspaceIds: [...workspaceIds], projectIds: [...projectIds] };
|
||||
});
|
||||
}
|
||||
|
||||
export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
|
||||
companyId: string; sourceCwd: string; targetCwd: string; actor: AuthorizationActor;
|
||||
captured: IsolatedWorkspaceSourceAccess; issueId?: string; phase?: "preparing" | "verified"; copyKind?: WorkspaceSourceCopyKind;
|
||||
}): Promise<void> {
|
||||
if (input.captured.companyId !== input.companyId) throw forbidden("Isolated copy source identity changed");
|
||||
let sourceFailure: unknown;
|
||||
const roots = new Set(input.captured.roots);
|
||||
try { for (const cwd of (await isolatedWorkspaceSourceRoots(db, input.companyId, input.sourceCwd, input.copyKind)).roots) roots.add(cwd); }
|
||||
catch (error) { sourceFailure = error; }
|
||||
const canonicalCwd = await measureRunPhase("workspace.sources.canonicalize", () => canonicalRepositoryRoot(input.targetCwd));
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const denied = await measureRunTransaction(db, "workspace", async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const existing = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaceRepositoryAccess).where(and(
|
||||
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey))));
|
||||
if (existing.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
|
||||
const existingKeys = repositoryAccessKeys(existing);
|
||||
const workspaceIds = new Set(input.captured.workspaceIds), projectIds = new Set(input.captured.projectIds);
|
||||
let failure = sourceFailure;
|
||||
for (const cwd of roots) {
|
||||
try {
|
||||
const closure = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd, inspectCurrentAliases: true }));
|
||||
for (const id of closure.sourceIds) workspaceIds.add(id);
|
||||
for (const id of closure.sourceProjectIds) projectIds.add(id);
|
||||
} catch (error) { failure ??= error; }
|
||||
}
|
||||
try { await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccess(tx, input.actor, input.companyId, workspaceIds)); }
|
||||
catch (error) { failure ??= error; }
|
||||
for (const id of projectIds) {
|
||||
try { await measureRunPhase("workspace.readers.projects", () => assertRootProjectAccess(tx, input.actor!, input.companyId, new Set([id]))); }
|
||||
catch (error) { failure ??= error; }
|
||||
}
|
||||
await retainRepositorySources(tx, { companyId: input.companyId, canonicalCwd, rootKey }, existingKeys,
|
||||
[["isolated_source", workspaceIds], ["isolated_project_source", projectIds]]);
|
||||
const phase = input.phase ?? "preparing";
|
||||
let preparingCopyProjectIds: Set<string> | undefined;
|
||||
let verifiedCopyProjectIds: Set<string> | undefined;
|
||||
try {
|
||||
const targetEntry = await fs.lstat(input.targetCwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (targetEntry?.isSymbolicLink()) throw forbidden("Isolated copy target cannot be a symbolic link");
|
||||
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");
|
||||
preparingCopyProjectIds = projectIds;
|
||||
verifiedCopyProjectIds = projectIds;
|
||||
} else if (linkedWorktree && await unusedIsolatedCopyTarget(input.targetCwd)) {
|
||||
preparingCopyProjectIds = projectIds;
|
||||
}
|
||||
} catch (error) { failure ??= error; }
|
||||
try {
|
||||
const target = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd: canonicalCwd, preparingCopyProjectIds }));
|
||||
await measureRunPhase("workspace.readers.trust", () => assertRootProjectTrust(tx, { companyId: input.companyId, actor: input.actor, issueId: input.issueId, projectIds: target.sourceProjectIds }));
|
||||
await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccess(tx, input.actor, input.companyId, target.sourceIds));
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectAccess(tx, input.actor!, input.companyId, target.sourceProjectIds,
|
||||
{ sharedProjectIds: target.sharedProjectIds, issueId: input.issueId }));
|
||||
const readerActors: AuthorizationActor[] = [];
|
||||
for (const { actor, issue } of await loadRepositoryRootReaders(tx, input.companyId, target.overlappingEntries.filter(entry => entry.kind === "reader"))) {
|
||||
readerActors.push(actor);
|
||||
await measureRunPhase("workspace.readers.trust", () => assertRootProjectTrust(tx, { companyId: input.companyId, actor, issueId: issue.id, projectIds: target.sourceProjectIds }));
|
||||
}
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectsReadAccessForActors(tx, readerActors, input.companyId, target.sourceProjectIds));
|
||||
await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccessForActors(tx, readerActors, input.companyId, target.sourceIds));
|
||||
} catch (error) { failure ??= error; }
|
||||
if (!failure && verifiedCopyProjectIds) {
|
||||
for (const scopeId of verifiedCopyProjectIds) if (!existing.some(entry => entry.kind === "isolated_copy" && entry.scopeId === scopeId)) {
|
||||
await measureRunPhase("workspace.write", () => tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "isolated_copy", scopeId }));
|
||||
}
|
||||
}
|
||||
return failure;
|
||||
});
|
||||
if (denied) throw denied;
|
||||
}
|
||||
|
||||
export type TaskWorkspaceCreationTrustContext = {
|
||||
parentIssueId?: string | null; projectId?: string | null; assigneeAgentId?: string | null; executionPolicy?: unknown;
|
||||
};
|
||||
|
||||
async function assertRootProjectTrust(db: Db | DbTransaction, input: {
|
||||
companyId: string; projectIds: Set<string>; actor: AuthorizationActor;
|
||||
issueId?: string | null; sourceWorkspaceId?: string; assigneeAgentId?: string | null; taskCreation?: TaskWorkspaceCreationTrustContext;
|
||||
}, owners?: RootProject[]) {
|
||||
if (!input.projectIds.size) return;
|
||||
const sourceOwners = owners ?? await db.select().from(projects).where(and(eq(projects.companyId, input.companyId),
|
||||
or(...[...input.projectIds].map(id => eq(projects.id, id)))));
|
||||
const restricted = sourceOwners.filter(owner => resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: owner }).kind !== "standard");
|
||||
if (!restricted.length) return;
|
||||
const incompatible = () => forbidden("Physical workspace source trust is incompatible with the task's execution policy");
|
||||
const runId = input.actor.runId;
|
||||
const [run] = runId ? await db.select(rootReaderRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, input.companyId))) : [];
|
||||
const creation = !input.issueId && input.actor.type === "agent" && runId ? input.taskCreation : undefined;
|
||||
const runIssueId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId ?? run?.issueId;
|
||||
const issueId = input.issueId ?? (creation && typeof runIssueId === "string" ? runIssueId : null);
|
||||
const [issue] = issueId ? await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) : [];
|
||||
const agentId = input.actor.type === "agent" ? input.actor.agentId : issue?.assigneeAgentId ?? input.assigneeAgentId;
|
||||
const [agent] = agentId ? await db.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, input.companyId))) : [];
|
||||
const [project] = issue?.projectId ? await db.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, input.companyId))) : [];
|
||||
const workspaceId = input.sourceWorkspaceId ?? issue?.executionWorkspaceId;
|
||||
const [workspace] = workspaceId ? await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, input.companyId))) : [];
|
||||
const [source] = workspace?.projectId ? await db.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, input.companyId))) : [];
|
||||
if ((agentId && !agent) || (runId && (!run || run.agentId !== agentId || !issue || !runRetainsIssueIdentity(run, issue.id)))) throw incompatible();
|
||||
const retainedPolicy = run?.contextSnapshot?.executionPolicy;
|
||||
const current = resolveCoreTrustPreset({ companyId: input.companyId, agent, project, workspaceSourceProject: source, issue,
|
||||
run: run ? { companyId: input.companyId, executionPolicy: retainedPolicy } : null });
|
||||
const selected = run
|
||||
? resolveCoreTrustPreset({ companyId: input.companyId, run: { companyId: input.companyId, executionPolicy: retainedPolicy } })
|
||||
: current;
|
||||
if (selected.kind !== "low_trust_review" || current.kind !== "low_trust_review"
|
||||
|| !isDeepStrictEqual(selected.boundary, current.boundary)) throw incompatible();
|
||||
if (creation) {
|
||||
const sponsor = run!.responsibleUserId === "local-board" ? null : run!.responsibleUserId;
|
||||
if ((input.actor.onBehalfOfUserId ?? null) !== sponsor) throw incompatible();
|
||||
let parentId = creation.parentIssueId;
|
||||
const ancestors: Array<typeof issues.$inferSelect> = [];
|
||||
for (let depth = 0; parentId && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth++) {
|
||||
const [parent] = await db.select().from(issues).where(and(eq(issues.id, parentId), eq(issues.companyId, input.companyId)));
|
||||
if (!parent || ancestors.some(ancestor => ancestor.id === parent.id)) throw incompatible();
|
||||
ancestors.push(parent);
|
||||
if (parent.id === issue!.id) break;
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
if (ancestors.at(-1)?.id !== issue!.id) throw incompatible();
|
||||
const decision = await authorizationService(db).decide({ actor: input.actor, action: "issue:mutate",
|
||||
resource: { type: "issue", companyId: input.companyId, parentIssueId: creation.parentIssueId,
|
||||
projectId: creation.projectId, assigneeAgentId: creation.assigneeAgentId } });
|
||||
if (!decision.allowed) throw incompatible();
|
||||
const [childAgent] = creation.assigneeAgentId ? await db.select().from(agents).where(and(
|
||||
eq(agents.id, creation.assigneeAgentId), eq(agents.companyId, input.companyId))) : [];
|
||||
if (creation.assigneeAgentId && !childAgent) throw incompatible();
|
||||
for (const candidate of [...ancestors, { companyId: input.companyId, projectId: creation.projectId,
|
||||
executionPolicy: creation.executionPolicy }]) {
|
||||
const [candidateProject] = candidate.projectId ? await db.select().from(projects).where(and(
|
||||
eq(projects.id, candidate.projectId), eq(projects.companyId, input.companyId))) : [];
|
||||
if (candidate.projectId && !candidateProject) throw incompatible();
|
||||
const prospective = resolveCoreTrustPreset({ companyId: input.companyId,
|
||||
agent: "id" in candidate ? agent : childAgent, project: candidateProject, workspaceSourceProject: source,
|
||||
issue: candidate, run: { companyId: input.companyId, executionPolicy: retainedPolicy } });
|
||||
if (prospective.kind !== "low_trust_review" || !isDeepStrictEqual(prospective.boundary, selected.boundary)) throw incompatible();
|
||||
}
|
||||
}
|
||||
const selectedPolicy = { trustPreset: selected.preset, authorizationPolicy: {
|
||||
trustPreset: selected.preset, trustBoundary: selected.boundary,
|
||||
} };
|
||||
for (const owner of restricted) {
|
||||
const combined = resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: owner,
|
||||
run: { companyId: input.companyId, executionPolicy: selectedPolicy } });
|
||||
if (combined.kind !== "low_trust_review" || !isDeepStrictEqual(combined.boundary, selected.boundary)) throw incompatible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransaction, input: {
|
||||
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string; taskCreation?: TaskWorkspaceCreationTrustContext;
|
||||
} & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">): Promise<void> {
|
||||
const { sourceIds, sourceProjectIds, sharedProjectIds } = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true }));
|
||||
await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccess(db, input.actor, input.companyId, sourceIds));
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds, { ...input, sharedProjectIds }));
|
||||
await measureRunPhase("workspace.readers.trust", () => assertRootProjectTrust(db, { ...input, projectIds: sourceProjectIds }));
|
||||
}
|
||||
|
||||
export function executionWorkspaceRepositoryService(db: Db) {
|
||||
async function catalog(companyId: string, userId: string | null, localTrusted: boolean) {
|
||||
return (await toolAccessService(db).listProjectRepositories(companyId, userId, localTrusted)).repositories;
|
||||
@@ -494,7 +73,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const [project] = issue.projectId ? await reader.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, issue.companyId))) : [];
|
||||
const [workspaceSourceProject] = workspace?.projectId ? await reader.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, issue.companyId))) : [];
|
||||
const runId = input.runId ?? issue.executionRunId;
|
||||
const [run] = runId ? await reader.select(rootReaderRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) : [];
|
||||
const [run] = runId ? await reader.select(taskRepositoryRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) : [];
|
||||
return requiresAnonymousRepositoryAcquisition({ companyId: issue.companyId, issue, agent, project,
|
||||
workspaceSourceProject, run, agentId, runId, explicitRunId: input.runId });
|
||||
}
|
||||
@@ -504,134 +83,6 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
if (!issue || !workspace || issue.executionWorkspaceId !== workspace.id) throw forbidden("Task repository workspace binding is unavailable");
|
||||
if (await requiresAnonymousAcquisition(db, { issue, workspace, agentId: input.agentId, runId: input.runId })) throw forbidden(controllerAcquisitionMessage);
|
||||
}
|
||||
async function reserveRootPolicy(tx: DbTransaction, companyId: string, cwd: string, mode: "anonymous_only" | "managed") {
|
||||
const canonicalCwd = await measureRunPhase("workspace.sources.canonicalize", () => canonicalRepositoryRoot(cwd));
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const reservations = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaceRepositoryRoots)
|
||||
.where(eq(executionWorkspaceRepositoryRoots.companyId, companyId)));
|
||||
const existing = reservations.find(entry => entry.rootKey === rootKey);
|
||||
const overlapping = reservations.filter(entry => repositoryRootsOverlap(entry.canonicalCwd, canonicalCwd));
|
||||
if ((existing && existing.canonicalCwd !== canonicalCwd)
|
||||
|| overlapping.some(entry => entry.mode !== mode || createHash("sha256").update(entry.canonicalCwd).digest("hex") !== entry.rootKey)) {
|
||||
throw forbidden(lowTrustRepositoryMessage);
|
||||
}
|
||||
if (mode === "anonymous_only") {
|
||||
const managed = await measureRunPhase("workspace.sources.inventory", async () => 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),
|
||||
isNotNull(executionWorkspaceRepositories.catalogRepositoryId))));
|
||||
if (managed.length) {
|
||||
const namespace = path.join(canonicalCwd, ".paperclip-repositories");
|
||||
const namespaceEntry = await fs.lstat(namespace).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (namespaceEntry) {
|
||||
if (!namespaceEntry.isDirectory() || namespaceEntry.isSymbolicLink() || await fs.realpath(namespace) !== namespace) {
|
||||
throw forbidden(lowTrustRepositoryMessage);
|
||||
}
|
||||
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;
|
||||
throw error;
|
||||
});
|
||||
if (entry) throw forbidden(lowTrustRepositoryMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const peer of managed) {
|
||||
if (peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd)) throw forbidden(lowTrustRepositoryMessage);
|
||||
}
|
||||
}
|
||||
if (!existing) await measureRunPhase("workspace.write", () => tx.insert(executionWorkspaceRepositoryRoots).values({ companyId, canonicalCwd, rootKey, mode }));
|
||||
return canonicalCwd;
|
||||
}
|
||||
type RootReader = { runId: string; issueId: string; agentId: string };
|
||||
async function authorizeRootAccess(tx: DbTransaction, input: {
|
||||
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; issueId?: string; preserveOwnAdmission?: boolean;
|
||||
}) {
|
||||
const { canonicalCwd, rootKey, entryKeys, overlappingEntries, sourceIds, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds } = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(tx, input));
|
||||
const readers = overlappingEntries.filter(entry => entry.kind === "reader");
|
||||
if (input.reader && !entryKeys.has(`reader:${input.reader.runId}`)) {
|
||||
const [run] = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.reader!.runId), eq(heartbeatRuns.companyId, input.companyId))));
|
||||
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() });
|
||||
}
|
||||
const ownAdmissionOnly = input.preserveOwnAdmission && input.reader && input.sourceWorkspaceId
|
||||
&& sourceIds.size === 1 && sourceIds.has(input.sourceWorkspaceId)
|
||||
&& readers.every(reader => reader.issueId === input.reader!.issueId);
|
||||
const sourceProjects = sourceIds.size ? await measureRunPhase("workspace.sources.inventory", async () => tx.selectDistinct({ projectId: executionWorkspaces.projectId }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), inArray(executionWorkspaces.id, [...sourceIds])))) : [];
|
||||
const sourceActors: AuthorizationActor[] = [];
|
||||
const projectReaders: AuthorizationActor[] = [];
|
||||
await measureRunPhase("workspace.readers", async () => {
|
||||
const identities = await loadRepositoryRootReaders(tx, input.companyId, readers);
|
||||
const projectIds = [...new Set([...sourceProjectIds, ...sourceProjects.flatMap(source => source.projectId ? [source.projectId] : []),
|
||||
...identities.flatMap(reader => reader.issue.projectId ? [reader.issue.projectId] : [])])];
|
||||
const projectRows: RootProject[] = [];
|
||||
for (let offset = 0; offset < projectIds.length; offset += 500) {
|
||||
projectRows.push(...await measureRunPhase("workspace.sources.inventory", () => tx.select({ id: projects.id,
|
||||
companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy }).from(projects).where(and(
|
||||
eq(projects.companyId, input.companyId), inArray(projects.id, projectIds.slice(offset, offset + 500))))));
|
||||
}
|
||||
const projectsById = new Map(projectRows.map(project => [project.id, project]));
|
||||
const sourceOwners = projectRows.filter(project => sourceProjectIds.has(project.id));
|
||||
for (const { entry: reader, actor, issue, agent, run } of identities) {
|
||||
await measureRunPhase("workspace.readers.trust", () => assertRootProjectTrust(tx, { companyId: input.companyId, projectIds: sourceProjectIds, actor, issueId: issue.id,
|
||||
sourceWorkspaceId: input.reader?.runId === reader.scopeId ? input.sourceWorkspaceId : undefined }, sourceOwners));
|
||||
if (input.reader?.runId === reader.scopeId) {
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds, { sharedProjectIds, issueId: issue.id }));
|
||||
} else projectReaders.push(actor);
|
||||
if (ownAdmissionOnly) {
|
||||
const [ownSource] = await measureRunPhase("workspace.sources.inventory", async () => tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.sourceWorkspaceId!),
|
||||
await executionWorkspaceReadSqlCondition(tx, { ...actor, onBehalfOfUserId: null }),
|
||||
actor.onBehalfOfUserId ? await executionWorkspaceReadSqlCondition(tx, {
|
||||
type: "board", userId: actor.onBehalfOfUserId, ignoreInstanceAdmin: true,
|
||||
}) : undefined)));
|
||||
if (!ownSource) throw forbidden("Task workspace access is no longer available");
|
||||
} else sourceActors.push(actor);
|
||||
for (const sourceWorkspace of sourceProjects.length ? sourceProjects : [null]) {
|
||||
if (requiresAnonymousRepositoryAcquisition({ companyId: input.companyId, issue, agent, run,
|
||||
agentId: reader.agentId, runId: reader.scopeId, explicitRunId: reader.scopeId,
|
||||
project: issue.projectId ? projectsById.get(issue.projectId) : undefined,
|
||||
workspaceSourceProject: sourceWorkspace?.projectId ? projectsById.get(sourceWorkspace.projectId) : undefined })) {
|
||||
await reserveRootPolicy(tx, input.companyId, canonicalCwd, "anonymous_only");
|
||||
}
|
||||
}
|
||||
}
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectsReadAccessForActors(tx, projectReaders, input.companyId, sourceProjectIds));
|
||||
});
|
||||
if (input.actor) {
|
||||
if (!input.issueId) throw forbidden("Workspace write authorization requires a task");
|
||||
await measureRunPhase("workspace.readers.trust", () => assertRootProjectTrust(tx, { ...input, projectIds: sourceProjectIds, actor: input.actor! }));
|
||||
await measureRunPhase("workspace.readers.projects", () => assertRootProjectAccess(tx, input.actor!, input.companyId, sourceProjectIds, { sharedProjectIds, issueId: input.issueId }));
|
||||
sourceActors.push(input.actor);
|
||||
}
|
||||
await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccessForActors(tx, sourceActors, input.companyId, sourceIds));
|
||||
await retainRepositorySources(tx, { companyId: input.companyId, canonicalCwd, rootKey }, entryKeys,
|
||||
[["source", sharedSourceIds], ["isolated_source", isolatedSourceIds], ["project_source", sourceProjectIds],
|
||||
["shared_project_source", sharedProjectIds], ["isolated_project_source", isolatedProjectIds]]);
|
||||
await measureRunPhase("workspace.readers", async () => {
|
||||
for (const reader of readers) {
|
||||
if (reader.rootKey === rootKey && !entryKeys.has(`reader:${reader.scopeId}`)) {
|
||||
await measureRunPhase("workspace.write", () => tx.insert(executionWorkspaceRepositoryAccess).values(reader));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async function reserveRootAccess(input: Parameters<typeof authorizeRootAccess>[1]) {
|
||||
return measureRunTransaction(db, "workspace", async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
await authorizeRootAccess(tx, input);
|
||||
});
|
||||
}
|
||||
async function reserveAdmissionRoot(companyId: string, cwd: string, mode: "anonymous_only" | "managed") {
|
||||
return measureRunTransaction(db, "workspace", async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, companyId);
|
||||
return reserveRootPolicy(tx, companyId, cwd, mode);
|
||||
});
|
||||
}
|
||||
async function request(input: { companyId: string; issueId: string; actor: AuthorizationActor; request: PrepareWorkspaceRepository }) {
|
||||
const parsed = prepareWorkspaceRepositorySchema.parse(input.request);
|
||||
const [requestedIssue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(db, input.actor)));
|
||||
@@ -654,28 +105,21 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const available = await catalog(input.companyId, userId, localTrusted);
|
||||
const requestedRef = parsed.ref ?? "HEAD";
|
||||
const receipt = await measureRunTransaction(db, "workspace", async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const [issue] = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(tx, input.actor))).for("no key update"));
|
||||
if (!issue) throw notFound("Task not found");
|
||||
const currentDecision = await authorizationService(tx).decide({ actor: input.actor, action: "issue:mutate", resource: issueResource(issue) });
|
||||
if (!currentDecision.allowed) throw forbidden("Task workspace modification is not allowed");
|
||||
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");
|
||||
const [workspace] = await measureRunPhase("workspace.sources.inventory", async () => 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 });
|
||||
await assertTaskWorkspaceExecutionAccess(tx, input.actor, input.companyId, { issueId: input.issueId, workspaceId: workspace.id });
|
||||
const anonymousOnly = await requiresAnonymousAcquisition(tx, { issue, workspace,
|
||||
agentId: input.actor.agentId, runId: input.actor.runId });
|
||||
const source = resolveAcquisitionSource(parsed.repository, available, anonymousOnly);
|
||||
if (anonymousOnly || source.catalogRepositoryId) {
|
||||
if (!workspace.cwd) throw conflict("The task workspace has no directory for repository preparation");
|
||||
await reserveRootPolicy(tx, input.companyId, workspace.cwd, anonymousOnly ? "anonymous_only" : "managed");
|
||||
}
|
||||
if (!workspace.cwd) throw conflict("The task workspace has no directory for repository preparation");
|
||||
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor, issueId: input.issueId });
|
||||
if (anonymousOnly) throw forbidden(controllerAcquisitionMessage);
|
||||
const existing = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`))));
|
||||
const existing = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`))));
|
||||
for (const row of existing) {
|
||||
assertAcquisitionReceipt(row, anonymousOnly);
|
||||
assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef);
|
||||
@@ -689,9 +133,6 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
return row;
|
||||
}
|
||||
const [row] = await measureRunPhase("workspace.write", () => tx.insert(executionWorkspaceRepositories).values({ companyId: input.companyId, executionWorkspaceId: workspace.id, requestedByIssueId: input.issueId, ...source, relativePath: taskRepositoryRelativePath(source.repositoryIdentity), requestedRef, requestKey: parsed.requestKey, requestKeys: [parsed.requestKey] }).returning());
|
||||
const canonicalCwd = await measureRunPhase("workspace.sources.canonicalize", () => canonicalRepositoryRoot(workspace.cwd!));
|
||||
await measureRunPhase("workspace.write", () => tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId,
|
||||
rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), canonicalCwd, kind: "repository", scopeId: row.id }));
|
||||
await measureRunPhase("workspace.write", () => 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;
|
||||
});
|
||||
@@ -700,156 +141,155 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
async function list(companyId: string, workspaceId: string) {
|
||||
return db.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.companyId, companyId), eq(executionWorkspaceRepositories.executionWorkspaceId, workspaceId)));
|
||||
}
|
||||
type PublicationInput = { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string };
|
||||
async function publicationBinding(tx: DbTransaction, input: PublicationInput) {
|
||||
const [issue] = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))));
|
||||
const [workspace] = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId))));
|
||||
type PublicationInput = { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string; responsibleUserId?: string | null };
|
||||
async function currentRunActor(reader: Db | DbTransaction, input: PublicationInput): Promise<AuthorizationActor> {
|
||||
const [run] = await reader.select(taskRepositoryRunColumns).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
if (!run || run.agentId !== input.agentId) throw forbidden("Task repository run identity is unavailable");
|
||||
if ("responsibleUserId" in input && run.responsibleUserId !== input.responsibleUserId) {
|
||||
throw forbidden("Task repository responsible user identity is unavailable");
|
||||
}
|
||||
return { type: "agent", source: "agent_jwt", companyId: input.companyId, agentId: input.agentId,
|
||||
runId: input.runId, onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId };
|
||||
}
|
||||
async function publicationBinding(reader: Db | DbTransaction, input: PublicationInput, verifyDirectory = true) {
|
||||
const [issue] = await reader.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
|
||||
const [workspace] = await reader.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId)));
|
||||
if (!issue || !workspace || issue.executionWorkspaceId !== workspace.id || !workspace.cwd
|
||||
|| await measureRunPhase("workspace.sources.canonicalize", () => canonicalRepositoryRoot(workspace.cwd!)) !== await measureRunPhase("workspace.sources.canonicalize", () => canonicalRepositoryRoot(input.cwd))) {
|
||||
|| (verifyDirectory && await canonicalRepositoryRoot(workspace.cwd) !== await canonicalRepositoryRoot(input.cwd))) {
|
||||
throw forbidden("Task repository publication binding is unavailable");
|
||||
}
|
||||
await requiresAnonymousAcquisition(tx, { issue, workspace, agentId: input.agentId, runId: input.runId });
|
||||
const [run] = await measureRunPhase("workspace.sources.inventory", async () => tx.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId))));
|
||||
const [{ actor }] = await loadRepositoryRootReaders(tx, input.companyId, [{
|
||||
scopeId: input.runId, issueId: input.issueId, agentId: input.agentId, responsibleUserId: run?.responsibleUserId ?? null,
|
||||
}]);
|
||||
return { workspace, actor };
|
||||
const acquisitionRestricted = await requiresAnonymousAcquisition(reader, { issue, workspace, agentId: input.agentId, runId: input.runId });
|
||||
const actor = await currentRunActor(reader, input);
|
||||
await assertTaskWorkspaceExecutionAccess(reader, actor, input.companyId, { issueId: issue.id, workspaceId: workspace.id });
|
||||
return { issue, workspace, actor, acquisitionRestricted };
|
||||
}
|
||||
async function guardForPublication(input: PublicationInput) {
|
||||
await measureRunTransaction(db, "workspace", async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const { workspace } = await publicationBinding(tx, input);
|
||||
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: input.cwd!, sourceWorkspaceId: workspace.id, reader: input });
|
||||
});
|
||||
async function assertPublicationAllowed(input: PublicationInput) {
|
||||
await publicationBinding(db, input);
|
||||
}
|
||||
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 () => {
|
||||
let sourceFailure: unknown;
|
||||
let commonRoot: string | undefined;
|
||||
try {
|
||||
roots.add(await fs.realpath(input.sourceCwd));
|
||||
const source = await isolatedWorkspaceSourceRoots(db, input.companyId, input.sourceCwd, "repository");
|
||||
commonRoot = source.commonRoot;
|
||||
for (const cwd of source.roots) roots.add(cwd);
|
||||
} catch (error) { sourceFailure = error; }
|
||||
const denied = await measureRunTransaction(db, "workspace", async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const { workspace, actor } = await publicationBinding(tx, input);
|
||||
const canonicalCwd = await measureRunPhase("workspace.sources.canonicalize", () => canonicalRepositoryRoot(input.cwd));
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const existing = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaceRepositoryAccess).where(and(
|
||||
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey))));
|
||||
if (existing.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
|
||||
const existingKeys = repositoryAccessKeys(existing);
|
||||
let failure = sourceFailure;
|
||||
for (const cwd of roots) {
|
||||
try {
|
||||
const source = await measureRunPhase("workspace.sources", () => resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd }));
|
||||
for (const id of source.sourceIds) workspaceIds.add(id);
|
||||
for (const id of source.sourceProjectIds) projectIds.add(id);
|
||||
} catch (error) { failure ??= error; }
|
||||
async function prepareConfiguredRepositoryCopy(input: PublicationInput & { sourceCwd: string; projectWorkspaceId?: string | null }): Promise<() => Promise<void>> {
|
||||
const sourceRoot = await fs.realpath(input.sourceCwd);
|
||||
const destinationRoot = await canonicalRepositoryRoot(input.cwd);
|
||||
const [selectedIssue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
|
||||
if (!selectedIssue) throw forbidden("Task repository copy identity is unavailable");
|
||||
const [selectedWorkspace] = await db.select({ projectId: executionWorkspaces.projectId,
|
||||
projectWorkspaceId: executionWorkspaces.projectWorkspaceId }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId)));
|
||||
const selectionSource = selectedIssue.workspaceSelection?.selection;
|
||||
const anchorSourceId = selectedWorkspace?.projectWorkspaceId ?? selectedIssue.projectWorkspaceId
|
||||
?? (selectionSource?.kind === "configured_source" ? selectionSource.projectWorkspaceId : null);
|
||||
const selectedSourceId = input.projectWorkspaceId ?? anchorSourceId;
|
||||
const [selectedSource] = selectedSourceId ? await db.select().from(projectWorkspaces).where(and(
|
||||
eq(projectWorkspaces.id, selectedSourceId), eq(projectWorkspaces.companyId, input.companyId))) : [];
|
||||
if (selectedSourceId && !selectedSource) throw forbidden("Task repository copy source is unavailable");
|
||||
const verify = async () => {
|
||||
if (await fs.realpath(input.sourceCwd) !== sourceRoot || await canonicalRepositoryRoot(input.cwd) !== destinationRoot) {
|
||||
throw conflict("Repository copy source or destination changed during preparation");
|
||||
}
|
||||
const [issue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
|
||||
if (!issue || issue.projectWorkspaceId !== selectedIssue.projectWorkspaceId || issue.projectId !== selectedIssue.projectId) {
|
||||
throw conflict("Repository copy configuration changed during preparation");
|
||||
}
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId)));
|
||||
if (selectedWorkspace && (!workspace || workspace.projectId !== selectedWorkspace.projectId
|
||||
|| workspace.projectWorkspaceId !== selectedWorkspace.projectWorkspaceId)) {
|
||||
throw conflict("Repository copy workspace configuration changed during preparation");
|
||||
}
|
||||
const currentSelection = issue.workspaceSelection?.selection;
|
||||
const currentAnchorSourceId = workspace?.projectWorkspaceId ?? issue.projectWorkspaceId
|
||||
?? (currentSelection?.kind === "configured_source" ? currentSelection.projectWorkspaceId : null);
|
||||
if (currentAnchorSourceId !== anchorSourceId) throw conflict("Repository copy configuration changed during preparation");
|
||||
await requiresAnonymousAcquisition(db, { issue, workspace: workspace ?? null, agentId: input.agentId, runId: input.runId });
|
||||
const actor = await currentRunActor(db, input);
|
||||
const access = await assertTaskWorkspaceExecutionAccess(db, actor, input.companyId, { issueId: issue.id,
|
||||
...(workspace ? { workspaceId: workspace.id } : {}), projectWorkspaceId: anchorSourceId });
|
||||
if (selectedSource && selectedSource.projectId !== (access.source?.projectId ?? access.workspace?.projectId ?? access.task.projectId)) {
|
||||
throw forbidden("Repository copy source is outside the selected project");
|
||||
}
|
||||
if (workspace) await publicationBinding(db, input);
|
||||
if (selectedSource) {
|
||||
const [source] = await db.select().from(projectWorkspaces).where(and(eq(projectWorkspaces.id, selectedSource.id), eq(projectWorkspaces.companyId, input.companyId)));
|
||||
if (!source || source.cwd !== selectedSource.cwd || source.repoUrl !== selectedSource.repoUrl || source.repoRef !== selectedSource.repoRef
|
||||
|| source.projectId !== selectedSource.projectId || source.sourceType !== selectedSource.sourceType) {
|
||||
throw conflict("Repository copy configuration changed during preparation");
|
||||
}
|
||||
try { await measureRunPhase("workspace.readers.sources", () => assertTaskWorkspacesReadAccess(tx, actor, input.companyId, workspaceIds)); }
|
||||
catch (error) { failure ??= error; }
|
||||
for (const id of projectIds) {
|
||||
try { await measureRunPhase("workspace.readers.projects", () => assertRootProjectAccess(tx, actor, input.companyId, new Set([id]))); }
|
||||
catch (error) { failure ??= error; }
|
||||
}
|
||||
await retainRepositorySources(tx, { companyId: input.companyId, canonicalCwd, rootKey }, existingKeys,
|
||||
[["isolated_source", workspaceIds], ["isolated_project_source", projectIds]]);
|
||||
try {
|
||||
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: input.cwd!, sourceWorkspaceId: workspace.id,
|
||||
actor, issueId: input.issueId });
|
||||
} catch (error) { failure ??= error; }
|
||||
if (!failure && commonRoot) {
|
||||
try { await retainGitMetadataOwners(tx, { companyId: input.companyId, commonRoot, workspaceIds, projectIds }); }
|
||||
catch (error) { failure ??= error; }
|
||||
}
|
||||
return failure;
|
||||
});
|
||||
if (denied) throw denied;
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, input.companyId, source.projectId);
|
||||
}
|
||||
};
|
||||
await retain();
|
||||
return retain;
|
||||
await verify();
|
||||
return verify;
|
||||
}
|
||||
async function reserveForRecovery(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string }) {
|
||||
async function assertRecoveryBinding(input: PublicationInput) {
|
||||
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) {
|
||||
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 { }
|
||||
const binding = execution?.binding;
|
||||
if (input.workspaceId !== input.runId || run?.runtimeMode !== "native" || !binding
|
||||
|| binding.companyId !== input.companyId || binding.runId !== input.runId
|
||||
|| binding.agentId !== input.agentId || binding.issueId !== input.issueId
|
||||
|| binding.executionWorkspaceId !== input.runId) {
|
||||
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
if (!issue || !run || run.agentId !== input.agentId) throw forbidden("Task repository recovery identity is unavailable");
|
||||
let execution;
|
||||
try { execution = parseNativeExecutionInput(run.runnerProfileJson?.nativeExecutionInput); } catch { /* Old non-native runs have no native input. */ }
|
||||
const binding = execution?.binding;
|
||||
if (run.runtimeMode === "native") {
|
||||
if (!binding || binding.companyId !== input.companyId || binding.runId !== input.runId
|
||||
|| binding.agentId !== input.agentId || binding.issueId !== input.issueId || binding.executionWorkspaceId !== input.workspaceId
|
||||
|| (!workspace && input.workspaceId !== input.runId)) {
|
||||
throw forbidden("Task repository recovery identity is unavailable");
|
||||
}
|
||||
} else if (!workspace || issue.executionWorkspaceId !== workspace.id) {
|
||||
throw forbidden("Task repository recovery identity is unavailable");
|
||||
}
|
||||
const anonymousOnly = await requiresAnonymousAcquisition(db, { issue, workspace: workspace ?? null, agentId: input.agentId, runId: input.runId });
|
||||
if (anonymousOnly) await reserveAdmissionRoot(input.companyId, input.cwd, "anonymous_only");
|
||||
else if ((await list(input.companyId, input.workspaceId)).some(row => row.catalogRepositoryId)) {
|
||||
await reserveAdmissionRoot(input.companyId, input.cwd, "managed");
|
||||
}
|
||||
await reserveRootAccess({ companyId: input.companyId, cwd: input.cwd, reader: input,
|
||||
...(workspace ? { sourceWorkspaceId: workspace.id } : {}),
|
||||
preserveOwnAdmission: !(await list(input.companyId, input.workspaceId)).length });
|
||||
await requiresAnonymousAcquisition(db, { issue, workspace: workspace ?? null, agentId: input.agentId, runId: input.runId });
|
||||
const actor = await currentRunActor(db, input);
|
||||
// Recovery uses the native run's immutable binding rather than the task's latest workspace pointer.
|
||||
await assertTaskWorkspaceExecutionAccess(db, actor, input.companyId, { issueId: issue.id });
|
||||
if (workspace?.projectId) await assertTaskWorkspaceSourceProjectAccess(db, actor, input.companyId, workspace.projectId);
|
||||
}
|
||||
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))
|
||||
.where(and(eq(executionWorkspaceRepositories.companyId, input.row.companyId), eq(executionWorkspaces.companyId, input.row.companyId),
|
||||
eq(executionWorkspaceRepositories.repositoryIdentity, input.row.repositoryIdentity), eq(executionWorkspaceRepositories.relativePath, input.row.relativePath)));
|
||||
async function sharedPublicationPin(input: { row: RepositoryRow; root: string; ownership: { repositoryId?: unknown; pinnedCommit?: unknown } | null }) {
|
||||
const validPin = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40,64}$/.test(value);
|
||||
let pin = input.row.pinnedCommit;
|
||||
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);
|
||||
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);
|
||||
assertRepositoryRetryMatches(repository, input.row.repositoryIdentity, input.row.requestedRef);
|
||||
if (!(await canActorReadExecutionWorkspace(db, input.actor, workspace.id))) throw forbidden("The shared repository retains files from a workspace outside this actor's access");
|
||||
await assertTaskWorkspaceAccess(db, input.actor, input.row.companyId, workspace.id, { write: true, issueId: input.issueId });
|
||||
if (pin && pin !== candidatePin) throw conflict("Shared repository publication receipts disagree; repair is required before reuse");
|
||||
pin = candidatePin;
|
||||
if (pin && !validPin(pin)) throw conflict("Repository preparation receipt has an invalid pinned commit; repair is required before reuse");
|
||||
const ownerId = input.ownership?.repositoryId;
|
||||
if (typeof ownerId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(ownerId)) return pin;
|
||||
if (ownerId === input.row.id) {
|
||||
if (validPin(input.ownership?.pinnedCommit)) {
|
||||
if (pin && pin !== input.ownership.pinnedCommit) throw conflict("Shared repository publication receipts disagree; repair is required before reuse");
|
||||
pin = input.ownership.pinnedCommit;
|
||||
}
|
||||
return pin;
|
||||
}
|
||||
return pin;
|
||||
// The checkout marker names at most one other receipt. This is integrity evidence,
|
||||
// not authority to disclose its originating workspace or task.
|
||||
const [owner] = await db.select({ repository: executionWorkspaceRepositories, workspace: executionWorkspaces })
|
||||
.from(executionWorkspaceRepositories).innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId))
|
||||
.where(and(eq(executionWorkspaceRepositories.id, ownerId), eq(executionWorkspaceRepositories.companyId, input.row.companyId),
|
||||
eq(executionWorkspaces.companyId, input.row.companyId)));
|
||||
if (!owner || !owner.workspace.cwd) return pin;
|
||||
const { repository, workspace } = owner;
|
||||
if (repository.repositoryIdentity !== input.row.repositoryIdentity || repository.relativePath !== input.row.relativePath
|
||||
|| await fs.realpath(workspace.cwd!).catch(() => null) !== input.root) return pin;
|
||||
assertRepositoryRetryMatches(repository, input.row.repositoryIdentity, input.row.requestedRef);
|
||||
const markerPin = validPin(input.ownership?.pinnedCommit) ? input.ownership.pinnedCommit : null;
|
||||
if (repository.pinnedCommit && markerPin && repository.pinnedCommit !== markerPin) {
|
||||
throw conflict("Shared repository publication receipts disagree; repair is required before reuse");
|
||||
}
|
||||
const candidatePin = repository.pinnedCommit ?? markerPin;
|
||||
if (!validPin(candidatePin)) return pin;
|
||||
if (pin && pin !== candidatePin) throw conflict("Shared repository publication receipts disagree; repair is required before reuse");
|
||||
return candidatePin;
|
||||
}
|
||||
async function prepareForAdmission(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string; responsibleUserId: string | null }) {
|
||||
const { acquisitionRestricted } = await publicationBinding(db, input, false);
|
||||
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,
|
||||
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 });
|
||||
if (!rows.length) {
|
||||
if (anonymousOnly) await reserveAdmissionRoot(input.companyId, input.cwd, "anonymous_only");
|
||||
await reserveRootAccess({ companyId: input.companyId, cwd: input.cwd, reader: input,
|
||||
sourceWorkspaceId: workspace.id, preserveOwnAdmission: true });
|
||||
return [];
|
||||
}
|
||||
const acquisitionRestricted = anonymousOnly;
|
||||
if (!rows.length) return [];
|
||||
await publicationBinding(db, input);
|
||||
if (acquisitionRestricted && rows.some(row => row.state !== "ready" || !row.pinnedCommit || !/^[a-f0-9]{40,64}$/.test(row.pinnedCommit))) {
|
||||
throw forbidden(controllerAcquisitionMessage);
|
||||
}
|
||||
await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true, issueId: input.issueId });
|
||||
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 });
|
||||
const sources = rows.map(row => {
|
||||
assertAcquisitionReceipt(row, anonymousOnly);
|
||||
return resolveAcquisitionSource(row.catalogRepositoryId ? { kind: "catalog", id: row.catalogRepositoryId } : { kind: "url", url: row.repoUrl }, available, anonymousOnly);
|
||||
assertAcquisitionReceipt(row, acquisitionRestricted);
|
||||
return resolveAcquisitionSource(row.catalogRepositoryId ? { kind: "catalog", id: row.catalogRepositoryId } : { kind: "url", url: row.repoUrl }, available, acquisitionRestricted);
|
||||
});
|
||||
if (anonymousOnly || sources.some(source => source.catalogRepositoryId)) {
|
||||
const reservedRoot = await reserveAdmissionRoot(input.companyId, root, anonymousOnly ? "anonymous_only" : "managed");
|
||||
if (reservedRoot !== root) throw conflict("Repository workspace root changed during admission");
|
||||
}
|
||||
await reserveRootAccess({ companyId: input.companyId, cwd: root, sourceWorkspaceId: input.workspaceId, reader: input });
|
||||
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");
|
||||
@@ -911,7 +351,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
} catch { return null; } finally { await handle.close(); }
|
||||
};
|
||||
const initialOwnership = await readOwnership();
|
||||
const authorizedPin = await sharedPublicationPin({ issueId: input.issueId, row, root, actor, anonymousOnly, ownership: initialOwnership });
|
||||
const authorizedPin = await sharedPublicationPin({ row, root, ownership: initialOwnership });
|
||||
if (existing && !authorizedPin && initialOwnership?.repositoryId !== row.id) {
|
||||
throw conflict("Repository directory has no matching preparation receipt; repair it before retrying");
|
||||
}
|
||||
@@ -924,7 +364,12 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
resolveGitAuth: source.catalogRepositoryId ? resolveAuth : async () => anonymous,
|
||||
beforePublish: async cloneCwd => {
|
||||
await assertCurrentControllerAcquisition(input);
|
||||
await reserveRootAccess({ companyId: input.companyId, cwd: root, sourceWorkspaceId: input.workspaceId, reader: input });
|
||||
await assertPublicationAllowed(input);
|
||||
if (source.catalogRepositoryId) {
|
||||
const currentCatalog = await catalog(input.companyId, input.responsibleUserId, input.responsibleUserId === "local-board");
|
||||
const currentSource = resolveAcquisitionSource({ kind: "catalog", id: source.catalogRepositoryId }, currentCatalog, false);
|
||||
if (currentSource.repositoryIdentity !== row.repositoryIdentity) throw conflict("Repository preparation receipt does not match its source");
|
||||
}
|
||||
const pinnedCommit = (await execFile("git", ["-C", cloneCwd, "rev-parse", "HEAD"], { timeout: 30_000 })).stdout.trim();
|
||||
await fs.writeFile(path.join(cloneCwd, ".git", "paperclip-workspace-owner.json"), JSON.stringify({ version: 1, repositoryId: row.id, pinnedCommit }), { flag: "wx", mode: 0o600 });
|
||||
},
|
||||
@@ -939,6 +384,29 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
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);
|
||||
await assertPublicationAllowed(input);
|
||||
if (!acquisitionRestricted) {
|
||||
await assertCurrentControllerAcquisition(input);
|
||||
if (source.catalogRepositoryId) {
|
||||
const currentCatalog = await catalog(input.companyId, input.responsibleUserId, input.responsibleUserId === "local-board");
|
||||
const currentSource = resolveAcquisitionSource({ kind: "catalog", id: source.catalogRepositoryId }, currentCatalog, false);
|
||||
if (currentSource.repositoryIdentity !== row.repositoryIdentity) throw conflict("Repository preparation receipt does not match its source");
|
||||
}
|
||||
}
|
||||
if (ownership?.repositoryId !== row.id || ownership?.pinnedCommit !== pinnedCommit) {
|
||||
const gitDir = path.join(cwd, ".git");
|
||||
const metadata = await fs.lstat(gitDir);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || await fs.realpath(gitDir) !== gitDir) {
|
||||
throw conflict("Repository Git metadata escapes the managed checkout");
|
||||
}
|
||||
const temporaryOwnershipPath = path.join(gitDir, `.paperclip-workspace-owner-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(temporaryOwnershipPath, JSON.stringify({ version: 1, repositoryId: row.id, pinnedCommit }), { flag: "wx", mode: 0o600 });
|
||||
// Keep bounded ownership lookup attached to a receipt for this root,
|
||||
// even if the workspace that originally published it later moves.
|
||||
await fs.rename(temporaryOwnershipPath, ownershipPath);
|
||||
} finally { await fs.rm(temporaryOwnershipPath, { force: true }); }
|
||||
}
|
||||
await db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit, branchName, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id));
|
||||
prepared.push({ id: row.id, cwd, repoUrl: row.repoUrl, relativePath: row.relativePath, pinnedCommit, branchName });
|
||||
} catch (error) {
|
||||
@@ -949,5 +417,5 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
return { request, list, prepareForAdmission, reserveForRecovery, guardForPublication, prepareConfiguredRepositorySource };
|
||||
return { request, list, prepareForAdmission, assertRecoveryBinding, assertPublicationAllowed, prepareConfiguredRepositoryCopy };
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { agents, heartbeatRuns, issues, projects, executionWorkspaceRepositoryAccess, type Db } from "@paperclipai/db";
|
||||
import { forbidden } from "../errors.js";
|
||||
import type { AuthorizationActor } from "./authorization.js";
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
|
||||
type ReaderDb = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type ReaderEntry = Pick<typeof executionWorkspaceRepositoryAccess.$inferSelect, "scopeId" | "issueId" | "agentId" | "responsibleUserId">;
|
||||
type ReaderAgent = Pick<typeof agents.$inferSelect, "id" | "companyId" | "permissions">;
|
||||
type ReaderIssue = Pick<typeof issues.$inferSelect, "id" | "companyId" | "projectId" | "executionPolicy">;
|
||||
type ReaderProject = Pick<typeof projects.$inferSelect, "companyId" | "executionWorkspacePolicy">;
|
||||
|
||||
export const rootReaderRunColumns = {
|
||||
id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId, nativeIssueId: heartbeatRuns.nativeIssueId, issueId: heartbeatRuns.issueId,
|
||||
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId', 'executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
|
||||
};
|
||||
type ReaderRunIdentity = Pick<typeof heartbeatRuns.$inferSelect, "nativeIssueId" | "issueId" | "contextSnapshot">;
|
||||
|
||||
// A wake for another task can coalesce into a running run and replace its context
|
||||
// task after admission. The run's bound task still identifies its retained reader.
|
||||
export function runRetainsIssueIdentity(run: ReaderRunIdentity, issueId: string) {
|
||||
return issueId === (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId)
|
||||
|| issueId === (run.nativeIssueId ?? run.issueId);
|
||||
}
|
||||
|
||||
// This snapshot belongs to one guarded operation. Never retain it across admissions
|
||||
// or publications: identities and permissions must be read again next time.
|
||||
export async function loadRepositoryRootReaders<T extends ReaderEntry>(db: ReaderDb, companyId: string, entries: T[]) {
|
||||
const runIds = [...new Set(entries.map(entry => entry.scopeId))];
|
||||
const agentIds = [...new Set(entries.flatMap(entry => entry.agentId ? [entry.agentId] : []))];
|
||||
const issueIds = [...new Set(entries.flatMap(entry => entry.issueId ? [entry.issueId] : []))];
|
||||
const runs = new Map<string, Pick<typeof heartbeatRuns.$inferSelect, keyof typeof rootReaderRunColumns>>();
|
||||
const agentRows = new Map<string, ReaderAgent>();
|
||||
const issueRows = new Map<string, ReaderIssue>();
|
||||
await measureRunPhase("workspace.readers.identity", async () => {
|
||||
for (let offset = 0; offset < runIds.length; offset += 500) {
|
||||
for (const row of await db.select(rootReaderRunColumns).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runIds.slice(offset, offset + 500))))) runs.set(row.id, row);
|
||||
}
|
||||
for (let offset = 0; offset < agentIds.length; offset += 500) {
|
||||
for (const row of await db.select({ id: agents.id, companyId: agents.companyId, permissions: agents.permissions }).from(agents).where(and(
|
||||
eq(agents.companyId, companyId), inArray(agents.id, agentIds.slice(offset, offset + 500))))) agentRows.set(row.id, row);
|
||||
}
|
||||
for (let offset = 0; offset < issueIds.length; offset += 500) {
|
||||
for (const row of await db.select({ id: issues.id, companyId: issues.companyId, projectId: issues.projectId,
|
||||
executionPolicy: issues.executionPolicy }).from(issues).where(and(
|
||||
eq(issues.companyId, companyId), inArray(issues.id, issueIds.slice(offset, offset + 500))))) issueRows.set(row.id, row);
|
||||
}
|
||||
});
|
||||
return entries.map(entry => {
|
||||
const run = runs.get(entry.scopeId);
|
||||
const agent = entry.agentId ? agentRows.get(entry.agentId) : undefined;
|
||||
const issue = entry.issueId ? issueRows.get(entry.issueId) : undefined;
|
||||
if (!run || !agent || !issue || run.agentId !== entry.agentId || run.responsibleUserId !== entry.responsibleUserId
|
||||
|| !runRetainsIssueIdentity(run, issue.id)) {
|
||||
throw forbidden("Repository root retains a reader whose identity is no longer available");
|
||||
}
|
||||
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId,
|
||||
agentId: agent.id, runId: run.id, onBehalfOfUserId: entry.responsibleUserId === "local-board" ? null : entry.responsibleUserId };
|
||||
return { entry, run, agent, issue, actor };
|
||||
});
|
||||
}
|
||||
|
||||
export function requiresAnonymousRepositoryAcquisition(input: {
|
||||
companyId: string;
|
||||
issue: ReaderIssue;
|
||||
agent: ReaderAgent | undefined;
|
||||
project: ReaderProject | undefined;
|
||||
workspaceSourceProject: ReaderProject | undefined;
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, keyof typeof rootReaderRunColumns> | undefined;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
explicitRunId?: string | null;
|
||||
}) {
|
||||
const { companyId, issue, agent, project, workspaceSourceProject, run, agentId, runId } = input;
|
||||
if (agentId && !agent) throw forbidden("Task repository agent is unavailable");
|
||||
if (runId && (!run || (input.explicitRunId && run.agentId !== agentId) || !runRetainsIssueIdentity(run, issue.id))) {
|
||||
throw forbidden("Task repository run identity is unavailable");
|
||||
}
|
||||
const trust = resolveCoreTrustPreset({ companyId, agent, project, workspaceSourceProject, issue,
|
||||
run: run ? { companyId: run.companyId, executionPolicy: run.contextSnapshot?.executionPolicy } : null });
|
||||
if (trust.kind === "denied") throw forbidden(trust.detail);
|
||||
return trust.kind === "low_trust_review";
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
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);
|
||||
@@ -20,166 +11,3 @@ export async function canonicalRepositoryRoot(cwd: string): Promise<string> {
|
||||
return path.join(await canonicalRepositoryRoot(parent), path.basename(absolute));
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRepositoryRoots(cwds: Iterable<string>, requireExisting = false): Promise<Map<string, string | null>> {
|
||||
const paths = [...new Set(cwds)];
|
||||
const roots = new Map<string, string | null>();
|
||||
let next = 0;
|
||||
const settled = await Promise.allSettled(Array.from({ length: Math.min(16, paths.length) }, async () => {
|
||||
while (next < paths.length) {
|
||||
const cwd = paths[next++];
|
||||
const root = await (requireExisting ? fs.realpath(cwd) : canonicalRepositoryRoot(cwd))
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")
|
||||
|| (requireExisting && error.code === "ENOENT")) return null;
|
||||
throw error;
|
||||
});
|
||||
roots.set(cwd, root);
|
||||
}
|
||||
}));
|
||||
const rejected = settled.find(result => result.status === "rejected");
|
||||
if (rejected?.status === "rejected") throw rejected.reason;
|
||||
return roots;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { measureRunTransaction } from "./execution-transaction-timing.js";
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js";
|
||||
import { assertExecutionWorkspaceRecordAccess, assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-access.js";
|
||||
import { verifiedRecordedIsolatedRemoteSource, withIsolatedRepositorySourceLock } from "./isolated-remote-workspace-source.js";
|
||||
import { expireReconstructableGitWorkspace, hasPendingGitExpiration } from "./git-workspace-expiration.js";
|
||||
import { readGitCacheRestoration, restoreGitCacheBase, recoverGitCacheExpirationLocks, GitCacheRestorationFailure } from "./git-workspace-restoration.js";
|
||||
@@ -9,7 +9,8 @@ import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
import { CONTROLLER_PROVISION_COPY_ONLY, workspaceRequiresCopyOnlyProvisioning } from "./workspace-host-provision.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
|
||||
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess, assertExecutionWorkspaceRootReadAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, canonicalRepositoryRoot, type IsolatedWorkspaceSourceAccess, type TaskWorkspaceCreationTrustContext } from "./execution-workspace-repositories.js";
|
||||
import { executionWorkspaceRepositoryService, canonicalRepositoryRoot } from "./execution-workspace-repositories.js";
|
||||
import { assertSelectedWorkspaceTrust, type TaskWorkspaceCreationTrustContext } from "./task-repository-acquisition.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
|
||||
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared";
|
||||
@@ -252,14 +253,9 @@ async function acquireExecutionWorkspaceLifecycleLock(
|
||||
|
||||
const workspaceSourceKeys = ["cwd", "projectId", "projectWorkspaceId", "sourceIssueId"] as const;
|
||||
|
||||
async function preserveWorkspaceOwnershipForPatch(
|
||||
tx: DbTransaction, current: typeof executionWorkspaces.$inferSelect, patch: Partial<typeof executionWorkspaces.$inferInsert>,
|
||||
function preserveWorkspaceMetadataForPatch(
|
||||
current: typeof executionWorkspaces.$inferSelect, patch: Partial<typeof executionWorkspaces.$inferInsert>,
|
||||
) {
|
||||
if (workspaceSourceKeys.some(key => patch[key] !== undefined && patch[key] !== current[key])) {
|
||||
await measureRunPhase("workspace.provenance", () => preserveExecutionWorkspaceRootProvenance(tx, {
|
||||
companyId: current.companyId, workspaceId: current.id, cwd: current.cwd,
|
||||
}));
|
||||
}
|
||||
const nextPatch = { ...patch };
|
||||
const retainedSources = {
|
||||
...parseObject(parseObject(patch.metadata)._issuePrivacySources),
|
||||
@@ -1424,19 +1420,21 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
return (await resolveSourceWorkspaceConfiguration(input, reader)).strategy;
|
||||
}
|
||||
|
||||
async function validateSelection(input: { taskCreation?: TaskWorkspaceCreationTrustContext; 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; preserveImplicitDefaultSettings?: boolean }, reader: Db | DbTransaction = db) {
|
||||
async function validateSelection(input: { taskCreation?: TaskWorkspaceCreationTrustContext; 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; preserveImplicitDefaultSettings?: boolean }, reader: Db | DbTransaction = db, storedForRun = false) {
|
||||
const selection = taskWorkspaceSelectionSchema.parse(input.selection);
|
||||
if (selection.kind === "existing") {
|
||||
const [workspace] = await reader.select().from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.id, selection.workspaceId), eq(executionWorkspaces.companyId, input.companyId),
|
||||
taskWorkspaceSelectableCondition(),
|
||||
await executionWorkspaceReadSqlCondition(reader, input.actor),
|
||||
storedForRun ? undefined : await executionWorkspaceReadSqlCondition(reader, input.actor),
|
||||
));
|
||||
if (!workspace) throw notFound("Workspace is unavailable or inaccessible");
|
||||
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId });
|
||||
if (workspace.cwd) await assertExecutionWorkspaceRootWriteAccess(reader, {
|
||||
companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor, taskCreation: input.taskCreation,
|
||||
issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId,
|
||||
if (!storedForRun) {
|
||||
await assertExecutionWorkspaceRecordAccess(reader, input.actor, input.companyId, workspace.id);
|
||||
}
|
||||
await assertSelectedWorkspaceTrust(reader, {
|
||||
companyId: input.companyId, sourceProjectId: workspace.projectId, sourceWorkspaceId: workspace.id,
|
||||
actor: input.actor, issueId: input.issueId, assigneeAgentId: input.assigneeAgentId, taskCreation: input.taskCreation,
|
||||
});
|
||||
return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } };
|
||||
@@ -1458,13 +1456,9 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
|| !isDeepStrictEqual(task.intent?.selection, selection)) throw conflict("Task workspace default changed");
|
||||
executionWorkspaceSettings = { ...parseObject(task.settings), mode };
|
||||
}
|
||||
const strategy = await resolveSourceWorkspaceStrategy({ ...input, typedSelection: true,
|
||||
await resolveSourceWorkspaceStrategy({ ...input, typedSelection: true,
|
||||
executionWorkspaceSettings, executionWorkspacePolicy: source.executionWorkspacePolicy }, reader);
|
||||
await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, {
|
||||
write: strategy !== "git_worktree",
|
||||
issueId: input.issueId, parentIssueId: input.parentIssueId,
|
||||
assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId,
|
||||
});
|
||||
await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId);
|
||||
return { executionWorkspaceId: null, projectWorkspaceId: source.id, executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings };
|
||||
}
|
||||
@@ -1472,13 +1466,22 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
executionWorkspaceSettings: { mode: "shared_workspace" } };
|
||||
}
|
||||
|
||||
async function validateStoredSelectionForRun(input: Parameters<typeof validateSelection>[0], reader: Db | DbTransaction = db) {
|
||||
if (!input.issueId) throw forbidden("Persisted workspace selection requires a current task");
|
||||
await assertTaskWorkspaceExecutionAccess(reader, input.actor, input.companyId, {
|
||||
issueId: input.issueId,
|
||||
persistedSelection: input.selection,
|
||||
});
|
||||
return validateSelection(input, reader, true);
|
||||
}
|
||||
|
||||
async function inspectTaskWorkspace(companyId: string, issueId: string, actor: AuthorizationActor) {
|
||||
const [task] = await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, companyId), await issueReadSqlCondition(db, actor)));
|
||||
if (!task) throw notFound("Task not found");
|
||||
const [workspace] = task.executionWorkspaceId ? await db.select().from(executionWorkspaces)
|
||||
.where(and(eq(executionWorkspaces.id, task.executionWorkspaceId), eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(db, actor))) : [];
|
||||
if (task.executionWorkspaceId && !workspace) throw notFound("Workspace is unavailable or inaccessible");
|
||||
if (workspace) await assertTaskWorkspaceAccess(db, actor, companyId, workspace.id);
|
||||
if (workspace) await assertExecutionWorkspaceRecordAccess(db, actor, companyId, workspace.id);
|
||||
return { issueId, bindingRevision: task.workspaceBindingRevision, selection: task.workspaceSelection,
|
||||
pendingSelection: task.workspacePendingSelection, workspace: workspace ? toExecutionWorkspace(workspace) : null };
|
||||
}
|
||||
@@ -1535,7 +1538,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
if (holder) throw conflict("Previous task run must finish persistence before changing its workspace");
|
||||
const unsettled = await hasPendingTaskWorkspaceRecovery(tx, { ...input, excludeRunId: input.runId });
|
||||
if (unsettled) throw conflict("Previous task run still owns workspace recovery; finish recovery before changing its workspace");
|
||||
const patch = await validateSelection({ ...input, selection: pending.intent.selection }, tx);
|
||||
const patch = await validateStoredSelectionForRun({ ...input, selection: pending.intent.selection }, tx);
|
||||
let assigneeAdapterOverrides = task.assigneeAdapterOverrides;
|
||||
if (pending.intent.source === "recovery") {
|
||||
patch.executionWorkspaceSettings = { ...parseObject(task.executionWorkspaceSettings), ...patch.executionWorkspaceSettings };
|
||||
@@ -1585,21 +1588,35 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
task: typeof issues.$inferSelect; actor: AuthorizationActor;
|
||||
}, tx: DbTransaction) {
|
||||
const { task, actor } = input;
|
||||
if (task.executionWorkspaceId) {
|
||||
await assertTaskWorkspaceAccess(tx, actor, task.companyId, task.executionWorkspaceId, { write: true, issueId: task.id });
|
||||
return;
|
||||
}
|
||||
if (!task.projectWorkspaceId) return;
|
||||
const [source] = await tx.select({ projectId: projectWorkspaces.projectId, executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projectWorkspaces).innerJoin(projects, eq(projects.id, projectWorkspaces.projectId))
|
||||
.where(and(eq(projectWorkspaces.id, task.projectWorkspaceId), eq(projectWorkspaces.companyId, task.companyId),
|
||||
await projectReadSqlCondition(tx, actor)));
|
||||
if (!source) throw notFound("Workspace source is unavailable or inaccessible");
|
||||
const strategy = await resolveSourceWorkspaceStrategy({ companyId: task.companyId, issueId: task.id,
|
||||
executionWorkspaceSettings: task.executionWorkspaceSettings, executionWorkspacePolicy: source.executionWorkspacePolicy }, tx);
|
||||
await assertTaskWorkspaceSourceProjectAccess(tx, actor, task.companyId, source.projectId, {
|
||||
issueId: task.id, write: strategy !== "git_worktree",
|
||||
});
|
||||
// A control-plane mutation may target an authorized child task while the
|
||||
// caller's JWT remains bound to its parent run. Admission validates run
|
||||
// identity separately when that child actually executes.
|
||||
const decision = await authorizationService(tx).decide({ actor, action: "issue:mutate", resource: {
|
||||
type: "issue", companyId: task.companyId, issueId: task.id, projectId: task.projectId,
|
||||
status: task.status, assigneeAgentId: task.assigneeAgentId, assigneeUserId: task.assigneeUserId,
|
||||
} });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
const [workspace] = task.executionWorkspaceId ? await tx.select({
|
||||
id: executionWorkspaces.id, projectId: executionWorkspaces.projectId,
|
||||
projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
|
||||
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.id, task.executionWorkspaceId),
|
||||
eq(executionWorkspaces.companyId, task.companyId))) : [];
|
||||
if (task.executionWorkspaceId && !workspace) throw notFound("Task workspace is unavailable");
|
||||
await assertTaskWorkspaceSourceProjectAccess(tx, actor, task.companyId, workspace?.projectId ?? null);
|
||||
const sourceId = workspace?.projectWorkspaceId ?? task.projectWorkspaceId;
|
||||
const [source] = sourceId ? await tx.select({ projectId: projectWorkspaces.projectId })
|
||||
.from(projectWorkspaces).where(and(eq(projectWorkspaces.id, sourceId),
|
||||
eq(projectWorkspaces.companyId, task.companyId))) : [];
|
||||
if (sourceId && !source) throw notFound("Workspace source is unavailable or inaccessible");
|
||||
const sourceProjectId = source?.projectId ?? workspace?.projectId;
|
||||
if (!sourceProjectId) return;
|
||||
await assertTaskWorkspaceSourceProjectAccess(tx, actor, task.companyId, sourceProjectId);
|
||||
const [sourceProject] = await tx.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projects).where(and(eq(projects.id, sourceProjectId), eq(projects.companyId, task.companyId)));
|
||||
if (!sourceProject) throw notFound("Workspace source is unavailable or inaccessible");
|
||||
await resolveSourceWorkspaceStrategy({ companyId: task.companyId, issueId: task.id,
|
||||
executionWorkspaceSettings: task.executionWorkspaceSettings,
|
||||
executionWorkspacePolicy: sourceProject.executionWorkspacePolicy }, tx);
|
||||
}
|
||||
|
||||
async function prepareTaskWorkspaceUpdate(input: {
|
||||
@@ -2456,10 +2473,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
requestTaskRepository: executionWorkspaceRepositoryService(db).request,
|
||||
listTaskRepositories: executionWorkspaceRepositoryService(db).list,
|
||||
prepareTaskRepositoriesForAdmission: executionWorkspaceRepositoryService(db).prepareForAdmission,
|
||||
guardTaskRepositoryRootForPublication: executionWorkspaceRepositoryService(db).guardForPublication,
|
||||
prepareConfiguredRepositorySource: executionWorkspaceRepositoryService(db).prepareConfiguredRepositorySource,
|
||||
reserveTaskRepositoryRootForRecovery: executionWorkspaceRepositoryService(db).reserveForRecovery,
|
||||
resolveSourceWorkspaceStrategy, validateSelection, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, prepareTaskWorkspaceUpdate, assertTaskWorkspaceUpdateAccess,
|
||||
assertTaskRepositoryPublicationAllowed: executionWorkspaceRepositoryService(db).assertPublicationAllowed,
|
||||
prepareConfiguredRepositoryCopy: executionWorkspaceRepositoryService(db).prepareConfiguredRepositoryCopy,
|
||||
assertTaskRepositoryRecoveryBinding: executionWorkspaceRepositoryService(db).assertRecoveryBinding,
|
||||
resolveSourceWorkspaceStrategy, validateSelection, validateStoredSelectionForRun, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, prepareTaskWorkspaceUpdate, assertTaskWorkspaceUpdateAccess,
|
||||
listOverview: async (
|
||||
companyId: string,
|
||||
filters: WorkspaceOverviewQuery,
|
||||
@@ -3659,12 +3676,11 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
.where(eq(executionWorkspaces.id, id)));
|
||||
if (!identity) return null;
|
||||
return measureRunTransaction(db, "workspace", async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, identity.companyId);
|
||||
const [current] = await measureRunPhase("workspace.row_lock", () => tx.select().from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.id, id), eq(executionWorkspaces.companyId, identity.companyId),
|
||||
)).for("no key update"));
|
||||
if (!current) return null;
|
||||
const nextPatch = await measureRunPhase("workspace.provenance", () => preserveWorkspaceOwnershipForPatch(tx, current, patch));
|
||||
const nextPatch = preserveWorkspaceMetadataForPatch(current, patch);
|
||||
await measureRunPhase("workspace.notification", () => notifyTerminalWorkspaceWork(tx, `workspace:${id}`));
|
||||
const [row] = await measureRunPhase("workspace.write", () => tx.update(executionWorkspaces).set({ ...nextPatch, updatedAt: new Date() })
|
||||
.where(and(eq(executionWorkspaces.id, id), eq(executionWorkspaces.companyId, current.companyId))).returning());
|
||||
@@ -3689,26 +3705,24 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
const authorizedSnapshot = boundSnapshot ? await db.select().from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, issue.companyId),
|
||||
eq(executionWorkspaces.mode, "isolated_workspace"),
|
||||
await executionWorkspaceReadSqlCondition(db, input.authorizationActor),
|
||||
)).then(rows => rows[0] ?? null) : null;
|
||||
if (!authorizedSnapshot) return { ok: false, code: "not_reopenable", message: "Execution workspace is not reopenable" };
|
||||
await assertTaskWorkspaceAccess(db, input.authorizationActor, issue.companyId, authorizedSnapshot.id);
|
||||
await assertTaskWorkspaceExecutionAccess(db, input.authorizationActor, issue.companyId, { issueId: issue.id, workspaceId: authorizedSnapshot.id });
|
||||
const sourceIssueId = authorizedSnapshot.sourceIssueId;
|
||||
const reopen = async (): Promise<ReopenClosedIsolatedExecutionWorkspaceResult> => {
|
||||
const freshSnapshot = await db.select().from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, issue.companyId),
|
||||
eq(executionWorkspaces.mode, "isolated_workspace"),
|
||||
await executionWorkspaceReadSqlCondition(db, input.authorizationActor),
|
||||
)).then(rows => rows[0] ?? null);
|
||||
if (!freshSnapshot || freshSnapshot.sourceIssueId !== sourceIssueId) {
|
||||
return { ok: false, code: "not_reopenable", message: "Execution workspace source changed during reopen" };
|
||||
}
|
||||
const snapshot = freshSnapshot;
|
||||
await assertTaskWorkspaceAccess(db, input.authorizationActor, issue.companyId, snapshot.id);
|
||||
await assertTaskWorkspaceExecutionAccess(db, input.authorizationActor, issue.companyId, { issueId: issue.id, workspaceId: snapshot.id });
|
||||
let recipe: ReturnType<typeof readGitCacheRestoration>;
|
||||
try { recipe = readGitCacheRestoration(snapshot); }
|
||||
catch { return { ok: false, code: "rebuild_failed", message: new GitCacheRestorationFailure().message }; }
|
||||
const gitAuth = createGitRemoteAuthProvider(db, snapshot.companyId, { issueId: snapshot.sourceIssueId ?? issue.id });
|
||||
const gitAuth = createGitRemoteAuthProvider(db, snapshot.companyId, { issueId: issue.id });
|
||||
const [
|
||||
{ ensurePersistedExecutionWorkspaceAvailable },
|
||||
{ workspaceOperationService },
|
||||
@@ -3750,17 +3764,21 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
companyId: snapshot.companyId,
|
||||
projectId: snapshot.projectId,
|
||||
repoUrl: snapshot.repoUrl,
|
||||
prepareLocalSource: async sourceCwd => {
|
||||
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, {
|
||||
companyId: snapshot.companyId, sourceCwd, actor: input.authorizationActor, copyKind: "repository",
|
||||
});
|
||||
return targetCwd => retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: snapshot.companyId, sourceCwd, targetCwd, actor: input.authorizationActor,
|
||||
captured, issueId: issue.id, copyKind: "repository",
|
||||
});
|
||||
prepareLocalSource: async (sourceCwd, targetCwd) => {
|
||||
const sourceIdentity = await fs.realpath(sourceCwd);
|
||||
const targetIdentity = await canonicalRepositoryRoot(targetCwd);
|
||||
return async () => {
|
||||
if (await fs.realpath(sourceCwd) !== sourceIdentity
|
||||
|| await canonicalRepositoryRoot(targetCwd) !== targetIdentity) {
|
||||
throw forbidden("Execution workspace copy identity changed during reopen");
|
||||
}
|
||||
await assertTaskWorkspaceExecutionAccess(db, input.authorizationActor, issue.companyId, {
|
||||
issueId: issue.id, workspaceId: snapshot.id,
|
||||
});
|
||||
};
|
||||
},
|
||||
resolveGitAuth: createGitRemoteAuthProvider(db, snapshot.companyId, {
|
||||
issueId: snapshot.sourceIssueId ?? issue.id,
|
||||
issueId: issue.id,
|
||||
}),
|
||||
});
|
||||
resolvedBaseCwd = managedWorkspace.cwd;
|
||||
@@ -3768,22 +3786,20 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" };
|
||||
}
|
||||
}
|
||||
let capturedSource: IsolatedWorkspaceSourceAccess | null = null;
|
||||
let authorizedTarget: string | null = null;
|
||||
let copyIdentity: { sourceCwd: string; configuredSourceCwd: string; targetCwd: string } | null = null;
|
||||
if (isClosedExecutionWorkspaceStatus(snapshot.status) && snapshot.strategyType === "git_worktree") {
|
||||
try {
|
||||
if (!resolvedBaseCwd || !(snapshot.providerRef ?? snapshot.cwd)) throw new Error("Missing workspace copy identity");
|
||||
authorizedTarget = await canonicalRepositoryRoot(snapshot.providerRef ?? snapshot.cwd!);
|
||||
if (snapshot.cwd && await canonicalRepositoryRoot(snapshot.cwd) !== authorizedTarget) {
|
||||
const targetCwd = await canonicalRepositoryRoot(snapshot.providerRef ?? snapshot.cwd!);
|
||||
if (snapshot.cwd && await canonicalRepositoryRoot(snapshot.cwd) !== targetCwd) {
|
||||
throw new Error("Workspace checkout and provider roots disagree");
|
||||
}
|
||||
capturedSource = await assertIsolatedWorkspaceSourceReadAccess(db, {
|
||||
companyId: snapshot.companyId, sourceCwd: resolvedBaseCwd, actor: input.authorizationActor,
|
||||
});
|
||||
await retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: snapshot.companyId, sourceCwd: resolvedBaseCwd, targetCwd: authorizedTarget,
|
||||
actor: input.authorizationActor, captured: capturedSource, issueId: issue.id, phase: "preparing",
|
||||
});
|
||||
const sourceRoot = await readGitStdout(["rev-parse", "--show-toplevel"], resolvedBaseCwd);
|
||||
if (!sourceRoot) throw new Error("Missing workspace Git source identity");
|
||||
copyIdentity = {
|
||||
sourceCwd: await fs.realpath(sourceRoot),
|
||||
configuredSourceCwd: await fs.realpath(resolvedBaseCwd), targetCwd,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" };
|
||||
}
|
||||
@@ -3802,7 +3818,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
.where(and(
|
||||
eq(executionWorkspaces.id, input.workspaceId),
|
||||
eq(executionWorkspaces.companyId, issue.companyId),
|
||||
await executionWorkspaceReadSqlCondition(tx, input.authorizationActor),
|
||||
eq(executionWorkspaces.mode, "isolated_workspace"),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
@@ -3810,11 +3825,8 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
// disclose no workspace detail.
|
||||
return { ok: false, code: "not_reopenable", message: "Execution workspace is not reopenable" };
|
||||
}
|
||||
await assertTaskWorkspaceAccess(tx, input.authorizationActor, issue.companyId, row.id);
|
||||
await assertTaskWorkspaceExecutionAccess(tx, input.authorizationActor, issue.companyId, { issueId: issue.id, workspaceId: row.id });
|
||||
if (!isClosedExecutionWorkspaceStatus(row.status)) {
|
||||
if (row.cwd) await assertExecutionWorkspaceRootReadAccess(tx, {
|
||||
companyId: row.companyId, cwd: row.cwd, actor: input.authorizationActor,
|
||||
});
|
||||
// A concurrent reopen already restored the row. Report success without a
|
||||
// second rebuild so the caller continues normally. The other request
|
||||
// owns the reopen-pending flag, so return its generation and let the
|
||||
@@ -3827,11 +3839,11 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
};
|
||||
}
|
||||
|
||||
if (capturedSource && !["projectId", "projectWorkspaceId", "cwd", "providerRef", "repoUrl", "baseRef", "branchName", "strategyType", "mode", "metadata"]
|
||||
if (copyIdentity && !["projectId", "projectWorkspaceId", "cwd", "providerRef", "repoUrl", "baseRef", "branchName", "strategyType", "mode", "metadata"]
|
||||
.every(key => isDeepStrictEqual(row[key as keyof typeof row], snapshot[key as keyof typeof snapshot]))) {
|
||||
return { ok: false, code: "not_reopenable", message: "Execution workspace source changed during reopen" };
|
||||
}
|
||||
if (capturedSource && snapshot.projectWorkspaceId) {
|
||||
if (copyIdentity && snapshot.projectWorkspaceId) {
|
||||
const [currentSource] = await tx.select({ cwd: projectWorkspaces.cwd }).from(projectWorkspaces)
|
||||
.where(and(eq(projectWorkspaces.id, snapshot.projectWorkspaceId), eq(projectWorkspaces.companyId, snapshot.companyId)));
|
||||
if (!isDeepStrictEqual(currentSource ?? null, projectWorkspace)) {
|
||||
@@ -3840,13 +3852,14 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
}
|
||||
try { await recoverGitCacheExpirationLocks(row); }
|
||||
catch { return { ok: false, code: "rebuild_failed", message: new GitCacheRestorationFailure().message }; }
|
||||
const verifyCopyIdentity = capturedSource && authorizedTarget ? async (sourceCwd: string, targetCwd: string) => {
|
||||
if (!capturedSource!.roots.includes(await fs.realpath(sourceCwd))
|
||||
|| await canonicalRepositoryRoot(targetCwd) !== authorizedTarget) {
|
||||
const verifyCopyIdentity = copyIdentity ? async (sourceCwd: string, targetCwd: string) => {
|
||||
if (await fs.realpath(sourceCwd) !== copyIdentity.sourceCwd
|
||||
|| await fs.realpath(resolvedBaseCwd!) !== copyIdentity.configuredSourceCwd
|
||||
|| await canonicalRepositoryRoot(targetCwd) !== copyIdentity.targetCwd) {
|
||||
throw forbidden("Execution workspace copy identity changed during reopen");
|
||||
}
|
||||
for (const cwd of [...capturedSource!.roots, authorizedTarget!]) await assertExecutionWorkspaceRootReadAccess(tx, {
|
||||
companyId: row.companyId, cwd, actor: input.authorizationActor,
|
||||
await assertTaskWorkspaceExecutionAccess(tx, input.authorizationActor, issue.companyId, {
|
||||
issueId: issue.id, workspaceId: row.id,
|
||||
});
|
||||
} : undefined;
|
||||
|
||||
@@ -3906,15 +3919,17 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
nextMetadata.gitCacheRestoration = { ...currentRecipe as Record<string, unknown>, contentsRestoredAt: at };
|
||||
},
|
||||
prepareRestoredLocalSource: async sourceCwd => {
|
||||
const captured = await assertIsolatedWorkspaceSourceReadAccess(tx as unknown as Db, {
|
||||
companyId: row.companyId, sourceCwd, actor: input.authorizationActor, copyKind: "repository",
|
||||
});
|
||||
const recheck = () => retainIsolatedWorkspaceSourceAccess(tx as unknown as Db, {
|
||||
companyId: row.companyId, sourceCwd, targetCwd: row.cwd!, actor: input.authorizationActor,
|
||||
captured, issueId: issue.id, copyKind: "repository",
|
||||
});
|
||||
await recheck();
|
||||
return recheck;
|
||||
const sourceIdentity = await fs.realpath(sourceCwd);
|
||||
const targetIdentity = await canonicalRepositoryRoot(row.cwd!);
|
||||
return async () => {
|
||||
if (await fs.realpath(sourceCwd) !== sourceIdentity
|
||||
|| await canonicalRepositoryRoot(row.cwd!) !== targetIdentity) {
|
||||
throw forbidden("Execution workspace copy identity changed during reopen");
|
||||
}
|
||||
await assertTaskWorkspaceExecutionAccess(tx, input.authorizationActor, issue.companyId, {
|
||||
issueId: issue.id, workspaceId: row.id,
|
||||
});
|
||||
};
|
||||
},
|
||||
skipHostProvision: trust.kind === "low_trust_review" || copyOnlyProvisioning,
|
||||
beforeSourceCopy: verifyCopyIdentity,
|
||||
@@ -4074,21 +4089,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
logger.warn({ error, executionWorkspaceId: snapshot.id }, "Execution workspace reopen failed");
|
||||
return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" };
|
||||
});
|
||||
if (capturedSource && authorizedTarget && resolvedBaseCwd) {
|
||||
try {
|
||||
await retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: snapshot.companyId, sourceCwd: resolvedBaseCwd, targetCwd: authorizedTarget,
|
||||
actor: input.authorizationActor, captured: capturedSource, issueId: issue.id, phase: "verified",
|
||||
});
|
||||
} catch {
|
||||
if (result.ok && result.reopened) {
|
||||
await executionWorkspaceService(db).clearReopenPendingConsumptionForUnconsumedReopen({
|
||||
workspaceId: snapshot.id, issue, actor, expectedGeneration: result.generation,
|
||||
});
|
||||
}
|
||||
return { ok: false, code: "rebuild_failed", message: recipe ? new GitCacheRestorationFailure().message : "Failed to rebuild the execution workspace" };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return authorizedSnapshot.sourceIssueId
|
||||
@@ -4194,7 +4194,6 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
.where(eq(executionWorkspaces.id, input.id));
|
||||
if (!identity) return null;
|
||||
return db.transaction(async (tx) => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, identity.companyId);
|
||||
await acquireExecutionWorkspaceLifecycleLock(tx, input.id);
|
||||
const fresh = await measureRunPhase("workspace.row_lock", () => tx
|
||||
.select()
|
||||
@@ -4214,7 +4213,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
// rebuilt worktree. The route maps this to HTTP 409.
|
||||
return { outcome: "reopen_pending" };
|
||||
}
|
||||
const nextPatch = await measureRunPhase("workspace.provenance", () => preserveWorkspaceOwnershipForPatch(tx, fresh, input.patch));
|
||||
const nextPatch = preserveWorkspaceMetadataForPatch(fresh, input.patch);
|
||||
const baseMetadata =
|
||||
(nextPatch.metadata as Record<string, unknown> | null | undefined)
|
||||
?? (fresh.metadata as Record<string, unknown> | null);
|
||||
|
||||
@@ -145,7 +145,7 @@ describe.skipIf(!support.supported)("heartbeat environment selection boundary",
|
||||
input.task.taskWorkspaceIntent = { version: 1, source: "explicit", selection: { kind: "task_directory" } };
|
||||
await db.update(issues).set({ originKind: "chat_channel" }).where(eq(issues.id, issue.id));
|
||||
const read = vi.spyOn(input.services.executionWorkspacesSvc, "getStoredById").mockRejectedValue(new Error("Task source must remain inaccessible"));
|
||||
const select = vi.spyOn(input.services.executionWorkspacesSvc, "validateSelection").mockRejectedValue(new Error("Task selection must remain inaccessible"));
|
||||
const select = vi.spyOn(input.services.executionWorkspacesSvc, "validateStoredSelectionForRun").mockRejectedValue(new Error("Task selection must remain inaccessible"));
|
||||
try {
|
||||
const result = await selectHeartbeatEnvironment(db, input);
|
||||
expect(result).toMatchObject({ isDotRun: true, selectedEnvironmentId: sandbox.id,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
resolveSharedWorkspaceConcurrency,
|
||||
type ParsedExecutionWorkspaceMode,
|
||||
} from "../execution-workspace-policy.js";
|
||||
import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "../task-workspace-source-access.js";
|
||||
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "../task-workspace-access.js";
|
||||
import { shouldUseIsolatedTaskDirectory } from "../isolated-task-directory.js";
|
||||
import {
|
||||
findNativeChatWorkspaceScope,
|
||||
@@ -64,7 +64,7 @@ export interface HeartbeatEnvironmentSelectionInput {
|
||||
issueAssigneeOverrides: { adapterConfig: Record<string, unknown> | null } | null;
|
||||
};
|
||||
services: {
|
||||
executionWorkspacesSvc: Pick<ReturnType<typeof executionWorkspaceService>, "getStoredById" | "validateSelection">;
|
||||
executionWorkspacesSvc: Pick<ReturnType<typeof executionWorkspaceService>, "getStoredById" | "validateStoredSelectionForRun">;
|
||||
environmentsSvc: Pick<ReturnType<typeof environmentService>,
|
||||
"ensureLocalEnvironment" | "findManagedSandboxEnvironment" | "findKubernetesEnvironment" | "ensureKubernetesEnvironment" | "getById">;
|
||||
instanceSettings: Pick<ReturnType<typeof instanceSettingsService>, "get" | "getExperimental">;
|
||||
@@ -132,10 +132,10 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
source: "agent_jwt" as const, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
|
||||
if (!isDotRun && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace;
|
||||
if (workspaceRequiringAccess) {
|
||||
await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true, issueId });
|
||||
if (workspaceRequiringAccess && issueId) {
|
||||
await assertTaskWorkspaceExecutionAccess(db, workspaceAuthorizationActor, agent.companyId, { issueId, workspaceId: workspaceRequiringAccess.id });
|
||||
} else if (!isDotRun && executionProjectId && (hasSelectedWorkspaceSource || workspaceProjectContext?.hasWorkspace)) {
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, sourceWorkspaceConfig) !== "git_worktree", issueId });
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId);
|
||||
}
|
||||
}
|
||||
const requestedReusableExecutionWorkspaceConfig =
|
||||
@@ -434,7 +434,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
}
|
||||
}
|
||||
if (!isDotRun && taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
|
||||
await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId,
|
||||
await executionWorkspacesSvc.validateStoredSelectionForRun({ companyId: agent.companyId,
|
||||
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
|
||||
selection: taskWorkspaceIntent.selection, issueId,
|
||||
preserveImplicitDefaultSettings: ["project", "task_default"].includes(taskWorkspaceIntent.source) });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, access, rename, realpath } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, access, rename } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -12,7 +12,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { environmentRunOrchestrator } from "../environment-run-orchestrator.js";
|
||||
import { environmentService } from "../environments.js";
|
||||
import { assertExecutionWorkspaceRootReadAccess } from "../execution-workspace-repositories.js";
|
||||
import { executionWorkspaceService } from "../execution-workspaces.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { workspaceOperationService } from "../workspace-operations.js";
|
||||
@@ -24,15 +23,8 @@ import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.t
|
||||
import * as managedRepositoryCheckout from "../managed-repository-checkout.js";
|
||||
import { isolatedRemoteSourcePath } from "../isolated-remote-workspace-source.js";
|
||||
import { cleanupExecutionWorkspaceArtifacts } from "../workspace-runtime.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { buildExecutionWorkspaceAdapterConfig } from "../execution-workspace-policy.js";
|
||||
import { prepareRestrictedWorkspaceRetry } from "../restricted-workspace-retry.js";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { issueRoutes } from "../../routes/issues.js";
|
||||
import { errorHandler } from "../../middleware/error-handler.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
|
||||
import { createStorageService } from "../../storage/service.js";
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
|
||||
@@ -116,6 +108,26 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
input.run = run;
|
||||
}
|
||||
|
||||
async function persistRecoveryInput(input: HeartbeatWorkspacePreparationInput, workspaceId: string, cwd: string) {
|
||||
const issue = await readIssue(input.task.issueId!);
|
||||
const admitted = buildNativeExecutionInput({ companyId: input.agent.companyId, agentId: input.agent.id,
|
||||
runId: input.run.id, provider: "codex",
|
||||
issue: { id: issue.id, identifier: issue.identifier, title: issue.title, description: null, workMode: "standard" },
|
||||
taskPrompt: "Continue admitted task", normalizedSessionId: null,
|
||||
workspace: { id: workspaceId, cwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
completionContract: { id: input.run.id, sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
|
||||
contract: { revision: "1", objective: "Continue admitted task", criteria: [{ id: "objective", requirement: "Finish" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
input.run.runtimeMode = "native";
|
||||
input.run.nativeIssueId = issue.id;
|
||||
input.run.runnerProfileJson = { nativeExecutionInput: admitted };
|
||||
input.reuse.persistedNativeExecutionInput = admitted;
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: issue.id,
|
||||
runnerProfileJson: input.run.runnerProfileJson }).where(eq(heartbeatRuns.id, input.run.id));
|
||||
return admitted;
|
||||
}
|
||||
|
||||
it.each(["muse", "openai_dot", "grok_bot"] as const)("uses private %s controller storage without rebinding task files or preparing repositories", async provider => {
|
||||
const { input, issue } = await fixture();
|
||||
input.agent.adapterType = "paperclip_runner";
|
||||
@@ -215,238 +227,54 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["create", "retry-restricted", "retry-fixed-branch", "retry-existing-branch", "retry-adapter-branch", "failed-save", "shared-retry-after-failed-save", "literal-branch", "legacy-explicit-source", "inherited-source", "legacy-untyped-source", "changed-source-intent", "context-source"] as const)("preserves restricted source isolation during %s", async scenario => {
|
||||
const { input, source, issue: originalIssue } = await fixture(true);
|
||||
await db.update(companies).set({ issueCounter: 1 }).where(eq(companies.id, originalIssue.companyId));
|
||||
const issue = await issueService(db).create(originalIssue.companyId, {
|
||||
title: "Prepare the selected source", projectId: originalIssue.projectId,
|
||||
assigneeAgentId: input.agent.id, executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||
...(scenario === "legacy-explicit-source" ? { projectWorkspaceId: originalIssue.projectWorkspaceId } : {}),
|
||||
...(scenario === "inherited-source" ? { parentId: originalIssue.id } : {}),
|
||||
workspaceSelectionActor: { type: "agent", source: "agent_jwt", companyId: originalIssue.companyId, agentId: input.agent.id },
|
||||
});
|
||||
input.task.issueRef = issue;
|
||||
input.task.issueId = issue.id;
|
||||
input.task.context.issueId = issue.id;
|
||||
input.task.workspaceSelection = issue.workspaceSelection ?? null;
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, input.run.id));
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: issue.companyId, agentId: input.agent.id,
|
||||
issueId: issue.id, scopeKind: "issue", status: "running", runtimeMode: "legacy" }).returning();
|
||||
input.run = run;
|
||||
if (scenario === "legacy-untyped-source") {
|
||||
await db.update(issues).set({ workspaceSelection: null }).where(eq(issues.id, issue.id));
|
||||
input.task.workspaceSelection = null;
|
||||
} else if (scenario === "changed-source-intent") {
|
||||
await db.update(issues).set({ workspaceSelection: { ...issue.workspaceSelection!, source: "explicit" } }).where(eq(issues.id, issue.id));
|
||||
} else if (scenario === "context-source") {
|
||||
await db.update(issues).set({ projectWorkspaceId: null, workspaceSelection: null }).where(eq(issues.id, issue.id));
|
||||
input.task.issueRef = { ...issue, projectWorkspaceId: null };
|
||||
input.task.workspaceSelection = null;
|
||||
input.task.context.projectWorkspaceId = issue.projectWorkspaceId;
|
||||
}
|
||||
const remoteUrl = "https://example.test/public/repository.git";
|
||||
await db.update(projectWorkspaces).set({ repoUrl: remoteUrl }).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!));
|
||||
const publishedSource = join(home, `published-${issue.id}`);
|
||||
await execFile("git", ["clone", "--no-hardlinks", source, publishedSource]);
|
||||
const [privateIssue] = await db.insert(issues).values({ companyId: issue.companyId, title: "Private work",
|
||||
visibility: "private", projectId: issue.projectId }).returning();
|
||||
it("prepares, reuses, and reopens a selected source containing an inaccessible private worktree", async () => {
|
||||
const { input, source, issue } = await fixture(true);
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
const [privateIssue] = await db.insert(issues).values({ companyId: issue.companyId,
|
||||
title: "Private historical task", visibility: "private", projectId: issue.projectId }).returning();
|
||||
const privateCwd = join(source, "private-work");
|
||||
await execFile("git", ["-C", source, "worktree", "add", "-b", "restricted-task", privateCwd, "main"]);
|
||||
await writeFile(join(privateCwd, "confidential.txt"), "Private source must not be copied");
|
||||
await execFile("git", ["-C", privateCwd, "add", "confidential.txt"]);
|
||||
await execFile("git", ["-C", privateCwd, "commit", "-m", "Restricted task work"]);
|
||||
const privateCommit = (await execFile("git", ["-C", privateCwd, "rev-parse", "HEAD"])).stdout.trim();
|
||||
await execFile("git", ["-C", source, "worktree", "add", "-b", "historical-private", privateCwd, "main"]);
|
||||
await writeFile(join(privateCwd, "confidential.txt"), "Shared OS storage is not task authorization");
|
||||
const [privateWorkspace] = await db.insert(executionWorkspaces).values({ companyId: issue.companyId,
|
||||
name: "Private workspace", projectId: issue.projectId, sourceIssueId: privateIssue.id, cwd: privateCwd, mode: "isolated_workspace",
|
||||
strategyType: "git_worktree", providerType: "git_worktree", providerRef: privateCwd, branchName: "restricted-task", status: "idle" }).returning();
|
||||
name: "Private workspace", sourceIssueId: privateIssue.id, cwd: privateCwd,
|
||||
mode: "isolated_workspace", strategyType: "git_worktree", providerType: "git_worktree",
|
||||
providerRef: privateCwd, branchName: "historical-private", status: "idle" }).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: privateWorkspace.id }).where(eq(issues.id, privateIssue.id));
|
||||
const clone = vi.spyOn(managedRepositoryCheckout, "materializeManagedProjectWorkspace").mockImplementation(async (cwd, options) => {
|
||||
expect(options.repoUrl).toBe(remoteUrl);
|
||||
expect(options.localSource).toBeUndefined();
|
||||
expect(options.resolveGitAuth).toBeTypeOf("function");
|
||||
await execFile("git", ["clone", "--no-hardlinks", publishedSource, cwd]);
|
||||
await execFile("git", ["-C", cwd, "remote", "set-url", "origin", remoteUrl]);
|
||||
return { cwd, warning: null };
|
||||
});
|
||||
const isolatedSource = isolatedRemoteSourcePath(issue.companyId, issue.id);
|
||||
if (scenario.startsWith("retry-")) {
|
||||
const fixedBranch = scenario === "retry-fixed-branch";
|
||||
const existingBranch = scenario === "retry-existing-branch";
|
||||
const projectPolicy = { enabled: true, defaultMode: "isolated_workspace" as const,
|
||||
workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", provisionCommand: "true",
|
||||
...(fixedBranch ? { branchTemplate: "fixed-project-branch" } : {}) } };
|
||||
await db.update(projects).set({ executionWorkspacePolicy: projectPolicy }).where(eq(projects.id, issue.projectId!));
|
||||
input.policy.projectExecutionWorkspacePolicy = projectPolicy;
|
||||
const [oldWorkspace] = await db.insert(executionWorkspaces).values({ companyId: issue.companyId,
|
||||
projectId: issue.projectId, projectWorkspaceId: issue.projectWorkspaceId, sourceIssueId: issue.id,
|
||||
name: "Original files", cwd: source, repoUrl: remoteUrl, mode: "shared_workspace",
|
||||
strategyType: "project_primary", providerType: "local", status: "idle" }).returning();
|
||||
await writeFile(join(source, "unfinished.txt"), "Keep my original files\n");
|
||||
await db.update(heartbeatRuns).set({ status: "failed", contextSnapshot: { issueId: issue.id, projectId: issue.projectId },
|
||||
resultJson: { configurationIncomplete: { reason: "workspace_source_restricted" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
await db.update(issues).set({ status: "blocked", executionWorkspaceId: oldWorkspace.id, executionWorkspacePreference: "reuse_existing",
|
||||
assigneeAdapterOverrides: scenario === "retry-adapter-branch" ? { adapterConfig: { model: "preserved-model", workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "fixed-adapter-branch" } } } : null,
|
||||
executionWorkspaceSettings: { mode: "shared_workspace", environmentId: input.environment.localEnvironment.id,
|
||||
...(existingBranch ? { workspaceStrategy: { type: "git_worktree", baseRef: "main", existingBranch: "restricted-task" } } : {}) },
|
||||
}).where(eq(issues.id, issue.id));
|
||||
const service = executionWorkspaceService(db);
|
||||
const actor = { type: "board" as const, source: "local_implicit" as const };
|
||||
const action = await issueRecoveryActionService(db).upsertSourceScoped({
|
||||
companyId: issue.companyId, sourceIssueId: issue.id, kind: "configuration_validation",
|
||||
ownerType: "board", returnOwnerAgentId: input.agent.id, cause: "configuration_incomplete",
|
||||
fingerprint: `restricted:${run.id}`, evidence: { latestRunId: run.id }, nextAction: "Retry the task.",
|
||||
});
|
||||
const retryPlan = await prepareRestrictedWorkspaceRetry(db, await readIssue(issue.id), action);
|
||||
expect(retryPlan?.selection).toEqual({ kind: "configured_source", projectWorkspaceId: issue.projectWorkspaceId!, mode: "managed_isolated" });
|
||||
const enqueue = vi.fn(async () => null);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", issueRoutes(db, createStorageService(createLocalDiskStorageProvider(join(home, "storage"))), {
|
||||
recoveryActionEnqueueWakeup: enqueue,
|
||||
}));
|
||||
app.use(errorHandler);
|
||||
const retry = () => request(app).post(`/api/issues/${issue.id}/recovery-actions/resolve`)
|
||||
.send({ actionId: action.id, outcome: "restored", sourceIssueStatus: "todo" });
|
||||
const failedResult = (await readRun(run.id)).resultJson!;
|
||||
await db.update(heartbeatRuns).set({ resultJson: { ...failedResult, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } })
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
expect((await retry()).status).toBe(409);
|
||||
expect((await readIssue(issue.id)).workspacePendingSelection).toBeNull();
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
await db.update(heartbeatRuns).set({ resultJson: failedResult }).where(eq(heartbeatRuns.id, run.id));
|
||||
await db.update(projects).set({ pausedAt: new Date() }).where(eq(projects.id, issue.projectId!));
|
||||
expect((await retry()).status).toBe(409);
|
||||
expect((await readIssue(issue.id)).workspacePendingSelection).toBeNull();
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
await db.update(projects).set({ pausedAt: null }).where(eq(projects.id, issue.projectId!));
|
||||
const responses = await Promise.all([retry(), retry()]);
|
||||
for (const response of responses) expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(enqueue).toHaveBeenCalledOnce();
|
||||
expect((await readIssue(issue.id)).workspacePendingSelection?.intent.source).toBe("recovery");
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBe(oldWorkspace.id);
|
||||
await service.applyPendingTaskWorkspaceSelection({ companyId: issue.companyId, issueId: issue.id, runId: run.id, actor });
|
||||
const selected = await readIssue(issue.id);
|
||||
input.task.issueRef = selected;
|
||||
input.task.workspaceSelection = selected.workspaceSelection;
|
||||
input.policy.issueExecutionWorkspaceSettings = selected.executionWorkspaceSettings;
|
||||
input.config.mergedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy,
|
||||
issueSettings: selected.executionWorkspaceSettings, mode: "isolated_workspace", legacyUseProjectWorkspace: null,
|
||||
adapterConfigOverrides: selected.assigneeAdapterOverrides?.adapterConfig as Record<string, unknown> | undefined });
|
||||
if (scenario === "retry-adapter-branch") expect(input.config.mergedConfig.model).toBe("preserved-model");
|
||||
}
|
||||
if (["literal-branch", "legacy-explicit-source", "inherited-source", "legacy-untyped-source", "changed-source-intent", "context-source"].includes(scenario)) {
|
||||
const selectedBranch = scenario === "literal-branch" ? "feature/literal-pin" : `test-${issue.identifier}`;
|
||||
await execFile("git", ["-C", source, "checkout", "-b", selectedBranch]);
|
||||
await writeFile(join(source, "selected-work.txt"), "Selected unpublished branch work\n");
|
||||
await execFile("git", ["-C", source, "add", "selected-work.txt"]);
|
||||
await execFile("git", ["-C", source, "commit", "-m", "Selected branch work"]);
|
||||
const branchTip = (await execFile("git", ["-C", source, "rev-parse", "HEAD"])).stdout.trim();
|
||||
await execFile("git", ["-C", source, "checkout", "main"]);
|
||||
if (scenario === "literal-branch") input.config.mergedConfig = { workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "feature/literal-pin" } };
|
||||
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toMatchObject({ code: "configuration_incomplete",
|
||||
resultJson: { configurationIncomplete: { reason: "workspace_source_restricted", issueId: issue.id } } });
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBeNull();
|
||||
expect((await execFile("git", ["-C", source, "rev-parse", `refs/heads/${selectedBranch}`])).stdout.trim()).toBe(branchTip);
|
||||
await expect(access(isolatedSource)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
return;
|
||||
}
|
||||
expect(issue.workspaceSelection).toMatchObject({ source: "project", selection: {
|
||||
kind: "configured_source", projectWorkspaceId: issue.projectWorkspaceId, mode: "managed_isolated",
|
||||
} });
|
||||
if (scenario === "failed-save" || scenario === "shared-retry-after-failed-save") {
|
||||
vi.spyOn(input.services.executionWorkspacesSvc, "create").mockRejectedValueOnce(new Error("Workspace persistence failed"));
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Workspace persistence failed");
|
||||
const worktrees = (await execFile("git", ["-C", isolatedSource, "worktree", "list", "--porcelain"])).stdout;
|
||||
expect(worktrees.match(/^worktree /gm)).toHaveLength(1);
|
||||
await expect(execFile("git", ["-C", isolatedSource, "rev-parse", "--verify", `refs/heads/test-${issue.identifier}`])).rejects.toBeDefined();
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBeNull();
|
||||
expect(await readFile(join(privateCwd, "confidential.txt"), "utf8")).toBe("Private source must not be copied");
|
||||
if (scenario === "shared-retry-after-failed-save") {
|
||||
await execFile("git", ["-C", source, "remote", "add", "origin", remoteUrl]);
|
||||
await db.update(issues).set({ visibility: "open" }).where(eq(issues.id, privateIssue.id));
|
||||
const saved = await prepareHeartbeatWorkspace(db, input);
|
||||
const workspace = saved.persistedExecutionWorkspace!;
|
||||
expect(saved.executionWorkspace.baseCwd).toBe(source);
|
||||
expect(workspace.metadata?.isolatedRemoteSource).toBeUndefined();
|
||||
await writeFile(join(workspace.cwd!, "unpublished-work.txt"), "Keep the shared-source task commits\n");
|
||||
await execFile("git", ["-C", workspace.cwd!, "add", "unpublished-work.txt"]);
|
||||
await execFile("git", ["-C", workspace.cwd!, "commit", "-m", "Unpublished task work"]);
|
||||
const branchTip = (await execFile("git", ["-C", workspace.cwd!, "rev-parse", "HEAD"])).stdout.trim();
|
||||
await expect(execFile("git", ["-C", isolatedSource, "cat-file", "-e", branchTip])).rejects.toBeDefined();
|
||||
await execFile("git", ["-C", source, "worktree", "remove", "--force", workspace.cwd!]);
|
||||
await reuse(input, workspace);
|
||||
const rebuilt = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(rebuilt.executionWorkspace.baseCwd).toBe(await realpath(source));
|
||||
expect((await execFile("git", ["-C", rebuilt.executionWorkspace.cwd, "rev-parse", "HEAD"])).stdout.trim()).toBe(branchTip);
|
||||
expect(await readFile(join(rebuilt.executionWorkspace.cwd, "unpublished-work.txt"), "utf8")).toBe("Keep the shared-source task commits\n");
|
||||
expect(clone).toHaveBeenCalledOnce();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const clone = vi.spyOn(managedRepositoryCheckout, "materializeManagedProjectWorkspace");
|
||||
const result = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(result.executionWorkspace.baseCwd).toBe(isolatedSource);
|
||||
expect(result.executionWorkspace.baseCwd).toBe(source);
|
||||
expect(result.persistedExecutionWorkspace!.metadata?.isolatedRemoteSource).toBeUndefined();
|
||||
expect(await readFile(join(result.executionWorkspace.cwd, "README.md"), "utf8")).toBe("source content\n");
|
||||
await expect(access(join(result.executionWorkspace.cwd, "private-work"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(clone).toHaveBeenCalledOnce();
|
||||
if (scenario.startsWith("retry-")) {
|
||||
expect((await readIssue(issue.id)).executionWorkspaceSettings).toMatchObject({ mode: "isolated_workspace", environmentId: input.environment.localEnvironment.id });
|
||||
expect(await readFile(join(source, "unfinished.txt"), "utf8")).toBe("Keep my original files\n");
|
||||
await expect(access(join(result.executionWorkspace.cwd, "unfinished.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
await expect(execFile("git", ["-C", isolatedSource, "cat-file", "-e", privateCommit])).rejects.toBeDefined();
|
||||
await expect(execFile("git", ["-C", isolatedSource, "rev-parse", "--verify", "refs/heads/restricted-task"])).rejects.toBeDefined();
|
||||
expect(await realpath(join(isolatedSource, ".git"))).not.toBe(await realpath(join(source, ".git")));
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
await expect(access(isolatedRemoteSourcePath(issue.companyId, issue.id))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await writeFile(join(result.executionWorkspace.cwd, "unpublished-work.txt"), "Keep current task edits\n");
|
||||
await reuse(input, result.persistedExecutionWorkspace!);
|
||||
const resumed = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(resumed.persistedExecutionWorkspace!.id).toBe(result.persistedExecutionWorkspace!.id);
|
||||
expect(resumed.executionWorkspace.baseCwd).toBe(await realpath(isolatedSource));
|
||||
expect(clone).toHaveBeenCalledOnce();
|
||||
expect((await readIssue(privateIssue.id)).visibility).toBe("private");
|
||||
expect(await readFile(join(resumed.executionWorkspace.cwd, "unpublished-work.txt"), "utf8")).toBe("Keep current task edits\n");
|
||||
const workspace = resumed.persistedExecutionWorkspace!;
|
||||
const cleanup = await cleanupExecutionWorkspaceArtifacts({ workspace,
|
||||
projectWorkspace: { cwd: source, cleanupCommand: null }, runCleanupCommands: false });
|
||||
expect(cleanup.warnings).toEqual([]);
|
||||
await expect(access(workspace.cwd!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await db.update(executionWorkspaces).set({ status: "archived" }).where(eq(executionWorkspaces.id, workspace.id));
|
||||
const reopened = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId: workspace.id,
|
||||
issue: { id: issue.id, companyId: issue.companyId, projectId: issue.projectId },
|
||||
actor: { agentId: input.agent.id, actorType: "agent" },
|
||||
authorizationActor: { type: "agent", source: "agent_jwt", agentId: input.agent.id, companyId: issue.companyId },
|
||||
authorizationActor: { type: "agent", source: "agent_jwt", agentId: input.agent.id, companyId: issue.companyId, runId: input.run.id },
|
||||
});
|
||||
expect(reopened).toMatchObject({ ok: true });
|
||||
expect(await readFile(join(workspace.cwd!, "README.md"), "utf8")).toBe("source content\n");
|
||||
expect(await readFile(join(privateCwd, "confidential.txt"), "utf8")).toBe("Private source must not be copied");
|
||||
expect(await readFile(join(privateCwd, "confidential.txt"), "utf8")).toContain("OS storage");
|
||||
});
|
||||
|
||||
it("keeps a reused shared-checkout workspace denied with an owner action after a restricted worktree appears", async () => {
|
||||
it("cleans up a new local worktree after workspace persistence fails", async () => {
|
||||
const { input, source, issue } = await fixture(true);
|
||||
const first = await prepareHeartbeatWorkspace(db, input);
|
||||
const workspace = first.persistedExecutionWorkspace!;
|
||||
expect(first.executionWorkspace.baseCwd).toBe(source);
|
||||
await writeFile(join(workspace.cwd!, "unpublished-work.txt"), "Keep this task's local work\n");
|
||||
const [privateIssue] = await db.insert(issues).values({ companyId: issue.companyId, title: "Private work",
|
||||
visibility: "private", projectId: issue.projectId }).returning();
|
||||
const privateCwd = join(source, "private-work");
|
||||
await execFile("git", ["-C", source, "worktree", "add", "-b", "restricted-task", privateCwd, "main"]);
|
||||
const [privateWorkspace] = await db.insert(executionWorkspaces).values({ companyId: issue.companyId,
|
||||
name: "Private workspace", projectId: issue.projectId, sourceIssueId: privateIssue.id, cwd: privateCwd, mode: "isolated_workspace",
|
||||
strategyType: "git_worktree", providerType: "git_worktree", providerRef: privateCwd, branchName: "restricted-task", status: "idle" }).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: privateWorkspace.id }).where(eq(issues.id, privateIssue.id));
|
||||
await reuse(input, workspace);
|
||||
const clone = vi.spyOn(managedRepositoryCheckout, "materializeManagedProjectWorkspace");
|
||||
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toMatchObject({ code: "configuration_incomplete",
|
||||
resultJson: { configurationIncomplete: { reason: "workspace_source_restricted", issueId: issue.id } } });
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBe(workspace.id);
|
||||
expect(await readFile(join(workspace.cwd!, "unpublished-work.txt"), "utf8")).toBe("Keep this task's local work\n");
|
||||
await expect(access(isolatedRemoteSourcePath(issue.companyId, issue.id))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
vi.spyOn(input.services.executionWorkspacesSvc, "create").mockRejectedValueOnce(new Error("Workspace persistence failed"));
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Workspace persistence failed");
|
||||
const worktrees = (await execFile("git", ["-C", source, "worktree", "list", "--porcelain"])).stdout;
|
||||
expect(worktrees.match(/^worktree /gm)).toHaveLength(1);
|
||||
await expect(execFile("git", ["-C", source, "rev-parse", "--verify", `refs/heads/test-${issue.identifier}`])).rejects.toBeDefined();
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["sandbox", "ssh", "local", null] as const)("uses the selected %s placement for controller provisioning", async driver => {
|
||||
@@ -467,38 +295,38 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
expect(input.config.mergedConfig).toEqual(originalConfig);
|
||||
});
|
||||
|
||||
it("checks physical root admission before publishing configured sibling repositories", async () => {
|
||||
it("checks current repository admission before publishing configured sibling repositories", async () => {
|
||||
const { input, source, issue } = await fixture();
|
||||
await db.insert(projectWorkspaces).values({ companyId: issue.companyId, projectId: issue.projectId!,
|
||||
name: "Sibling repository", repoUrl: pathToFileURL(source).href });
|
||||
vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission")
|
||||
.mockRejectedValue(new Error("Physical root reader cannot access this source"));
|
||||
.mockRejectedValue(new Error("Current run identity is unavailable"));
|
||||
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Physical root reader cannot access this source");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Current run identity is unavailable");
|
||||
await expect(access(join(source, ".paperclip-repositories"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks physical root access after configured clone and before publication", async () => {
|
||||
it("rechecks current binding after configured clone and before publication", async () => {
|
||||
const { input, source, issue } = await fixture();
|
||||
await db.insert(projectWorkspaces).values({ companyId: issue.companyId, projectId: issue.projectId!,
|
||||
name: "Sibling repository", repoUrl: pathToFileURL(source).href });
|
||||
const namespace = join(source, ".paperclip-repositories");
|
||||
const guard = vi.spyOn(input.services.executionWorkspacesSvc, "guardTaskRepositoryRootForPublication")
|
||||
const guard = vi.spyOn(input.services.executionWorkspacesSvc, "assertTaskRepositoryPublicationAllowed")
|
||||
.mockImplementation(async () => {
|
||||
const candidates = await readdir(namespace);
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0]).toContain(".clone-");
|
||||
expect(await readFile(join(namespace, candidates[0]!, "README.md"), "utf8")).toBe("source content\n");
|
||||
throw new Error("Physical root access revoked during clone");
|
||||
throw new Error("Task binding changed during clone");
|
||||
});
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Physical root access revoked during clone");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Task binding changed during clone");
|
||||
expect(guard).toHaveBeenCalledOnce();
|
||||
expect(await readdir(namespace)).toEqual([]);
|
||||
expect(await leases(input.run.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains sibling privacy before copying when its source disappears before publication", async () => {
|
||||
it("rejects a configured sibling copy when its source disappears before publication", async () => {
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
try {
|
||||
@@ -515,8 +343,8 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
const [configured] = await db.insert(projectWorkspaces).values({ companyId: issue.companyId, projectId: issue.projectId!,
|
||||
name: "Private sibling", cwd: sibling, repoUrl: pathToFileURL(sibling).href }).returning();
|
||||
const namespace = join(source, ".paperclip-repositories");
|
||||
const originalPrepare = input.services.executionWorkspacesSvc.prepareConfiguredRepositorySource;
|
||||
const prepare = vi.spyOn(input.services.executionWorkspacesSvc, "prepareConfiguredRepositorySource").mockImplementation(async args => {
|
||||
const originalPrepare = input.services.executionWorkspacesSvc.prepareConfiguredRepositoryCopy;
|
||||
const prepare = vi.spyOn(input.services.executionWorkspacesSvc, "prepareConfiguredRepositoryCopy").mockImplementation(async args => {
|
||||
const recheck = await originalPrepare(args);
|
||||
return async () => {
|
||||
const candidates = await readdir(namespace);
|
||||
@@ -524,7 +352,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
expect(candidates[0]).toContain(".clone-");
|
||||
expect(await readFile(join(namespace, candidates[0]!, "private.txt"), "utf8")).toBe("PRIVATE_SIBLING_STAGING_BYTES");
|
||||
const retained = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, issue.companyId));
|
||||
expect(retained).toContainEqual(expect.objectContaining({ kind: "isolated_source", scopeId: privateWorkspace.id }));
|
||||
expect(retained).toEqual([]);
|
||||
await rename(sibling, sibling + "-moved");
|
||||
await db.update(projectWorkspaces).set({ cwd: sibling + "-moved" }).where(eq(projectWorkspaces.id, configured.id));
|
||||
await recheck();
|
||||
@@ -535,8 +363,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
expect(await readdir(namespace)).toEqual([]);
|
||||
expect(await leases(input.run.id)).toEqual([]);
|
||||
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssue.id));
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: issue.companyId, cwd: source,
|
||||
actor: { type: "agent", source: "agent_jwt", companyId: issue.companyId, agentId: input.agent.id } })).rejects.toThrow();
|
||||
|
||||
} finally {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", previousPrivacyMode);
|
||||
}
|
||||
@@ -595,9 +422,10 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
await db.update(issues).set({ executionWorkspaceId: null, projectWorkspaceId: null }).where(eq(issues.id, issue.id));
|
||||
input.task.issueRef = { ...input.task.issueRef!, executionWorkspaceId: null, projectWorkspaceId: null };
|
||||
input.reuse.nativeRecoveryExecutionWorkspaceId = first.persistedExecutionWorkspace!.id;
|
||||
await persistRecoveryInput(input, first.persistedExecutionWorkspace!.id, first.executionWorkspace.cwd);
|
||||
const anchor = vi.spyOn(input.services, "resolveReusedGitWorkspaceAnchor");
|
||||
const prepareRepositories = vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission");
|
||||
const reserveRoot = vi.spyOn(input.services.executionWorkspacesSvc, "reserveTaskRepositoryRootForRecovery");
|
||||
const reserveRoot = vi.spyOn(input.services.executionWorkspacesSvc, "assertTaskRepositoryRecoveryBinding");
|
||||
const next = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(reserveRoot).toHaveBeenCalledWith({ companyId: issue.companyId, issueId: issue.id,
|
||||
workspaceId: first.persistedExecutionWorkspace!.id, cwd: first.executionWorkspace.cwd,
|
||||
@@ -614,7 +442,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
{ transport: "daytona", descriptor: "valid" },
|
||||
{ transport: "daytona", descriptor: "absent" },
|
||||
{ transport: "daytona", descriptor: "corrupt" },
|
||||
] as const)("reserves the admitted host root for $transport recovery with $descriptor descriptor", async ({ transport, descriptor }) => {
|
||||
] as const)("validates the admitted host binding for $transport recovery with $descriptor descriptor", async ({ transport, descriptor }) => {
|
||||
const { input, issue } = await fixture(true);
|
||||
const first = await prepareHeartbeatWorkspace(db, input);
|
||||
await reuse(input, first.persistedExecutionWorkspace!);
|
||||
@@ -624,19 +452,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
const admittedRoot = join(home, `admitted-${input.run.id}`);
|
||||
await mkdir(admittedRoot);
|
||||
const remoteCwd = "/remote/admitted-task";
|
||||
input.reuse.persistedNativeExecutionInput = {
|
||||
schema: "paperclip.native-execution-input.v1",
|
||||
binding: { companyId: issue.companyId, runId: input.run.id, issueId: issue.id,
|
||||
agentId: input.agent.id, executionWorkspaceId: first.persistedExecutionWorkspace!.id },
|
||||
task: { identifier: "WSP-1", title: issue.title, description: null, prompt: "Continue admitted work", workMode: "standard" },
|
||||
workspace: { cwd: transport === "local" ? admittedRoot : remoteCwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: null, driverKind: "codex_app_server", protocolVersion: 1,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
|
||||
provider: { kind: "codex", model: null },
|
||||
completionContract: { id: "contract", sha256: "abc123", schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: { revision: "1", objective: "Continue admitted work", criteria: [{ id: "objective", requirement: "Finish" }] } },
|
||||
interactionResponses: [], credentialBindings: [],
|
||||
};
|
||||
await persistRecoveryInput(input, first.persistedExecutionWorkspace!.id, transport === "local" ? admittedRoot : remoteCwd);
|
||||
const target: AdapterExecutionTarget = transport === "local" ? { kind: "local" }
|
||||
: transport === "ssh" ? { kind: "remote", transport: "ssh", remoteCwd,
|
||||
spec: { host: "fixture.invalid", port: 22, username: "fixture", remoteWorkspacePath: remoteCwd,
|
||||
@@ -655,12 +471,13 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
baseline: serializeDirectorySnapshot(baseline), baselineSha256: directorySnapshotSha256(baseline),
|
||||
gitSnapshot: null, seed: null, createdAt: new Date().toISOString(), finalizedAt: null, finalHostSha256: null, resourceDisposition: null,
|
||||
});
|
||||
input.run.runnerProfileJson = { nativeWorkspaceSync: reference };
|
||||
input.run.runnerProfileJson = { ...input.run.runnerProfileJson, nativeWorkspaceSync: reference };
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: input.run.runnerProfileJson }).where(eq(heartbeatRuns.id, input.run.id));
|
||||
if (descriptor === "corrupt") {
|
||||
await writeFile(nativeWorkspaceSyncInternals.descriptorPath(input.run.id, reference.descriptorSha256), "{}");
|
||||
}
|
||||
}
|
||||
const reserve = vi.spyOn(input.services.executionWorkspacesSvc, "reserveTaskRepositoryRootForRecovery");
|
||||
const reserve = vi.spyOn(input.services.executionWorkspacesSvc, "assertTaskRepositoryRecoveryBinding");
|
||||
const prepare = vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission");
|
||||
if (transport !== "daytona" || descriptor === "valid") {
|
||||
const result = await prepareHeartbeatWorkspace(db, input);
|
||||
|
||||
@@ -32,13 +32,7 @@ import { readNativeWorkspaceSyncReference, readNativeWorkspaceSyncLocalCwd } fro
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
import { materializeNativeChatTaskRoot } from "../native-runtime/native-chat-workspace.js";
|
||||
import { materializeIsolatedTaskDirectory } from "../isolated-task-directory.js";
|
||||
import { canIsolateRemoteWorkspaceSource, materializeIsolatedRemoteWorkspaceSource, withIsolatedRepositorySourceLock } from "../isolated-remote-workspace-source.js";
|
||||
import { isRestrictedWorkspaceSourceDenial, RESTRICTED_WORKSPACE_SOURCE_REASON } from "../workspace-source-restriction.js";
|
||||
import { ConfigurationIncompleteFailure } from "./run-preparation.js";
|
||||
import {
|
||||
assertIsolatedWorkspaceSourceReadAccess,
|
||||
retainIsolatedWorkspaceSourceAccess,
|
||||
} from "../execution-workspace-repositories.js";
|
||||
import { withIsolatedRepositorySourceLock } from "../isolated-remote-workspace-source.js";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
@@ -138,7 +132,7 @@ export interface HeartbeatWorkspacePreparationInput {
|
||||
};
|
||||
services: {
|
||||
envOrchestrator: Pick<ReturnType<typeof environmentRunOrchestrator>, "resolveEnvironment" | "acquireForRun" | "realizeForRun">;
|
||||
executionWorkspacesSvc: Pick<ReturnType<typeof executionWorkspaceService>, "create" | "update" | "bindTaskWorkspace" | "prepareTaskRepositoriesForAdmission" | "guardTaskRepositoryRootForPublication" | "prepareConfiguredRepositorySource" | "reserveTaskRepositoryRootForRecovery">;
|
||||
executionWorkspacesSvc: Pick<ReturnType<typeof executionWorkspaceService>, "create" | "update" | "bindTaskWorkspace" | "prepareTaskRepositoriesForAdmission" | "assertTaskRepositoryPublicationAllowed" | "prepareConfiguredRepositoryCopy" | "assertTaskRepositoryRecoveryBinding">;
|
||||
workspaceOperationsSvc: Pick<ReturnType<typeof workspaceOperationService>, "createRecorder">;
|
||||
resolveWorkspaceForRun: WorkspaceResolver["resolveWorkspaceForRun"];
|
||||
resolveReusedGitWorkspaceAnchor: WorkspaceResolver["resolveReusedGitWorkspaceAnchor"];
|
||||
@@ -152,19 +146,6 @@ function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A restricted task worktree in the shared checkout keeps this task's source denied when
|
||||
* no fresh isolated clone is safe (an existing workspace may hold unpublished work).
|
||||
* Name the owner action instead of failing as an opaque setup error.
|
||||
*/
|
||||
function restrictedWorkspaceSourceFailure(error: unknown, issueId: string): unknown {
|
||||
if (!isRestrictedWorkspaceSourceDenial(error)) return error;
|
||||
return new ConfigurationIncompleteFailure(
|
||||
"This task's workspace source is a shared project checkout that also holds a task workspace this run cannot read, so Paperclip did not use it.",
|
||||
{ configurationIncomplete: { reason: RESTRICTED_WORKSPACE_SOURCE_REASON, issueId } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspacePreparationInput) {
|
||||
const sourceIssueId = input.reuse.reusableExistingExecutionWorkspace?.sourceIssueId
|
||||
?? input.reuse.existingExecutionWorkspace?.sourceIssueId ?? input.task.issueId;
|
||||
@@ -510,54 +491,6 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
agentId: agent.id,
|
||||
},
|
||||
);
|
||||
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 };
|
||||
let isolatedWorkspaceSourceAccess = null;
|
||||
let isolatedRemoteSource: Record<string, unknown> | null = null;
|
||||
if (!isDotRun && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId
|
||||
&& (latestWorkspaceStrategyType === "git_worktree"
|
||||
|| (workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"))) {
|
||||
try {
|
||||
isolatedWorkspaceSourceAccess = await timeWorkspace("workspace_source_authorization", () => assertIsolatedWorkspaceSourceReadAccess(db, {
|
||||
companyId: agent.companyId, sourceCwd: executionWorkspaceBase.baseCwd, actor: isolatedWorkspaceSourceActor,
|
||||
}));
|
||||
} catch (error) {
|
||||
const [sourceChoice] = await db.select({ projectWorkspaceId: issues.projectWorkspaceId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
selection: issues.workspaceSelection }).from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)));
|
||||
if (!sourceChoice || sourceChoice.executionWorkspaceId
|
||||
|| (sourceChoice.projectWorkspaceId && sourceChoice.projectWorkspaceId !== resolvedWorkspace.workspaceId)) {
|
||||
throw restrictedWorkspaceSourceFailure(error, issueId);
|
||||
}
|
||||
if (!canIsolateRemoteWorkspaceSource({ error, repoUrl: executionWorkspaceBase.repoUrl,
|
||||
reuseExisting: requestedShouldReuseExisting, hasExistingWorkspace: Boolean(existingExecutionWorkspace),
|
||||
selectedProjectWorkspaceId: sourceChoice.projectWorkspaceId ?? readNonEmptyString(context.projectWorkspaceId),
|
||||
selection: sourceChoice.selection, strategy: parseObject(hostExecutionWorkspaceConfig.workspaceStrategy),
|
||||
trustPresetKind: trustPreset.kind })) throw restrictedWorkspaceSourceFailure(error, issueId);
|
||||
const cwd = await timeWorkspace("workspace_source_materialize", () => materializeIsolatedRemoteWorkspaceSource({ companyId: agent.companyId,
|
||||
issueId, repoUrl: executionWorkspaceBase.repoUrl!, resolveGitAuth: workspaceGitAuthProvider }));
|
||||
executionWorkspaceBase.baseCwd = cwd;
|
||||
resolvedWorkspace.cwd = cwd;
|
||||
isolatedRemoteSource = { schema: "paperclip.isolated-remote-source.v1", companyId: agent.companyId,
|
||||
issueId, repoUrl: executionWorkspaceBase.repoUrl };
|
||||
isolatedWorkspaceSourceAccess = await timeWorkspace("workspace_source_authorization", () => assertIsolatedWorkspaceSourceReadAccess(db, {
|
||||
companyId: agent.companyId, sourceCwd: cwd, actor: isolatedWorkspaceSourceActor,
|
||||
}));
|
||||
}
|
||||
}
|
||||
const beforeIsolatedSourceCopy = isolatedWorkspaceSourceAccess
|
||||
? (sourceCwd: string, targetCwd: string) => retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: agent.companyId, sourceCwd, targetCwd,
|
||||
actor: isolatedWorkspaceSourceActor, captured: isolatedWorkspaceSourceAccess, issueId: issueId ?? undefined,
|
||||
phase: "preparing",
|
||||
}) : undefined;
|
||||
const afterIsolatedSourceCopy = isolatedWorkspaceSourceAccess
|
||||
? (sourceCwd: string, targetCwd: string) => retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: agent.companyId, sourceCwd, targetCwd,
|
||||
actor: isolatedWorkspaceSourceActor, captured: isolatedWorkspaceSourceAccess, issueId: issueId ?? undefined,
|
||||
phase: "verified",
|
||||
}) : undefined;
|
||||
const {
|
||||
executionWorkspace,
|
||||
reusedExecutionWorkspace,
|
||||
@@ -573,7 +506,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
restoreExistingWorkspace: reusableExistingExecutionWorkspace
|
||||
? () =>
|
||||
ensurePersistedExecutionWorkspaceAvailable({
|
||||
prepareRestoredLocalSource: issueId ? sourceCwd => executionWorkspacesSvc.prepareConfiguredRepositorySource({
|
||||
prepareRestoredLocalSource: issueId ? sourceCwd => executionWorkspacesSvc.prepareConfiguredRepositoryCopy({
|
||||
companyId: agent.companyId, issueId, workspaceId: reusableExistingExecutionWorkspace.id,
|
||||
cwd: reusableExistingExecutionWorkspace.cwd!, agentId: agent.id, runId: run.id, sourceCwd,
|
||||
}) : undefined,
|
||||
@@ -630,8 +563,6 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
.enableWorkspaceDirtyQuarantineRepair,
|
||||
recorder: workspaceOperationRecorder,
|
||||
resolveGitAuth: workspaceGitAuthProvider,
|
||||
beforeSourceCopy: beforeIsolatedSourceCopy,
|
||||
afterSourceCopy: afterIsolatedSourceCopy,
|
||||
})
|
||||
: null,
|
||||
realizeWorkspace: () =>
|
||||
@@ -665,22 +596,9 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
.enableWorkspaceDirtyQuarantineRepair,
|
||||
recorder: workspaceOperationRecorder,
|
||||
resolveGitAuth: workspaceGitAuthProvider,
|
||||
beforeSourceCopy: beforeIsolatedSourceCopy,
|
||||
afterSourceCopy: afterIsolatedSourceCopy,
|
||||
}),
|
||||
},
|
||||
));
|
||||
if (isolatedWorkspaceSourceAccess && executionWorkspace.strategy === "git_worktree") {
|
||||
await timeWorkspace("workspace_source_retention", () => retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: agent.companyId,
|
||||
sourceCwd: executionWorkspaceBase.baseCwd,
|
||||
targetCwd: executionWorkspace.cwd,
|
||||
actor: isolatedWorkspaceSourceActor,
|
||||
captured: isolatedWorkspaceSourceAccess,
|
||||
issueId: issueId ?? undefined,
|
||||
phase: "verified",
|
||||
}));
|
||||
}
|
||||
const resolvedProjectId =
|
||||
reusableExistingExecutionWorkspace
|
||||
? reusableExistingExecutionWorkspace.projectId
|
||||
@@ -811,7 +729,6 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
}
|
||||
const nextExecutionWorkspaceMetadata = {
|
||||
...baseExecutionWorkspaceMetadata,
|
||||
...(isolatedRemoteSource ? { isolatedRemoteSource } : {}),
|
||||
...(copyOnlyProvisioning ? { [CONTROLLER_PROVISION_COPY_ONLY]: true } : {}),
|
||||
...(persistedWorktreeInstanceRoot
|
||||
? {
|
||||
@@ -975,15 +892,10 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
|
||||
const projectRepositoryPaths: string[] = [];
|
||||
if (!isDotRun && persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
const taskRepositories = await timeWorkspace("workspace_repository_admission", async () => {
|
||||
try {
|
||||
return await executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId,
|
||||
issueId, workspaceId: persistedExecutionWorkspace!.id, cwd: executionWorkspace.cwd,
|
||||
agentId: agent.id, runId: run.id, responsibleUserId });
|
||||
} catch (error) {
|
||||
throw restrictedWorkspaceSourceFailure(error, issueId);
|
||||
}
|
||||
});
|
||||
const taskRepositories = await timeWorkspace("workspace_repository_admission", () =>
|
||||
executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId,
|
||||
issueId, workspaceId: persistedExecutionWorkspace!.id, cwd: executionWorkspace.cwd,
|
||||
agentId: agent.id, runId: run.id, responsibleUserId }));
|
||||
projectRepositoryPaths.push(...taskRepositories.map(repository => repository.relativePath));
|
||||
}
|
||||
if (executionWorkspace.projectId
|
||||
@@ -1000,12 +912,12 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
restoredContents: persistedExecutionWorkspace ? readGitCacheRestoration(persistedExecutionWorkspace)?.contents : undefined,
|
||||
resolveGitAuth: workspaceGitAuthProvider,
|
||||
prepareLocalSource: persistedExecutionWorkspace && issueId
|
||||
? (sourceCwd) => executionWorkspacesSvc.prepareConfiguredRepositorySource({
|
||||
? (sourceCwd, projectWorkspaceId) => executionWorkspacesSvc.prepareConfiguredRepositoryCopy({
|
||||
companyId: agent.companyId, issueId, workspaceId: persistedExecutionWorkspace!.id,
|
||||
cwd: executionWorkspace.cwd, agentId: agent.id, runId: run.id, sourceCwd,
|
||||
cwd: executionWorkspace.cwd, agentId: agent.id, runId: run.id, sourceCwd, projectWorkspaceId,
|
||||
}) : undefined,
|
||||
beforePublish: persistedExecutionWorkspace && issueId && !nativeRecoveryExecutionWorkspaceId
|
||||
? () => executionWorkspacesSvc.guardTaskRepositoryRootForPublication({
|
||||
? () => executionWorkspacesSvc.assertTaskRepositoryPublicationAllowed({
|
||||
companyId: agent.companyId, issueId, workspaceId: persistedExecutionWorkspace!.id,
|
||||
cwd: executionWorkspace.cwd, agentId: agent.id, runId: run.id,
|
||||
}) : undefined,
|
||||
@@ -1196,7 +1108,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
&& executionTarget?.kind !== "remote"
|
||||
? persistedNativeExecutionInput.workspace.cwd
|
||||
: executionWorkspace.cwd;
|
||||
await timeWorkspace("workspace_recovery_root", () => executionWorkspacesSvc.reserveTaskRepositoryRootForRecovery({ companyId: agent.companyId,
|
||||
await timeWorkspace("workspace_recovery_root", () => executionWorkspacesSvc.assertTaskRepositoryRecoveryBinding({ companyId: agent.companyId,
|
||||
issueId, workspaceId, cwd, agentId: agent.id, runId: run.id }));
|
||||
}
|
||||
// Preserve the host-owned source before adapter context can share lease
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess } from "../execution-workspace-repositories.js";
|
||||
import { canonicalRepositoryRoot } from "../execution-workspace-source-roots.js";
|
||||
import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js";
|
||||
import { selectRestoredProjectRepositories, type GitWorkspaceContents } from "../git-workspace-contents.js";
|
||||
import { GitCacheRestorationFailure } from "../git-workspace-restoration.js";
|
||||
import { deriveRepoNameFromRepoUrl, managedProjectCheckoutName, projectRepositoryIdentity } from "../managed-project-checkout-name.js";
|
||||
@@ -499,7 +500,7 @@ export async function prepareProjectRepositoryWorkspaces(input: {
|
||||
workspaces: Array<Pick<typeof projectWorkspaces.$inferSelect, "id" | "repoUrl" | "repoRef"> & { cwd?: string | null }>;
|
||||
resolveGitAuth?: GitRemoteAuthProvider | null;
|
||||
beforePublish?: () => Promise<void>;
|
||||
prepareLocalSource?: (sourceCwd: string) => Promise<() => Promise<void>>;
|
||||
prepareLocalSource?: (sourceCwd: string, projectWorkspaceId: string) => Promise<() => Promise<void>>;
|
||||
restoredContents?: GitWorkspaceContents;
|
||||
}): Promise<Array<{ workspaceId: string; cwd: string; repoUrl: string; repoRef: string | null }>> {
|
||||
const identity = projectRepositoryIdentity;
|
||||
@@ -532,7 +533,7 @@ export async function prepareProjectRepositoryWorkspaces(input: {
|
||||
const urlLocalSource = localRepositoryPath(repoUrl);
|
||||
const sourceToAuthorize = localSource ?? urlLocalSource;
|
||||
const recheckLocalSource = sourceToAuthorize
|
||||
? await input.prepareLocalSource?.(sourceToAuthorize) : undefined;
|
||||
? await input.prepareLocalSource?.(sourceToAuthorize, workspace.id) : undefined;
|
||||
const beforePublish = async () => {
|
||||
await recheckLocalSource?.();
|
||||
await input.beforePublish?.();
|
||||
@@ -3256,7 +3257,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
|
||||
issueId: string | null,
|
||||
runId: string | null,
|
||||
): PrepareManagedLocalSource {
|
||||
return async (sourceCwd) => {
|
||||
return async (sourceCwd, targetCwd) => {
|
||||
const [run] = runId
|
||||
? await db.select().from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, runId),
|
||||
@@ -3277,21 +3278,38 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
|
||||
runId: run.id,
|
||||
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId,
|
||||
};
|
||||
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, {
|
||||
companyId: agent.companyId,
|
||||
sourceCwd,
|
||||
actor,
|
||||
copyKind: "repository",
|
||||
});
|
||||
return targetCwd => retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: agent.companyId,
|
||||
sourceCwd,
|
||||
targetCwd,
|
||||
actor,
|
||||
captured,
|
||||
issueId: issueId ?? undefined,
|
||||
copyKind: "repository",
|
||||
});
|
||||
if (!issueId) throw forbidden("Local repository source requires a current task");
|
||||
const sourceIdentity = await fs.realpath(sourceCwd);
|
||||
const targetIdentity = await canonicalRepositoryRoot(targetCwd);
|
||||
const selected = await assertTaskWorkspaceExecutionAccess(db, actor, agent.companyId, { issueId });
|
||||
const [sourceConfiguration] = selected.task.projectWorkspaceId
|
||||
? await db.select({ id: projectWorkspaces.id, cwd: projectWorkspaces.cwd,
|
||||
repoUrl: projectWorkspaces.repoUrl, repoRef: projectWorkspaces.repoRef })
|
||||
.from(projectWorkspaces).where(and(eq(projectWorkspaces.id, selected.task.projectWorkspaceId),
|
||||
eq(projectWorkspaces.companyId, agent.companyId)))
|
||||
: [];
|
||||
const validate = async () => {
|
||||
if (await fs.realpath(sourceCwd) !== sourceIdentity
|
||||
|| await canonicalRepositoryRoot(targetCwd) !== targetIdentity) {
|
||||
throw forbidden("Local repository copy identity changed during preparation");
|
||||
}
|
||||
const current = await assertTaskWorkspaceExecutionAccess(db, actor, agent.companyId, { issueId });
|
||||
if (current.task.projectWorkspaceId !== selected.task.projectWorkspaceId
|
||||
|| current.task.projectId !== selected.task.projectId) {
|
||||
throw forbidden("Local repository source configuration changed during preparation");
|
||||
}
|
||||
if (sourceConfiguration) {
|
||||
const [source] = await db.select({ id: projectWorkspaces.id, cwd: projectWorkspaces.cwd,
|
||||
repoUrl: projectWorkspaces.repoUrl, repoRef: projectWorkspaces.repoRef }).from(projectWorkspaces)
|
||||
.where(and(eq(projectWorkspaces.id, sourceConfiguration.id), eq(projectWorkspaces.companyId, agent.companyId)));
|
||||
if (!source || source.cwd !== sourceConfiguration.cwd || source.repoUrl !== sourceConfiguration.repoUrl
|
||||
|| source.repoRef !== sourceConfiguration.repoRef) {
|
||||
throw forbidden("Local repository source configuration changed during preparation");
|
||||
}
|
||||
}
|
||||
};
|
||||
await validate();
|
||||
return validate;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { automaticReadRecoveryCleanupCondition, touchedByUserCondition } from ".
|
||||
import { HUMAN_TASK_ORIGIN_KINDS } from "@paperclipai/shared";
|
||||
import { resolveProjectDefaultWorkspaceId } from "./project-default-workspace.js";
|
||||
import { type AuthorizationActor, executionWorkspaceReadSqlCondition, projectReadSqlCondition } from "./authorization.js";
|
||||
import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js";
|
||||
import { assertExecutionWorkspaceRecordAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-access.js";
|
||||
import { type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { notifyChatPublicationWork } from "./chat-work-notifications.js";
|
||||
@@ -10208,20 +10208,17 @@ export function issueService(db: Db) {
|
||||
.innerJoin(projects, eq(projects.id, projectWorkspaces.projectId))
|
||||
.where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, companyId), await projectReadSqlCondition(tx, workspaceActor)));
|
||||
if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible");
|
||||
const sourceStrategy = await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId,
|
||||
await executionWorkspaceService(db).resolveSourceWorkspaceStrategy({ companyId,
|
||||
assigneeAgentId: issueData.assigneeAgentId, assigneeAdapterOverrides: issueData.assigneeAdapterOverrides,
|
||||
executionWorkspaceSettings, executionWorkspacePolicy: readableSource.executionWorkspacePolicy,
|
||||
typedSelection: Boolean(workspaceIntent), projectId: issueData.projectId, executionPolicy: issueData.executionPolicy }, tx);
|
||||
await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId, {
|
||||
parentIssueId: issueData.parentId, assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId,
|
||||
write: !executionWorkspaceId && sourceStrategy !== "git_worktree",
|
||||
});
|
||||
await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId);
|
||||
}
|
||||
if (executionWorkspaceId && workspaceSelectionActor) {
|
||||
const [readableWorkspace] = await tx.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId }).from(executionWorkspaces)
|
||||
.where(and(eq(executionWorkspaces.id, executionWorkspaceId), eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(tx, workspaceActor)));
|
||||
if (!readableWorkspace) throw notFound("Workspace is unavailable or inaccessible");
|
||||
await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true, parentIssueId: issueData.parentId, assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId });
|
||||
await assertExecutionWorkspaceRecordAccess(tx, workspaceActor, companyId, readableWorkspace.id);
|
||||
}
|
||||
if (projectWorkspaceId) {
|
||||
await assertValidProjectWorkspace(
|
||||
|
||||
@@ -3,7 +3,7 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agents, agentWakeupRequests, assets, authUsers, companies, completionContracts, createDb, environmentLeases, heartbeatRuns, issueAttachments, issueComments, issueRecoveryActions, issues, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db";
|
||||
import { activityLog, agents, agentWakeupRequests, assets, authUsers, companies, companyMemberships, completionContracts, createDb, environmentLeases, heartbeatRuns, issueAttachments, issueComments, issueRecoveryActions, issues, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../../middleware/index.js";
|
||||
import { issueRoutes } from "../../routes/issues.js";
|
||||
@@ -47,6 +47,7 @@ describe("reconstruction after confirmed native workspace loss", () => {
|
||||
contractId = randomUUID(), leaseId = randomUUID(), actionId = randomUUID(), providerLeaseId = randomUUID(), userId = randomUUID(), attachmentId = randomUUID(), assetId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Operator", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companies).values({ id: companyId, name: "Reconstruction", issuePrefix: `REC${companyId.replaceAll("-", "").slice(0, 6)}`, defaultResponsibleUserId: userId, budgetMonthlyCents: 10000 });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", status: "idle", adapterType: "paperclip_runner", budgetMonthlyCents: 5000, runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Finish bounded work", status: "blocked", assigneeAgentId: agentId });
|
||||
await db.insert(completionContracts).values({ id: contractId, companyId, issueId, revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard", completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal", contractJson: { objective: "Finish bounded work" }, canonicalSha256: contractId, createdByActorType: "system", createdByActorId: "test" });
|
||||
|
||||
@@ -38,14 +38,10 @@ import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-poli
|
||||
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { recordProjectLifecycleEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
|
||||
import { lockExecutionWorkspaceRootProvenance, preserveConfiguredProjectWorkspaceRootProvenance } from "./execution-workspace-repositories.js";
|
||||
|
||||
type ProjectTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export 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;
|
||||
await lockExecutionWorkspaceRootProvenance(tx, identity.companyId);
|
||||
return tx.select().from(projects).where(eq(projects.id, projectId)).for("update").then(rows => rows[0] ?? null);
|
||||
}
|
||||
|
||||
@@ -1072,8 +1068,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
const [retained] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.projectId, id)).limit(1);
|
||||
if (retained) throw conflict("This project still supplies access policy for retained task workspaces. Archive the project or explicitly clean up its workspaces before deleting it.");
|
||||
const workspaces = await tx.select().from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
|
||||
for (const workspace of workspaces) await preserveConfiguredProjectWorkspaceRootProvenance(tx, workspace);
|
||||
await tx.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
|
||||
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, id, "delete");
|
||||
const [row] = await tx.delete(projects).where(eq(projects.id, id)).returning();
|
||||
return row ? { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) } : null;
|
||||
@@ -1259,11 +1254,6 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
: data.metadata;
|
||||
}
|
||||
|
||||
if ((["cwd", "sourceType", "repoUrl", "remoteProvider", "remoteWorkspaceRef", "sharedWorkspaceKey"] as const)
|
||||
.some(key => patch[key] !== undefined && patch[key] !== existing[key])) {
|
||||
await preserveConfiguredProjectWorkspaceRootProvenance(tx, existing);
|
||||
}
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
if (data.isPrimary === true) {
|
||||
await tx
|
||||
@@ -1367,7 +1357,6 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
await preserveConfiguredProjectWorkspaceRootProvenance(tx, existing);
|
||||
const removed = await db.transaction(async (tx) => {
|
||||
await detachProjectSkillSource(tx as unknown as Db, existing.companyId, workspaceId);
|
||||
const row = await tx
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { agents, heartbeatRuns, issues, projects, executionWorkspaces, type Db } from "@paperclipai/db";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
type TaskRepositoryAgent = Pick<typeof agents.$inferSelect, "id" | "companyId" | "permissions">;
|
||||
type TaskRepositoryIssue = Pick<typeof issues.$inferSelect, "id" | "companyId" | "projectId" | "executionPolicy">;
|
||||
type TaskRepositoryProject = Pick<typeof projects.$inferSelect, "companyId" | "executionWorkspacePolicy">;
|
||||
|
||||
export const taskRepositoryRunColumns = {
|
||||
id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId, nativeIssueId: heartbeatRuns.nativeIssueId, issueId: heartbeatRuns.issueId,
|
||||
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId', 'executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
|
||||
};
|
||||
type TaskRepositoryRunIdentity = Pick<typeof heartbeatRuns.$inferSelect, "nativeIssueId" | "issueId" | "contextSnapshot">;
|
||||
|
||||
// A wake for another task can coalesce into a running run and replace its context
|
||||
// task after admission. The immutable task binding still identifies the admitted work.
|
||||
export function runRetainsIssueIdentity(run: TaskRepositoryRunIdentity, issueId: string) {
|
||||
return issueId === (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId)
|
||||
|| issueId === (run.nativeIssueId ?? run.issueId);
|
||||
}
|
||||
|
||||
export function requiresAnonymousRepositoryAcquisition(input: {
|
||||
companyId: string;
|
||||
issue: TaskRepositoryIssue;
|
||||
agent: TaskRepositoryAgent | undefined;
|
||||
project: TaskRepositoryProject | undefined;
|
||||
workspaceSourceProject: TaskRepositoryProject | undefined;
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, keyof typeof taskRepositoryRunColumns> | undefined;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
explicitRunId?: string | null;
|
||||
}) {
|
||||
const { companyId, issue, agent, project, workspaceSourceProject, run, agentId, runId } = input;
|
||||
if (agentId && !agent) throw forbidden("Task repository agent is unavailable");
|
||||
if (runId && (!run || (input.explicitRunId && run.agentId !== agentId) || !runRetainsIssueIdentity(run, issue.id))) {
|
||||
throw forbidden("Task repository run identity is unavailable");
|
||||
}
|
||||
const trust = resolveCoreTrustPreset({ companyId, agent, project, workspaceSourceProject, issue,
|
||||
run: run ? { companyId: run.companyId, executionPolicy: run.contextSnapshot?.executionPolicy } : null });
|
||||
if (trust.kind === "denied") throw forbidden(trust.detail);
|
||||
return trust.kind === "low_trust_review";
|
||||
}
|
||||
|
||||
export type TaskWorkspaceCreationTrustContext = {
|
||||
parentIssueId?: string | null; projectId?: string | null; assigneeAgentId?: string | null; executionPolicy?: unknown;
|
||||
};
|
||||
|
||||
export async function assertSelectedWorkspaceTrust(db: Db | DbTransaction, input: {
|
||||
companyId: string; sourceProjectId: string | null; actor: AuthorizationActor;
|
||||
issueId?: string | null; sourceWorkspaceId?: string | null; assigneeAgentId?: string | null; taskCreation?: TaskWorkspaceCreationTrustContext;
|
||||
}) {
|
||||
if (!input.sourceProjectId) return;
|
||||
const [selectedSource] = await db.select({ id: projects.id, companyId: projects.companyId,
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy }).from(projects).where(and(
|
||||
eq(projects.companyId, input.companyId), eq(projects.id, input.sourceProjectId)));
|
||||
if (!selectedSource) throw forbidden("Selected workspace source project is unavailable");
|
||||
if (resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: selectedSource }).kind === "standard") return;
|
||||
const incompatible = () => forbidden("Selected workspace source trust is incompatible with the task's execution policy");
|
||||
const runId = input.actor.runId;
|
||||
const [run] = runId ? await db.select(taskRepositoryRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, input.companyId))) : [];
|
||||
const creation = !input.issueId && input.actor.type === "agent" && runId ? input.taskCreation : undefined;
|
||||
const runIssueId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId ?? run?.issueId;
|
||||
const issueId = input.issueId ?? (creation && typeof runIssueId === "string" ? runIssueId : null);
|
||||
const [issue] = issueId ? await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) : [];
|
||||
const agentId = input.actor.type === "agent" ? input.actor.agentId : issue?.assigneeAgentId ?? input.assigneeAgentId;
|
||||
const [agent] = agentId ? await db.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, input.companyId))) : [];
|
||||
const [project] = issue?.projectId ? await db.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, input.companyId))) : [];
|
||||
const workspaceId = input.sourceWorkspaceId === undefined ? issue?.executionWorkspaceId : input.sourceWorkspaceId;
|
||||
const [workspace] = workspaceId ? await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, input.companyId))) : [];
|
||||
const [source] = workspace?.projectId ? await db.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, input.companyId))) : [];
|
||||
if ((agentId && !agent) || (runId && (!run || run.agentId !== agentId || !issue || !runRetainsIssueIdentity(run, issue.id)))) throw incompatible();
|
||||
const retainedPolicy = run?.contextSnapshot?.executionPolicy;
|
||||
const current = resolveCoreTrustPreset({ companyId: input.companyId, agent, project, workspaceSourceProject: source, issue,
|
||||
run: run ? { companyId: input.companyId, executionPolicy: retainedPolicy } : null });
|
||||
const selected = run
|
||||
? resolveCoreTrustPreset({ companyId: input.companyId, run: { companyId: input.companyId, executionPolicy: retainedPolicy } })
|
||||
: current;
|
||||
if (selected.kind !== "low_trust_review" || current.kind !== "low_trust_review"
|
||||
|| !isDeepStrictEqual(selected.boundary, current.boundary)) throw incompatible();
|
||||
if (creation) {
|
||||
const sponsor = run!.responsibleUserId === "local-board" ? null : run!.responsibleUserId;
|
||||
if ((input.actor.onBehalfOfUserId ?? null) !== sponsor) throw incompatible();
|
||||
let parentId = creation.parentIssueId;
|
||||
const ancestors: Array<typeof issues.$inferSelect> = [];
|
||||
for (let depth = 0; parentId && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth++) {
|
||||
const [parent] = await db.select().from(issues).where(and(eq(issues.id, parentId), eq(issues.companyId, input.companyId)));
|
||||
if (!parent || ancestors.some(ancestor => ancestor.id === parent.id)) throw incompatible();
|
||||
ancestors.push(parent);
|
||||
if (parent.id === issue!.id) break;
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
if (ancestors.at(-1)?.id !== issue!.id) throw incompatible();
|
||||
const decision = await authorizationService(db).decide({ actor: input.actor, action: "issue:mutate",
|
||||
resource: { type: "issue", companyId: input.companyId, parentIssueId: creation.parentIssueId,
|
||||
projectId: creation.projectId, assigneeAgentId: creation.assigneeAgentId } });
|
||||
if (!decision.allowed) throw incompatible();
|
||||
const [childAgent] = creation.assigneeAgentId ? await db.select().from(agents).where(and(
|
||||
eq(agents.id, creation.assigneeAgentId), eq(agents.companyId, input.companyId))) : [];
|
||||
if (creation.assigneeAgentId && !childAgent) throw incompatible();
|
||||
for (const candidate of [...ancestors, { companyId: input.companyId, projectId: creation.projectId,
|
||||
executionPolicy: creation.executionPolicy }]) {
|
||||
const [candidateProject] = candidate.projectId ? await db.select().from(projects).where(and(
|
||||
eq(projects.id, candidate.projectId), eq(projects.companyId, input.companyId))) : [];
|
||||
if (candidate.projectId && !candidateProject) throw incompatible();
|
||||
const prospective = resolveCoreTrustPreset({ companyId: input.companyId,
|
||||
agent: "id" in candidate ? agent : childAgent, project: candidateProject, workspaceSourceProject: source,
|
||||
issue: candidate, run: { companyId: input.companyId, executionPolicy: retainedPolicy } });
|
||||
if (prospective.kind !== "low_trust_review" || !isDeepStrictEqual(prospective.boundary, selected.boundary)) throw incompatible();
|
||||
}
|
||||
}
|
||||
const selectedPolicy = { trustPreset: selected.preset, authorizationPolicy: {
|
||||
trustPreset: selected.preset, trustBoundary: selected.boundary,
|
||||
} };
|
||||
const combined = resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: selectedSource,
|
||||
run: { companyId: input.companyId, executionPolicy: selectedPolicy } });
|
||||
if (combined.kind !== "low_trust_review" || !isDeepStrictEqual(combined.boundary, selected.boundary)) throw incompatible();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { agents, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db";
|
||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { canonicalExecutionUuid } from "./execution-identity.js";
|
||||
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns } from "./task-repository-acquisition.js";
|
||||
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export async function assertTaskWorkspaceSourceProjectAccess(
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null,
|
||||
) {
|
||||
if (!projectId) return;
|
||||
const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({
|
||||
actor, action: "project:read", resource: { type: "project", companyId, projectId },
|
||||
}));
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
}
|
||||
|
||||
export async function assertExecutionWorkspaceRecordAccess(
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string,
|
||||
) {
|
||||
const [workspace] = await measureRunPhase("workspace.access.inventory", async () => db.select().from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId),
|
||||
await measureRunPhase("workspace.access.privacy", () => executionWorkspaceReadSqlCondition(db, actor)),
|
||||
)));
|
||||
if (!workspace) throw forbidden("Task workspace access is no longer available");
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId);
|
||||
const retainedIssueIds = Object.keys(workspace.metadata?._issuePrivacySources ?? {});
|
||||
if (workspace.sourceIssueId) retainedIssueIds.push(workspace.sourceIssueId);
|
||||
const sources = await measureRunPhase("workspace.access.inventory", async () => db.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
or(eq(issues.executionWorkspaceId, workspaceId), retainedIssueIds.length > 0
|
||||
? inArray(sql`${issues.id}`, retainedIssueIds.map(id => canonicalExecutionUuid(sql`${id}::text`))) : undefined),
|
||||
)));
|
||||
const sourceIds = new Set(sources.map(source => source.id));
|
||||
if (retainedIssueIds.some(id => !sourceIds.has(id))) throw forbidden("Task workspace source is no longer accessible");
|
||||
const access = accessService(db as Db);
|
||||
if (sources.length === 0 && !workspace.projectId) {
|
||||
const decision = await measureRunPhase("workspace.access.authorization", () => access.decide({ actor, action: "company_scope:read", resource: { type: "company", companyId } }));
|
||||
if (!decision.allowed) throw forbidden("An unscoped workspace is outside this actor's authorization boundary");
|
||||
}
|
||||
for (const source of sources) {
|
||||
const decision = await measureRunPhase("workspace.access.authorization", () => access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: source.id } }));
|
||||
if (!decision.allowed) throw forbidden("Workspace record contains task data outside this actor's authorization boundary");
|
||||
}
|
||||
}
|
||||
|
||||
/** Authorize this task's selected execution inputs without classifying disk contents. */
|
||||
export async function assertTaskWorkspaceExecutionAccess(
|
||||
db: Db | DbTransaction,
|
||||
actor: AuthorizationActor,
|
||||
companyId: string,
|
||||
input: { issueId: string; workspaceId?: string | null; projectWorkspaceId?: string | null; persistedSelection?: TaskWorkspaceSelection },
|
||||
) {
|
||||
if (actor.companyId && actor.companyId !== companyId) throw forbidden("Task workspace belongs to another company");
|
||||
const [task] = await db.select({
|
||||
id: issues.id, companyId: issues.companyId, projectId: issues.projectId,
|
||||
executionPolicy: issues.executionPolicy, executionWorkspaceId: issues.executionWorkspaceId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId, workspaceSelection: issues.workspaceSelection,
|
||||
workspacePendingSelection: issues.workspacePendingSelection, workspaceBindingRevision: issues.workspaceBindingRevision,
|
||||
}).from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, companyId)));
|
||||
if (!task) throw forbidden("Task workspace task is unavailable");
|
||||
const access = accessService(db as Db);
|
||||
const [agent] = actor.type === "agent" && actor.agentId
|
||||
? await db.select({ id: agents.id, companyId: agents.companyId, permissions: agents.permissions })
|
||||
.from(agents).where(and(eq(agents.id, actor.agentId), eq(agents.companyId, companyId))) : [];
|
||||
if (actor.type === "agent" && !agent) throw forbidden("Task workspace agent is unavailable");
|
||||
const [run] = actor.runId ? await db.select(taskRepositoryRunColumns).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, actor.runId), eq(heartbeatRuns.companyId, companyId))) : [];
|
||||
if ((actor.source === "agent_jwt" && !actor.runId) || (actor.runId && (!run
|
||||
|| run.agentId !== actor.agentId || !runRetainsIssueIdentity(run, task.id)))) {
|
||||
throw forbidden("Task workspace run identity is unavailable");
|
||||
}
|
||||
if (run && (actor.onBehalfOfUserId ?? null) !== (run.responsibleUserId === "local-board" ? null : run.responsibleUserId)) {
|
||||
throw forbidden("Task workspace responsible user changed");
|
||||
}
|
||||
const decision = await access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: task.id } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
const selection = task.workspaceSelection?.selection;
|
||||
const pending = task.workspacePendingSelection;
|
||||
const pendingSelection = pending?.expectedBindingRevision === task.workspaceBindingRevision ? pending.intent.selection : undefined;
|
||||
const effectiveSelection = input.persistedSelection !== undefined
|
||||
? taskWorkspaceSelectionSchema.parse(input.persistedSelection) : undefined;
|
||||
if (effectiveSelection && (input.workspaceId != null || input.projectWorkspaceId != null
|
||||
|| (!isDeepStrictEqual(effectiveSelection, selection) && !isDeepStrictEqual(effectiveSelection, pendingSelection)))) {
|
||||
throw forbidden("Task workspace selection is not a current persisted intent");
|
||||
}
|
||||
const workspaceId = effectiveSelection
|
||||
? effectiveSelection.kind === "existing" ? effectiveSelection.workspaceId : null
|
||||
: input.workspaceId;
|
||||
const [workspace] = workspaceId ? await db.select({
|
||||
id: executionWorkspaces.id, companyId: executionWorkspaces.companyId,
|
||||
projectId: executionWorkspaces.projectId, projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
|
||||
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId))) : [];
|
||||
if (workspaceId && (!workspace || (task.executionWorkspaceId !== workspace.id
|
||||
&& !(selection?.kind === "existing" && selection.workspaceId === workspace.id)
|
||||
&& !(pendingSelection?.kind === "existing" && pendingSelection.workspaceId === workspace.id)))) {
|
||||
throw forbidden("Task workspace binding is unavailable");
|
||||
}
|
||||
const selectedSourceId = effectiveSelection
|
||||
? effectiveSelection.kind === "configured_source" ? effectiveSelection.projectWorkspaceId : workspace?.projectWorkspaceId ?? null
|
||||
: input.projectWorkspaceId ?? workspace?.projectWorkspaceId ?? task.projectWorkspaceId;
|
||||
if (input.projectWorkspaceId && input.projectWorkspaceId !== task.projectWorkspaceId
|
||||
&& input.projectWorkspaceId !== workspace?.projectWorkspaceId
|
||||
&& !(selection?.kind === "configured_source" && selection.projectWorkspaceId === input.projectWorkspaceId)
|
||||
&& !(pendingSelection?.kind === "configured_source" && pendingSelection.projectWorkspaceId === input.projectWorkspaceId)) {
|
||||
throw forbidden("Task workspace source binding is unavailable");
|
||||
}
|
||||
const [source] = selectedSourceId ? await db.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId })
|
||||
.from(projectWorkspaces).where(and(eq(projectWorkspaces.id, selectedSourceId), eq(projectWorkspaces.companyId, companyId))) : [];
|
||||
if (selectedSourceId && !source) throw forbidden("Task workspace source is unavailable");
|
||||
const projectIds = [...new Set([task.projectId, workspace?.projectId, source?.projectId].filter((id): id is string => Boolean(id)))];
|
||||
const selectedProjects = projectIds.length ? await db.select({ id: projects.id, companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projects).where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds))) : [];
|
||||
if (selectedProjects.length !== projectIds.length) throw forbidden("Task workspace project is unavailable");
|
||||
for (const projectId of projectIds) await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId);
|
||||
const sourceProjectId = source?.projectId ?? workspace?.projectId;
|
||||
const trust = resolveCoreTrustPreset({ companyId, agent, issue: task,
|
||||
project: selectedProjects.find(project => project.id === task.projectId),
|
||||
workspaceSourceProject: selectedProjects.find(project => project.id === sourceProjectId),
|
||||
run: run ? { companyId: run.companyId, executionPolicy: run.contextSnapshot?.executionPolicy } : null });
|
||||
if (trust.kind === "denied") throw forbidden(trust.detail);
|
||||
await assertSelectedWorkspaceTrust(db, { companyId, actor, issueId: task.id,
|
||||
sourceProjectId: sourceProjectId ?? null, sourceWorkspaceId: effectiveSelection ? workspace?.id ?? null : workspace?.id });
|
||||
return { task, workspace, source };
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { executionWorkspaces, issues, type Db } from "@paperclipai/db";
|
||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { authorizationService, executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { canonicalExecutionUuid } from "./execution-identity.js";
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type WorkspaceAccessContext = { write?: boolean; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null };
|
||||
|
||||
export async function assertTaskWorkspaceSourceProjectAccess(
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: WorkspaceAccessContext = {},
|
||||
) {
|
||||
if (!projectId) return;
|
||||
const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({
|
||||
actor, action: "project:read", resource: { type: "project", companyId, projectId },
|
||||
}));
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
if (options.write) {
|
||||
const [task] = actor.type === "board" && options.issueId
|
||||
? await measureRunPhase("workspace.access.inventory", async () => db.select({ assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues)
|
||||
.where(and(eq(issues.id, options.issueId!), eq(issues.companyId, companyId))))
|
||||
: [];
|
||||
const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : task?.assigneeAgentId ?? options.assigneeAgentId ?? null;
|
||||
const assigneeUserId = actor.type === "agent" ? null : task?.assigneeUserId ?? options.assigneeUserId ?? null;
|
||||
const assignment = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({
|
||||
actor, action: "project:write_workspace",
|
||||
resource: { type: "issue", companyId, projectId, assigneeAgentId, assigneeUserId,
|
||||
issueId: options.issueId, parentIssueId: options.parentIssueId },
|
||||
scope: { projectId, assigneeAgentId, assigneeUserId },
|
||||
}));
|
||||
if (!assignment.allowed) throw forbidden(assignment.explanation);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertTaskWorkspaceAccess(
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, options: WorkspaceAccessContext = {},
|
||||
) {
|
||||
const [workspace] = await measureRunPhase("workspace.access.inventory", async () => db.select().from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId),
|
||||
await measureRunPhase("workspace.access.privacy", () => executionWorkspaceReadSqlCondition(db, actor)),
|
||||
)));
|
||||
if (!workspace) throw forbidden("Task workspace access is no longer available");
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.strategyType !== "git_worktree" });
|
||||
const retainedIssueIds = Object.keys(workspace.metadata?._issuePrivacySources ?? {});
|
||||
if (workspace.sourceIssueId) retainedIssueIds.push(workspace.sourceIssueId);
|
||||
const sources = await measureRunPhase("workspace.access.inventory", async () => db.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
or(eq(issues.executionWorkspaceId, workspaceId), retainedIssueIds.length > 0
|
||||
? inArray(sql`${issues.id}`, retainedIssueIds.map(id => canonicalExecutionUuid(sql`${id}::text`))) : undefined),
|
||||
)));
|
||||
const sourceIds = new Set(sources.map(source => source.id));
|
||||
if (retainedIssueIds.some(id => !sourceIds.has(id))) throw forbidden("Task workspace source is no longer accessible");
|
||||
const access = accessService(db as Db);
|
||||
if (sources.length === 0 && !workspace.projectId) {
|
||||
const decision = await measureRunPhase("workspace.access.authorization", () => access.decide({ actor, action: "company_scope:read", resource: { type: "company", companyId } }));
|
||||
if (!decision.allowed) throw forbidden("An unscoped workspace is outside this actor's authorization boundary");
|
||||
}
|
||||
for (const source of sources) {
|
||||
const decision = await measureRunPhase("workspace.access.authorization", () => access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: source.id } }));
|
||||
if (!decision.allowed) throw forbidden("Task workspace retains files outside this actor's authorization boundary");
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertTaskWorkspacesReadAccess(
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceIds: Iterable<string>,
|
||||
) {
|
||||
return assertTaskWorkspacesReadAccessForActors(db, [actor], companyId, workspaceIds);
|
||||
}
|
||||
|
||||
export async function assertTaskWorkspacesReadAccessForActors(
|
||||
db: Db | DbTransaction, actors: AuthorizationActor[], companyId: string, workspaceIds: Iterable<string>,
|
||||
) {
|
||||
return measureRunPhase("workspace.access", async () => {
|
||||
const ids = [...new Set(workspaceIds)];
|
||||
if (!ids.length || !actors.length) return;
|
||||
const privacyActors = [...new Map(actors.map(actor => [JSON.stringify({ ...actor, runId: undefined }), actor])).values()];
|
||||
const privacy = and(...await Promise.all(privacyActors.map(actor => executionWorkspaceReadSqlCondition(db, actor))));
|
||||
const projectIds = new Set<string>(), sourceIds = new Set<string>();
|
||||
let needsCompanyRead = false;
|
||||
for (let offset = 0; offset < ids.length; offset += 500) {
|
||||
const batch = ids.slice(offset, offset + 500);
|
||||
const workspaces = await measureRunPhase("workspace.access.inventory", async () => db.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId,
|
||||
sourceIssueId: executionWorkspaces.sourceIssueId, metadata: executionWorkspaces.metadata,
|
||||
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, batch), privacy)));
|
||||
if (workspaces.length !== batch.length) throw forbidden("Task workspace access is no longer available");
|
||||
const retained = new Set(workspaces.flatMap(workspace => [
|
||||
...Object.keys(workspace.metadata?._issuePrivacySources ?? {}), ...(workspace.sourceIssueId ? [workspace.sourceIssueId] : []),
|
||||
]));
|
||||
const attached = await measureRunPhase("workspace.access.inventory", async () => db.select({ id: issues.id, workspaceId: issues.executionWorkspaceId }).from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.executionWorkspaceId, batch))));
|
||||
const attachedWorkspaceIds = new Set(attached.map(source => source.workspaceId));
|
||||
for (const source of attached) sourceIds.add(source.id);
|
||||
const retainedIds = [...retained];
|
||||
for (let start = 0; start < retainedIds.length; start += 500) {
|
||||
const retainedBatch = retainedIds.slice(start, start + 500);
|
||||
const sources = await measureRunPhase("workspace.access.inventory", async () => db.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, companyId),
|
||||
inArray(sql`${issues.id}`, retainedBatch.map(id => canonicalExecutionUuid(sql`${id}::text`))))));
|
||||
const found = new Set(sources.map(source => source.id));
|
||||
if (retainedBatch.some(id => !found.has(id))) throw forbidden("Task workspace source is no longer accessible");
|
||||
for (const source of sources) sourceIds.add(source.id);
|
||||
}
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.projectId) projectIds.add(workspace.projectId);
|
||||
else if (!workspace.sourceIssueId && !Object.keys(workspace.metadata?._issuePrivacySources ?? {}).length
|
||||
&& !attachedWorkspaceIds.has(workspace.id)) needsCompanyRead = true;
|
||||
}
|
||||
}
|
||||
const resources = [
|
||||
...[...projectIds].map(projectId => ({ type: "project" as const, companyId, projectId })),
|
||||
...[...sourceIds].map(issueId => ({ type: "issue" as const, companyId, issueId })),
|
||||
...(needsCompanyRead ? [{ type: "company" as const, companyId }] : []),
|
||||
];
|
||||
const batches = await measureRunPhase("workspace.access.authorization", () => authorizationService(db).decideReadsForActors(actors, resources));
|
||||
for (const decisions of batches) for (let index = 0; index < decisions.length; index++) {
|
||||
if (decisions[index].allowed) continue;
|
||||
throw forbidden(resources[index].type === "issue"
|
||||
? "Task workspace retains files outside this actor's authorization boundary"
|
||||
: resources[index].type === "company"
|
||||
? "An unscoped workspace is outside this actor's authorization boundary" : decisions[index].explanation);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { issueReadSqlCondition, executionWorkspaceReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { issueReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import fs from "node:fs/promises";
|
||||
import { assertExecutionWorkspaceRootReadAccess } from "./execution-workspace-repositories.js";
|
||||
import path from "node:path";
|
||||
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -1043,24 +1042,36 @@ async function listChangedWorkspaceFiles(input: {
|
||||
}
|
||||
|
||||
export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor) {
|
||||
async function visibleCandidates(candidates: WorkspaceCandidate[], skipDeniedSources = false) {
|
||||
async function visibleCandidates(candidates: WorkspaceCandidate[], skipDeniedSources = false, task?: IssueRow) {
|
||||
if (!actor) return candidates;
|
||||
const visible: WorkspaceCandidate[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const rows = candidate.workspaceKind === "execution_workspace"
|
||||
? await db.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
||||
.where(and(eq(executionWorkspaces.id, candidate.workspaceId), await executionWorkspaceReadSqlCondition(db, actor)))
|
||||
.where(and(eq(executionWorkspaces.id, candidate.workspaceId), eq(executionWorkspaces.companyId, candidate.companyId)))
|
||||
: await db.select({ id: projectWorkspaces.id }).from(projectWorkspaces)
|
||||
.innerJoin(projects, eq(projects.id, projectWorkspaces.projectId))
|
||||
.where(and(eq(projectWorkspaces.id, candidate.workspaceId), await projectReadSqlCondition(db, actor)));
|
||||
if (!rows.length) continue;
|
||||
if (candidate.workspaceKind === "execution_workspace") {
|
||||
// A task-bound root grants file navigation, never disclosure of the workspace record.
|
||||
if (!task || task.companyId !== candidate.companyId) continue;
|
||||
if (task.executionWorkspaceId !== candidate.workspaceId) {
|
||||
const [owner] = await db.select({ sourceIssueId: executionWorkspaces.sourceIssueId })
|
||||
.from(executionWorkspaces).where(and(eq(executionWorkspaces.id, candidate.workspaceId), eq(executionWorkspaces.companyId, task.companyId)));
|
||||
if (owner?.sourceIssueId !== task.id) {
|
||||
if (!task.parentId || owner?.sourceIssueId !== task.parentId) continue;
|
||||
const [parent] = await db.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.id, task.parentId), eq(issues.companyId, task.companyId), await issueReadSqlCondition(db, actor)));
|
||||
if (!parent) continue;
|
||||
}
|
||||
}
|
||||
candidate.label = "Task workspace";
|
||||
}
|
||||
if (candidate.rootPath && (!candidate.remote || candidate.provider === "local_fs" || LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES.has(candidate.provider))) {
|
||||
try {
|
||||
const canonicalCwd = await fs.realpath(candidate.rootPath);
|
||||
await assertExecutionWorkspaceRootReadAccess(db, {
|
||||
companyId: candidate.companyId, cwd: canonicalCwd, actor,
|
||||
sourceWorkspaceId: candidate.workspaceKind === "execution_workspace" ? candidate.workspaceId : undefined,
|
||||
});
|
||||
|
||||
candidate.rootPath = canonicalCwd;
|
||||
} catch (error) {
|
||||
if (isHttpStatus(error, 403) || isHttpStatus(error, 404)
|
||||
@@ -1169,7 +1180,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
|
||||
}
|
||||
}
|
||||
|
||||
return visibleCandidates(candidates, selector === "auto");
|
||||
return visibleCandidates(candidates, selector === "auto", issue);
|
||||
}
|
||||
|
||||
async function loadAvailabilityTargets(
|
||||
|
||||
Reference in new issue
Block a user