fix(projects): make workspace storage the project's only placement choice (#20835)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A repository project gets its working files from core staging or
from a workspace storage plugin
> - #20459 reported that an installed storage plugin attached every new
repository project, so sandbox agents could not use those projects
> - #20587 fixed creation with an **Available in** choice that selects
storage
> - #20651 then saved an environment on every new project and used it
before the agent default to place runs. That solved a problem that did
not exist: the project needs storage, not a run environment
> - Creation saves the first environment of the chosen group, so a
project silently sends every agent's runs to that environment. Project
Configuration also shows two controls (Environment and Workspace
storage) for one decision
> - This pull request removes the project run environment and makes
storage the only placement choice a project owns. Storage is fixed at
creation
> - The benefit is that runs follow the agent again, and project
settings show one clear, read-only fact about storage

## Linked Issues or Issue Description

Refs #20459. Follow-up to #20587 and #20651.

Refs #11644. That issue asks for a project-level run environment
override. This pull request removes the override that #20651 added for
it, because the override is not necessary to fix #20459. #11644 stays a
separate feature request.

Related: #18176 (open) repairs placement through the legacy
workspace-policy field. This pull request does not restore project
placement, so it conflicts with the direction of #18176.

## What Changed

- **Remove the project run environment.** Migration `0466` drops
`projects.environment_id`. Run placement no longer reads the project.
Runs use the agent default, then the instance default, then local
execution. The low-trust task sandbox and the enforced sandbox policies
keep their precedence.
- **Keep the #20651 recovery and binding guards.** A recovered run still
uses its admitted environment lease (source `run`). Company-binding
checks before admission and during lease creation stay.
- **Keep the creation choice.** **Available in** in New Project still
selects storage. It still shows only when the environments need more
than one storage owner. That can only occur when a storage plugin is
installed. `POST /projects` with `environmentId` selects the storage of
that environment and does not save the environment.
- **Fix storage at creation.** `PATCH /projects/:id` rejects any storage
change with `409 project_storage_locked`, from core to a plugin and from
a plugin to core. Before, a core project could move to a plugin.
- **Drop legacy writes.** Older clients can still send
`executionWorkspacePolicy.environmentId`. The service removes it on
create and update.
- **Simplify Project Configuration.** Remove the Environment and
Workspace storage selectors. Show an advisory **Available in** list of
the environments that can use the project's storage. Show it when a
storage plugin is installed or when the project uses one. When the
project's plugin is not installed, show **Storage plugin unavailable**.
Without a storage plugin, show nothing.
- **Issue workspace card.** Remove the "project default" environment
label.
- **Shared contract.** Remove `Project.environmentId`, the
`environmentId` project field, and `expectedStorageProviderPluginId`
from the update schema. The create schema keeps `environmentId` and
`expectedStorageProviderPluginId` from #20587.
- **Docs and maps.** Update `doc/project-environment-compatibility.md`,
`docs/api/goals-and-projects.md`, and the project feature and
agent-access maps.
- **SDK source pins.** Refresh
`tests/runner-e2e/source-sdk-public-inputs.json` for the two shared
files. The shared file blobs return to their values before #20651. The
refresh also corrects the `pnpm-lock.yaml` pin and `lockSha256`:
`master` pins an older lockfile blob than the one it contains.

## Verification

- Targeted server suites pass (227 tests): `project-environments`,
`project-workspace-provider.integration`,
`heartbeat/environment-selection`, `execution-workspace-policy`,
`environment-selection-route-guards`, `environment-run-orchestrator`,
`openapi-routes`.
- Related server suites pass (349 tests): `environment-service`,
`environment-routes`, `plugin-workspaces.integration`, `issues-service`.
- Targeted UI suites pass: `ProjectProperties.concurrency`,
`IssueWorkspaceCard`, `NewProjectDialog`,
`NewProjectDialog.managed-sandbox`, `ProjectDetail`.
- New tests:
- Creation saves storage, not an environment. Core and plugin projects
both reject a storage change with `project_storage_locked`.
  - A legacy policy `environmentId` is dropped on create and update.
- A new run follows the agent default for any project. A recovered run
keeps its admitted lease after the agent default changes.
- Project Configuration has no storage or environment selector. The note
is hidden without a plugin. It lists the core or plugin environments for
the project's storage. It shows **Storage plugin unavailable** for a
removed plugin.
- `pnpm --filter @paperclipai/server typecheck`, `pnpm --filter
@paperclipai/ui typecheck`, and `pnpm --filter @paperclipai/shared
typecheck` pass.
- `pnpm --filter @paperclipai/db check:migrations`, `pnpm
check:token-gates`, `node scripts/check-maps.mjs`, and `node
tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check` pass.
- Not run locally: the full `pnpm test:run`, `pnpm build`, and browser
suites. CI supplies that coverage.
- Manual check: open Project Configuration on an instance with a storage
plugin. Confirm that there is no Environment or Workspace storage
selector, and that **Available in** lists the environments.

## Risks

- **Migration.** `0466` drops `projects.environment_id` and its foreign
key. The migration deletes the saved project environments. Apply it
before you serve the new revision. An old server revision that reads
`projects` fails until it stops. This is the same pattern as earlier
drop-column migrations (`0229`, `0289`).
- **Behavior change for existing projects.** Projects created after
#20651 shipped have a saved environment that overrides the agent. After
this change, their runs follow the agent default again. This is the
intended fix. Before #20651, a project environment selection was not
applied to runs (#11644), so no older behavior is lost.
- **No path from core to a plugin.** A core project, including one
created before a plugin was installed, cannot move to the plugin. To use
plugin storage, create a new project.
- **API.** A `PATCH` that sends `environmentId` or
`expectedStorageProviderPluginId` no longer has an effect. The schema
strips these fields.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) by Anthropic, in Claude Code, with
tool use (shell, file edits, test runs). Extended thinking was
available.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nicky LeachandClaude Opus 5.5 authored and GitHub committed 2026-11-11 08:00:21 -08:00
1 parent 39442400b7
commit 369e238295
28 files changed
+295 -496

No files matched your search

+19 -27
View File
@@ -2,9 +2,9 @@
A repository project belongs to one storage owner. Creation shows **Available in**
when the configured environments require more than one owner. Each choice names
all environments that use that owner. An environment choice saves both the storage owner and the project's
`environmentId`. This is a project-owned execution target, not a company or agent
setting. Compatible environments can share its repository storage.
all environments that use that owner. The choice selects storage only. It is not
an allowlist, and it does not override an agent's run environment. A project has
no run environment of its own.
## Compatibility rule
@@ -48,10 +48,9 @@ provider configs, credentials, endpoints, or host paths. A cold worker does not
change a durable storage choice.
`POST /api/companies/:companyId/projects` accepts an optional `environmentId`.
The server validates the choice again and stores its storage owner and environment
on that project. Creation saves the effective default once; changing the instance
or primary agent later does not rewrite existing projects. Compatible environments
share repository storage; the saved environment is the run target. An explicit `executionWorkspacePolicy.storageProviderPluginId`
The server validates the choice again and stores only its storage owner. The
project is then available to the compatible environments in that group; no single
run environment is saved. An explicit `executionWorkspacePolicy.storageProviderPluginId`
continues to work. The UI sends both the environment and its observed storage
owner in `expectedStorageProviderPluginId`, so a changed environment cannot
silently select different storage. This guard does not create a workspace-mode
@@ -73,25 +72,18 @@ workspace provider operation, with a message naming the storage and environment.
## Project Configuration
`PATCH /api/projects/:id` accepts `environmentId` and the optional
`expectedStorageProviderPluginId` precondition. The Environment selector reads
and writes this project field, independently of checkout/isolation settings.
Changing one project never changes another project, the agent, or instance settings.
Only environments compatible with the project's current storage are offered.
Changing storage ownership is subject to the existing retained-data safeguards.
Storage is fixed when the project is created. `PATCH /api/projects/:id` rejects a
change of `executionWorkspacePolicy.storageProviderPluginId` with `409`
`project_storage_locked`, in both directions: core to plugin and plugin to core.
A policy update that omits the storage owner keeps the stored owner.
Ordinary run selection uses the project's saved target before the agent default,
instance default, and local fallback. A null project target explicitly inherits
those defaults. An operator-selected low-trust task sandbox and enforced sandbox
policies retain precedence. Provider API execution keeps its existing placement.
Project Configuration has no storage or environment control. When a storage plugin
is installed, or the project uses one, it shows an advisory **Available in** list:
the environments that can use the project's storage. If the project's storage
plugin is no longer installed, it shows **Storage plugin unavailable**. Without a
storage plugin, the advisory is hidden.
The migration preserves existing valid project-policy environment selections in
the new project field. Older clients may still write the policy field; the service
normalizes it into the project association. Existing projects with no selection
remain unset. Environment deletion counts and clears only matching associations.
Changing or clearing placement requires project workspace-write authority. A
saved selection is checked against company bindings before runtime admission and
again during lease creation. Recovery uses the admitted run's owned environment
lease; a later project edit affects new admissions. The task's organizational
project owns placement even when its files come from another project.
Runs use the agent default, then the instance default, then local execution. An
operator-selected low-trust task sandbox and enforced sandbox policies retain
precedence. A project does not change run placement. Older clients may still send
`executionWorkspacePolicy.environmentId`; the service drops it.
+8 -9
View File
@@ -61,7 +61,7 @@ GET /api/companies/{companyId}/projects
GET /api/projects/{projectId}
```
Returns project details including workspaces and the project-owned `environmentId`.
Returns project details including workspaces.
### Create Project
@@ -84,9 +84,10 @@ POST /api/companies/{companyId}/projects
Notes:
- `environmentId` optionally selects the project environment. When omitted, creation
saves the creator's effective default. The saved choice does not change when the
creator or instance default changes later.
- `environmentId` optionally selects the project's workspace storage: the storage
that this environment uses. When omitted, storage follows the creator's effective
default environment. The project does not save the environment, and it does not
change where runs execute.
- `expectedStorageProviderPluginId` optionally guards the observed storage owner.
Use `GET /api/companies/{companyId}/project-environments` to discover compatible
groups and the creator's default. Unknown or changed storage is rejected.
@@ -103,11 +104,9 @@ PATCH /api/projects/{projectId}
}
```
`environmentId` can be updated independently of workspace/isolation policy. Set it
to an available compatible environment ID, or `null` to inherit agent/instance
defaults. The update affects only this project. An incompatible or unavailable
environment returns `409`; company access is required. Existing plugin-owned data
cannot be moved to another storage owner through this field.
Workspace storage is fixed at creation. A change of
`executionWorkspacePolicy.storageProviderPluginId` returns `409`
(`project_storage_locked`). Runs use the agent's environment, not a project setting.
## Project Workspaces
+2 -2
View File
@@ -151,6 +151,6 @@ Gaps: The dedicated create_project tool does not advertise a visibility field; e
Project configuration responses also expose effective workspace storage through
`workspaceProvider`, separate from the stored execution policy. Existing project
read/update permissions apply. Omitted provider IDs inherit; null explicitly selects
Core/local. [Offline API coverage](../../server/src/__tests__/project-workspace-provider.integration.test.ts)
read/update permissions apply. Omitted provider IDs keep the stored owner; null explicitly selects
Core/local at creation. An update that changes the owner returns `409` `project_storage_locked`. [Offline API coverage](../../server/src/__tests__/project-workspace-provider.integration.test.ts)
checks overrides and cross-company denial; live agent credential verification remains unqualified.
+3 -2
View File
@@ -29,8 +29,9 @@ Open `/projects`, create a project, and enter its overview or Issues view.
### `repository-settings`
Open `/projects/:projectId/configuration` and its repository/workspace settings.
Workspace storage shows the saved provider or Core/local choice. Plugin-backed
projects retain their storage owner.
Storage is fixed at creation and has no control here. When a storage plugin is
installed, or the project uses one, **Available in** lists the environments that
can use the project's storage.
### `project-access`
@@ -0,0 +1,2 @@
ALTER TABLE "projects" DROP CONSTRAINT IF EXISTS "projects_environment_id_environments_id_fk";--> statement-breakpoint
ALTER TABLE "projects" DROP COLUMN IF EXISTS "environment_id";
@@ -1,6 +1,6 @@
{
"id": "f409684f-c629-4648-9123-a5e1064308ef",
"prevId": "f93ccf18-7e86-4788-be77-b17430c86b2f",
"id": "593f81c4-2b4d-4fb4-a3e3-848b19bc9b0f",
"prevId": "38dbf2b9-da26-4c3a-b813-f37d0c19cba2",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -6233,7 +6233,7 @@
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": false
},
"account_id": {
"name": "account_id",
@@ -6309,11 +6309,11 @@
}
},
"indexes": {
"account_issuer_account_id_uq": {
"name": "account_issuer_account_id_uq",
"account_provider_account_id_uq": {
"name": "account_provider_account_id_uq",
"columns": [
{
"expression": "issuer",
"expression": "provider_id",
"isExpression": false,
"asc": true,
"nulls": "last"
@@ -49107,6 +49107,93 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.plugin_lifecycle_resource_cursors": {
"name": "plugin_lifecycle_resource_cursors",
"schema": "",
"columns": {
"plugin_id": {
"name": "plugin_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"acknowledged_through_id": {
"name": "acknowledged_through_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk": {
"name": "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk",
"tableFrom": "plugin_lifecycle_resource_cursors",
"tableTo": "plugins",
"columnsFrom": [
"plugin_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"plugin_lifecycle_resource_cursors_company_id_companies_id_fk": {
"name": "plugin_lifecycle_resource_cursors_company_id_companies_id_fk",
"tableFrom": "plugin_lifecycle_resource_cursors",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"plugin_lifecycle_resource_cursors_pk": {
"name": "plugin_lifecycle_resource_cursors_pk",
"columns": [
"plugin_id",
"company_id",
"resource_type",
"resource_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.plugin_logs": {
"name": "plugin_logs",
"schema": "",
@@ -59516,6 +59603,13 @@
"notNull": true,
"default": "''"
},
"pending_reveal": {
"name": "pending_reveal",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"allowed_actions": {
"name": "allowed_actions",
"type": "jsonb",
+7
View File
@@ -3249,6 +3249,13 @@
"when": 1794400732201,
"tag": "0465_better_auth_account_identity",
"breakpoints": true
},
{
"idx": 466,
"version": "7",
"when": 1794408985337,
"tag": "0466_drop_project_environment",
"breakpoints": true
}
]
}
-2
View File
@@ -2,7 +2,6 @@ import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, date, index, jsonb, check, uniqueIndex } from "drizzle-orm/pg-core";
import type { AgentEnvConfig } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
import { goals } from "./goals.js";
import { agents } from "./agents.js";
import { connectionGrants } from "./tool_access.js";
@@ -24,7 +23,6 @@ export const projects = pgTable(
color: text("color"),
icon: text("icon"),
env: jsonb("env").$type<AgentEnvConfig>(),
environmentId: uuid("environment_id").references(() => environments.id, { onDelete: "set null" }),
gitAuthorizationGrantId: uuid("git_authorization_grant_id").references(() => connectionGrants.id, { onDelete: "set null" }),
gitAuthorizedByUserId: text("git_authorized_by_user_id"),
pauseReason: text("pause_reason"),
-2
View File
@@ -147,8 +147,6 @@ export interface Project {
color: string | null;
icon: string | null;
env: AgentEnvConfig | null;
/** Project-owned execution target; null inherits the agent/instance default. */
environmentId?: string | null;
pauseReason: PauseReason | null;
pausedAt: Date | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
+1 -2
View File
@@ -119,7 +119,6 @@ const projectFields = {
color: z.string().optional().nullable(),
icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(),
env: envConfigSchema.optional().nullable(),
environmentId: z.string().guid().optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(),
};
@@ -137,7 +136,7 @@ export const createProjectSchema = z.object({
export type CreateProject = z.infer<typeof createProjectSchema>;
export const updateProjectSchema = objectWithoutDefaults(
z.object({ ...projectFields, expectedStorageProviderPluginId: z.string().guid().nullable().optional() }),
z.object(projectFields),
).partial();
export type UpdateProject = z.infer<typeof updateProjectSchema>;
@@ -6,8 +6,6 @@ import { errorHandler } from "../middleware/index.js";
import { projectRoutes } from "../routes/projects.js";
import { issueRoutes } from "../routes/issues.js";
const projectPermissions = vi.hoisted(() => ({ canWrite: true }));
const mockProjectService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
@@ -65,7 +63,7 @@ vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
decide: vi.fn(async (input: { action: string }) => ({
allowed: ["project:read", "issue:read"].includes(input.action) || (projectPermissions.canWrite && ["tasks:assign", "project:write_workspace"].includes(input.action)),
allowed: ["project:read", "issue:read"].includes(input.action),
action: input.action,
reason: ["project:read", "issue:read"].includes(input.action) ? "allow_explicit_grant" : "deny_missing_grant",
explanation: ["project:read", "issue:read"].includes(input.action) ? "Allowed by test default." : "Missing permission.",
@@ -185,7 +183,6 @@ describe("execution environment route guards", () => {
});
beforeEach(() => {
projectPermissions.canWrite = true;
mockProjectService.create.mockReset();
mockProjectService.getById.mockReset();
mockProjectService.update.mockReset();
@@ -214,17 +211,12 @@ describe("execution environment route guards", () => {
mockLogActivity.mockReset();
});
it("accepts sandbox environments on project create", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: sandboxEnvironmentId,
companyId: "company-1",
driver: "sandbox",
config: { provider: "fake-plugin" },
});
it("ignores a stale legacy policy environment on project create", async () => {
mockEnvironmentService.getById.mockResolvedValue(null);
mockProjectService.create.mockResolvedValue({
id: "project-1",
companyId: "company-1",
name: "Sandboxed Project",
name: "Legacy Project",
status: "backlog",
});
const app = createProjectApp();
@@ -232,35 +224,31 @@ describe("execution environment route guards", () => {
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({
name: "Sandboxed Project",
name: "Legacy Project",
executionWorkspacePolicy: {
enabled: true,
environmentId: sandboxEnvironmentId,
},
});
expect(res.status).not.toBe(422);
expect(res.status).toBe(201);
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockProjectService.create).toHaveBeenCalled();
});
it("accepts sandbox environments on project update", async () => {
it("ignores a stale legacy policy environment on project update", async () => {
mockProjectService.getById.mockResolvedValue({
id: "project-1",
companyId: "company-1",
name: "Sandboxed Project",
name: "Legacy Project",
status: "backlog",
archivedAt: null,
});
mockEnvironmentService.getById.mockResolvedValue({
id: sandboxEnvironmentId,
companyId: "company-1",
driver: "sandbox",
config: { provider: "fake-plugin" },
});
mockEnvironmentService.getById.mockResolvedValue(null);
mockProjectService.update.mockResolvedValue({
id: "project-1",
companyId: "company-1",
name: "Sandboxed Project",
name: "Legacy Project",
status: "backlog",
});
const app = createProjectApp();
@@ -274,18 +262,11 @@ describe("execution environment route guards", () => {
},
});
expect(res.status).not.toBe(422);
expect(res.status).toBe(200);
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockProjectService.update).toHaveBeenCalled();
});
it.each([sandboxEnvironmentId, null])("rejects a project environment change or clear without workspace-write permission (%s)", async environmentId => {
projectPermissions.canWrite = false;
mockProjectService.getById.mockResolvedValue({ id: "project-1", companyId: "company-1", environmentId: "other-environment" });
const res = await request(createProjectApp()).patch("/api/projects/project-1").send({ environmentId });
expect(res.status).toBe(403);
expect(mockProjectService.update).not.toHaveBeenCalled();
});
it("accepts sandbox environments on issue create", async () => {
mockEnvironmentService.getById.mockResolvedValue({
id: sandboxEnvironmentId,
@@ -780,15 +780,6 @@ describe("operator default isolated execution workspaces", () => {
});
});
describe("project-owned environment", () => {
it("gives the project priority while keeping task trust and managed-only restrictions", () => {
const defaults = { agentDefaultEnvironmentId: "agent-env", instanceDefaultEnvironmentId: "instance-env", localDefaultEnvironmentId: "local-env" };
expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "project-env" })).toEqual({ environmentId: "project-env", source: "project" });
expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "project-env", lowTrustIssueEnvironmentId: "task-sandbox" })).toEqual({ environmentId: "task-sandbox", source: "issue" });
expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "local-env", managedSandboxOnly: true, managedSandboxEnvironmentId: "managed-env" })).toEqual({ environmentId: "managed-env", source: "managed" });
});
});
describe("task-scoped low-trust environment", () => {
it("uses the explicit phone-task sandbox while preserving ordinary agent defaults", () => {
const defaults = {agentDefaultEnvironmentId: "agent-env", instanceDefaultEnvironmentId: "instance-env", localDefaultEnvironmentId: "local-env"};
@@ -590,7 +590,7 @@ const support = await getEmbeddedPostgresTestSupport();
svc.update(f.input.projectId, {
executionWorkspacePolicy: { storageProviderPluginId: null },
}),
).rejects.toThrow("stays attached");
).rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
await expect(
svc.updateWorkspace(f.input.projectId, f.input.projectWorkspaceId, {
repoUrl: "https://github.com/example/other.git",
@@ -1,14 +1,11 @@
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import { agents, authUsers, companyMemberships, builtInManagedResources, companies, createDb, environments, instanceSettings, plugins, projects, userCompanyPreferences } from "@paperclipai/db";
import { projectEnvironmentOptions, resolveProjectEnvironment } from "../services/project-environments.js";
import { agents, authUsers, companyMemberships, builtInManagedResources, companies, createDb, environments, instanceSettings, plugins, userCompanyPreferences } from "@paperclipai/db";
import { projectEnvironmentOptions, withDefaultProjectStorage } from "../services/project-environments.js";
import { assertProjectStorageEnvironment, projectStorageForEnvironment } from "../services/project-workspace-provider.js";
import { environmentRunOrchestrator } from "../services/environment-run-orchestrator.js";
import { environmentService } from "../services/environments.js";
import { issueService } from "../services/issues.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { applyDefaultIsolatedExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType } from "../services/execution-workspace-policy.js";
@@ -83,8 +80,8 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
expect((await projectEnvironmentOptions(db, f.companyId)).defaultEnvironmentId).toBe(f.sandbox.id);
expect((await projectEnvironmentOptions(db, f.companyId, { userId: "person" })).defaultEnvironmentId).toBe(f.computer.id);
expect((await projectEnvironmentOptions(db, f.companyId, { agentId: f.input.agentId })).defaultEnvironmentId).toBe(f.computer.id);
expect(await resolveProjectEnvironment(db, f.companyId, null)).toEqual({ environmentId: f.sandbox.id, executionWorkspacePolicy: null });
expect(await resolveProjectEnvironment(db, f.companyId, null, { userId: "person" })).toMatchObject({ executionWorkspacePolicy: { storageProviderPluginId: f.pluginId } });
expect(await withDefaultProjectStorage(db, f.companyId, null)).toBeNull();
expect(await withDefaultProjectStorage(db, f.companyId, null, { userId: "person" })).toMatchObject({ storageProviderPluginId: f.pluginId });
await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, f.input.agentId));
expect((await projectEnvironmentOptions(db, f.companyId, { userId: "person" })).defaultEnvironmentId).toBe(f.sandbox.id);
await db.update(instanceSettings).set({ defaultEnvironmentId: null });
@@ -101,69 +98,36 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
const computerProject = await svc.create(f.companyId, { name: "Computer project" }, { environmentId: f.computer.id });
expect(computerProject.workspaceProvider?.pluginId).toBe(f.pluginId);
const explicit = { enabled: true, storageProviderPluginId: null };
expect(await resolveProjectEnvironment(db, f.companyId, explicit)).toEqual({ environmentId: null, executionWorkspacePolicy: explicit });
await expect(resolveProjectEnvironment(db, f.companyId, explicit, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
expect(await withDefaultProjectStorage(db, f.companyId, explicit)).toEqual(explicit);
await expect(withDefaultProjectStorage(db, f.companyId, explicit, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id });
expect((await svc.getById(sandboxProject.id))?.workspaceProvider?.pluginId).toBeNull();
});
it("keeps environment changes on one project and snapshots creation defaults", async () => {
it("saves storage, not a run environment, and keeps that storage after creation", async () => {
const f = await fixture();
const svc = projectService(db);
const first = await svc.create(f.companyId, { name: "First project" });
const second = await svc.create(f.companyId, { name: "Second project" });
expect(first.environmentId).toBe(f.sandbox.id);
expect(second.environmentId).toBe(f.sandbox.id);
await svc.update(first.id, { environmentId: f.ssh.id, expectedStorageProviderPluginId: null });
await db.update(instanceSettings).set({ defaultEnvironmentId: f.local.id });
expect((await svc.getById(first.id))?.environmentId).toBe(f.ssh.id);
expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id);
expect((await svc.getById(first.id))?.executionWorkspacePolicy).toBeNull();
expect((await db.select().from(agents).where(eq(agents.id, f.input.agentId)))[0].defaultEnvironmentId).toBeNull();
expect((await environmentService(db).getDeleteBlastRadius(f.ssh.id))?.staticReferences.projectSelectionCount).toBe(1);
expect(await svc.clearExecutionWorkspaceEnvironmentSelection(f.companyId, f.ssh.id)).toBe(1);
expect((await svc.getById(first.id))?.environmentId).toBeNull();
expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id);
const core = await svc.create(f.companyId, { name: "Core project" }, { environmentId: f.ssh.id, expectedStorageProviderPluginId: null });
const managed = await svc.create(f.companyId, { name: "Managed project" }, { environmentId: f.computer.id });
expect(core).not.toHaveProperty("environmentId");
expect(core.workspaceProvider?.pluginId).toBeNull();
expect(managed.workspaceProvider?.pluginId).toBe(f.pluginId);
await expect(svc.update(core.id, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } }))
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
await expect(svc.update(managed.id, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }))
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
expect((await svc.getById(core.id))?.workspaceProvider?.pluginId).toBeNull();
expect((await svc.getById(managed.id))?.workspaceProvider?.pluginId).toBe(f.pluginId);
});
it.each([{ enabled: true }, null])("preserves the storage owner before validating a combined environment/policy edit (%j)", async policy => {
it("drops a legacy policy environment instead of saving it", async () => {
const f = await fixture();
const svc = projectService(db);
const project = await svc.create(f.companyId, { name: "Managed project" }, { environmentId: f.computer.id });
await expect(svc.update(project.id, { environmentId: f.local.id, executionWorkspacePolicy: policy }))
.rejects.toMatchObject({ status: 409 });
const saved = await svc.getById(project.id);
expect(saved?.environmentId).toBe(f.computer.id);
expect(saved?.executionWorkspacePolicy?.storageProviderPluginId).toBe(f.pluginId);
});
it.each([false, true])("migrates a saved legacy project selection without changing its checkout policy (uppercase=%s)", async uppercase => {
const f = await fixture();
const migration = await readFile(new URL("../../../packages/db/src/migrations/0464_project_environment.sql", import.meta.url), "utf8");
const project = await projectService(db).create(f.companyId, { name: "Existing project" });
const [target] = await db.insert(environments).values({ id: "abcdefab-cdef-4abc-8def-abcdefabcdef", name: "Legacy target", driver: "ssh", config: { host: "legacy.example.test" } }).returning();
await expect(db.transaction(async tx => {
await tx.update(projects).set({ executionWorkspacePolicy: { enabled: false, environmentId: uppercase ? target.id.toUpperCase() : target.id } }).where(eq(projects.id, project.id));
await tx.execute(sql`alter table projects drop column environment_id`);
for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) {
await tx.execute(sql.raw(statement));
}
const [saved] = await tx.select().from(projects).where(eq(projects.id, project.id));
expect(saved.environmentId).toBe(target.id);
expect(saved.executionWorkspacePolicy).toEqual({ enabled: false });
throw new Error("rollback migration fixture");
})).rejects.toThrow("rollback migration fixture");
});
it("accepts legacy project policy environment edits without changing other projects", async () => {
const f = await fixture();
const svc = projectService(db);
const first = await svc.create(f.companyId, { name: "Legacy editor" });
const second = await svc.create(f.companyId, { name: "Other project" });
const saved = await svc.update(first.id, { executionWorkspacePolicy: { enabled: false, environmentId: f.ssh.id.toUpperCase() } });
expect(saved?.environmentId).toBe(f.ssh.id);
expect(saved?.executionWorkspacePolicy).toEqual({ enabled: false });
expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id);
const created = await svc.create(f.companyId, { name: "Legacy create", executionWorkspacePolicy: { enabled: false, environmentId: f.ssh.id } });
expect(created.executionWorkspacePolicy).toEqual({ enabled: false });
const updated = await svc.update(created.id, { executionWorkspacePolicy: { enabled: false, environmentId: f.local.id } });
expect(updated?.executionWorkspacePolicy).toEqual({ enabled: false });
expect(updated).not.toHaveProperty("environmentId");
});
it("preserves inherited task isolation when checking the observed core storage owner", async () => {
@@ -198,15 +162,12 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
const [otherCompany] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTHER" }).returning();
await db.insert(builtInManagedResources).values({ companyId: otherCompany.id, bundleKey: "test", resourceKind: "environment",
resourceKey: "environment", resourceId: f.ssh.id, stockVersion: "1", stockHash: "test" });
await expect(environmentRunOrchestrator(db).resolveEnvironment({ companyId: f.companyId, selectedEnvironmentId: f.ssh.id,
localEnvironmentId: f.local.id })).rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } });
await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.local.id));
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, f.pluginId));
const options = await projectEnvironmentOptions(db, f.companyId);
expect(options.groups.flatMap(group => group.environments).map(value => value.id)).toEqual([f.sandbox.id]);
for (const environmentId of [f.local.id, f.ssh.id, f.computer.id, randomUUID()]) {
await expect(resolveProjectEnvironment(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 });
await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 });
}
});
@@ -214,10 +175,10 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
const f = await fixture();
await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id });
await db.update(plugins).set({ status: "error" }).where(eq(plugins.id, f.pluginId));
expect(await resolveProjectEnvironment(db, f.companyId, null)).toMatchObject({ executionWorkspacePolicy: { storageProviderPluginId: f.pluginId } });
expect(await withDefaultProjectStorage(db, f.companyId, null)).toMatchObject({ storageProviderPluginId: f.pluginId });
await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.computer.id));
expect((await projectEnvironmentOptions(db, f.companyId)).error).toBeTruthy();
await expect(resolveProjectEnvironment(db, f.companyId, null)).rejects.toMatchObject({ status: 409 });
await expect(withDefaultProjectStorage(db, f.companyId, null)).rejects.toMatchObject({ status: 409 });
});
it.each(["instance", "primary"] as const)("creates repository-free private tasks despite an archived %s default", async source => {
@@ -251,7 +212,7 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
await db.update(instanceSettings).set({ general: { executionMode: "kubernetes" } });
const forced = await projectEnvironmentOptions(db, f.companyId);
expect(forced.groups).toEqual([{ storageProviderPluginId: null, environments: [{ id: f.sandbox.id, name: f.sandbox.name }] }]);
await expect(resolveProjectEnvironment(db, f.companyId, null, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
});
it("exposes safe groups and saves a validated choice through the normal API", async () => {
@@ -261,8 +222,6 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
result.use((req, _res, next) => { req.actor = actor; next(); });
result.use("/api", projectRoutes(db)); result.use(errorHandler); return result;
}
await db.insert(authUsers).values({ id: "person", name: "Person", email: "person@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: "person", status: "active", membershipRole: "member" });
const board = app({ type: "board", userId: "person", source: "session", companyIds: [f.companyId] });
const url = `/api/companies/${f.companyId}`;
const options = await request(board).get(`${url}/project-environments`);
@@ -271,20 +230,7 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
const created = await request(board).post(`${url}/projects`).send({ name: "Selected", environmentId: f.computer.id });
expect(created.status).toBe(201);
expect(created.body.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
expect(created.body.environmentId).toBe(f.computer.id);
const first = await request(board).post(`${url}/projects`).send({ name: "First core project" });
const second = await request(board).post(`${url}/projects`).send({ name: "Second core project" });
const updateUrl = `/api/projects/${first.body.id}`;
const saved = await request(board).patch(updateUrl).send({ environmentId: f.ssh.id, expectedStorageProviderPluginId: null });
expect(saved.status).toBe(200);
expect(saved.body.environmentId).toBe(f.ssh.id);
expect((await request(board).get(`/api/projects/${second.body.id}`)).body.environmentId).toBe(f.sandbox.id);
expect((await db.select().from(instanceSettings))[0].defaultEnvironmentId).toBe(f.sandbox.id);
expect((await request(app({ type: "none" })).patch(updateUrl).send({ environmentId: f.local.id })).status).toBe(404);
expect((await request(app({ type: "agent", agentId: f.input.agentId, companyId: randomUUID() }))
.patch(updateUrl).send({ environmentId: f.local.id })).status).toBe(404);
expect((await request(board).patch(updateUrl).send({ environmentId: f.computer.id, expectedStorageProviderPluginId: null })).status).toBe(409);
expect((await projectService(db).getById(first.body.id))?.environmentId).toBe(f.ssh.id);
expect(created.body).not.toHaveProperty("environmentId");
const denied = app({ type: "agent", agentId: f.input.agentId, companyId: randomUUID() });
expect((await request(denied).get(`${url}/project-environments`)).status).toBe(403);
expect((await request(app({ type: "none" })).get(`${url}/project-environments`)).status).toBe(401);
@@ -114,18 +114,24 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: null }))!.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId });
for (const storageProviderPluginId of [null, other.pluginId]) {
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId } }))
.rejects.toThrow("stays attached");
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
}
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspace!.id)))[0]).toEqual(workspace);
});
it("lets a Core project move to a storage plugin, and then keeps it there", async () => {
it("keeps a Core project on Core storage", async () => {
const f = await fixture();
const other = await createPluginWorkspaceFixture(db, f.companyId);
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
const svc = projectService(db);
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } }))!.workspaceProvider)
.toMatchObject({ source: "project", pluginId: f.pluginId });
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })).rejects.toThrow("stays attached");
for (const storageProviderPluginId of [f.pluginId, other.pluginId]) {
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId } }))
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
}
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }))!.workspaceProvider)
.toMatchObject({ pluginId: null });
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" } }))!.workspaceProvider)
.toMatchObject({ pluginId: null });
});
it("preserves explicit alternatives and the explicit Core/local choice", async () => {
+2 -32
View File
@@ -54,8 +54,6 @@ import {
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
import { getTelemetryClient } from "../telemetry.js";
import { appendWithCap } from "../adapters/utils.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import { environmentService } from "../services/environments.js";
import { secretService } from "../services/secrets.js";
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
@@ -87,7 +85,6 @@ export function projectRoutes(db: Db) {
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
});
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
const environmentsSvc = environmentService(db);
/**
* Managed-sandbox-only policy (`enableManagedSandboxOnly`): a project
@@ -114,21 +111,6 @@ export function projectRoutes(db: Db) {
);
}
async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) {
if (environmentId === undefined || environmentId === null) return;
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
allowedDrivers: ["local", "ssh", "sandbox"],
});
}
function readProjectPolicyEnvironmentId(policy: unknown): string | null | undefined {
if (!policy || typeof policy !== "object" || !("environmentId" in policy)) {
return undefined;
}
const environmentId = (policy as { environmentId?: unknown }).environmentId;
return typeof environmentId === "string" || environmentId === null ? environmentId : undefined;
}
async function resolveCompanyIdForProjectReference(req: Request) {
const companyIdQuery = req.query.companyId;
const requestedCompanyId =
@@ -173,9 +155,9 @@ export function projectRoutes(db: Db) {
return false;
}
async function assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id?: string; companyId: string }) {
async function assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
const assigneeAgentId = req.actor.type === "agent" ? req.actor.agentId ?? null : null;
const decision = await access.decide({ actor: req.actor, action: project.id ? "project:write_workspace" : "tasks:assign",
const decision = await access.decide({ actor: req.actor, action: "project:write_workspace",
resource: { type: "issue", companyId: project.companyId, projectId: project.id, assigneeAgentId },
scope: { projectId: project.id, assigneeAgentId } });
if (decision.allowed) return true;
@@ -401,12 +383,6 @@ export function projectRoutes(db: Db) {
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
? await projectToolContext(db, req.actor, true) : null;
if ((environmentId || readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy))
&& !(await assertProjectWorkspaceWriteAllowed(req, res, { companyId }))) return;
await assertProjectEnvironmentSelection(
companyId,
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
);
assertNoAgentHostWorkspaceCommandMutation(
req,
[
@@ -524,9 +500,6 @@ export function projectRoutes(db: Db) {
if (!existing) return;
if (!(await assertProjectReadAllowed(req, res, existing))) return;
const body = { ...req.body };
const nextEnvironmentId = body.environmentId !== undefined ? body.environmentId : readProjectPolicyEnvironmentId(body.executionWorkspacePolicy);
const environmentChanged = nextEnvironmentId !== undefined && nextEnvironmentId !== (existing.environmentId ?? null);
if (environmentChanged && !(await assertProjectWorkspaceWriteAllowed(req, res, existing))) return;
if (body.visibility !== undefined) {
if (!(await assertCanManageProjectPrivacy(req, res, existing))) return;
}
@@ -535,9 +508,6 @@ export function projectRoutes(db: Db) {
req,
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
);
if (environmentChanged && body.environmentId === undefined) {
await assertProjectEnvironmentSelection(existing.companyId, nextEnvironmentId);
}
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
+2 -2
View File
@@ -11,7 +11,7 @@ import { withEnvironmentLeaseController } from "./environment-lease-controller.j
import { randomUUID } from "node:crypto";
import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js";
import { and, asc, desc, eq, inArray, isNull, ne, or, sql, type SQL } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, ne, sql, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -1390,7 +1390,7 @@ export function environmentService(db: Db) {
db
.select({ count: sql<number>`count(*)::int` })
.from(projects)
.where(or(eq(projects.environmentId, id), sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`)),
.where(sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`),
db
.select({ count: sql<number>`count(*)::int` })
.from(companySecretBindings)
@@ -274,7 +274,6 @@ export function selectEnvironmentExecutionWorkspaceSettings(
export type ExecutionWorkspaceEnvironmentSource =
| "issue"
| "project"
| "run"
| "agent"
| "instance"
@@ -300,7 +299,6 @@ export class ManagedSandboxUnavailableError extends Error {
export function resolveExecutionWorkspaceEnvironmentId(input: {
/** Explicit operator-selected sandbox for a task with a low-trust boundary. */
lowTrustIssueEnvironmentId?: string | null;
projectEnvironmentId?: string | null;
agentDefaultEnvironmentId: string | null;
instanceDefaultEnvironmentId: string | null;
localDefaultEnvironmentId: string;
@@ -319,9 +317,6 @@ export function resolveExecutionWorkspaceEnvironmentId(input: {
if (input.lowTrustIssueEnvironmentId) {
return {environmentId: input.lowTrustIssueEnvironmentId, source: "issue"};
}
if (input.projectEnvironmentId) {
return { environmentId: input.projectEnvironmentId, source: "project" };
}
if (input.agentDefaultEnvironmentId) {
return {
environmentId: input.agentDefaultEnvironmentId,
@@ -82,40 +82,24 @@ describe.skipIf(!support.supported)("heartbeat environment selection boundary",
return { input, issue, project, workspace, recover };
}
it("uses each project's saved environment before the shared agent and instance defaults", async () => {
it("places a new run by the agent default, not by its project", async () => {
const f = await fixture();
const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId);
const [target] = await db.insert(environments).values({ name: `Project host ${randomUUID()}`, driver: "ssh", config: { host: "project.example.test" } }).returning();
const [other] = await db.insert(projects).values({ companyId: f.input.agent.companyId, name: "Other project", environmentId: local.id }).returning();
await db.update(projects).set({ environmentId: target.id }).where(eq(projects.id, f.project.id));
const [other] = await db.insert(projects).values({ companyId: f.input.agent.companyId, name: "Other project" }).returning();
f.input.agent.defaultEnvironmentId = local.id;
f.input.policy.isolatedWorkspacesEnabled = false;
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: target.id, source: "project" });
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "agent" });
f.input.task.executionProjectId = other.id;
f.input.task.issueRef!.projectId = other.id;
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "project" });
f.input.task.executionProjectId = f.project.id;
f.input.task.issueRef!.projectId = f.project.id;
await db.update(projects).set({ environmentId: null }).where(eq(projects.id, f.project.id));
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "agent" });
});
it("keeps task project placement when files come from another project", async () => {
const f = await fixture();
const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId);
const [source] = await db.insert(projects).values({ companyId: f.project.companyId, name: "File source" }).returning();
await db.update(projects).set({ environmentId: local.id }).where(eq(projects.id, f.project.id));
f.input.task.executionProjectId = source.id;
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "project" });
});
it("restores the admitted environment after a project edit and rejects a foreign lease", async () => {
it("restores the admitted environment after a default change and rejects a foreign lease", async () => {
const f = await fixture();
const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId);
const [next] = await db.insert(environments).values({ name: `Next target ${randomUUID()}`, driver: "ssh", config: { host: "next.example.test" } }).returning();
const saved = await f.workspace();
f.recover(saved.id);
await db.update(projects).set({ environmentId: next.id }).where(eq(projects.id, f.project.id));
f.input.agent.defaultEnvironmentId = next.id;
const [lease] = await db.insert(environmentLeases).values({ companyId: f.project.companyId, environmentId: local.id,
heartbeatRunId: f.input.run.id, issueId: f.issue.id, executionWorkspaceId: saved.id, metadata: { agentId: f.input.agent.id } }).returning();
f.input.run.contextSnapshot = { paperclipEnvironment: { id: local.id, leaseId: lease.id } };
@@ -1,6 +1,6 @@
import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js";
import { heartbeatExternalProvider, nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
import { heartbeatRuns, issues, projects, type Db, type agents } from "@paperclipai/db";
import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { readGitCacheExpiration } from "../git-workspace-restoration.js";
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings, ProjectExecutionWorkspacePolicy } from "@paperclipai/shared";
@@ -47,7 +47,7 @@ export interface HeartbeatEnvironmentSelectionInput {
"id" | "companyId" | "adapterType" | "adapterConfig" | "defaultEnvironmentId">;
task: {
freshWorkspace?: import("../fresh-workspace-continuation.js").ContinuationReadiness | null;
issueRef: Pick<Issue, "id" | "projectWorkspaceId" | "executionWorkspaceId" | "executionWorkspacePreference"> & { projectId?: string | null } | null;
issueRef: Pick<Issue, "id" | "projectWorkspaceId" | "executionWorkspaceId" | "executionWorkspacePreference"> | null;
issueContext: Pick<Issue, "assigneeAgentId" | "executionWorkspaceSettings"> | null;
issueId: string | null;
context: Record<string, unknown>;
@@ -203,12 +203,6 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
const managedSandboxEnvironment = managedSandboxOnly && !providerApiPlacement
? await environmentsSvc.findManagedSandboxEnvironment(agent.companyId)
: null;
const placementProjectId = issueRef ? issueRef.projectId ?? null : executionProjectId;
const [projectEnvironment] = placementProjectId && !controllerOnly && !persistedNativeExecutionInput
? await db.select({ environmentId: projects.environmentId }).from(projects)
.where(and(eq(projects.id, placementProjectId), eq(projects.companyId, agent.companyId)))
: [];
// A project edit affects new admissions, never a surviving provider process.
const savedEnvironment = parseObject(parseObject(run.contextSnapshot).paperclipEnvironment);
const admittedLeaseId = persistedNativeExecutionInput && !controllerOnly
? readNonEmptyString(savedEnvironment.leaseId) ?? readNativeWorkspaceSyncReference(persistedRunnerProfile.nativeWorkspaceSync)?.leaseId
@@ -227,7 +221,6 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
: resolveExecutionWorkspaceEnvironmentId({
lowTrustIssueEnvironmentId: trustPreset.kind === "low_trust_review"
? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings, {includeEnvironmentId: true})?.environmentId : null,
projectEnvironmentId: projectEnvironment?.environmentId,
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
instanceDefaultEnvironmentId:
resolvedInstanceSettings.defaultEnvironmentId ?? null,
+4 -4
View File
@@ -70,9 +70,9 @@ export async function projectEnvironmentOptions(db: Db, companyId: string, actor
};
}
export async function resolveProjectEnvironment(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) {
export async function withDefaultProjectStorage(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) {
const policy = parseProjectExecutionWorkspacePolicy(rawPolicy);
if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return { environmentId: null, executionWorkspacePolicy: rawPolicy };
if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return rawPolicy;
const options = await projectEnvironmentOptions(db, companyId, actor);
const environmentId = actor.environmentId?.toLowerCase() ?? options.defaultEnvironmentId;
const group = options.groups.find(value => value.environments.some(environment => environment.id === environmentId));
@@ -85,7 +85,7 @@ export async function resolveProjectEnvironment(db: Db, companyId: string, rawPo
if (policy?.storageProviderPluginId !== undefined && policy.storageProviderPluginId !== pluginId) {
throw conflict("The selected environment cannot use the requested project workspace storage.", { code: "project_environment_incompatible" });
}
return { environmentId: environmentId ?? null, executionWorkspacePolicy: pluginId || policy?.storageProviderPluginId !== undefined
return pluginId || policy?.storageProviderPluginId !== undefined
? { enabled: true, ...policy, storageProviderPluginId: pluginId }
: rawPolicy };
: rawPolicy;
}
+19 -42
View File
@@ -1,6 +1,6 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
import { resolveProjectEnvironment, type ProjectCreationEnvironment } from "./project-environments.js";
import { withDefaultProjectStorage, type ProjectCreationEnvironment } from "./project-environments.js";
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js";
import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
@@ -59,6 +59,12 @@ function legacyProjectEnvironmentId(policy: Record<string, unknown> | null | und
return typeof id === "string" || id === null ? id : undefined;
}
function withoutLegacyProjectEnvironment<T extends Record<string, unknown> | null | undefined>(policy: T): T {
if (legacyProjectEnvironmentId(policy) === undefined) return policy;
const normalized = parseProjectExecutionWorkspacePolicy(policy);
return (normalized ? { ...normalized } : null) as T;
}
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
@@ -681,14 +687,8 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
// skipped validation whenever `goalIds: []` and `goalId` arrive
// together (goalIds wins resolution, mirroring the update path).
const legacyGoalId = ids?.[0] ?? null;
const legacyEnvironmentId = legacyProjectEnvironmentId(projectData.executionWorkspacePolicy);
const workspacePolicy = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy);
const selected = await resolveProjectEnvironment(db, companyId,
legacyEnvironmentId !== undefined ? workspacePolicy : projectData.executionWorkspacePolicy ?? null, {
...environment, environmentId: environment.environmentId ?? projectData.environmentId ?? legacyEnvironmentId ?? undefined,
});
const executionWorkspacePolicy = selected.executionWorkspacePolicy as typeof projectData.executionWorkspacePolicy;
projectData.environmentId = selected.environmentId;
const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId,
withoutLegacyProjectEnvironment(projectData.executionWorkspacePolicy) ?? null, environment) as typeof projectData.executionWorkspacePolicy;
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
@@ -984,48 +984,28 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
update: async (
id: string,
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[]; expectedStorageProviderPluginId?: string | null },
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
): Promise<ProjectWithGoals | null> => db.transaction(async tx => {
const db = tx as unknown as Db;
const { goalIds: inputGoalIds, expectedStorageProviderPluginId, ...projectData } = data;
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
const existingProject = await db
.select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt, environmentId: projects.environmentId, executionWorkspacePolicy: projects.executionWorkspacePolicy })
.select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt, executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(eq(projects.id, id)).for("update")
.then((rows) => rows[0] ?? null);
if (!existingProject) return null;
const legacyEnvironmentId = legacyProjectEnvironmentId(projectData.executionWorkspacePolicy);
if (projectData.environmentId === undefined && legacyEnvironmentId !== undefined) {
projectData.environmentId = legacyEnvironmentId;
}
if (legacyEnvironmentId !== undefined) {
const normalized = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy);
projectData.executionWorkspacePolicy = normalized ? { ...normalized } : null;
}
projectData.executionWorkspacePolicy = withoutLegacyProjectEnvironment(projectData.executionWorkspacePolicy);
const currentStorage = parseProjectExecutionWorkspacePolicy(existingProject.executionWorkspacePolicy)?.storageProviderPluginId;
if (currentStorage && projectData.executionWorkspacePolicy !== undefined) {
if (projectData.executionWorkspacePolicy !== undefined) {
const requestedStorage = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId;
if (requestedStorage !== undefined && requestedStorage !== currentStorage) {
throw conflict("A plugin-backed project stays attached to its storage plugin");
if (requestedStorage !== undefined && requestedStorage !== (currentStorage ?? null)) {
throw conflict("A project stays on the workspace storage chosen when it was created", { code: "project_storage_locked" });
}
if (requestedStorage === undefined) {
if (requestedStorage === undefined && currentStorage) {
projectData.executionWorkspacePolicy = { enabled: true, ...projectData.executionWorkspacePolicy, storageProviderPluginId: currentStorage };
}
}
const requestedStorage = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId;
const storageChanged = requestedStorage !== undefined && requestedStorage !== currentStorage;
const selectedEnvironmentId = projectData.environmentId === undefined && storageChanged
? existingProject.environmentId : projectData.environmentId;
if (selectedEnvironmentId && (selectedEnvironmentId !== existingProject.environmentId || storageChanged || expectedStorageProviderPluginId !== undefined)) {
const selected = await resolveProjectEnvironment(db, existingProject.companyId,
projectData.executionWorkspacePolicy ?? existingProject.executionWorkspacePolicy,
{ environmentId: selectedEnvironmentId, expectedStorageProviderPluginId });
projectData.executionWorkspacePolicy = selected.executionWorkspacePolicy as typeof projectData.executionWorkspacePolicy;
}
if (!currentStorage && requestedStorage) {
await retireRemovedPluginWorkspaceDependents(db, id);
}
if (ids && ids.length > 0) {
await assertGoalsBelongToCompany(db, existingProject.companyId, ids);
}
@@ -1101,7 +1081,6 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
const rows = await db
.select({
id: projects.id,
environmentId: projects.environmentId,
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
@@ -1109,12 +1088,10 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
let cleared = 0;
for (const row of rows) {
const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy);
if (row.environmentId !== environmentId && legacyProjectEnvironmentId(row.executionWorkspacePolicy) !== environmentId) continue;
if (legacyProjectEnvironmentId(row.executionWorkspacePolicy) !== environmentId) continue;
await projectService(db).update(row.id, {
environmentId: null,
...(legacyProjectEnvironmentId(row.executionWorkspacePolicy) === environmentId ? { executionWorkspacePolicy: policy ? { ...policy } : null } : {}),
executionWorkspacePolicy: withoutLegacyProjectEnvironment(row.executionWorkspacePolicy),
});
cleared += 1;
}
@@ -406,7 +406,7 @@
"packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec",
"packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358",
"packages/shared/src/types/plugin.ts": "36e0f50d97f96532cb1fbdf3223368d92a7e9b1e",
"packages/shared/src/types/project.ts": "2a20b4d8772d5f0fa5015828818b025abc7cedcf",
"packages/shared/src/types/project.ts": "ae2e718acd652e362cf1ef91e69aad96d12042fe",
"packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e",
"packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8",
"packages/shared/src/types/routine.ts": "e5e5a93d63233029b9bf258558ad155677a4eca7",
@@ -487,7 +487,7 @@
"packages/shared/src/validators/partial.ts": "fa60ec7481fc14175365e9e500c814b159f1ab53",
"packages/shared/src/validators/plugin.test.ts": "23bc443850d46d815877a71a44625fd42aebb00a",
"packages/shared/src/validators/plugin.ts": "054de474635e080073e85413211cca4b3eb8681b",
"packages/shared/src/validators/project.ts": "dc051327b2eaf4a28288a84e21c1f3baab906bc9",
"packages/shared/src/validators/project.ts": "0257acc29400f438aefc0a657250d8f28ec60838",
"packages/shared/src/validators/provider-trace.ts": "b38b6200a2e51513f5dca0f8b008f2fe68175e82",
"packages/shared/src/validators/resource-memberships.ts": "b124ac8a8f24b4dbb03ae82bcc87541f6b4ecdfd",
"packages/shared/src/validators/routine.test.ts": "1501907ade577c1ab4c4b3098397e5d080446878",
@@ -201,7 +201,6 @@ describe("IssueWorkspaceCard", () => {
issue={createIssue()}
project={{
id: "project-1",
environmentId: "env-project",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "isolated_workspace",
@@ -263,7 +262,6 @@ describe("IssueWorkspaceCard", () => {
issue={createIssue()}
project={{
id: "project-1",
environmentId: "env-project",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "isolated_workspace",
-7
View File
@@ -165,7 +165,6 @@ interface IssueWorkspaceCardProps {
currentExecutionWorkspace?: ExecutionWorkspace | null;
};
project: {
environmentId?: string | null;
id: string;
executionWorkspacePolicy?: {
enabled?: boolean;
@@ -254,15 +253,11 @@ export function IssueWorkspaceCard({
const [draftSelection, setDraftSelection] = useState(currentSelection);
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
const projectEnvironmentId = environmentsEnabled
? project?.environmentId ?? null
: null;
const currentReusableEnvironmentId = selectedReusableExecutionWorkspace?.config?.environmentId ?? null;
const currentEnvironmentId = environmentsEnabled
? (
(currentSelection === "reuse_existing" && currentReusableEnvironmentId)
?? workspace?.config?.environmentId
?? projectEnvironmentId
)
: null;
const currentEnvironment =
@@ -411,8 +406,6 @@ export function IssueWorkspaceCard({
Environment: <span className="text-foreground">{currentEnvironment?.name ?? currentEnvironmentId}</span>
{currentSelection === "reuse_existing" && currentReusableEnvironmentId === currentEnvironmentId
? " · reused workspace"
: !issue.executionWorkspaceSettings?.environmentId && projectEnvironmentId === currentEnvironmentId
? " · project default"
: null}
</div>
)}
@@ -20,7 +20,12 @@ function act(callback: () => void) {
const noop = vi.hoisted(() => () => undefined);
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } }));
const environmentOptions = vi.hoisted(() => ({ groups: [
{ storageProviderPluginId: null, environments: [{ id: "local", name: "Local" }, { id: "sandbox", name: "Paperclip Sandbox" }] },
{ storageProviderPluginId: "provider-one", environments: [{ id: "computer", name: "Agent computers" }] },
], defaultEnvironmentId: "local", error: null }));
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn(),
environmentOptions: vi.fn(async () => environmentOptions) } }));
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } }));
vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } }));
@@ -57,6 +62,7 @@ function primedClient(hiddenSettings: string[] = []) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.health, { hiddenSettings });
client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true });
client.setQueryData(queryKeys.projects.environments("company-1"), environmentOptions);
return client;
}
@@ -140,31 +146,44 @@ it("hides project isolation controls without rewriting its policy", () => {
});
describe("ProjectProperties — workspace storage selection", () => {
describe("ProjectProperties — workspace storage availability", () => {
const inherited = {
source: "core" as const, pluginId: null, displayName: "Managed storage", error: null,
effectivePolicy: { enabled: true, defaultMode: "isolated_workspace" as const, storageProviderPluginId: "provider-one", workspaceStrategy: { type: "git_worktree" as const } },
providers: [{ id: "provider-one", displayName: "Managed storage", isDefault: true }, { id: "provider-two", displayName: "Alternative", isDefault: false }],
};
function storageSelect() { return container.querySelector<HTMLSelectElement>('select[aria-label="Workspace storage provider"]')!; }
it("shows an unattached project on Core storage and offers each plugin", () => {
render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), vi.fn());
expect(storageSelect().value).toBe("core");
expect(storageSelect().disabled).toBe(false);
expect(Array.from(storageSelect().options).map(option => option.value)).toEqual(["core", "provider-one", "provider-two"]);
expect(container.textContent).toContain("A project moved to a storage plugin stays on that plugin.");
const core = { source: "core" as const, pluginId: null, displayName: "Core / local", error: null, effectivePolicy: null, providers: inherited.providers };
function note() { return container.querySelector('[data-testid="project-storage-availability"]'); }
it("offers no storage or environment control", () => {
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: core }), vi.fn());
expect(container.querySelector('select[aria-label="Workspace storage provider"]')).toBeNull();
expect(container.querySelector('select[id^="project-environment-"]')).toBeNull();
});
it("saves a move to a plugin while preserving explicit settings", () => {
const onFieldUpdate = vi.fn();
render(makeProject({ executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" }, workspaceProvider: inherited }), onFieldUpdate);
act(() => { storageSelect().value = "provider-two"; storageSelect().dispatchEvent(new Event("change", { bubbles: true })); });
expect(onFieldUpdate).toHaveBeenCalledWith("workspace_storage_provider", { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize", storageProviderPluginId: "provider-two" } });
it("hides the note when no storage plugin is installed", () => {
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: { ...core, providers: [] } }), vi.fn());
expect(note()).toBeNull();
expect(container.textContent).not.toContain("Available in");
});
it("locks the control on a plugin-backed project", () => {
render(makeProject({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" }, workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one" } }), vi.fn());
expect(storageSelect().value).toBe("provider-one");
expect(storageSelect().disabled).toBe(true);
expect(container.textContent).toContain("This project stays on Managed storage.");
it("lists the environments that share a core project's storage when a plugin is installed", () => {
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: core }), vi.fn());
expect(note()?.textContent).toContain("Available in");
expect(note()?.textContent).toContain("Local, Paperclip Sandbox");
expect(note()?.textContent).not.toContain("Agent computers");
expect(note()?.textContent).toContain("Core / local workspace storage was chosen when this project was created");
});
it("lists the plugin's environments for a plugin-backed project", () => {
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" },
workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one" } }), vi.fn());
expect(note()?.textContent).toContain("Agent computers");
expect(note()?.textContent).not.toContain("Paperclip Sandbox");
expect(note()?.textContent).toContain("Managed storage workspace storage");
});
it("reports an uninstalled storage plugin instead of hiding the note", () => {
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" },
workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one", displayName: "Unavailable provider",
providers: [], error: "The workspace storage plugin is unavailable" } }), vi.fn());
expect(note()?.querySelector('[role="alert"]')?.textContent).toBe("Storage plugin unavailable");
expect(note()?.querySelectorAll('[role="alert"]')).toHaveLength(1);
});
it("does not copy inherited mode, strategy, or provider when another setting changes", () => {
const onFieldUpdate = vi.fn();
@@ -1,98 +0,0 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { useState } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { projectsApi } from "../api/projects";
import { ProjectProperties } from "./ProjectProperties";
import { TooltipProvider } from "./ui/tooltip";
import { queryKeys } from "../lib/queryKeys";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const options = vi.hoisted(() => ({ groups: [
{ storageProviderPluginId: null, environments: [{ id: "local", name: "Local" }, { id: "ssh", name: "Build host" }] },
{ storageProviderPluginId: "plugin", environments: [{ id: "computer", name: "Computers" }] },
], defaultEnvironmentId: "local", error: null }));
vi.mock("../api/projects", () => ({ projectsApi: { environmentOptions: vi.fn(async () => options) } }));
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn(async () => []), listUserSecretDefinitions: vi.fn(async () => []) } }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn(async () => ({ enableEnvironments: true, enableIsolatedWorkspaces: false })) } }));
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company", companies: [] }) }));
vi.mock("./ProjectRepositories", () => ({ ProjectRepositories: () => null }));
vi.mock("./ProjectGitAuthorization", () => ({ ProjectGitAuthorization: () => null }));
vi.mock("./ProjectAccessMembers", () => ({ ProjectAccessMembers: () => null }));
vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null }));
vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
const update = vi.fn();
let container: HTMLDivElement, root: Root;
beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); });
afterEach(() => { flushSync(() => root.unmount()); container.remove(); vi.clearAllMocks(); });
function makeProject(id: string): Project {
return { id, companyId: "company", name: id, urlKey: id, status: "planned", goalIds: [], goals: [], env: null,
environmentId: "local", executionWorkspacePolicy: null, workspaces: [], primaryWorkspace: null,
workspaceProvider: { source: "core", pluginId: null, displayName: "Core / local", providers: [{ id: "plugin", displayName: "Managed storage" }], effectivePolicy: null, error: null },
codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null },
} as unknown as Project;
}
function renderProjects(seedOptions = true) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(queryKeys.health, { hiddenSettings: [] });
client.setQueryData(queryKeys.instance.experimentalSettings, { enableEnvironments: true, enableIsolatedWorkspaces: false });
if (seedOptions) client.setQueryData(queryKeys.projects.environments("company"), options);
function Projects() {
const [projects, setProjects] = useState([makeProject("first"), makeProject("second")]);
return projects.map(project => <fieldset key={project.id} data-project={project.id}>
<ProjectProperties project={project} onFieldUpdate={(field, data) => {
update(project.id, field, data);
setProjects(previous => previous.map(value => value.id === project.id ? { ...value, ...data } as Project : value));
}} />
</fieldset>);
}
flushSync(() => root.render(<QueryClientProvider client={client}><TooltipProvider><Projects /></TooltipProvider></QueryClientProvider>));
return client;
}
it("keeps environment selection on its project with isolation disabled and a shared query cache", () => {
const client = renderProjects();
const first = container.querySelector<HTMLSelectElement>('[data-project="first"] select[id^="project-environment-"]')!;
const second = container.querySelector<HTMLSelectElement>('[data-project="second"] select[id^="project-environment-"]')!;
expect(first.value).toBe("local");
expect(second.value).toBe("local");
expect([...first.options].map(option => option.value)).not.toContain("computer");
flushSync(() => { first.value = "ssh"; first.dispatchEvent(new Event("change", { bubbles: true })); });
expect(update).toHaveBeenCalledExactlyOnceWith("first", "execution_workspace_environment", { environmentId: "ssh", expectedStorageProviderPluginId: null });
expect(first.value).toBe("ssh");
expect(second.value).toBe("local");
expect(client.getQueryData(queryKeys.projects.environments("company"))).toEqual(options);
flushSync(() => { first.value = ""; first.dispatchEvent(new Event("change", { bubbles: true })); });
expect(update).toHaveBeenLastCalledWith("first", "execution_workspace_environment", { environmentId: null });
expect(second.value).toBe("local");
});
it("changes storage and the compatible environment together on only the selected project", () => {
renderProjects();
const firstStorage = container.querySelector<HTMLSelectElement>('[data-project="first"] select#workspace-storage-provider')!;
flushSync(() => { firstStorage.value = "plugin"; firstStorage.dispatchEvent(new Event("change", { bubbles: true })); });
expect(update).toHaveBeenCalledExactlyOnceWith("first", "workspace_storage_provider", {
executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "plugin" },
environmentId: "computer", expectedStorageProviderPluginId: "plugin",
});
expect(container.querySelector<HTMLSelectElement>('[data-project="first"] select[id^="project-environment-"]')!.value).toBe("computer");
expect(container.querySelector<HTMLSelectElement>('[data-project="second"] select[id^="project-environment-"]')!.value).toBe("local");
});
it("keeps the saved mapping and allows an explicit clear while the environment list loads", () => {
vi.mocked(projectsApi.environmentOptions).mockReturnValueOnce(new Promise(() => {}));
renderProjects(false);
const first = container.querySelector<HTMLSelectElement>('[data-project="first"] select[id^="project-environment-"]')!;
expect(first.value).toBe("local");
expect(first.selectedOptions[0].textContent).toBe("Saved environment");
expect(first.disabled).toBe(false);
flushSync(() => { first.value = ""; first.dispatchEvent(new Event("change", { bubbles: true })); });
expect(update).toHaveBeenCalledExactlyOnceWith("first", "execution_workspace_environment", { environmentId: null });
expect(container.querySelector<HTMLSelectElement>('[data-project="second"] select[id^="project-environment-"]')!.value).toBe("local");
});
+25 -71
View File
@@ -24,7 +24,6 @@ import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { QueryErrorState, useQueryView } from "./QueryView";
import { NativeSelect } from "./ui/select";
import { Badge } from "@/components/ui/badge";
import { ProjectAccessMembers } from "./ProjectAccessMembers";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
@@ -48,11 +47,9 @@ export type ProjectConfigFieldKey =
| "visibility"
| "goals"
| "env"
| "workspace_storage_provider"
| "execution_workspace_enabled"
| "execution_workspace_default_mode"
| "execution_workspace_shared_concurrency"
| "execution_workspace_environment"
| "execution_workspace_base_ref"
| "execution_workspace_branch_template"
| "execution_workspace_worktree_parent_dir"
@@ -240,7 +237,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
@@ -263,10 +259,15 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const providerConfiguration = project.workspaceProvider;
const projectStoragePluginId = project.executionWorkspacePolicy?.storageProviderPluginId ?? null;
const showStorageAvailability = Boolean(projectStoragePluginId || providerConfiguration?.providers.length);
const storagePluginUnavailable = Boolean(projectStoragePluginId
&& !providerConfiguration?.providers.some(provider => provider.id === projectStoragePluginId));
const projectEnvironments = useQuery({
queryKey: queryKeys.projects.environments(project.companyId),
queryFn: () => projectsApi.environmentOptions(project.companyId),
enabled: Boolean(project.companyId),
enabled: Boolean(project.companyId) && showStorageAvailability && !storagePluginUnavailable,
});
const projectEnvironmentView = useQueryView(projectEnvironments);
@@ -275,7 +276,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
const primaryCodebaseWorkspace = project.primaryWorkspace ?? null;
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId);
const storedExecutionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const providerConfiguration = project.workspaceProvider;
const executionWorkspacePolicy = providerConfiguration?.effectivePolicy ?? storedExecutionWorkspacePolicy;
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
@@ -298,9 +298,9 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
// policy reads as "not managed", which would show the local folder the policy
// exists to hide.
const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly;
const projectEnvironmentChoices = projectEnvironmentView.data?.groups
.filter(group => group.storageProviderPluginId === (storedExecutionWorkspacePolicy?.storageProviderPluginId ?? null))
.flatMap(group => group.environments) ?? [];
const storageEnvironmentNames = projectEnvironmentView.data?.groups
.filter(group => group.storageProviderPluginId === projectStoragePluginId)
.flatMap(group => group.environments.map(environment => environment.name)) ?? [];
const invalidateProject = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
@@ -726,70 +726,24 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
)}
</div>}
{environmentsEnabled && project.companyId ? (
<div className="py-1.5 space-y-2">
<label htmlFor={`project-environment-${project.id}`} className="flex items-center gap-2 text-sm font-medium">
Environment
<SaveIndicator state={fieldState("execution_workspace_environment")} />
</label>
<NativeSelect id={`project-environment-${project.id}`} value={project.environmentId ?? ""}
disabled={(!onUpdate && !onFieldUpdate) || fieldState("execution_workspace_environment") === "saving"}
onChange={event => {
const environmentId = event.target.value || null;
const group = projectEnvironmentView.data?.groups.find(value => value.environments.some(environment => environment.id === environmentId));
commitField("execution_workspace_environment", { environmentId,
...(group ? { expectedStorageProviderPluginId: group.storageProviderPluginId } : {}) });
}}>
<option value="">Use agent default</option>
{project.environmentId && !projectEnvironmentChoices.some(environment => environment.id === project.environmentId)
? <option value={project.environmentId}>{projectEnvironmentView.data ? "Unavailable environment" : "Saved environment"}</option> : null}
{projectEnvironmentChoices.map(environment => <option key={environment.id} value={environment.id}>{environment.name}</option>)}
</NativeSelect>
<p className="text-xs text-muted-foreground">Applies only to this project. Agent and company defaults stay unchanged.</p>
{projectEnvironmentView.kind === "loading" || projectEnvironmentView.kind === "reconnecting"
? <p role="status" className="text-xs text-muted-foreground">Loading environments…</p> : null}
{projectEnvironmentView.kind === "error" ? <QueryErrorState error={projectEnvironmentView.error} size="inline"
action="load project environments" onRetry={projectEnvironmentView.retry} retrying={projectEnvironmentView.isFetching} /> : null}
</div>
) : null}
{providerConfiguration ? (
<div className="py-1.5 space-y-2">
<label className="flex items-center gap-2 text-sm font-medium" htmlFor="workspace-storage-provider">
Workspace storage
<SaveIndicator state={fieldState("workspace_storage_provider")} />
</label>
<select
id="workspace-storage-provider"
aria-label="Workspace storage provider"
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
disabled={Boolean(storedExecutionWorkspacePolicy?.storageProviderPluginId) || (!onUpdate && !onFieldUpdate) || Boolean(project.companyId && !projectEnvironmentView.data)}
value={storedExecutionWorkspacePolicy?.storageProviderPluginId ?? "core"}
onChange={(event) => {
const policy = { enabled: true, ...storedExecutionWorkspacePolicy };
policy.storageProviderPluginId = event.target.value === "core" ? null : event.target.value;
const group = projectEnvironmentView.data?.groups.find(value => value.storageProviderPluginId === policy.storageProviderPluginId);
const target = group?.environments.find(environment => environment.id === project.environmentId)
?? group?.environments.find(environment => environment.id === projectEnvironmentView.data?.defaultEnvironmentId)
?? group?.environments[0];
commitField("workspace_storage_provider", { executionWorkspacePolicy: policy,
...(target ? { environmentId: target.id, expectedStorageProviderPluginId: policy.storageProviderPluginId } : {}) });
}}
>
<option value="core">Core / local</option>
{providerConfiguration.providers.map(provider => (
<option key={provider.id} value={provider.id} disabled={Boolean(project.companyId && !projectEnvironmentView.data?.groups.some(group => group.storageProviderPluginId === provider.id))}>{provider.displayName}</option>
))}
{storedExecutionWorkspacePolicy?.storageProviderPluginId && !providerConfiguration.providers.some(provider => provider.id === storedExecutionWorkspacePolicy.storageProviderPluginId) ? (
<option value={storedExecutionWorkspacePolicy.storageProviderPluginId}>Unavailable provider</option>
) : null}
</select>
{showStorageAvailability ? (
<div className="py-1.5 space-y-1" data-testid="project-storage-availability">
<div className="text-sm font-medium">Available in</div>
{storagePluginUnavailable ? (
<p role="alert" className="text-sm text-destructive">Storage plugin unavailable</p>
) : projectEnvironmentView.kind === "loading" || projectEnvironmentView.kind === "reconnecting" ? (
<p role="status" className="text-sm text-muted-foreground">Loading environments…</p>
) : projectEnvironmentView.kind === "error" ? (
<QueryErrorState error={projectEnvironmentView.error} size="inline" action="load project environments"
onRetry={projectEnvironmentView.retry} retrying={projectEnvironmentView.isFetching} />
) : (
<p className="text-sm">{storageEnvironmentNames.length ? storageEnvironmentNames.join(", ") : "No available environments"}</p>
)}
<p className="text-xs text-muted-foreground">
{storedExecutionWorkspacePolicy?.storageProviderPluginId
? `This project stays on ${providerConfiguration.displayName}.`
: "A project moved to a storage plugin stays on that plugin."}
{providerConfiguration?.displayName ?? "Core / local"} workspace storage was chosen when this project was created and does not change.
</p>
{providerConfiguration.error ? <p role="alert" className="text-xs text-destructive">{providerConfiguration.error}</p> : null}
{providerConfiguration?.error && !storagePluginUnavailable
? <p role="alert" className="text-xs text-destructive">{providerConfiguration.error}</p> : null}
</div>
) : null}