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 ? (