From f30ac226d6288fbd3e0829110267cff5797012f9 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 10 Nov 2026 14:31:48 -0800 Subject: [PATCH] Keep project environment mappings independent of shared defaults (#20651) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A project supplies work context and repository storage to task runs. > - Creation selected a storage owner but did not retain the selected environment on the project. > - Configuration stored an environment inside checkout policy, but policy reads and run selection dropped it. > - Projects therefore continued to follow shared agent and instance defaults. > - This pull request saves placement on each project and uses it for new runs. > - The benefit is an independent Project to Environment association with the existing storage, trust, and recovery safeguards. ## Linked Issues or Issue Description Refs #20459 and #11644. Follow-up to #20587. The reported journey is Project > Configuration: changing one project's environment must not change another project's mapping or shared defaults. Related open PR #18176 repairs placement through the legacy workspace-policy field. This follow-up introduces a separate project association, saves creation defaults, and migrates saved legacy references. It overlaps that PR's placement, permission, and recovery safeguards. The changes should be reviewed together. #9588 concerns environment-variable response privacy and does not address placement. ROADMAP.md has no separate project-association work planned. ## What Changed - Add nullable `projects.environment_id` and expose `Project.environmentId`. Save the effective creation default once on the project. Keep environment placement separate from checkout/isolation policy. - Read and write Environment directly in Project Configuration. Keep the control available with isolated workspaces disabled. Each form reads its project's value, even when environment choices share a query cache. - Offer environments compatible with the current storage owner. Change the target and storage together when the existing storage selector attaches a core project to a plugin. Preserve retained-data restrictions and validate the observed storage owner again on save. - Resolve new task runs from their organizational project's target before agent and instance defaults. File sources from another project do not change placement. Explicit low-trust sandbox choices and enforced sandbox policies retain precedence. External provider placement remains separate. - Restore admitted native runs from their owned environment lease. Later project edits apply to new admissions. Require workspace-write permission for environment changes and clears. Check company bindings before runtime admission and during lease creation. - Migrate valid saved policy references into the project column. Normalize legacy API writes. Count and clear matching project references when an environment is deleted. Update API and behavior documentation. - Refresh the maintained SDK source registry for the two changed shared files. Runtime pins are unchanged. ## Verification - [Hosted CI](https://github.com/paperclipai/paperclip/actions/runs/42908045146) is green on `8b3b7d5b5aed51fc4c8e9c2e3506508bbaf9e00c`: **60 successful checks**, three skipped gates, no failures, and `ci / verify` passed. GitHub reports no merge conflicts. Standard Greptile scored the same commit **5/5** with both review threads resolved. - Source checkout on macOS arm64, Node 26.12.0, with fresh isolated PostgreSQL. No production data or live provider calls. - The two-project component test changes the first form with isolation disabled and a shared query cache. The second form keeps its target. Storage changes update only the selected project. A saved target remains selected while the list loads, and an explicit clear remains possible. - API integration creates two projects from the same default, patches one, reads both, and checks that agent and instance defaults remain unchanged. A later instance-default change does not rewrite either saved project target. Tests cover unavailable/incompatible choices, anonymous and cross-company denials, and storage preconditions. - The migration test runs the actual SQL against an existing project row and verifies its environment and disabled checkout policy survive. Deletion accounting and clearing affect only matching project references. - The final guard set passes **53 tests across four files**: `project-environments.test.ts`, `environment-selection-route-guards.test.ts`, `heartbeat/environment-selection.test.ts`, and `ProjectProperties.environment.test.tsx`. All **64 environment-run-orchestrator tests** pass, including direct lease acquisition without an extra caller argument. The broader affected set passed **109 tests across eight files** before the additional admission guards. These sets overlap. - UI typecheck, migration numbering/safety, token gates, module boundaries, query-error rendering, and diff checks pass. `pnpm verify --explain` returns exit 2 and requires broad CI because shared schema/runtime paths and behavior documentation changed. Broad coverage is delegated to current-head CI. - Early iterations failed due to a legacy parser that drops the environment field, a fixture without real company membership, and incorrect expectations for privacy-preserving 404 denials. The service now reads legacy references before normalization, the API fixture has real membership, and denial assertions match the existing privacy contract. The orchestrator mock also needed the reused company-binding guard in its service contract. The focused suites above pass after those corrections. - Local server typecheck remains blocked by installed ACPX, Slack, AI SDK, and Runner type mismatches in unchanged dependency-facing code. It reports no errors in the changed production paths. This is not a full local typecheck or suite pass. Hosted CI supplies full current-head coverage. - SDK source-proof and privacy qualification passes **56 tests across three files**, including the real built SDK. The maintained source registry matches the committed revision; runtime pins are unchanged. - Review reproduced two failures: an incoming policy without a storage owner could be validated before the existing owner was restored, and uppercase legacy UUIDs missed migration matching. Preserve the owner before validation and compare normalized UUID text. Both regressions now pass. The post-fix project/storage/orchestrator batch passes **107 tests**: `node node_modules/vitest/vitest.mjs run server/src/__tests__/project-environments.test.ts server/src/__tests__/project-workspace-provider.integration.test.ts server/src/__tests__/environment-run-orchestrator.test.ts --silent`. - The first hosted run failed build, typecheck, and canary at the same `run-completion.test.ts` call because lease acquisition required a new argument. The lease method now enforces binding internally. Its existing caller compiles without a new field, and the orchestrator tests prove the binding flag reaches the runtime. The full server check no longer reports that error; the local dependency errors above remain. ## Risks - Apply the normal database migration before serving the new revision. The migration adds a nullable foreign key and copies valid legacy project selections. It does not move repository data or resize provider storage. The five newest schema snapshots are retained. - New projects retain their creation target when shared defaults change. Existing projects without a saved selection continue to inherit defaults. A null target explicitly opts into inheritance. - Existing saved project selections now affect placement. Missing, archived, incompatible, and foreign-company targets fail validation; they do not silently choose another storage owner. A running admitted process keeps its original owned lease. - Local dependency limitations are listed above. No live-provider, deployed-browser, or production rollout qualification is claimed. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The exact serving snapshot and context-window size are not exposed in this session. ## 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 --------- Co-authored-by: Paperclip --- doc/project-environment-compatibility.md | 37 +++- docs/api/goals-and-projects.md | 14 +- .../migrations/0464_project_environment.sql | 7 + ...{0459_snapshot.json => 0464_snapshot.json} | 161 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/projects.ts | 2 + packages/shared/src/types/project.ts | 2 + packages/shared/src/validators/project.ts | 3 +- .../environment-run-orchestrator.test.ts | 10 ++ ...environment-selection-route-guards.test.ts | 13 +- .../execution-workspace-policy.test.ts | 9 + .../__tests__/project-environments.test.ts | 101 +++++++++-- server/src/routes/projects.ts | 16 +- .../services/environment-run-orchestrator.ts | 5 +- server/src/services/environment-runtime.ts | 2 + server/src/services/environments.ts | 4 +- .../services/execution-workspace-policy.ts | 6 + .../heartbeat/environment-selection.test.ts | 44 ++++- .../heartbeat/environment-selection.ts | 29 +++- server/src/services/project-environments.ts | 10 +- server/src/services/projects.ts | 70 +++++--- .../runner-e2e/source-sdk-public-inputs.json | 4 +- ui/src/components/IssueWorkspaceCard.test.tsx | 4 +- ui/src/components/IssueWorkspaceCard.tsx | 3 +- .../ProjectProperties.environment.test.tsx | 98 +++++++++++ ui/src/components/ProjectProperties.tsx | 94 +++++----- 26 files changed, 643 insertions(+), 112 deletions(-) create mode 100644 packages/db/src/migrations/0464_project_environment.sql rename packages/db/src/migrations/meta/{0459_snapshot.json => 0464_snapshot.json} (99%) create mode 100644 ui/src/components/ProjectProperties.environment.test.tsx diff --git a/doc/project-environment-compatibility.md b/doc/project-environment-compatibility.md index b8828321b90..f719861899d 100644 --- a/doc/project-environment-compatibility.md +++ b/doc/project-environment-compatibility.md @@ -2,8 +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 selects storage; it -is not an allowlist or an override of an agent's run environment. +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. ## Compatibility rule @@ -47,9 +48,10 @@ 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. The project -is then available to the compatible environments in that group; no single run -environment is pinned. An explicit `executionWorkspacePolicy.storageProviderPluginId` +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` 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 @@ -68,3 +70,28 @@ projects keep their stored owner. This change does not migrate managed storage or detach projects that contain existing work. A managed project run in an incompatible environment fails with `project_environment_incompatible` before a 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. + +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. + +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. diff --git a/docs/api/goals-and-projects.md b/docs/api/goals-and-projects.md index c669c54dc48..650ea14041a 100644 --- a/docs/api/goals-and-projects.md +++ b/docs/api/goals-and-projects.md @@ -61,7 +61,7 @@ GET /api/companies/{companyId}/projects GET /api/projects/{projectId} ``` -Returns project details including workspaces. +Returns project details including workspaces and the project-owned `environmentId`. ### Create Project @@ -84,6 +84,12 @@ 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. +- `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. - `workspace` is optional. If present, the project is created and seeded with that workspace. - A workspace must include at least one of `cwd` or `repoUrl`. - For repo-only projects, omit `cwd` and provide `repoUrl`. @@ -97,6 +103,12 @@ 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. + ## Project Workspaces Workspaces link a project to a repository and directory: diff --git a/packages/db/src/migrations/0464_project_environment.sql b/packages/db/src/migrations/0464_project_environment.sql new file mode 100644 index 00000000000..e09246704e3 --- /dev/null +++ b/packages/db/src/migrations/0464_project_environment.sql @@ -0,0 +1,7 @@ +ALTER TABLE "projects" ADD COLUMN "environment_id" uuid;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint +UPDATE "projects" p +SET "environment_id" = e."id", "execution_workspace_policy" = p."execution_workspace_policy" - 'environmentId' +FROM "environments" e +WHERE lower(p."execution_workspace_policy" ->> 'environmentId') = e."id"::text; diff --git a/packages/db/src/migrations/meta/0459_snapshot.json b/packages/db/src/migrations/meta/0464_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0459_snapshot.json rename to packages/db/src/migrations/meta/0464_snapshot.json index 4ec4872e79f..13043fc2e01 100644 --- a/packages/db/src/migrations/meta/0459_snapshot.json +++ b/packages/db/src/migrations/meta/0464_snapshot.json @@ -1,6 +1,6 @@ { - "id": "e070677d-cfb8-4438-847c-29c4da7ca8c5", - "prevId": "3347b37d-b35b-4357-b08c-a78c017086ac", + "id": "e2a678da-05cc-4316-a40e-d85a69be01e3", + "prevId": "aaf06ddb-56bd-4455-b2d1-fdb0254445e7", "version": "7", "dialect": "postgresql", "tables": { @@ -36017,6 +36017,28 @@ "method": "btree", "with": {} }, + "heartbeat_runs_company_ctx_execution_workspace_idx": { + "name": "heartbeat_runs_company_ctx_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"context_snapshot\" ->> 'executionWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_runs\".\"context_snapshot\" ? 'executionWorkspaceId'", + "concurrently": true, + "method": "btree", + "with": {} + }, "heartbeat_runs_company_ctx_paperclip_issue_created_idx": { "name": "heartbeat_runs_company_ctx_paperclip_issue_created_idx", "columns": [ @@ -43558,6 +43580,28 @@ "method": "btree", "with": {} }, + "issues_company_legacy_origin_run_idx": { + "name": "issues_company_legacy_origin_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"issues\".\"origin_issue_id\" is null and \"issues\".\"origin_run_id\" is not null", + "concurrently": true, + "method": "btree", + "with": {} + }, "issues_company_project_workspace_idx": { "name": "issues_company_project_workspace_idx", "columns": [ @@ -49063,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": "", @@ -50927,6 +51058,12 @@ "primaryKey": false, "notNull": false }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "git_authorization_grant_id": { "name": "git_authorization_grant_id", "type": "uuid", @@ -51078,6 +51215,19 @@ "onDelete": "no action", "onUpdate": "no action" }, + "projects_environment_id_environments_id_fk": { + "name": "projects_environment_id_environments_id_fk", + "tableFrom": "projects", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, "projects_git_authorization_grant_id_connection_grants_id_fk": { "name": "projects_git_authorization_grant_id_connection_grants_id_fk", "tableFrom": "projects", @@ -59472,6 +59622,13 @@ "notNull": true, "default": "''" }, + "pending_reveal": { + "name": "pending_reveal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "allowed_actions": { "name": "allowed_actions", "type": "jsonb", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 06e7c126641..1deaa2d7742 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -3235,6 +3235,13 @@ "when": 1794340358740, "tag": "0463_gateway_token_pending_reveal", "breakpoints": true + }, + { + "idx": 464, + "version": "7", + "when": 1794346237220, + "tag": "0464_project_environment", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/projects.ts b/packages/db/src/schema/projects.ts index 10e320891bb..2302caf0768 100644 --- a/packages/db/src/schema/projects.ts +++ b/packages/db/src/schema/projects.ts @@ -2,6 +2,7 @@ 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"; @@ -23,6 +24,7 @@ export const projects = pgTable( color: text("color"), icon: text("icon"), env: jsonb("env").$type(), + 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"), diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index ae2e718acd6..2a20b4d8772 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -147,6 +147,8 @@ 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; diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 0257acc2940..dc051327b2e 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -119,6 +119,7 @@ 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(), }; @@ -136,7 +137,7 @@ export const createProjectSchema = z.object({ export type CreateProject = z.infer; export const updateProjectSchema = objectWithoutDefaults( - z.object(projectFields), + z.object({ ...projectFields, expectedStorageProviderPluginId: z.string().guid().nullable().optional() }), ).partial(); export type UpdateProject = z.infer; diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 5b7629cd36e..735e03ec415 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -32,6 +32,7 @@ vi.mock("../services/workspace-realization.js", () => ({ })); vi.mock("../services/environments.js", () => ({ + assertEnvironmentCompanyBinding: vi.fn(async () => {}), environmentService: vi.fn(() => ({ ensureLocalEnvironment: vi.fn(), getById: mockGetEnvironment, @@ -284,6 +285,15 @@ describe("environmentRunOrchestrator — realizeForRun", () => { ); }); + it("enforces company bindings when a caller acquires a lease directly", async () => { + const runtime = makeMockRuntime(); + await environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }).acquireLease({ + companyId: "company-1", environment: makeEnvironment(), issueId: null, agentId: "agent-1", heartbeatRunId: "run-1", + persistedExecutionWorkspace: null, executionWorkspaceSettings: null, adapterType: null, + }); + expect(runtime.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ assertCompanyBinding: true })); + }); + it("realizes an enrolled remote workspace in place and persists its authoritative remote root", async () => { const environment = { ...makeEnvironment("sandbox"), id: randomUUID() }; const lease = makeLease({ id: randomUUID(), environmentId: environment.id, provider: "daytona", providerLeaseId: "sandbox-allocation" }); diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index da2f44a69b0..487080a56f3 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -6,6 +6,8 @@ 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(), @@ -63,7 +65,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), + allowed: ["project:read", "issue:read"].includes(input.action) || (projectPermissions.canWrite && ["tasks:assign", "project:write_workspace"].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.", @@ -183,6 +185,7 @@ describe("execution environment route guards", () => { }); beforeEach(() => { + projectPermissions.canWrite = true; mockProjectService.create.mockReset(); mockProjectService.getById.mockReset(); mockProjectService.update.mockReset(); @@ -275,6 +278,14 @@ describe("execution environment route guards", () => { 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, diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index e44ea327f3a..9389d655ccc 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -780,6 +780,15 @@ 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"}; diff --git a/server/src/__tests__/project-environments.test.ts b/server/src/__tests__/project-environments.test.ts index 815fa3c1cdd..1f01ca95736 100644 --- a/server/src/__tests__/project-environments.test.ts +++ b/server/src/__tests__/project-environments.test.ts @@ -1,11 +1,14 @@ +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, userCompanyPreferences } from "@paperclipai/db"; -import { projectEnvironmentOptions, withDefaultProjectStorage } from "../services/project-environments.js"; +import { agents, authUsers, companyMemberships, builtInManagedResources, companies, createDb, environments, instanceSettings, plugins, projects, userCompanyPreferences } from "@paperclipai/db"; +import { projectEnvironmentOptions, resolveProjectEnvironment } 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"; @@ -80,8 +83,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 withDefaultProjectStorage(db, f.companyId, null)).toBeNull(); - expect(await withDefaultProjectStorage(db, f.companyId, null, { userId: "person" })).toMatchObject({ storageProviderPluginId: f.pluginId }); + 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 } }); 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 }); @@ -98,12 +101,71 @@ 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 withDefaultProjectStorage(db, f.companyId, explicit)).toEqual(explicit); - await expect(withDefaultProjectStorage(db, f.companyId, explicit, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 }); + 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 }); 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 () => { + 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); + }); + + it.each([{ enabled: true }, null])("preserves the storage owner before validating a combined environment/policy edit (%j)", async policy => { + 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); + }); + it("preserves inherited task isolation when checking the observed core storage owner", async () => { const f = await fixture(); const { experimental: settings } = await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: true }); @@ -136,12 +198,15 @@ 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(withDefaultProjectStorage(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 }); + await expect(resolveProjectEnvironment(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 }); } }); @@ -149,10 +214,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 withDefaultProjectStorage(db, f.companyId, null)).toMatchObject({ storageProviderPluginId: f.pluginId }); + expect(await resolveProjectEnvironment(db, f.companyId, null)).toMatchObject({ executionWorkspacePolicy: { 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(withDefaultProjectStorage(db, f.companyId, null)).rejects.toMatchObject({ status: 409 }); + await expect(resolveProjectEnvironment(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 => { @@ -186,7 +251,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(withDefaultProjectStorage(db, f.companyId, null, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 }); + await expect(resolveProjectEnvironment(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 () => { @@ -196,6 +261,8 @@ 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`); @@ -204,6 +271,20 @@ 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); 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); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 47a2a179946..7b667384f67 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -173,9 +173,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:write_workspace", + const decision = await access.decide({ actor: req.actor, action: project.id ? "project:write_workspace" : "tasks:assign", resource: { type: "issue", companyId: project.companyId, projectId: project.id, assigneeAgentId }, scope: { projectId: project.id, assigneeAgentId } }); if (decision.allowed) return true; @@ -401,6 +401,8 @@ 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), @@ -522,6 +524,9 @@ 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; } @@ -530,10 +535,9 @@ export function projectRoutes(db: Db) { req, collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), ); - await assertProjectEnvironmentSelection( - existing.companyId, - readProjectPolicyEnvironmentId(body.executionWorkspacePolicy), - ); + if (environmentChanged && body.environmentId === undefined) { + await assertProjectEnvironmentSelection(existing.companyId, nextEnvironmentId); + } if (typeof body.archivedAt === "string") { body.archivedAt = new Date(body.archivedAt); } diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 62a8310a482..8149b84004f 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -40,7 +40,7 @@ import { runnerLifecycleDefaultNeedsLeaseProbe, resolveWarmRunnerIdleTimeoutMsForRun, } from "./runner-environment-lifecycle.js"; -import { environmentService } from "./environments.js"; +import { assertEnvironmentCompanyBinding, environmentService } from "./environments.js"; import { environmentRuntimeService, buildEnvironmentLeaseContext, @@ -209,6 +209,7 @@ export function environmentRunOrchestrator( }); } + await assertEnvironmentCompanyBinding(db, environment.id, input.companyId); return environment; } @@ -231,7 +232,7 @@ export function environmentRunOrchestrator( runnerReconnectGraceMs?: number; }): Promise { try { - return await environmentRuntime.acquireRunLease(input); + return await environmentRuntime.acquireRunLease({ ...input, assertCompanyBinding: true }); } catch (err) { // A transient provider refusal (HTTP 5xx/429/timeout) is retried by the // setup path with its bounded bootstrap budget. Saved-sandbox resume and diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 1d082cd7f33..49634c140c3 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -1142,6 +1142,7 @@ function createLocalEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { heartbeatRunId: input.heartbeatRunId, leasePolicy: "ephemeral", provider: "local", + assertCompanyBinding: input.assertCompanyBinding, metadata: { ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, @@ -1216,6 +1217,7 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { heartbeatRunId: input.heartbeatRunId, leasePolicy: "ephemeral", provider: "ssh", + assertCompanyBinding: input.assertCompanyBinding, providerLeaseId: `ssh://${parsed.config.username}@${parsed.config.host}:${parsed.config.port}${remoteCwd}`, metadata: { ...(input.agentId ? { agentId: input.agentId } : {}), diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 29b73fc9eb9..6de1229c272 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -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, sql, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, ne, or, 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`count(*)::int` }) .from(projects) - .where(sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`), + .where(or(eq(projects.environmentId, id), sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`)), db .select({ count: sql`count(*)::int` }) .from(companySecretBindings) diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index f25ee1451f0..3b433f9c027 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -274,6 +274,8 @@ export function selectEnvironmentExecutionWorkspaceSettings( export type ExecutionWorkspaceEnvironmentSource = | "issue" + | "project" + | "run" | "agent" | "instance" | "default" @@ -298,6 +300,7 @@ 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; @@ -316,6 +319,9 @@ 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, diff --git a/server/src/services/heartbeat/environment-selection.test.ts b/server/src/services/heartbeat/environment-selection.test.ts index d416429daea..730233aa512 100644 --- a/server/src/services/heartbeat/environment-selection.test.ts +++ b/server/src/services/heartbeat/environment-selection.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { agents, companies, createDb, environments, heartbeatRuns, issues, projects, projectWorkspaces, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, type Db } from "@paperclipai/db"; +import { agents, companies, createDb, environments, environmentLeases, heartbeatRuns, issues, projects, projectWorkspaces, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, type Db } from "@paperclipai/db"; import { eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; @@ -82,6 +82,48 @@ 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 () => { + 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)); + f.input.agent.defaultEnvironmentId = local.id; + f.input.policy.isolatedWorkspacesEnabled = false; + expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: target.id, source: "project" }); + 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 () => { + 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)); + 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 } }; + expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "run" }); + await db.update(environmentLeases).set({ metadata: { agentId: randomUUID() } }).where(eq(environmentLeases.id, lease.id)); + await expect(selectHeartbeatEnvironment(db, f.input)).rejects.toThrow("Cannot restore the admitted execution environment"); + }); + it("keeps the admitted workspace binding when the issue points at a newer workspace", async () => { const { input, workspace, recover } = await fixture(); const original = await workspace(); diff --git a/server/src/services/heartbeat/environment-selection.ts b/server/src/services/heartbeat/environment-selection.ts index 43a8422c90f..58c4a46728a 100644 --- a/server/src/services/heartbeat/environment-selection.ts +++ b/server/src/services/heartbeat/environment-selection.ts @@ -1,6 +1,6 @@ import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js"; import { heartbeatExternalProvider, nativeExecutionUsesTaskWorkspace } from "./external-provider.js"; -import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db"; +import { heartbeatRuns, issues, projects, 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"; @@ -26,6 +26,7 @@ import { nativeChatWorkspaceCwd, nativeChatWorkspaceMatches, } from "../native-runtime/native-chat-workspace.js"; +import { readNativeWorkspaceSyncReference } from "../native-runtime/native-workspace-sync.js"; import { NativeRunnerOwnershipUnverifiedError } from "../native-runtime/native-runner-ownership.js"; import type { environmentService } from "../environments.js"; import type { executionWorkspaceService } from "../execution-workspaces.js"; @@ -41,12 +42,12 @@ type Issue = typeof issues.$inferSelect; /** Selects policy and reuse inputs; lease acquisition and cleanup belong to the executor. */ export interface HeartbeatEnvironmentSelectionInput { run: Pick; + "id" | "runtimeMode" | "runnerProfileJson" | "scheduledRetryReason" | "scheduledRetryAttempt"> & { contextSnapshot?: Record | null }; agent: Pick; task: { freshWorkspace?: import("../fresh-workspace-continuation.js").ContinuationReadiness | null; - issueRef: Pick | null; + issueRef: Pick & { projectId?: string | null } | null; issueContext: Pick | null; issueId: string | null; context: Record; @@ -73,7 +74,7 @@ export interface HeartbeatEnvironmentSelectionInput { executionWorkspacesSvc: Pick, "getStoredById" | "validateStoredSelectionForRun" | "reopenClosedIsolatedExecutionWorkspaceForIssue" | "clearReopenPendingConsumptionForUnconsumedReopen">; environmentsSvc: Pick, - "ensureLocalEnvironment" | "findManagedSandboxEnvironment" | "findKubernetesEnvironment" | "ensureKubernetesEnvironment" | "getById">; + "ensureLocalEnvironment" | "findManagedSandboxEnvironment" | "findKubernetesEnvironment" | "ensureKubernetesEnvironment" | "getById" | "getLeaseById">; instanceSettings: Pick, "get" | "getExperimental">; findSharedWorkspaceHolder: ReturnType["findSharedWorkspaceHolder"]; findExecutionWorkspaceHolder: ReturnType["findExecutionWorkspaceHolder"]; @@ -202,11 +203,31 @@ 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 + : null; + const admittedLease = admittedLeaseId ? await environmentsSvc.getLeaseById(admittedLeaseId) : null; + if (admittedLeaseId && (!admittedLease?.environmentId || admittedLease.companyId !== agent.companyId + || admittedLease.heartbeatRunId !== run.id || admittedLease.issueId !== issueId || admittedLease.metadata?.agentId !== agent.id + || (typeof savedEnvironment.id === "string" && savedEnvironment.id !== admittedLease.environmentId))) { + throw new ConfigurationIncompleteFailure("Cannot restore the admitted execution environment.", { + configurationIncomplete: { reason: "native_recovery_environment_binding_mismatch" }, + }); + } const environmentResolution = providerApiPlacement ? { environmentId: null, source: "default" as const } + : admittedLease?.environmentId ? { environmentId: admittedLease.environmentId, source: "run" as const } : resolveExecutionWorkspaceEnvironmentId({ lowTrustIssueEnvironmentId: trustPreset.kind === "low_trust_review" ? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings, {includeEnvironmentId: true})?.environmentId : null, + projectEnvironmentId: projectEnvironment?.environmentId, agentDefaultEnvironmentId: agent.defaultEnvironmentId, instanceDefaultEnvironmentId: resolvedInstanceSettings.defaultEnvironmentId ?? null, diff --git a/server/src/services/project-environments.ts b/server/src/services/project-environments.ts index 945f5052823..9c84bc2fc30 100644 --- a/server/src/services/project-environments.ts +++ b/server/src/services/project-environments.ts @@ -70,11 +70,11 @@ export async function projectEnvironmentOptions(db: Db, companyId: string, actor }; } -export async function withDefaultProjectStorage(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) { +export async function resolveProjectEnvironment(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) { const policy = parseProjectExecutionWorkspacePolicy(rawPolicy); - if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return rawPolicy; + if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return { environmentId: null, executionWorkspacePolicy: rawPolicy }; const options = await projectEnvironmentOptions(db, companyId, actor); - const environmentId = actor.environmentId ?? options.defaultEnvironmentId; + const environmentId = actor.environmentId?.toLowerCase() ?? options.defaultEnvironmentId; const group = options.groups.find(value => value.environments.some(environment => environment.id === environmentId)); if (actor.environmentId && !group) throw conflict("The selected environment is unavailable for this project. Select an available environment.", { code: "project_environment_unavailable" }); if (!actor.environmentId && options.error) throw conflict(options.error, { code: "project_environment_unavailable" }); @@ -85,7 +85,7 @@ export async function withDefaultProjectStorage(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 pluginId || policy?.storageProviderPluginId !== undefined + return { environmentId: environmentId ?? null, executionWorkspacePolicy: pluginId || policy?.storageProviderPluginId !== undefined ? { enabled: true, ...policy, storageProviderPluginId: pluginId } - : rawPolicy; + : rawPolicy }; } diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index cd2275b690d..e007bde811b 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,6 +1,6 @@ import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js"; -import { withDefaultProjectStorage, type ProjectCreationEnvironment } from "./project-environments.js"; +import { resolveProjectEnvironment, 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"; @@ -53,6 +53,12 @@ export async function lockProjectRootMutation(tx: ProjectTransaction, projectId: } type ProjectRow = typeof projects.$inferSelect; + +function legacyProjectEnvironmentId(policy: Record | null | undefined) { + const id = policy?.environmentId; + return typeof id === "string" || id === null ? id : undefined; +} + type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; @@ -675,7 +681,14 @@ 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 executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null, environment) as typeof projectData.executionWorkspacePolicy; + 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; return db.transaction(async (tx) => { const txDb = tx as unknown as Db; @@ -971,30 +984,47 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool update: async ( id: string, - data: Partial & { goalIds?: string[] }, + data: Partial & { goalIds?: string[]; expectedStorageProviderPluginId?: string | null }, ): Promise => db.transaction(async tx => { const db = tx as unknown as Db; - const { goalIds: inputGoalIds, ...projectData } = data; + const { goalIds: inputGoalIds, expectedStorageProviderPluginId, ...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, executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt, environmentId: projects.environmentId, executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) .where(eq(projects.id, id)).for("update") .then((rows) => rows[0] ?? null); if (!existingProject) return null; - if (projectData.executionWorkspacePolicy !== undefined) { - const attachedPluginId = parseProjectExecutionWorkspacePolicy(existingProject.executionWorkspacePolicy)?.storageProviderPluginId; - const requestedPluginId = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId; - if (attachedPluginId) { - if (requestedPluginId !== undefined && requestedPluginId !== attachedPluginId) { - throw conflict("A plugin-backed project stays attached to its storage plugin"); - } - if (requestedPluginId === undefined) { - projectData.executionWorkspacePolicy = { enabled: true, ...projectData.executionWorkspacePolicy, storageProviderPluginId: attachedPluginId }; - } - } else if (requestedPluginId) { - await retireRemovedPluginWorkspaceDependents(db, id); + 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; + } + const currentStorage = parseProjectExecutionWorkspacePolicy(existingProject.executionWorkspacePolicy)?.storageProviderPluginId; + if (currentStorage && 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) { + 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); @@ -1071,6 +1101,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool const rows = await db .select({ id: projects.id, + environmentId: projects.environmentId, executionWorkspacePolicy: projects.executionWorkspacePolicy, }) .from(projects) @@ -1079,10 +1110,11 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool let cleared = 0; for (const row of rows) { const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy); - if (policy?.environmentId !== environmentId) continue; + if (row.environmentId !== environmentId && legacyProjectEnvironmentId(row.executionWorkspacePolicy) !== environmentId) continue; await projectService(db).update(row.id, { - executionWorkspacePolicy: { ...policy, environmentId: null }, + environmentId: null, + ...(legacyProjectEnvironmentId(row.executionWorkspacePolicy) === environmentId ? { executionWorkspacePolicy: policy ? { ...policy } : null } : {}), }); cleared += 1; } diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index 6f0c45dd1a3..b65e6eefb1c 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -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": "07c409d7daca3fc9ad550538d4a7ecac03176a07", - "packages/shared/src/types/project.ts": "ae2e718acd652e362cf1ef91e69aad96d12042fe", + "packages/shared/src/types/project.ts": "2a20b4d8772d5f0fa5015828818b025abc7cedcf", "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": "e145e15cb98ffe35880b9f8eea88809910ed5886", "packages/shared/src/validators/plugin.ts": "318fac91061e0373f9acd7e9b29cb427dfdc4b7f", - "packages/shared/src/validators/project.ts": "0257acc29400f438aefc0a657250d8f28ec60838", + "packages/shared/src/validators/project.ts": "dc051327b2eaf4a28288a84e21c1f3baab906bc9", "packages/shared/src/validators/provider-trace.ts": "b38b6200a2e51513f5dca0f8b008f2fe68175e82", "packages/shared/src/validators/resource-memberships.ts": "b124ac8a8f24b4dbb03ae82bcc87541f6b4ecdfd", "packages/shared/src/validators/routine.test.ts": "1501907ade577c1ab4c4b3098397e5d080446878", diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index 7d88e8ddfa9..41e9bb4157f 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -201,10 +201,10 @@ describe("IssueWorkspaceCard", () => { issue={createIssue()} project={{ id: "project-1", + environmentId: "env-project", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", - environmentId: "env-project", }, }} onUpdate={onUpdate} @@ -263,10 +263,10 @@ describe("IssueWorkspaceCard", () => { issue={createIssue()} project={{ id: "project-1", + environmentId: "env-project", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", - environmentId: "env-project", }, }} onUpdate={vi.fn()} diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 372fec857cf..ff4853c5123 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -165,6 +165,7 @@ interface IssueWorkspaceCardProps { currentExecutionWorkspace?: ExecutionWorkspace | null; }; project: { + environmentId?: string | null; id: string; executionWorkspacePolicy?: { enabled?: boolean; @@ -254,7 +255,7 @@ export function IssueWorkspaceCard({ const [draftSelection, setDraftSelection] = useState(currentSelection); const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? ""); const projectEnvironmentId = environmentsEnabled - ? project?.executionWorkspacePolicy?.environmentId ?? null + ? project?.environmentId ?? null : null; const currentReusableEnvironmentId = selectedReusableExecutionWorkspace?.config?.environmentId ?? null; const currentEnvironmentId = environmentsEnabled diff --git a/ui/src/components/ProjectProperties.environment.test.tsx b/ui/src/components/ProjectProperties.environment.test.tsx new file mode 100644 index 00000000000..baf63a17a2d --- /dev/null +++ b/ui/src/components/ProjectProperties.environment.test.tsx @@ -0,0 +1,98 @@ +// @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 =>
+ { + update(project.id, field, data); + setProjects(previous => previous.map(value => value.id === project.id ? { ...value, ...data } as Project : value)); + }} /> +
); + } + flushSync(() => root.render()); + return client; +} + +it("keeps environment selection on its project with isolation disabled and a shared query cache", () => { + const client = renderProjects(); + const first = container.querySelector('[data-project="first"] select[id^="project-environment-"]')!; + const second = container.querySelector('[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('[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('[data-project="first"] select[id^="project-environment-"]')!.value).toBe("computer"); + expect(container.querySelector('[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('[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('[data-project="second"] select[id^="project-environment-"]')!.value).toBe("local"); +}); diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 788e16eea73..f3e547aab45 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -3,14 +3,12 @@ import { accessApi } from "@/api/access"; import { canManageProjectPrivacy } from "@/lib/issuePrivacy"; import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { useState, type ReactNode } from "react"; -import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment"; import { Link } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared"; import { ProjectGitAuthorization } from "./ProjectGitAuthorization"; import { ProjectRepositories } from "./ProjectRepositories"; import { cn, formatDate } from "../lib/utils"; -import { environmentsApi } from "../api/environments"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; import { secretsApi } from "../api/secrets"; @@ -25,6 +23,8 @@ import { ToggleSwitch } from "@/components/ui/toggle-switch"; 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, @@ -263,11 +263,12 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); }, }); - const { data: environments } = useQuery({ - queryKey: queryKeys.environments.list(selectedCompanyId!), - queryFn: () => environmentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId && environmentsEnabled, + const projectEnvironments = useQuery({ + queryKey: queryKeys.projects.environments(project.companyId), + queryFn: () => projectsApi.environmentOptions(project.companyId), + enabled: Boolean(project.companyId), }); + const projectEnvironmentView = useQueryView(projectEnvironments); const workspaces = project.workspaces ?? []; const codebase = project.codebase; @@ -283,7 +284,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda // Absent/unset round-trips as "auto" — we only write a value once the user picks one. const executionWorkspaceSharedConcurrency: SharedWorkspaceConcurrency = executionWorkspacePolicy?.sharedWorkspaceConcurrency ?? "auto"; - const executionWorkspaceEnvironmentId = executionWorkspacePolicy?.environmentId ?? ""; const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? { type: "git_worktree", baseRef: "", @@ -298,16 +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 runSelectableEnvironments = filterManagedSandboxSelectableEnvironments( - environments ?? [], - managedSandboxOnly, - ).filter((environment) => { - if (environment.driver === "local" || environment.driver === "ssh") return true; - if (environment.driver !== "sandbox") return false; - const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; - return provider !== null && provider !== "fake"; - }); - const showExecutionWorkspaceEnvironmentControl = environmentsEnabled && runSelectableEnvironments.length > 1; + const projectEnvironmentChoices = projectEnvironmentView.data?.groups + .filter(group => group.storageProviderPluginId === (storedExecutionWorkspacePolicy?.storageProviderPluginId ?? null)) + .flatMap(group => group.environments) ?? []; const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); @@ -733,6 +726,33 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda )} } + {environmentsEnabled && project.companyId ? ( +
+ + { + 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 } : {}) }); + }}> + + {project.environmentId && !projectEnvironmentChoices.some(environment => environment.id === project.environmentId) + ? : null} + {projectEnvironmentChoices.map(environment => )} + +

Applies only to this project. Agent and company defaults stay unchanged.

+ {projectEnvironmentView.kind === "loading" || projectEnvironmentView.kind === "reconnecting" + ?

Loading environments…

: null} + {projectEnvironmentView.kind === "error" ? : null} +
+ ) : null} + {providerConfiguration ? (