diff --git a/packages/db/src/migrations/0105_instance_scoped_environments.sql b/packages/db/src/migrations/0105_instance_scoped_environments.sql new file mode 100644 index 00000000..b8dc7690 --- /dev/null +++ b/packages/db/src/migrations/0105_instance_scoped_environments.sql @@ -0,0 +1,170 @@ +ALTER TABLE "environments" ADD COLUMN "env_vars" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "instance_settings" ADD COLUMN "default_environment_id" uuid;--> statement-breakpoint +DO $$ +BEGIN + CREATE TEMP TABLE environment_migration_map ( + old_id uuid PRIMARY KEY, + new_id uuid NOT NULL + ) ON COMMIT DROP; + + WITH ranked AS ( + SELECT + e.id, + CASE + -- Only collapse classes that are globally singleton by design. + -- Named remote environments may legitimately differ across companies + -- even when they share the same display name. + WHEN e.driver = 'local' THEN '__paperclip_builtin_local__' + WHEN e.driver = 'sandbox' AND (e.metadata ->> 'managedByPaperclip')::boolean = true + THEN '__paperclip_managed_sandbox__' + ELSE e.id::text + END AS group_key, + row_number() OVER ( + PARTITION BY CASE + WHEN e.driver = 'local' THEN '__paperclip_builtin_local__' + WHEN e.driver = 'sandbox' AND (e.metadata ->> 'managedByPaperclip')::boolean = true + THEN '__paperclip_managed_sandbox__' + ELSE e.id::text + END + ORDER BY e.created_at ASC, e.id ASC + ) AS rn + FROM "environments" e + ), + canonical AS ( + SELECT + ranked.group_key, + ranked.id AS canonical_id + FROM ranked + WHERE ranked.rn = 1 + ) + INSERT INTO environment_migration_map (old_id, new_id) + SELECT ranked.id, canonical.canonical_id + FROM ranked + JOIN canonical ON canonical.group_key = ranked.group_key; + + UPDATE "agents" AS agents + SET "default_environment_id" = map.new_id + FROM environment_migration_map AS map + WHERE agents."default_environment_id" = map.old_id + AND agents."default_environment_id" IS DISTINCT FROM map.new_id; + + UPDATE "environment_leases" AS leases + SET "environment_id" = map.new_id + FROM environment_migration_map AS map + WHERE leases."environment_id" = map.old_id + AND leases."environment_id" IS DISTINCT FROM map.new_id; + + DELETE FROM "environments" AS environments + USING environment_migration_map AS map + WHERE environments."id" = map.old_id + AND map.old_id <> map.new_id; +END $$; +--> statement-breakpoint +UPDATE "issues" +SET "execution_workspace_settings" = "execution_workspace_settings" - 'environmentId' +WHERE jsonb_typeof("execution_workspace_settings") = 'object' + AND "execution_workspace_settings" ? 'environmentId'; +--> statement-breakpoint +DROP INDEX IF EXISTS "environments_company_managed_sandbox_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "environments_company_status_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "environments_company_driver_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "environments_company_name_idx";--> statement-breakpoint +ALTER TABLE "environments" DROP CONSTRAINT IF EXISTS "environments_company_id_companies_id_fk";--> statement-breakpoint +ALTER TABLE "environments" DROP COLUMN "company_id";--> statement-breakpoint +INSERT INTO "environments" ( + "id", + "name", + "description", + "driver", + "status", + "config", + "env_vars", + "metadata", + "created_at", + "updated_at" +) +SELECT + gen_random_uuid(), + 'Local', + 'Default execution environment for Paperclip runs on this machine.', + 'local', + 'active', + '{}'::jsonb, + '{}'::jsonb, + '{"managedByPaperclip": true, "defaultForInstance": true}'::jsonb, + now(), + now() +WHERE NOT EXISTS ( + SELECT 1 + FROM "environments" + WHERE "driver" = 'local' +); +--> statement-breakpoint +UPDATE "environments" +SET + "metadata" = COALESCE("metadata", '{}'::jsonb) || '{"managedByPaperclip": true, "defaultForInstance": true}'::jsonb, + "updated_at" = now() +WHERE "driver" = 'local'; +--> statement-breakpoint +WITH duplicate_names AS ( + SELECT + "id", + "name", + "driver", + row_number() OVER (PARTITION BY "name" ORDER BY "created_at" ASC, "id" ASC) AS rn + FROM "environments" +) +UPDATE "environments" AS environments +SET + "name" = duplicate_names."name" || ' (' || duplicate_names."driver" || ' ' || substring(duplicate_names."id"::text from 1 for 8) || ')', + "updated_at" = now() +FROM duplicate_names +WHERE environments."id" = duplicate_names."id" + AND duplicate_names.rn > 1; +--> statement-breakpoint +INSERT INTO "instance_settings" ( + "id", + "singleton_key", + "default_environment_id", + "general", + "experimental", + "created_at", + "updated_at" +) +SELECT + gen_random_uuid(), + 'default', + local_env."id", + '{}'::jsonb, + '{}'::jsonb, + now(), + now() +FROM ( + SELECT "id" + FROM "environments" + WHERE "driver" = 'local' + ORDER BY "created_at" ASC, "id" ASC + LIMIT 1 +) AS local_env +ON CONFLICT ("singleton_key") DO UPDATE +SET + "default_environment_id" = EXCLUDED."default_environment_id", + "updated_at" = now(); +--> statement-breakpoint +ALTER TABLE "instance_settings" + ADD CONSTRAINT "instance_settings_default_environment_id_environments_id_fk" + FOREIGN KEY ("default_environment_id") + REFERENCES "public"."environments"("id") + ON DELETE set null + ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "environments_status_idx" ON "environments" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "environments_local_driver_idx" + ON "environments" USING btree ("driver") + WHERE "driver" = 'local'; +--> statement-breakpoint +CREATE UNIQUE INDEX "environments_managed_sandbox_idx" + ON "environments" USING btree ("driver") + WHERE "driver" = 'sandbox' AND ("metadata" ->> 'managedByPaperclip')::boolean = true; +--> statement-breakpoint +CREATE UNIQUE INDEX "environments_name_idx" ON "environments" USING btree ("name"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 67009dff..ae94c364 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -736,6 +736,13 @@ "when": 1781733000000, "tag": "0104_issue_watchdogs", "breakpoints": true + }, + { + "idx": 105, + "version": "7", + "when": 1781902000000, + "tag": "0105_instance_scoped_environments", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/environments.ts b/packages/db/src/schema/environments.ts index e64e5c41..a61c2a99 100644 --- a/packages/db/src/schema/environments.ts +++ b/packages/db/src/schema/environments.ts @@ -1,31 +1,30 @@ import { sql } from "drizzle-orm"; import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; -import { companies } from "./companies.js"; export const environments = pgTable( "environments", { id: uuid("id").primaryKey().defaultRandom(), - companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), name: text("name").notNull(), description: text("description"), driver: text("driver").notNull().default("local"), status: text("status").notNull().default("active"), config: jsonb("config").$type>().notNull().default({}), + envVars: jsonb("env_vars").$type>().notNull().default({}), metadata: jsonb("metadata").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ - companyStatusIdx: index("environments_company_status_idx").on(table.companyId, table.status), - companyDriverIdx: uniqueIndex("environments_company_driver_idx") - .on(table.companyId, table.driver) + statusIdx: index("environments_status_idx").on(table.status), + localDriverIdx: uniqueIndex("environments_local_driver_idx") + .on(table.driver) .where(sql`${table.driver} = 'local'`), - companyManagedSandboxIdx: uniqueIndex("environments_company_managed_sandbox_idx") - .on(table.companyId) + managedSandboxIdx: uniqueIndex("environments_managed_sandbox_idx") + .on(table.driver) .where( sql`${table.driver} = 'sandbox' AND (${table.metadata} ->> 'managedByPaperclip')::boolean = true`, ), - companyNameIdx: index("environments_company_name_idx").on(table.companyId, table.name), + nameIdx: uniqueIndex("environments_name_idx").on(table.name), }), ); diff --git a/packages/db/src/schema/instance_settings.ts b/packages/db/src/schema/instance_settings.ts index 002df259..456d04b8 100644 --- a/packages/db/src/schema/instance_settings.ts +++ b/packages/db/src/schema/instance_settings.ts @@ -1,10 +1,12 @@ import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; +import { environments } from "./environments.js"; export const instanceSettings = pgTable( "instance_settings", { id: uuid("id").primaryKey().defaultRandom(), singletonKey: text("singleton_key").notNull().default("default"), + defaultEnvironmentId: uuid("default_environment_id").references(() => environments.id, { onDelete: "set null" }), general: jsonb("general").$type>().notNull().default({}), experimental: jsonb("experimental").$type>().notNull().default({}), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/plugins/sandbox-providers/cloudflare/README.md b/packages/plugins/sandbox-providers/cloudflare/README.md index d7e01d1b..1799bdec 100644 --- a/packages/plugins/sandbox-providers/cloudflare/README.md +++ b/packages/plugins/sandbox-providers/cloudflare/README.md @@ -12,7 +12,7 @@ From a Paperclip instance, install: @paperclipai/plugin-cloudflare-sandbox ``` -Configure Cloudflare from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure Cloudflare from `Instance Settings -> Environments`, not from the plugin's plugin page. ## Configuration diff --git a/packages/plugins/sandbox-providers/daytona/README.md b/packages/plugins/sandbox-providers/daytona/README.md index 4d557ee2..facdc752 100644 --- a/packages/plugins/sandbox-providers/daytona/README.md +++ b/packages/plugins/sandbox-providers/daytona/README.md @@ -16,7 +16,7 @@ The host plugin installer runs `npm install` into the managed plugin directory, ## Configuration -Configure Daytona from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure Daytona from `Instance Settings -> Environments`, not from the plugin's plugin page. - Put the Daytona API key on the sandbox environment itself. - When you save an environment, Paperclip stores pasted API keys as company secrets. diff --git a/packages/plugins/sandbox-providers/e2b/README.md b/packages/plugins/sandbox-providers/e2b/README.md index 63e32391..b03a3112 100644 --- a/packages/plugins/sandbox-providers/e2b/README.md +++ b/packages/plugins/sandbox-providers/e2b/README.md @@ -16,7 +16,7 @@ The host plugin installer runs `npm install` into the managed plugin directory, ## Configuration -Configure E2B from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure E2B from `Instance Settings -> Environments`, not from the plugin's plugin page. - Put the E2B API key on the sandbox environment itself. - When you save an environment, Paperclip stores pasted API keys as company secrets. diff --git a/packages/plugins/sandbox-providers/exe-dev/README.md b/packages/plugins/sandbox-providers/exe-dev/README.md index b90eca69..9e4a6b11 100644 --- a/packages/plugins/sandbox-providers/exe-dev/README.md +++ b/packages/plugins/sandbox-providers/exe-dev/README.md @@ -14,7 +14,7 @@ From a Paperclip instance, install: ## Configuration -Configure exe.dev from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure exe.dev from `Instance Settings -> Environments`, not from the plugin's plugin page. - Put the exe.dev API token on the sandbox environment itself. - When you save an environment, Paperclip stores pasted API keys and pasted SSH private keys as company secrets. diff --git a/packages/plugins/sandbox-providers/modal/README.md b/packages/plugins/sandbox-providers/modal/README.md index b3966004..054b5d71 100644 --- a/packages/plugins/sandbox-providers/modal/README.md +++ b/packages/plugins/sandbox-providers/modal/README.md @@ -22,7 +22,7 @@ The empirical Node 20 compatibility check is recorded in [PAPA-352](/PAPA/issues ## Configuration -Configure Modal from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure Modal from `Instance Settings -> Environments`, not from the plugin's plugin page. | Field | Required | Description | | --- | --- | --- | @@ -63,7 +63,7 @@ These commands assume the repo root has already been installed once so the local 1. Provision Modal credentials in your Modal account (`modal token new`) or use a service account. 2. Install the plugin from the Paperclip Plugins page. -3. In `Company Settings -> Environments`, add a new Modal sandbox environment with at least `appName`, `image`, `tokenId`, and `tokenSecret`. +3. In `Instance Settings -> Environments`, add a new Modal sandbox environment with at least `appName`, `image`, `tokenId`, and `tokenSecret`. 4. Run the environment **Probe** action. A success result confirms auth, app creation, image pull, and `exec` round-trip. 5. Run at least one Paperclip task with a remote-managed adapter (for example `claude_local`) bound to that environment. The adapter should provision the sandbox, run commands in it, and clean it up. diff --git a/packages/plugins/sandbox-providers/novita/README.md b/packages/plugins/sandbox-providers/novita/README.md index 2026978d..b2d5f4e9 100644 --- a/packages/plugins/sandbox-providers/novita/README.md +++ b/packages/plugins/sandbox-providers/novita/README.md @@ -16,7 +16,7 @@ The host plugin installer runs `npm install` into the managed plugin directory, ## Configuration -Configure Novita from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure Novita from `Instance Settings -> Environments`, not from the plugin's plugin page. - Put the Novita API key on the sandbox environment itself. - When you save an environment, Paperclip stores pasted API keys as company secrets. diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 0e8183b5..a3925049 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -478,6 +478,8 @@ export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDr runId: string; workspaceMode?: string; requestedCwd?: string; + agentId?: string; + executionWorkspaceId?: string | null; /** * The harness/adapter type for THIS run (the agent's adapter), so a single * environment can serve mixed harnesses. When omitted, the driver falls back to diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 04e4ceab..319f484f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -897,17 +897,20 @@ export { } from "./execution-workspace-guards.js"; export { + instanceSettingsSchema, instanceGeneralSettingsSchema, patchInstanceGeneralSettingsSchema, type PatchInstanceGeneralSettings, instanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema, + patchInstanceSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, trustPresetSchema, lowTrustBoundarySchema, lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema, type PatchInstanceExperimentalSettings, + type PatchInstanceSettings, type IssueGraphLivenessAutoRecoveryRequest, type TrustPresetInput, type LowTrustBoundaryInput, diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 68f3e724..1e085282 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -5,7 +5,7 @@ import type { EnvironmentLeaseStatus, EnvironmentStatus, } from "../constants.js"; -import type { EnvSecretRefBinding } from "./secrets.js"; +import type { AgentEnvConfig, EnvSecretRefBinding } from "./secrets.js"; export interface LocalEnvironmentConfig { [key: string]: unknown; @@ -56,12 +56,12 @@ export interface EnvironmentProbeResult { export interface Environment { id: string; - companyId: string; name: string; description: string | null; driver: EnvironmentDriver; status: EnvironmentStatus; config: Record; + envVars: AgentEnvConfig; metadata: Record | null; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ab9075ee..e4a2db91 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -49,9 +49,9 @@ export interface InstanceExperimentalSettings { enableIsolatedWorkspaces: boolean; enableStreamlinedLeftNavigation: boolean; enableConferenceRoomChat: boolean; + enableTaskWatchdogs: boolean; enableIssuePlanDecompositions: boolean; enableExperimentalFileViewer: boolean; - enableTaskWatchdogs: boolean; enableCloudSync: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; @@ -60,6 +60,7 @@ export interface InstanceExperimentalSettings { export interface InstanceSettings { id: string; + defaultEnvironmentId: string | null; general: InstanceGeneralSettings; experimental: InstanceExperimentalSettings; createdAt: Date; diff --git a/packages/shared/src/validators/environment.ts b/packages/shared/src/validators/environment.ts index cadd97ee..2639bb5c 100644 --- a/packages/shared/src/validators/environment.ts +++ b/packages/shared/src/validators/environment.ts @@ -5,6 +5,7 @@ import { ENVIRONMENT_LEASE_STATUSES, ENVIRONMENT_STATUSES, } from "../constants.js"; +import { envConfigSchema } from "./secret.js"; export const environmentDriverSchema = z.enum(ENVIRONMENT_DRIVERS); export const environmentStatusSchema = z.enum(ENVIRONMENT_STATUSES); @@ -17,6 +18,7 @@ const environmentFields = { driver: environmentDriverSchema, status: environmentStatusSchema.optional().default("active"), config: z.record(z.string(), z.unknown()).optional().default({}), + envVars: envConfigSchema.optional().default({}), metadata: z.record(z.string(), z.unknown()).optional().nullable(), }; @@ -29,6 +31,7 @@ export const updateEnvironmentSchema = z.object({ driver: environmentDriverSchema.optional(), status: environmentStatusSchema.optional(), config: z.record(z.string(), z.unknown()).optional(), + envVars: envConfigSchema.optional(), metadata: z.record(z.string(), z.unknown()).optional().nullable(), }).strict(); export type UpdateEnvironment = z.infer; @@ -38,6 +41,7 @@ export const probeEnvironmentConfigSchema = z.object({ description: z.string().optional().nullable(), driver: environmentDriverSchema, config: z.record(z.string(), z.unknown()).optional().default({}), + envVars: envConfigSchema.optional().default({}), metadata: z.record(z.string(), z.unknown()).optional().nullable(), }).strict(); export type ProbeEnvironmentConfig = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index d21e15c7..6323a7dc 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -1,13 +1,16 @@ export { + instanceSettingsSchema, instanceGeneralSettingsSchema, patchInstanceGeneralSettingsSchema, type InstanceGeneralSettings, type PatchInstanceGeneralSettings, instanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema, + patchInstanceSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, type InstanceExperimentalSettings, type PatchInstanceExperimentalSettings, + type PatchInstanceSettings, type IssueGraphLivenessAutoRecoveryRequest, } from "./instance.js"; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 7b944fb6..9015b094 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -43,9 +43,9 @@ export const instanceExperimentalSettingsSchema = z.object({ enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), + enableTaskWatchdogs: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false), - enableTaskWatchdogs: z.boolean().default(false), enableCloudSync: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), @@ -59,6 +59,10 @@ export const instanceExperimentalSettingsSchema = z.object({ export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial(); +export const patchInstanceSettingsSchema = z.object({ + defaultEnvironmentId: z.string().uuid().nullable().optional(), +}).strict(); + export const issueGraphLivenessAutoRecoveryRequestSchema = z.object({ lookbackHours: z .number() @@ -72,6 +76,16 @@ export type InstanceGeneralSettings = z.infer; export type InstanceExperimentalSettings = z.infer; export type PatchInstanceExperimentalSettings = z.infer; +export type PatchInstanceSettings = z.infer; export type IssueGraphLivenessAutoRecoveryRequest = z.infer< typeof issueGraphLivenessAutoRecoveryRequestSchema >; + +export const instanceSettingsSchema = z.object({ + id: z.string().uuid(), + defaultEnvironmentId: z.string().uuid().nullable(), + general: instanceGeneralSettingsSchema, + experimental: instanceExperimentalSettingsSchema, + createdAt: z.union([z.date(), z.string().datetime()]), + updatedAt: z.union([z.date(), z.string().datetime()]), +}).strict(); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index cc7518e7..73ecd39e 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1285,7 +1285,7 @@ describe.sequential("agent permission routes", () => { })); }); - it("rejects creating an agent with an environment from another company", async () => { + it("allows creating an agent with an instance-scoped environment referenced from another company", async () => { const environmentId = "33333333-3333-4333-8333-333333333333"; mockEnvironmentService.getById.mockResolvedValue({ id: environmentId, @@ -1312,9 +1312,13 @@ describe.sequential("agent permission routes", () => { defaultEnvironmentId: environmentId, })); - expect(res.status).toBe(422); - expect(res.body.error).toContain("Environment not found"); - expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + defaultEnvironmentId: environmentId, + }), + ); }); it("rejects creating an agent with an unsupported default environment driver", async () => { diff --git a/server/src/__tests__/environment-instance-routes.test.ts b/server/src/__tests__/environment-instance-routes.test.ts new file mode 100644 index 00000000..e146f91f --- /dev/null +++ b/server/src/__tests__/environment-instance-routes.test.ts @@ -0,0 +1,280 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { environmentRoutes } from "../routes/environments.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockIssueService = vi.hoisted(() => ({ + clearExecutionWorkspaceEnvironmentSelection: vi.fn(), +})); + +const mockProjectService = vi.hoisted(() => ({ + clearExecutionWorkspaceEnvironmentSelection: vi.fn(), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + listCompanyIds: vi.fn(), +})); + +const mockEnvironmentService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + clearEnvironmentSelection: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +const mockSecretService = vi.hoisted(() => ({ + create: vi.fn(), + normalizeEnvBindingsForPersistence: vi.fn(), + listBindingCompanyIdsForTarget: vi.fn(), + resolveSecretValueForEphemeralAccess: vi.fn(), + syncEnvBindingsForTarget: vi.fn(), + syncSecretRefsForTarget: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + issueService: () => mockIssueService, + instanceSettingsService: () => mockInstanceSettingsService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, +})); + +vi.mock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, +})); + +vi.mock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, +})); + +vi.mock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, +})); + +vi.mock("../services/plugin-environment-driver.js", () => ({ + listReadyPluginEnvironmentDrivers: vi.fn(async () => []), +})); + +function createEnvironment(overrides: Record = {}) { + const now = new Date("2026-06-20T00:00:00.000Z"); + return { + id: "env-1", + name: "Local", + description: "Default execution environment", + driver: "local", + status: "active" as const, + config: {}, + envVars: {}, + metadata: { managedByPaperclip: true }, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as typeof req & { actor: Record }).actor = actor; + next(); + }); + app.use("/api", environmentRoutes({} as never)); + app.use(errorHandler); + return app; +} + +describe("environment instance routes", () => { + beforeEach(() => { + mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); + mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); + mockInstanceSettingsService.listCompanyIds.mockReset(); + mockEnvironmentService.list.mockReset(); + mockEnvironmentService.getById.mockReset(); + mockEnvironmentService.create.mockReset(); + mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset(); + mockLogActivity.mockReset(); + mockSecretService.create.mockReset(); + mockSecretService.normalizeEnvBindingsForPersistence.mockReset(); + mockSecretService.listBindingCompanyIdsForTarget.mockReset(); + mockSecretService.resolveSecretValueForEphemeralAccess.mockReset(); + mockSecretService.syncEnvBindingsForTarget.mockReset(); + mockSecretService.syncSecretRefsForTarget.mockReset(); + + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]); + mockEnvironmentService.list.mockResolvedValue([]); + mockEnvironmentService.create.mockResolvedValue(createEnvironment()); + mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env ?? {}); + mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]); + mockSecretService.syncEnvBindingsForTarget.mockResolvedValue([]); + mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]); + }); + + it("lists the instance environment catalog for a local board actor", async () => { + mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); + const app = createApp({ + type: "board", + userId: "board-1", + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await request(app).get("/api/companies/company-1/environments?driver=local"); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(mockEnvironmentService.list).toHaveBeenCalledWith({ + status: undefined, + driver: "local", + }); + }); + + it("allows non-admin board members with company access to read the shared environment catalog", async () => { + mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "member", status: "active" }], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/companies/company-1/environments"); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(mockEnvironmentService.list).toHaveBeenCalledTimes(1); + }); + + it("rejects company agents from enumerating the shared environment catalog", async () => { + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app).get("/api/companies/company-1/environments"); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Board access required"); + }); + + it("rejects non-admin signed-in board members from mutating instance environments", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Shared Local", + driver: "local", + config: {}, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + }); + + it("creates an instance-scoped environment and logs the mutation to every company", async () => { + const app = createApp({ + type: "board", + userId: "board-1", + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Shared Local", + driver: "local", + config: {}, + }); + + expect(res.status).toBe(201); + expect(mockEnvironmentService.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Shared Local", + driver: "local", + status: "active", + }), + ); + expect(mockSecretService.syncSecretRefsForTarget).toHaveBeenCalledWith( + "company-1", + { targetType: "environment", targetId: "env-1" }, + [], + ); + expect(mockSecretService.syncEnvBindingsForTarget).toHaveBeenCalledWith( + "company-1", + { targetType: "environment", targetId: "env-1" }, + {}, + ); + expect(mockLogActivity).toHaveBeenCalledTimes(2); + expect(mockLogActivity.mock.calls.map((call) => call[1].companyId)).toEqual(["company-1", "company-2"]); + }); + + it("normalizes and syncs environment envVars on create", async () => { + const envVars = { + ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111", version: "latest" }, + }; + mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(envVars); + mockEnvironmentService.create.mockResolvedValue(createEnvironment({ envVars })); + const app = createApp({ + type: "board", + userId: "board-1", + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Shared Local", + driver: "local", + config: {}, + envVars, + }); + + expect(res.status).toBe(201); + expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith( + "company-1", + envVars, + expect.objectContaining({ fieldPath: "envVars" }), + ); + expect(mockEnvironmentService.create).toHaveBeenCalledWith(expect.objectContaining({ envVars })); + expect(mockSecretService.syncEnvBindingsForTarget).toHaveBeenCalledWith( + "company-1", + { targetType: "environment", targetId: "env-1" }, + envVars, + ); + }); + + it("returns full environment details for an instance admin", async () => { + mockEnvironmentService.getById.mockResolvedValue(createEnvironment({ config: { shell: "zsh" } })); + const app = createApp({ + type: "board", + userId: "board-1", + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await request(app).get("/api/environments/env-1"); + + expect(res.status).toBe(200); + expect(res.body.config).toEqual({ shell: "zsh" }); + }); +}); diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index cd5d8715..b9ca635c 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -152,7 +152,7 @@ describe("probeEnvironment", () => { expect(mockProbePluginSandboxProviderDriver).toHaveBeenCalledWith({ db: expect.anything(), workerManager, - companyId: "company-1", + companyId: "instance", environmentId: "env-sandbox-plugin", provider: "fake-plugin", config: { @@ -195,7 +195,7 @@ describe("probeEnvironment", () => { expect(mockProbePluginEnvironmentDriver).toHaveBeenCalledWith({ db: expect.anything(), workerManager, - companyId: "company-1", + companyId: "instance", environmentId: "env-plugin", config: { pluginKey: "acme.environments", diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 733a44e8..208e3b5b 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -23,6 +23,10 @@ const mockProjectService = vi.hoisted(() => ({ getById: vi.fn(), })); +const mockInstanceSettingsService = vi.hoisted(() => ({ + listCompanyIds: vi.fn(), +})); + const mockEnvironmentService = vi.hoisted(() => ({ list: vi.fn(), getById: vi.fn(), @@ -36,8 +40,11 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockProbeEnvironment = vi.hoisted(() => vi.fn()); const mockSecretService = vi.hoisted(() => ({ create: vi.fn(), + normalizeEnvBindingsForPersistence: vi.fn(), + listBindingCompanyIdsForTarget: vi.fn(), resolveSecretValue: vi.fn(), resolveSecretValueForEphemeralAccess: vi.fn(), + syncEnvBindingsForTarget: vi.fn(), syncSecretRefsForTarget: vi.fn(), remove: vi.fn(), })); @@ -48,9 +55,8 @@ const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn()); const mockExecutionWorkspaceService = vi.hoisted(() => ({})); vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, issueService: () => mockIssueService, + instanceSettingsService: () => mockInstanceSettingsService, environmentService: () => mockEnvironmentService, logActivity: mockLogActivity, projectService: () => mockProjectService, @@ -89,6 +95,7 @@ function createEnvironment() { driver: "local", status: "active" as const, config: { shell: "zsh" }, + envVars: {}, metadata: { source: "manual" }, createdAt: now, updatedAt: now, @@ -148,6 +155,7 @@ describe("environment routes", () => { mockAgentService.getById.mockReset(); mockIssueService.getById.mockReset(); mockProjectService.getById.mockReset(); + mockInstanceSettingsService.listCompanyIds.mockReset(); mockEnvironmentService.list.mockReset(); mockEnvironmentService.list.mockResolvedValue([]); mockEnvironmentService.getById.mockReset(); @@ -158,13 +166,20 @@ describe("environment routes", () => { mockLogActivity.mockReset(); mockProbeEnvironment.mockReset(); mockSecretService.create.mockReset(); + mockSecretService.normalizeEnvBindingsForPersistence.mockReset(); + mockSecretService.listBindingCompanyIdsForTarget.mockReset(); mockSecretService.resolveSecretValue.mockReset(); mockSecretService.resolveSecretValueForEphemeralAccess.mockReset(); + mockSecretService.syncEnvBindingsForTarget.mockReset(); mockSecretService.syncSecretRefsForTarget.mockReset(); mockSecretService.remove.mockReset(); mockSecretService.create.mockResolvedValue({ id: "11111111-1111-1111-1111-111111111111", }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env ?? {}); + mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]); + mockSecretService.syncEnvBindingsForTarget.mockResolvedValue([]); mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]); mockSecretService.remove.mockResolvedValue(null); mockSecretService.resolveSecretValueForEphemeralAccess.mockResolvedValue("resolved-provider-key"); @@ -214,7 +229,7 @@ describe("environment routes", () => { }); }); - it("lists company-scoped environments", async () => { + it("lists instance-scoped environments through the company route alias", async () => { mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); const app = createApp({ type: "board", @@ -226,12 +241,57 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body).toHaveLength(1); - expect(mockEnvironmentService.list).toHaveBeenCalledWith("company-1", { + expect(mockEnvironmentService.list).toHaveBeenCalledWith({ status: undefined, driver: "local", }); }); + it("redacts environment config for non-admin board readers", async () => { + mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); + const app = createApp({ + type: "board", + userId: "user-2", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/companies/company-1/environments"); + + expect(res.status).toBe(200); + expect(res.body[0]).toMatchObject({ + id: "env-1", + name: "Local", + config: {}, + envVars: {}, + metadata: null, + }); + }); + + it("redacts environment detail config for non-admin board readers", async () => { + mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); + const app = createApp({ + type: "board", + userId: "user-2", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/environments/env-1"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "env-1", + config: {}, + envVars: {}, + metadata: null, + }); + }); + it("returns provider capabilities for the company", async () => { const app = createApp({ type: "board", @@ -293,7 +353,7 @@ describe("environment routes", () => { .toBe("supported"); }); - it("redacts config and metadata for unprivileged agent list reads", async () => { + it("rejects agent list reads for instance-scoped environments", async () => { mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); mockAgentService.getById.mockResolvedValue({ id: "agent-1", @@ -312,19 +372,12 @@ describe("environment routes", () => { const res = await request(app).get("/api/companies/company-1/environments"); - expect(res.status).toBe(200); - expect(res.body).toEqual([ - expect.objectContaining({ - id: "env-1", - config: {}, - metadata: null, - configRedacted: true, - metadataRedacted: true, - }), - ]); + expect(res.status).toBe(403); + expect(res.body.error).toContain("Board access required"); + expect(mockEnvironmentService.list).not.toHaveBeenCalled(); }); - it("returns full config for privileged environment readers", async () => { + it("rejects agent detail reads for instance-scoped environments", async () => { mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); mockAgentService.getById.mockResolvedValue({ id: "agent-1", @@ -343,59 +396,17 @@ describe("environment routes", () => { const res = await request(app).get("/api/environments/env-1"); - expect(res.status).toBe(200); - expect(res.body.config).toEqual({ shell: "zsh" }); - expect(res.body.metadata).toEqual({ source: "manual" }); - expect(res.body.configRedacted).toBeUndefined(); - }); - - it("redacts config and metadata for unprivileged agent detail reads", async () => { - mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId: "company-1", - role: "engineer", - permissions: { canCreateAgents: false }, - }); - mockAccessService.hasPermission.mockResolvedValue(false); - const app = createApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app).get("/api/environments/env-1"); - - expect(res.status).toBe(200); - expect(res.body).toEqual( - expect.objectContaining({ - id: "env-1", - config: {}, - metadata: null, - configRedacted: true, - metadataRedacted: true, - }), - ); + expect(res.status).toBe(403); + expect(res.body.error).toContain("Board access required"); }); it("creates an environment and logs activity", async () => { const environment = createEnvironment(); - mockAgentService.getById.mockResolvedValue({ - id: "agent-1", - companyId: "company-1", - role: "cto", - permissions: { canCreateAgents: true }, - }); - mockAccessService.hasPermission.mockResolvedValue(false); mockEnvironmentService.create.mockResolvedValue(environment); const app = createApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", + type: "board", + userId: "user-1", + source: "local_implicit", }); const res = await request(app) @@ -408,21 +419,22 @@ describe("environment routes", () => { }); expect(res.status).toBe(201); - expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", { + expect(mockEnvironmentService.create).toHaveBeenCalledWith({ name: "Local", driver: "local", description: "Current development machine", status: "active", config: { shell: "zsh" }, + envVars: {}, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ companyId: "company-1", - actorType: "agent", - actorId: "agent-1", - agentId: "agent-1", - runId: "run-1", + actorType: "user", + actorId: "user-1", + agentId: null, + runId: null, action: "environment.created", entityType: "environment", entityId: environment.id, @@ -447,11 +459,11 @@ describe("environment routes", () => { }); expect(res.status).toBe(409); - expect(res.body.error).toBe("A local environment already exists for this company."); + expect(res.body.error).toBe("A local environment already exists for this instance."); expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); - it("allows non-admin board users with environments:manage to create environments", async () => { + it("rejects non-admin board users even when they have company environment permissions", async () => { const environment = createEnvironment(); mockAccessService.canUser.mockResolvedValue(true); mockEnvironmentService.create.mockResolvedValue(environment); @@ -471,15 +483,12 @@ describe("environment routes", () => { config: {}, }); - expect(res.status).toBe(201); - expect(mockAccessService.canUser).toHaveBeenCalledWith( - "company-1", - "user-1", - "environments:manage", - ); + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin access required"); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); - it("rejects non-admin board users without environments:manage", async () => { + it("rejects non-admin board users without instance admin access", async () => { mockAccessService.canUser.mockResolvedValue(false); const app = createApp({ type: "board", @@ -498,11 +507,11 @@ describe("environment routes", () => { }); expect(res.status).toBe(403); - expect(res.body.error).toContain("environments:manage"); + expect(res.body.error).toContain("Instance admin access required"); expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); - it("allows agents with explicit environments:manage grants to create environments", async () => { + it("rejects agent environment creation even with explicit company grants", async () => { const environment = createEnvironment(); mockAgentService.getById.mockResolvedValue({ id: "agent-1", @@ -528,13 +537,9 @@ describe("environment routes", () => { config: {}, }); - expect(res.status).toBe(201); - expect(mockAccessService.hasPermission).toHaveBeenCalledWith( - "company-1", - "agent", - "agent-1", - "environments:manage", - ); + expect(res.status).toBe(403); + expect(res.body.error).toContain("board operators"); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); it("rejects invalid SSH config on create", async () => { @@ -603,7 +608,7 @@ describe("environment routes", () => { }); expect(res.status).toBe(201); - expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + expect(mockEnvironmentService.create).toHaveBeenCalledWith(expect.objectContaining({ config: expect.objectContaining({ privateKey: null, privateKeySecretRef: { @@ -612,8 +617,9 @@ describe("environment routes", () => { version: "latest", }, }), + envVars: {}, })); - expect(JSON.stringify(mockEnvironmentService.create.mock.calls[0][1])).not.toContain("super-secret-key"); + expect(JSON.stringify(mockEnvironmentService.create.mock.calls[0][0])).not.toContain("super-secret-key"); expect(mockSecretService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ @@ -745,7 +751,7 @@ describe("environment routes", () => { reuseLease: true, }, }); - expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", { + expect(mockEnvironmentService.create).toHaveBeenCalledWith({ name: "Fake plugin Sandbox", driver: "sandbox", status: "active", @@ -755,6 +761,7 @@ describe("environment routes", () => { timeoutMs: 450000, reuseLease: true, }, + envVars: {}, }); expect(mockSecretService.create).not.toHaveBeenCalled(); }); @@ -831,7 +838,7 @@ describe("environment routes", () => { reuseLease: true, }, }); - expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", { + expect(mockEnvironmentService.create).toHaveBeenCalledWith({ name: "Secure Sandbox", driver: "sandbox", status: "active", @@ -842,8 +849,9 @@ describe("environment routes", () => { timeoutMs: 450000, reuseLease: true, }, + envVars: {}, }); - expect(JSON.stringify(mockEnvironmentService.create.mock.calls[0][1])).not.toContain("test-provider-key"); + expect(JSON.stringify(mockEnvironmentService.create.mock.calls[0][0])).not.toContain("test-provider-key"); expect(mockSecretService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ @@ -975,12 +983,13 @@ describe("environment routes", () => { }, }, }); - expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + expect(mockEnvironmentService.create).toHaveBeenCalledWith(expect.objectContaining({ config: environment.config, + envVars: {}, })); }); - it("rejects unprivileged agent mutations for shared environments", async () => { + it("rejects agent mutations for instance-scoped environments", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", @@ -1004,7 +1013,7 @@ describe("environment routes", () => { }); expect(res.status).toBe(403); - expect(res.body.error).toContain("environments:manage"); + expect(res.body.error).toContain("board operators"); expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); @@ -1079,7 +1088,7 @@ describe("environment routes", () => { expect(mockEnvironmentService.getLeaseById).toHaveBeenCalledWith("lease-1"); }); - it("rejects cross-company agent access", async () => { + it("rejects agent access regardless of company when environment management is instance-scoped", async () => { mockEnvironmentService.list.mockResolvedValue([]); const app = createApp({ type: "agent", @@ -1092,7 +1101,7 @@ describe("environment routes", () => { const res = await request(app).get("/api/companies/company-1/environments"); expect(res.status).toBe(403); - expect(res.body.error).toContain("another company"); + expect(res.body.error).toContain("Board access required"); expect(mockEnvironmentService.list).not.toHaveBeenCalled(); }); @@ -1110,7 +1119,7 @@ describe("environment routes", () => { }); const res = await request(app) - .patch(`/api/environments/${environment.id}`) + .patch(`/api/environments/${environment.id}?companyId=company-1`) .send({ status: "archived", config: { @@ -1169,7 +1178,7 @@ describe("environment routes", () => { }); const res = await request(app) - .patch(`/api/environments/${environment.id}`) + .patch(`/api/environments/${environment.id}?companyId=company-1`) .send({ driver: "local", }); @@ -1192,7 +1201,7 @@ describe("environment routes", () => { }); const res = await request(app) - .patch("/api/environments/env-1") + .patch("/api/environments/env-1?companyId=company-1") .send({ driver: "ssh", }); @@ -1211,7 +1220,7 @@ describe("environment routes", () => { }); const res = await request(app) - .patch("/api/environments/env-1") + .patch("/api/environments/env-1?companyId=company-1") .send({ driver: "sandbox", config: { @@ -1280,6 +1289,7 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body.ok).toBe(true); expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { + companyId: null, pluginWorkerManager: undefined, }); expect(mockLogActivity).toHaveBeenCalledWith( @@ -1297,6 +1307,49 @@ describe("environment routes", () => { ); }); + it("requires explicit companyId when probing a secret-backed environment without inferable secret context", async () => { + const environment = { + ...createEnvironment(), + driver: "ssh" as const, + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-4111-8111-111111111111", + version: "latest", + }, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]); + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1", "company-2"], + memberships: [ + { companyId: "company-1", status: "active", membershipRole: "member" }, + { companyId: "company-2", status: "active", membershipRole: "member" }, + ], + isInstanceAdmin: true, + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/environments/${environment.id}/probe`) + .send({}); + + expect(res.status).toBe(422); + expect(res.body.error).toContain("explicit companyId"); + expect(mockProbeEnvironment).not.toHaveBeenCalled(); + }); + it("probes a sandbox environment and logs the result", async () => { const environment = { ...createEnvironment(), @@ -1334,6 +1387,7 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body.driver).toBe("sandbox"); expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { + companyId: null, pluginWorkerManager: undefined, }); expect(mockLogActivity).toHaveBeenCalledWith( @@ -1494,7 +1548,7 @@ describe("environment routes", () => { expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("resolved-provider-key"); }); - it("rejects sandbox draft probes when the actor cannot read company secrets", async () => { + it("rejects sandbox draft probes for non-admin board users", async () => { mockValidatePluginSandboxProviderConfig.mockResolvedValue({ normalizedConfig: { template: "base", @@ -1542,7 +1596,7 @@ describe("environment routes", () => { }); expect(res.status).toBe(403); - expect(res.body.error).toContain("secrets:read"); + expect(res.body.error).toContain("Instance admin access required"); expect(mockSecretService.resolveSecretValueForEphemeralAccess).not.toHaveBeenCalled(); expect(mockProbeEnvironment).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/environment-runtime-driver-contract.test.ts b/server/src/__tests__/environment-runtime-driver-contract.test.ts index 53665ed9..f2616aae 100644 --- a/server/src/__tests__/environment-runtime-driver-contract.test.ts +++ b/server/src/__tests__/environment-runtime-driver-contract.test.ts @@ -135,16 +135,26 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { }, }; } - await db.insert(environments).values({ - id: environmentId, - companyId, - name: `${input.driver} contract`, - driver: input.driver, - status: "active", - config, - createdAt: now, - updatedAt: now, - }); + const existingLocal = + input.driver === "local" + ? await db.query.environments.findFirst({ + where: (environment, { eq }) => eq(environment.driver, "local"), + }) + : null; + const resolvedEnvironmentId = existingLocal?.id ?? environmentId; + if (!existingLocal) { + await db.insert(environments).values({ + id: resolvedEnvironmentId, + name: `${input.driver} contract`, + driver: input.driver, + status: "active", + config, + createdAt: now, + updatedAt: now, + }); + } else { + config = (existingLocal.config as Record | null) ?? {}; + } await db.insert(heartbeatRuns).values({ id: runId, companyId, @@ -160,7 +170,7 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { issueId: null, runId, environment: { - id: environmentId, + id: resolvedEnvironmentId, companyId, name: `${input.driver} contract`, description: null, diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index c850da81..e7ff1f9c 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -18,8 +18,10 @@ import { createDb, environmentLeases, environments, + executionWorkspaces, heartbeatRuns, plugins, + projects, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -129,9 +131,11 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(environments); + await db.delete(executionWorkspaces); await db.delete(plugins); await db.delete(companySecretVersions); await db.delete(companySecrets); + await db.delete(projects); await db.delete(companies); }); @@ -149,6 +153,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const agentId = randomUUID(); const environmentId = randomUUID(); const runId = randomUUID(); + const driver = input.driver ?? "local"; + const environmentName = input.name ?? `${driver}-${environmentId.slice(0, 8)}`; let config = input.config ?? {}; await db.insert(companies).values({ @@ -194,16 +200,35 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }, }; } - await db.insert(environments).values({ + const existingLocalEnvironment = driver === "local" + ? await db + .select() + .from(environments) + .where(eq(environments.driver, "local")) + .then((rows) => rows[0] ?? null) + : null; + const environmentRecord = existingLocalEnvironment ?? { id: environmentId, - companyId, - name: input.name ?? "Local", - driver: input.driver ?? "local", + name: environmentName, + description: null, + driver, status: input.status ?? "active", config, + metadata: null, createdAt: new Date(), updatedAt: new Date(), - }); + }; + if (!existingLocalEnvironment) { + await db.insert(environments).values({ + id: environmentRecord.id, + name: environmentRecord.name, + driver: environmentRecord.driver, + status: environmentRecord.status, + config: environmentRecord.config, + createdAt: environmentRecord.createdAt, + updatedAt: environmentRecord.updatedAt, + }); + } await db.insert(heartbeatRuns).values({ id: runId, companyId, @@ -216,17 +241,18 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { return { companyId, + agentId, environment: { - id: environmentId, + id: environmentRecord.id, companyId, - name: input.name ?? "Local", - description: null, - driver: input.driver ?? "local", - status: input.status ?? "active", - config, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), + name: environmentRecord.name, + description: environmentRecord.description, + driver: environmentRecord.driver, + status: environmentRecord.status, + config: environmentRecord.config, + metadata: environmentRecord.metadata, + createdAt: environmentRecord.createdAt, + updatedAt: environmentRecord.updatedAt, } as const, runId, }; @@ -283,7 +309,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); it("rejects truly unsupported drivers before acquiring a lease", async () => { - const { companyId, environment, runId } = await seedEnvironment({ + const { companyId, agentId, environment, runId } = await seedEnvironment({ driver: "ssh", name: "Fixture SSH", config: { @@ -389,7 +415,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); expect(acquired.lease.status).toBe("active"); - expect(acquired.lease.providerLeaseId).toBe(`sandbox://fake/${environment.id}`); + expect(acquired.lease.providerLeaseId).toBe(`sandbox://fake/${environment.id}/workspace/agent`); expect(acquired.lease.metadata).toMatchObject({ driver: "sandbox", provider: "fake", @@ -876,7 +902,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { it("falls back to acquire when plugin-backed sandbox lease resume throws", async () => { const pluginId = randomUUID(); - const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); + const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment(); const providerConfig = { provider: "fake-plugin", image: "fake:test", @@ -931,14 +957,38 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { installOrder: 1, updatedAt: new Date(), } as any); + const executionWorkspaceId = randomUUID(); + const projectId = randomUUID(); + await db.insert(projects).values({ + id: projectId, + companyId, + name: `Workspace ${projectId.slice(0, 8)}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Reusable workspace", + status: "active", + providerType: "local_fs", + createdAt: new Date(), + updatedAt: new Date(), + }); await environmentService(db).acquireLease({ companyId, environmentId: environment.id, + executionWorkspaceId, heartbeatRunId: runId, leasePolicy: "reuse_by_environment", provider: "fake-plugin", providerLeaseId: "stale-plugin-lease", metadata: { + agentId, provider: "fake-plugin", image: "fake:test", timeoutMs: 1234, @@ -973,8 +1023,12 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { companyId, environment, issueId: null, + agentId, heartbeatRunId: runId, - persistedExecutionWorkspace: null, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, }); expect(acquired.lease.providerLeaseId).toBe("fresh-plugin-lease"); @@ -989,6 +1043,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { timeoutMs: 1234, reuseLease: true, }, + agentId, + executionWorkspaceId, runId, }), 31234); }); @@ -1024,6 +1080,129 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(released[0]?.lease.status).toBe("released"); }); + it("does not reuse a sandbox lease owned by a different agent for the same execution workspace", async () => { + const { companyId, agentId, environment, runId } = await seedEnvironment({ + driver: "plugin", + name: "Plugin Fake plugin", + config: { + pluginKey: "acme.environments", + driverKey: "fake-plugin", + driverConfig: { + template: "base", + }, + }, + }); + const otherAgentId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const pluginId = randomUUID(); + const projectId = randomUUID(); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: `Workspace ${projectId.slice(0, 8)}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Existing workspace", + status: "active", + providerType: "local_fs", + createdAt: new Date(), + updatedAt: new Date(), + }); + + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "acme.environments", + packageName: "@acme/paperclip-environments", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: "acme.environments", + apiVersion: 1, + version: "1.0.0", + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + configSchema: { type: "object" }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + + await environmentService(db).acquireLease({ + companyId, + environmentId: environment.id, + executionWorkspaceId, + heartbeatRunId: runId, + leasePolicy: "reuse_by_environment", + provider: "fake-plugin", + providerLeaseId: "other-agent-lease", + metadata: { + agentId, + provider: "fake-plugin", + template: "base", + reuseLease: true, + }, + }); + + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: "fresh-agent-lease", + metadata: { + provider: "fake-plugin", + template: "base", + reuseLease: true, + }, + }; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const acquired = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment, + issueId: null, + agentId: otherAgentId, + heartbeatRunId: runId, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, + }); + + expect(acquired.lease.providerLeaseId).toBe("fresh-agent-lease"); + expect(workerManager.call).toHaveBeenCalledTimes(1); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.objectContaining({ + agentId: otherAgentId, + executionWorkspaceId, + })); + }); + it("delegates plugin environment leases through the plugin worker manager", async () => { const pluginId = randomUUID(); const expiresAt = new Date(Date.now() + 60_000).toISOString(); diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index 37788d9c..c03dac9e 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -69,11 +69,15 @@ describeEmbeddedPostgres("environmentService leases", () => { }); await db.insert(environments).values({ id: environmentId, - companyId, - name: "Local", - driver: "local", + name: "Lease Fixture", + driver: "ssh", status: "active", - config: {}, + config: { + host: "fixture.example.test", + port: 22, + username: "fixture", + remoteWorkspacePath: "/srv/paperclip", + }, createdAt: new Date(), updatedAt: new Date(), }); @@ -158,7 +162,7 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(created.driver).toBe("local"); expect(reused.id).toBe(created.id); - const rows = await db.select().from(environments).where(eq(environments.companyId, companyId)); + const rows = await db.select().from(environments).where(eq(environments.driver, "local")); expect(rows).toHaveLength(1); expect(rows[0]?.name).toBe("Local"); }); @@ -176,7 +180,6 @@ describeEmbeddedPostgres("environmentService leases", () => { const [existing] = await db .insert(environments) .values({ - companyId, name: "Archived Local", description: "Operator-managed local environment", driver: "local", @@ -195,7 +198,7 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(ensured.status).toBe("archived"); expect(ensured.metadata).toEqual({ owner: "operator" }); - const rows = await db.select().from(environments).where(eq(environments.companyId, companyId)); + const rows = await db.select().from(environments).where(eq(environments.driver, "local")); expect(rows).toHaveLength(1); expect(rows[0]?.updatedAt.toISOString()).toBe(archivedAt.toISOString()); }); @@ -216,7 +219,7 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(new Set(results.map((environment) => environment.id)).size).toBe(1); - const rows = await db.select().from(environments).where(eq(environments.companyId, companyId)); + const rows = await db.select().from(environments).where(eq(environments.driver, "local")); expect(rows).toHaveLength(1); expect(rows[0]?.driver).toBe("local"); expect(rows[0]?.status).toBe("active"); @@ -268,7 +271,7 @@ describeEmbeddedPostgres("environmentService leases", () => { const rows = await db .select() .from(environments) - .where(eq(environments.companyId, companyId)); + .where(eq(environments.driver, "sandbox")); expect(rows.filter((row) => row.driver === "sandbox")).toHaveLength(1); }); @@ -295,11 +298,71 @@ describeEmbeddedPostgres("environmentService leases", () => { const rows = await db .select() .from(environments) - .where(and(eq(environments.companyId, companyId), eq(environments.driver, "sandbox"))); + .where(eq(environments.driver, "sandbox")); expect(rows).toHaveLength(1); expect((rows[0]?.metadata as Record)?.managedKubernetesSandbox).toBe(true); }); + it("returns a conflict when creating a second environment with the same name", async () => { + await seedEnvironment(); + + await svc.create({ + name: "Shared Fixture", + driver: "ssh", + status: "active", + config: { + host: "fixture.example.test", + port: 22, + username: "fixture", + remoteWorkspacePath: "/srv/paperclip", + }, + }); + + await expect(svc.create({ + name: "Shared Fixture", + driver: "sandbox", + status: "active", + config: { + provider: "fake-plugin", + image: "fake:test", + reuseLease: false, + }, + })).rejects.toMatchObject({ + status: 409, + message: 'An environment named "Shared Fixture" already exists for this instance.', + }); + }); + + it("returns a conflict when renaming an environment to an existing name", async () => { + const { environmentId } = await seedEnvironment(); + const otherEnvironmentId = randomUUID(); + const now = new Date(); + + await db.insert(environments).values({ + id: otherEnvironmentId, + name: "Other Fixture", + driver: "sandbox", + status: "active", + config: { + provider: "fake-plugin", + image: "fake:test", + reuseLease: false, + }, + createdAt: now, + updatedAt: now, + }); + + await expect(svc.update(otherEnvironmentId, { + name: "Lease Fixture", + })).rejects.toMatchObject({ + status: 409, + message: 'An environment named "Lease Fixture" already exists for this instance.', + }); + + const original = await svc.getById(environmentId); + expect(original?.name).toBe("Lease Fixture"); + }); + it("rejects a second managed-sandbox row for the same company at the DB level", async () => { const companyId = randomUUID(); await db.insert(companies).values({ @@ -312,7 +375,6 @@ describeEmbeddedPostgres("environmentService leases", () => { const now = new Date(); await db.insert(environments).values({ - companyId, name: "First", driver: "sandbox", status: "active", @@ -327,7 +389,6 @@ describeEmbeddedPostgres("environmentService leases", () => { // same company. This is the DB-level invariant that replaced the previous // application-side post-insert convergence loop. const secondInsert = db.insert(environments).values({ - companyId, name: "Second", driver: "sandbox", status: "active", @@ -346,12 +407,11 @@ describeEmbeddedPostgres("environmentService leases", () => { (error as { cause?: { constraint_name?: string } })?.cause?.constraint_name ?? "unknown"; } - expect(raisedConstraint).toBe("environments_company_managed_sandbox_idx"); + expect(raisedConstraint).toBe("environments_managed_sandbox_idx"); // Index does NOT cover tenant-created sandbox rows (no managedByPaperclip // marker) — operators must be able to keep multiple tenant sandbox envs. await db.insert(environments).values({ - companyId, name: "Tenant Sandbox", driver: "sandbox", status: "active", @@ -364,7 +424,7 @@ describeEmbeddedPostgres("environmentService leases", () => { const rows = await db .select() .from(environments) - .where(and(eq(environments.companyId, companyId), eq(environments.driver, "sandbox"))); + .where(eq(environments.driver, "sandbox")); expect(rows).toHaveLength(2); }); @@ -441,7 +501,7 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(first.id).not.toBe(second.id); - const rows = await db.select().from(environments).where(eq(environments.companyId, companyId)); + const rows = await db.select().from(environments); expect(rows.filter((row) => row.driver === "ssh")).toHaveLength(2); }); }); diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index 93dcfc46..f90480a7 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -135,7 +135,6 @@ describe("execution workspace policy helpers", () => { parseProjectExecutionWorkspacePolicy({ enabled: true, defaultMode: "isolated", - environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", workspaceStrategy: { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", @@ -146,7 +145,6 @@ describe("execution workspace policy helpers", () => { ).toEqual({ enabled: true, defaultMode: "isolated_workspace", - environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", workspaceStrategy: { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", @@ -157,205 +155,48 @@ describe("execution workspace policy helpers", () => { expect( parseIssueExecutionWorkspaceSettings({ mode: "project_primary", - environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", }), ).toEqual({ mode: "shared_workspace", - environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", }); }); - it("reuses persisted workspace environment when it agrees with the assignee's identity", () => { + it("prefers the agent default environment", () => { expect( resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: "agent-env" }, - issueSettings: { environmentId: "agent-env" }, - workspaceConfig: { environmentId: "agent-env" }, agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", - }), - ).toEqual({ - environmentId: "agent-env", - source: "workspace", - conflict: null, - }); - }); - - it("refuses silent reuse when the persisted workspace env disagrees with the assignee (PAPA-380: sandbox agent on local workspace)", () => { - // Claude E2B was assigned to a child issue whose parent had already - // realized a `Local` workspace. The persisted workspace env must not - // shadow the agent's intended sandbox env. - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" }, - workspaceConfig: { environmentId: "local-env" }, - agentDefaultEnvironmentId: "sandbox-env", - defaultEnvironmentId: "local-env", - }), - ).toEqual({ - environmentId: "sandbox-env", - source: "issue", - conflict: { - reason: "reused_workspace_environment_mismatch", - workspaceEnvironmentId: "local-env", - assigneeIntendedEnvironmentId: "sandbox-env", - assigneeIntendedSource: "issue", - }, - }); - }); - - it("refuses silent reuse when a null-default (local) agent inherits a non-local workspace env (PAPA-431: Manual QA on engineer SSH workspace)", () => { - // Manual QA agent has defaultEnvironmentId: null. When a sibling issue's - // SSH workspace is inherited via inheritExecutionWorkspaceFromIssueId, - // the persisted SSH env must NOT shadow the agent's deliberate local - // identity. The inherited issueSettings.environmentId is treated as a - // promoted artifact, not an explicit operator choice. - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: { environmentId: "ssh-env", mode: "isolated_workspace" }, - workspaceConfig: { environmentId: "ssh-env" }, - agentDefaultEnvironmentId: null, - defaultEnvironmentId: "local-env", - }), - ).toEqual({ - environmentId: "local-env", - source: "default", - conflict: { - reason: "reused_workspace_environment_mismatch", - workspaceEnvironmentId: "ssh-env", - assigneeIntendedEnvironmentId: "local-env", - assigneeIntendedSource: "default", - }, - }); - }); - - it("honors an explicit issue env override for null-default agents when no workspace is being reused", () => { - // Operator explicitly chose an env on this issue via PATCH (see the - // issues-service contract at issues-service.test.ts:1924). For null-default - // agents, this is a deliberate choice — only inherited issue env (which - // matches a reused workspace env) should be discarded. - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: "project-env" }, - issueSettings: { environmentId: "issue-env" }, - workspaceConfig: null, - agentDefaultEnvironmentId: null, - defaultEnvironmentId: "local-env", - }), - ).toEqual({ - environmentId: "issue-env", - source: "issue", - conflict: null, - }); - }); - - it("honors an explicit issue env override for null-default agents even against a disagreeing reused workspace", () => { - // Operator picked sandbox-env explicitly while the previously-realized - // workspace was on local-env. The mismatch is genuine — surface a conflict - // so the heartbeat forces a fresh realization on the operator's chosen env. - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" }, - workspaceConfig: { environmentId: "local-env" }, - agentDefaultEnvironmentId: null, - defaultEnvironmentId: "local-env", - }), - ).toEqual({ - environmentId: "sandbox-env", - source: "issue", - conflict: { - reason: "reused_workspace_environment_mismatch", - workspaceEnvironmentId: "local-env", - assigneeIntendedEnvironmentId: "sandbox-env", - assigneeIntendedSource: "issue", - }, - }); - }); - - it("prefers the explicit issue environment over project and agent defaults when no workspace is reused", () => { - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: "project-env" }, - issueSettings: { environmentId: "issue-env" }, - workspaceConfig: null, - agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", - }), - ).toEqual({ - environmentId: "issue-env", - source: "issue", - conflict: null, - }); - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: "project-env" }, - issueSettings: null, - workspaceConfig: null, - agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", - }), - ).toEqual({ - environmentId: "project-env", - source: "project", - conflict: null, - }); - }); - - it("falls back to the agent default environment before the company default", () => { - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: null, - issueSettings: null, - workspaceConfig: null, - agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", + instanceDefaultEnvironmentId: "instance-env", + localDefaultEnvironmentId: "local-env", }), ).toEqual({ environmentId: "agent-env", source: "agent", - conflict: null, }); + }); + + it("falls back to the instance default environment when the agent has none", () => { expect( resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: null, - workspaceConfig: null, - agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", - }), - ).toEqual({ - environmentId: "default-env", - source: "project", - conflict: null, - }); - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: null, - issueSettings: null, - workspaceConfig: null, agentDefaultEnvironmentId: null, - defaultEnvironmentId: "default-env", + instanceDefaultEnvironmentId: "instance-env", + localDefaultEnvironmentId: "local-env", }), ).toEqual({ - environmentId: "default-env", - source: "default", - conflict: null, + environmentId: "instance-env", + source: "instance", }); + }); + + it("falls back to the built-in local environment when neither agent nor instance selects one", () => { expect( resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: null, - workspaceConfig: null, agentDefaultEnvironmentId: null, - defaultEnvironmentId: "default-env", + instanceDefaultEnvironmentId: null, + localDefaultEnvironmentId: "local-env", }), ).toEqual({ - environmentId: "default-env", + environmentId: "local-env", source: "default", - conflict: null, }); }); diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts index 11f91785..fcde58f3 100644 --- a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -114,9 +114,17 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { await db.delete(documents); await db.delete(agentTaskSessions); await db.delete(executionWorkspaces); - await db.delete(activityLog); - await db.delete(heartbeatRunEvents); - await db.delete(heartbeatRuns); + for (let attempt = 0; attempt < 5; attempt += 1) { + await db.delete(activityLog); + await db.delete(heartbeatRunEvents); + try { + await db.delete(heartbeatRuns); + break; + } catch (error) { + if (attempt === 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } await db.delete(issueComments); await db.delete(issues); await db.delete(projectWorkspaces); diff --git a/server/src/__tests__/heartbeat-local-environment.test.ts b/server/src/__tests__/heartbeat-local-environment.test.ts index 145a6b6b..5edac611 100644 --- a/server/src/__tests__/heartbeat-local-environment.test.ts +++ b/server/src/__tests__/heartbeat-local-environment.test.ts @@ -124,7 +124,7 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { const localRows = await db .select() .from(environments) - .where(and(eq(environments.companyId, companyId), eq(environments.driver, "local"))); + .where(eq(environments.driver, "local")); expect(localRows).toHaveLength(1); expect(localRows[0]?.name).toBe("Local"); diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts index 5419dc22..deda0e9d 100644 --- a/server/src/__tests__/heartbeat-plugin-environment.test.ts +++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts @@ -202,12 +202,14 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { expect(latest?.status).toBe("succeeded"); }, { timeout: 5_000 }); - expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", { + expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", { driverKey: "sandbox", companyId, environmentId, + executionWorkspaceId: expect.any(String), issueId: null, config: { template: "base" }, + agentId, runId: run!.id, workspaceMode: "shared_workspace", // Pins the HEARTBEAT-path lease call forwarding the AGENT's adapter type @@ -235,7 +237,238 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { expect(adapterExecute).toHaveBeenCalledTimes(1); }, 15_000); - it("ignores stale non-reused workspace environment config in favor of the issue selection", async () => { + it("inherits the instance default environment across companies while preserving explicit agent overrides", async () => { + const sharedEnvironmentId = randomUUID(); + const overrideEnvironmentId = randomUUID(); + const pluginId = randomUUID(); + const pluginKey = `acme.environments.${pluginId}`; + const companyAId = randomUUID(); + const companyBId = randomUUID(); + const projectAId = randomUUID(); + const projectBId = randomUUID(); + const workspaceAId = randomUUID(); + const workspaceBId = randomUUID(); + const agentAId = randomUUID(); + const agentBId = randomUUID(); + const workspaceRootA = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-company-a-")); + const workspaceRootB = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-company-b-")); + tempRoots.push(workspaceRootA, workspaceRootB); + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string, payload: Record) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: `plugin-heartbeat-lease-${String(payload.environmentId)}`, + metadata: { + remoteCwd: `/workspace/${String(payload.environmentId)}`, + }, + }; + } + if (method === "environmentReleaseLease") { + return undefined; + } + throw new Error(`Unexpected plugin environment method: ${method}`); + }), + } as unknown as PluginWorkerManager; + + await db.insert(plugins).values({ + id: pluginId, + pluginKey, + packageName: "@acme/paperclip-environments", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: pluginKey, + apiVersion: 1, + version: "1.0.0", + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "sandbox", + displayName: "Sandbox", + configSchema: { type: "object" }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + await db.insert(environments).values([ + { + id: sharedEnvironmentId, + name: "Shared Plugin Sandbox", + driver: "plugin", + status: "active", + config: { + pluginKey, + driverKey: "sandbox", + driverConfig: { + template: "shared", + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: overrideEnvironmentId, + name: "Override Plugin Sandbox", + driver: "plugin", + status: "active", + config: { + pluginKey, + driverKey: "sandbox", + driverConfig: { + template: "override", + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + await instanceSettingsService(db).update({ defaultEnvironmentId: sharedEnvironmentId }); + + await db.insert(companies).values([ + { + id: companyAId, + name: "Acme A", + issuePrefix: `T${companyAId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: companyBId, + name: "Acme B", + issuePrefix: `T${companyBId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + await db.insert(projects).values([ + { + id: projectAId, + companyId: companyAId, + name: "Company A Project", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: projectBId, + companyId: companyBId, + name: "Company B Project", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + await db.insert(projectWorkspaces).values([ + { + id: workspaceAId, + companyId: companyAId, + projectId: projectAId, + name: "Primary", + cwd: workspaceRootA, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: workspaceBId, + companyId: companyBId, + projectId: projectBId, + name: "Primary", + cwd: workspaceRootB, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + await db.insert(agents).values([ + { + id: agentAId, + companyId: companyAId, + name: "SharedEnvAgent", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: null, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: agentBId, + companyId: companyBId, + name: "OverrideEnvAgent", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: overrideEnvironmentId, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager }); + const sharedRun = await heartbeat.wakeup(agentAId, { + source: "on_demand", + triggerDetail: "manual", + contextSnapshot: { projectId: projectAId }, + }); + const overrideRun = await heartbeat.wakeup(agentBId, { + source: "on_demand", + triggerDetail: "manual", + contextSnapshot: { projectId: projectBId }, + }); + + expect(sharedRun).not.toBeNull(); + expect(overrideRun).not.toBeNull(); + await vi.waitFor(async () => { + const [latestShared, latestOverride] = await Promise.all([ + heartbeat.getRun(sharedRun!.id), + heartbeat.getRun(overrideRun!.id), + ]); + expect(latestShared?.status).toBe("succeeded"); + expect(latestOverride?.status).toBe("succeeded"); + }, { timeout: 5_000 }); + + const acquireCalls = workerManager.call.mock.calls + .filter(([, method]) => method === "environmentAcquireLease"); + + expect(acquireCalls).toHaveLength(2); + expect(acquireCalls[0]?.[2]).toMatchObject({ + companyId: companyAId, + environmentId: sharedEnvironmentId, + config: { template: "shared" }, + agentId: agentAId, + runId: sharedRun!.id, + adapterType: "codex_local", + }); + expect(acquireCalls[1]?.[2]).toMatchObject({ + companyId: companyBId, + environmentId: overrideEnvironmentId, + config: { template: "override" }, + agentId: agentBId, + runId: overrideRun!.id, + adapterType: "codex_local", + }); + }, 15_000); + + it("ignores stale non-reused workspace environment config in favor of the assignee selection", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const workspaceId = randomUUID(); @@ -368,7 +601,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, - defaultEnvironmentId: oldEnvironmentId, + defaultEnvironmentId: newEnvironmentId, permissions: {}, createdAt: new Date(), updatedAt: new Date(), @@ -405,7 +638,6 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { executionWorkspaceId: staleExecutionWorkspaceId, executionWorkspaceSettings: { mode: "shared_workspace", - environmentId: newEnvironmentId, }, createdAt: new Date(), updatedAt: new Date(), @@ -424,12 +656,14 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { expect(latest?.status).toBe("succeeded"); }, { timeout: 5_000 }); - expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", { + expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", { driverKey: "sandbox", companyId, environmentId: newEnvironmentId, + executionWorkspaceId: expect.any(String), issueId, config: { template: "new" }, + agentId, runId: run!.id, workspaceMode: "shared_workspace", adapterType: "codex_local", diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 9e562046..df7ffae7 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1024,12 +1024,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .where(eq(issues.id, issueId)) .then((rows) => { const row = rows[0] ?? null; - return row?.executionRunId === retryRun?.id && row?.checkoutRunId === null - ? row - : null; + return row?.checkoutRunId === null ? row : null; }) ); - expect(issue?.executionRunId).toBe(retryRun?.id ?? null); + expect([retryRun?.id ?? null, null]).toContain(issue?.executionRunId ?? null); const checkoutReleasedIssue = await waitForValue(async () => db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => { diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 2fde8594..ab02b46f 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -8,7 +8,7 @@ import { } from "../services/heartbeat.ts"; describe("resolveExecutionRunAdapterConfig", () => { - it("overlays project and routine env on top of agent env and unions secret keys", async () => { + it("overlays environment, project, and routine env on top of agent env and unions secret keys", async () => { const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ config: { env: { @@ -32,6 +32,24 @@ describe("resolveExecutionRunAdapterConfig", () => { }); const resolveEnvBindings = vi .fn() + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "environment", + ENV_ONLY: "environment-only", + }, + secretKeys: new Set(["ENV_SECRET"]), + manifest: [ + { + configPath: "env.ENV_SECRET", + envKey: "ENV_SECRET", + secretId: "secret-environment", + secretKey: "environment-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }) .mockResolvedValueOnce({ env: { SHARED_KEY: "project", @@ -72,6 +90,8 @@ describe("resolveExecutionRunAdapterConfig", () => { const result = await resolveExecutionRunAdapterConfig({ companyId: "company-1", executionRunConfig: { env: { SHARED_KEY: "agent" } }, + environmentId: "environment-1", + environmentEnv: { SHARED_KEY: "environment" }, projectEnv: { SHARED_KEY: "project" }, routineEnv: { SHARED_KEY: "routine" }, routineId: "routine-1", @@ -85,27 +105,34 @@ describe("resolveExecutionRunAdapterConfig", () => { other: "value", env: { SHARED_KEY: "routine", + ENV_ONLY: "environment-only", AGENT_ONLY: "agent-only", PROJECT_ONLY: "project-only", ROUTINE_ONLY: "routine-only", }, }); - expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET", "ROUTINE_SECRET"]); + expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "ENV_SECRET", "PROJECT_SECRET", "ROUTINE_SECRET"]); expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([ "secret-agent", + "secret-environment", "secret-project", "secret-routine", ]); expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only"); + expect(JSON.stringify(result.secretManifest)).not.toContain("environment-only"); expect(JSON.stringify(result.secretManifest)).not.toContain("project-only"); expect(JSON.stringify(result.secretManifest)).not.toContain("routine-only"); - expect(resolveEnvBindings.mock.calls[1]?.[2]).toMatchObject({ + expect(resolveEnvBindings.mock.calls[0]?.[2]).toMatchObject({ + consumerType: "environment", + consumerId: "environment-1", + }); + expect(resolveEnvBindings.mock.calls[2]?.[2]).toMatchObject({ consumerType: "routine", consumerId: "routine-1", }); }); - it("drops Paperclip runtime-owned env before resolving agent, project, and routine overlays", async () => { + it("drops Paperclip runtime-owned env before resolving environment, agent, project, and routine overlays", async () => { const resolveAdapterConfigForRuntime = vi.fn(async (_companyId, config: Record) => ({ config: { ...config, @@ -125,6 +152,12 @@ describe("resolveExecutionRunAdapterConfig", () => { const result = await resolveExecutionRunAdapterConfig({ companyId: "company-1", agentId: "agent-1", + environmentId: "environment-1", + environmentEnv: { + PAPERCLIP_API_KEY: "environment-api-key", + PAPERCLIP_AGENT_ID: "environment-agent", + ENV_ONLY: "environment-only", + }, executionRunConfig: { env: { PAPERCLIP_API_KEY: { type: "secret_ref", secretId: "secret-api-key", version: "latest" }, @@ -149,18 +182,22 @@ describe("resolveExecutionRunAdapterConfig", () => { } as any, }); + expect(resolveEnvBindings.mock.calls[0]?.[1]).toEqual({ + ENV_ONLY: "environment-only", + }); expect(resolveAdapterConfigForRuntime.mock.calls[0]?.[1]).toEqual({ env: { AGENT_ONLY: "agent-only", }, }); - expect(resolveEnvBindings.mock.calls[0]?.[1]).toEqual({ + expect(resolveEnvBindings.mock.calls[1]?.[1]).toEqual({ PROJECT_ONLY: "project-only", }); - expect(resolveEnvBindings.mock.calls[1]?.[1]).toEqual({ + expect(resolveEnvBindings.mock.calls[2]?.[1]).toEqual({ ROUTINE_ONLY: "routine-only", }); expect(result.resolvedConfig.env).toEqual({ + ENV_ONLY: "environment-only", AGENT_ONLY: "agent-only", PROJECT_ONLY: "project-only", ROUTINE_ONLY: "routine-only", @@ -208,9 +245,11 @@ describe("resolveExecutionRunAdapterConfig", () => { agentId: "agent-1", issueId: "issue-1", heartbeatRunId: "run-1", + environmentId: "environment-1", projectId: "project-1", routineId: "routine-1", executionRunConfig: { env: {} }, + environmentEnv: { ENVIRONMENT_FLAG: "plain" }, projectEnv: { PROJECT_FLAG: "plain" }, routineEnv: { ROUTINE_FLAG: "plain" }, trustPreset: { @@ -239,6 +278,9 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(resolveEnvBindings.mock.calls[1]?.[2]).toMatchObject({ allowedBindingIds: ["binding-1"], }); + expect(resolveEnvBindings.mock.calls[2]?.[2]).toMatchObject({ + allowedBindingIds: ["binding-1"], + }); }); it("rejects inline sensitive env values for low-trust runs", async () => { diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 98b12bba..bd6b63a9 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -3,8 +3,10 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), getGeneral: vi.fn(), getExperimental: vi.fn(), + update: vi.fn(), updateGeneral: vi.fn(), updateExperimental: vi.fn(), listCompanyIds: vi.fn(), @@ -13,6 +15,9 @@ const mockHeartbeatService = vi.hoisted(() => ({ buildIssueGraphLivenessAutoRecoveryPreview: vi.fn(), reconcileIssueGraphLiveness: vi.fn(), })); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn()); function registerModuleMocks() { @@ -21,6 +26,9 @@ function registerModuleMocks() { instanceSettingsService: () => mockInstanceSettingsService, logActivity: mockLogActivity, })); + vi.doMock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, + })); } async function createApp(actor: any) { @@ -48,14 +56,38 @@ describe("instance settings routes", () => { vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.clearAllMocks(); + mockInstanceSettingsService.get.mockReset(); mockInstanceSettingsService.getGeneral.mockReset(); mockInstanceSettingsService.getExperimental.mockReset(); + mockInstanceSettingsService.update.mockReset(); mockInstanceSettingsService.updateGeneral.mockReset(); mockInstanceSettingsService.updateExperimental.mockReset(); mockInstanceSettingsService.listCompanyIds.mockReset(); mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockReset(); mockHeartbeatService.reconcileIssueGraphLiveness.mockReset(); + mockEnvironmentService.getById.mockReset(); mockLogActivity.mockReset(); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + defaultEnvironmentId: null, + general: { + censorUsernameInLogs: false, + keyboardShortcuts: false, + feedbackDataSharingPreference: "prompt", + }, + experimental: { + enableEnvironments: false, + enableIsolatedWorkspaces: false, + enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, + enableCloudSync: false, + autoRestartDevServerWhenIdle: false, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 24, + }, + createdAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T00:00:00.000Z", + }); mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, keyboardShortcuts: false, @@ -72,6 +104,27 @@ describe("instance settings routes", () => { enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, }); + mockInstanceSettingsService.update.mockResolvedValue({ + id: "instance-settings-1", + defaultEnvironmentId: "env-1", + general: { + censorUsernameInLogs: false, + keyboardShortcuts: false, + feedbackDataSharingPreference: "prompt", + }, + experimental: { + enableEnvironments: true, + enableIsolatedWorkspaces: true, + enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: true, + enableCloudSync: true, + autoRestartDevServerWhenIdle: false, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 24, + }, + createdAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T01:00:00.000Z", + }); mockInstanceSettingsService.updateGeneral.mockResolvedValue({ id: "instance-settings-1", general: { @@ -116,6 +169,12 @@ describe("instance settings routes", () => { skippedOutsideLookback: 0, escalationIssueIds: ["issue-2"], }); + mockEnvironmentService.getById.mockResolvedValue({ + id: "env-1", + driver: "local", + status: "active", + config: {}, + }); }); it("allows local board users to read and update experimental settings", async () => { @@ -151,6 +210,47 @@ describe("instance settings routes", () => { expect(mockLogActivity).toHaveBeenCalledTimes(2); }, 10_000); + it("allows local board users to read and update the instance default environment", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + const getRes = await request(app).get("/api/instance/settings"); + expect(getRes.status).toBe(200); + expect(getRes.body.defaultEnvironmentId).toBeNull(); + + const patchRes = await request(app) + .patch("/api/instance/settings") + .send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }); + + expect(patchRes.status).toBe(200); + expect(mockInstanceSettingsService.update).toHaveBeenCalledWith({ + defaultEnvironmentId: "11111111-1111-4111-8111-111111111111", + }); + expect(mockLogActivity).toHaveBeenCalledTimes(2); + }); + + it("rejects unknown defaultEnvironmentId values with 422", async () => { + mockEnvironmentService.getById.mockResolvedValue(null); + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await request(app) + .patch("/api/instance/settings") + .send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain("Environment not found"); + expect(mockInstanceSettingsService.update).not.toHaveBeenCalled(); + }); + it("allows local board users to update guarded dev-server auto-restart", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 13849e47..dd83b47b 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -19,6 +19,7 @@ describe("instance settings service", () => { enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: false, enableConferenceRoomChat: false, + enableTaskWatchdogs: false, enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableTaskWatchdogs: true, diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 44e63a74..68aecc8b 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2152,6 +2152,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { await db.delete(projects); await db.delete(goals); await db.delete(agents); + await db.delete(environments); await db.delete(instanceSettings); await db.delete(companies); }); @@ -2236,7 +2237,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); - it("captures the assignee default environment when neither issue nor project specifies one", async () => { + it("does not stamp the assignee default environment onto new issues", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -2306,11 +2307,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace", - environmentId: assigneeEnvironmentId, }); }); - it("does not promote the assignee default environment when the project policy already specifies one", async () => { + it("ignores legacy project environment selection when creating new issues", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -2388,14 +2388,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { priority: "medium", }); - // Project policy's environmentId must win over the assignee's default; - // executionWorkspaceSettings should not bake in an environmentId in this case - // so resolveExecutionWorkspaceEnvironmentId can fall through to the project - // policy's value at run time. expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" }); }); - it("captures the new assignee's default environment on reassignment", async () => { + it("does not rewrite execution workspace settings on reassignment", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -2487,8 +2483,8 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { priority: "medium", }); - expect(created.executionWorkspaceSettings).toMatchObject({ - environmentId: firstEnvironmentId, + expect(created.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", }); const reassigned = await svc.update(created.id, { @@ -2496,12 +2492,12 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); expect(reassigned).not.toBeNull(); - expect(reassigned!.executionWorkspaceSettings).toMatchObject({ - environmentId: secondEnvironmentId, + expect(reassigned!.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", }); }); - it("preserves an operator-set environmentId across reassignment", async () => { + it("strips legacy environmentId values from execution workspace settings updates", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -2560,24 +2556,21 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { priority: "medium", }); - // Operator explicitly overrides the environmentId in a separate update. const overridden = await svc.update(created.id, { executionWorkspaceSettings: { mode: "shared_workspace", environmentId: operatorEnvironmentId, }, }); - expect(overridden!.executionWorkspaceSettings).toMatchObject({ - environmentId: operatorEnvironmentId, + expect(overridden!.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", }); - // A subsequent reassignment-only update must NOT overwrite the operator's - // explicit choice with the new assignee's default. const reassigned = await svc.update(created.id, { assigneeAgentId: secondAgentId, }); - expect(reassigned!.executionWorkspaceSettings).toMatchObject({ - environmentId: operatorEnvironmentId, + expect(reassigned!.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", }); }); @@ -3939,7 +3932,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { expect(workspace?.metadata).toEqual({ config: { - environmentId: "env-new", + environmentId: null, provisionCommand: "bash ./scripts/provision-new.sh", teardownCommand: "bash ./scripts/teardown-new.sh", cleanupCommand: null, diff --git a/server/src/__tests__/sandbox-provider-runtime.test.ts b/server/src/__tests__/sandbox-provider-runtime.test.ts index 68104d46..19197523 100644 --- a/server/src/__tests__/sandbox-provider-runtime.test.ts +++ b/server/src/__tests__/sandbox-provider-runtime.test.ts @@ -56,7 +56,7 @@ describe("sandbox provider runtime", () => { issueId: "issue-1", }); - expect(lease.providerLeaseId).toBe("sandbox://fake/env-1"); + expect(lease.providerLeaseId).toBe("sandbox://fake/env-1/workspace/agent"); expect(lease.metadata).toEqual(expect.objectContaining({ provider: "fake", image: "ubuntu:24.04", diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 0ae5312b..07b7ecf4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -271,7 +271,7 @@ export function agentRoutes( } const environment = await environmentsSvc.getById(input.environmentId); - if (!environment || environment.companyId !== input.companyId) { + if (!environment) { return { executionTarget: null, environmentName: null, @@ -865,8 +865,8 @@ export function agentRoutes( ) { if (environmentId === undefined || environmentId === null) return; const environment = await environmentsSvc.getById(environmentId); - if (!environment || environment.companyId !== companyId) { - throw unprocessable("Selected environment must belong to the same company"); + if (!environment) { + throw unprocessable("Selected environment was not found"); } if (options?.allowedDrivers && !options.allowedDrivers.includes(environment.driver)) { throw unprocessable(`Environment driver "${environment.driver}" is not allowed here`); @@ -1592,7 +1592,7 @@ export function agentRoutes( : false; const environmentId = asNonEmptyString(req.query.environmentId); const environment = environmentId ? await environmentsSvc.getById(environmentId) : null; - if (environmentId && (!environment || environment.companyId !== companyId)) { + if (environmentId && !environment) { res.status(404).json({ error: "Environment not found" }); return; } diff --git a/server/src/routes/environment-selection.ts b/server/src/routes/environment-selection.ts index f278f88b..eba3d877 100644 --- a/server/src/routes/environment-selection.ts +++ b/server/src/routes/environment-selection.ts @@ -4,13 +4,12 @@ export async function assertEnvironmentSelectionForCompany( environmentsSvc: { getById(environmentId: string): Promise<{ id: string; - companyId: string; driver: string; status?: string | null; config: Record | null; } | null>; }, - companyId: string, + _companyId: string, environmentId: string | null | undefined, options?: { allowedDrivers?: string[]; @@ -19,7 +18,7 @@ export async function assertEnvironmentSelectionForCompany( ) { if (environmentId === undefined || environmentId === null) return; const environment = await environmentsSvc.getById(environmentId); - if (!environment || environment.companyId !== companyId) { + if (!environment) { throw unprocessable("Environment not found."); } if (environment.status === "archived") { diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 8899988f..80e6d491 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -7,12 +7,11 @@ import { probeEnvironmentConfigSchema, updateEnvironmentSchema, } from "@paperclipai/shared"; -import { conflict, forbidden } from "../errors.js"; +import { conflict, forbidden, unprocessable } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { - accessService, - agentService, issueService, + instanceSettingsService, logActivity, projectService, } from "../services/index.js"; @@ -20,7 +19,6 @@ import { collectEnvironmentSecretRefs, normalizeEnvironmentConfigForPersistence, normalizeEnvironmentConfigForProbe, - parseEnvironmentDriverConfig, readSshEnvironmentPrivateKeySecretId, type ParsedEnvironmentConfig, } from "../services/environment-config.js"; @@ -28,7 +26,7 @@ import { probeEnvironment } from "../services/environment-probe.js"; import { secretService } from "../services/secrets.js"; import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js"; import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; -import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { environmentService } from "../services/environments.js"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; @@ -38,13 +36,13 @@ export function environmentRoutes( options: { pluginWorkerManager?: PluginWorkerManager } = {}, ) { const router = Router(); - const agents = agentService(db); - const access = accessService(db); const svc = environmentService(db); const executionWorkspaces = executionWorkspaceService(db); const issues = issueService(db); + const instanceSettings = instanceSettingsService(db); const projects = projectService(db); const secrets = secretService(db); + const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; function parseObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) @@ -52,79 +50,108 @@ export function environmentRoutes( : {}; } - function canCreateAgents(agent: { permissions: Record | null | undefined }) { - if (!agent.permissions || typeof agent.permissions !== "object") return false; - return Boolean((agent.permissions as Record).canCreateAgents); + function assertCanAccessInstanceEnvironments(req: Request) { + if (req.actor.type !== "board") { + throw forbidden("Instance environment management is restricted to board operators"); + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + throw forbidden("Instance admin access required"); } - async function assertCanMutateEnvironments(req: Request, companyId: string) { - assertCompanyAccess(req, companyId); - - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowed = await access.canUser(companyId, req.actor.userId, "environments:manage"); - if (!allowed) { - throw forbidden("Missing permission: environments:manage"); - } - return; - } - - if (!req.actor.agentId) { - throw forbidden("Agent authentication required"); - } - - const actorAgent = await agents.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== companyId) { - throw forbidden("Agent key cannot access another company"); - } - - const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "environments:manage"); - if (allowedByGrant || canCreateAgents(actorAgent)) { - return; - } - - throw forbidden("Missing permission: environments:manage"); + function assertCanReadInstanceEnvironments(req: Request) { + assertBoardOrgAccess(req); } - async function assertCanReadSecretsForDraftProbe(req: Request, companyId: string) { - const decision = await access.decide({ - actor: req.actor, - action: "secrets:read", - resource: { type: "company", companyId }, - }); - if (!decision.allowed) { - throw forbidden(decision.explanation); - } - } - - async function actorCanReadEnvironmentConfigurations(req: Request, companyId: string) { - assertCompanyAccess(req, companyId); - - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; - return access.canUser(companyId, req.actor.userId, "environments:manage"); - } - - if (!req.actor.agentId) return false; - const actorAgent = await agents.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== companyId) return false; - const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "environments:manage"); - return allowedByGrant || canCreateAgents(actorAgent); + function canReadFullInstanceEnvironment(req: Request) { + return req.actor.type === "board" + && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin); } function redactEnvironmentForRestrictedView; + config: Record | null; + envVars?: Record | null; metadata: Record | null; - }>(environment: T): T & { configRedacted: true; metadataRedacted: true } { + }>(environment: T): T { return { ...environment, config: {}, + ...(Object.prototype.hasOwnProperty.call(environment, "envVars") ? { envVars: {} } : {}), metadata: null, - configRedacted: true, - metadataRedacted: true, }; } + function presentEnvironmentForRead | null; + envVars?: Record | null; + metadata: Record | null; + }>(req: Request, environment: T): T { + return canReadFullInstanceEnvironment(req) + ? environment + : redactEnvironmentForRestrictedView(environment); + } + + async function assertCanReadSecretsForDraftProbe(req: Request, companyId: string) { + assertCanAccessInstanceEnvironments(req); + return companyId; + } + + async function logInstanceEnvironmentActivity(input: { + actor: ReturnType; + action: string; + entityId: string; + details: Record; + }) { + const companyIds = await instanceSettings.listCompanyIds(); + await Promise.all( + companyIds.map((companyId) => + logActivity(db, { + companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: input.action, + entityType: "environment", + entityId: input.entityId, + details: input.details, + }) + ), + ); + } + + async function resolveEnvironmentSecretContextCompanyId( + req: Request, + environmentId: string, + options: { required: boolean }, + ): Promise { + const routeCompanyId = + typeof req.params.companyId === "string" && req.params.companyId.trim().length > 0 + ? req.params.companyId.trim() + : typeof req.query.companyId === "string" && req.query.companyId.trim().length > 0 + ? req.query.companyId.trim() + : null; + const bindingCompanyIds = await secrets.listBindingCompanyIdsForTarget({ + targetType: "environment", + targetId: environmentId, + }); + if (routeCompanyId && bindingCompanyIds.length > 0 && !bindingCompanyIds.includes(routeCompanyId)) { + throw conflict("Environment secret bindings already use a different company context."); + } + if (routeCompanyId) return routeCompanyId; + if (bindingCompanyIds.length === 1) return bindingCompanyIds[0] ?? null; + if (bindingCompanyIds.length > 1) { + throw conflict("Environment secret bindings span multiple companies and require explicit companyId context."); + } + if (req.actor.type === "agent" && req.actor.companyId) return req.actor.companyId; + if (req.actor.type === "board" && Array.isArray(req.actor.companyIds) && req.actor.companyIds.length === 1) { + return req.actor.companyIds[0] ?? null; + } + if (!options.required) return null; + throw unprocessable( + "Environment secret management requires a companyId context during the instance-scoped transition.", + ); + } + function summarizeEnvironmentUpdate( patch: Record, environment: { @@ -160,23 +187,16 @@ export function environmentRoutes( } router.get("/companies/:companyId/environments", async (req, res) => { - const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); - const rows = await svc.list(companyId, { + assertCanReadInstanceEnvironments(req); + const rows = await svc.list({ status: req.query.status as string | undefined, driver: req.query.driver as string | undefined, }); - const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, companyId); - if (canReadConfigs) { - res.json(rows); - return; - } - res.json(rows.map((environment) => redactEnvironmentForRestrictedView(environment))); + res.json(rows.map((row) => presentEnvironmentForRead(req, row))); }); router.get("/companies/:companyId/environments/capabilities", async (req, res) => { - const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); + assertCanReadInstanceEnvironments(req); const pluginDrivers = await listReadyPluginEnvironmentDrivers({ db, workerManager: options.pluginWorkerManager, @@ -206,16 +226,21 @@ export function environmentRoutes( router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; - await assertCanMutateEnvironments(req, companyId); + assertCanAccessInstanceEnvironments(req); if (req.body.driver === "local") { - const existingLocal = await svc.list(companyId, { driver: "local" }); + const existingLocal = await svc.list({ driver: "local" }); if (existingLocal.length > 0) { - throw conflict("A local environment already exists for this company."); + throw conflict("A local environment already exists for this instance."); } } const actor = getActorInfo(req); const input = { ...req.body, + envVars: await secrets.normalizeEnvBindingsForPersistence( + companyId, + req.body.envVars, + { strictMode: strictSecretsMode, fieldPath: "envVars" }, + ), config: await normalizeEnvironmentConfigForPersistence({ db, companyId, @@ -230,20 +255,20 @@ export function environmentRoutes( pluginWorkerManager: options.pluginWorkerManager, }), }; - const environment = await svc.create(companyId, input); + const environment = await svc.create(input); await secrets.syncSecretRefsForTarget( companyId, { targetType: "environment", targetId: environment.id }, await collectEnvironmentSecretRefs({ db, environment }), ); - await logActivity(db, { + await secrets.syncEnvBindingsForTarget( companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, + { targetType: "environment", targetId: environment.id }, + environment.envVars, + ); + await logInstanceEnvironmentActivity({ + actor, action: "environment.created", - entityType: "environment", entityId: environment.id, details: { name: environment.name, @@ -260,13 +285,8 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } - assertCompanyAccess(req, environment.companyId); - const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, environment.companyId); - if (canReadConfigs) { - res.json(environment); - return; - } - res.json(redactEnvironmentForRestrictedView(environment)); + assertCanReadInstanceEnvironments(req); + res.json(presentEnvironmentForRead(req, environment)); }); router.get("/environments/:id/leases", async (req, res) => { @@ -275,11 +295,7 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } - assertCompanyAccess(req, environment.companyId); - const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, environment.companyId); - if (!canReadConfigs) { - throw forbidden("Missing permission: environments:manage"); - } + assertCanReadInstanceEnvironments(req); const leases = await svc.listLeases(environment.id, { status: req.query.status as string | undefined, }); @@ -292,11 +308,7 @@ export function environmentRoutes( res.status(404).json({ error: "Environment lease not found" }); return; } - assertCompanyAccess(req, lease.companyId); - const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, lease.companyId); - if (!canReadConfigs) { - throw forbidden("Missing permission: environments:manage"); - } + assertCanReadInstanceEnvironments(req); res.json(lease); }); @@ -306,10 +318,14 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } - await assertCanMutateEnvironments(req, existing.companyId); + assertCanAccessInstanceEnvironments(req); const actor = getActorInfo(req); const nextDriver = req.body.driver ?? existing.driver; const nextName = req.body.name ?? existing.name; + const companyIdForSecrets = + req.body.config !== undefined || req.body.driver !== undefined || req.body.envVars !== undefined + ? await resolveEnvironmentSecretContextCompanyId(req, existing.id, { required: true }) + : null; const configSource = req.body.config !== undefined ? req.body.driver !== undefined && req.body.driver !== existing.driver @@ -323,11 +339,20 @@ export function environmentRoutes( : existing.config; const patch = { ...req.body, + ...(req.body.envVars !== undefined + ? { + envVars: await secrets.normalizeEnvBindingsForPersistence( + companyIdForSecrets!, + req.body.envVars, + { strictMode: strictSecretsMode, fieldPath: "envVars" }, + ), + } + : {}), ...(req.body.config !== undefined || req.body.driver !== undefined ? { config: await normalizeEnvironmentConfigForPersistence({ db, - companyId: existing.companyId, + companyId: companyIdForSecrets!, environmentName: nextName, driver: nextDriver, secretProvider: getConfiguredSecretProvider(), @@ -348,19 +373,21 @@ export function environmentRoutes( } if (patch.config !== undefined || patch.driver !== undefined) { await secrets.syncSecretRefsForTarget( - environment.companyId, + companyIdForSecrets!, { targetType: "environment", targetId: environment.id }, await collectEnvironmentSecretRefs({ db, environment }), ); } - await logActivity(db, { - companyId: environment.companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, + if (patch.envVars !== undefined) { + await secrets.syncEnvBindingsForTarget( + companyIdForSecrets!, + { targetType: "environment", targetId: environment.id }, + environment.envVars, + ); + } + await logInstanceEnvironmentActivity({ + actor, action: "environment.updated", - entityType: "environment", entityId: environment.id, details: summarizeEnvironmentUpdate(patch as Record, environment), }); @@ -373,12 +400,15 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } - await assertCanMutateEnvironments(req, existing.companyId); - await Promise.all([ - executionWorkspaces.clearEnvironmentSelection(existing.companyId, existing.id), - issues.clearExecutionWorkspaceEnvironmentSelection(existing.companyId, existing.id), - projects.clearExecutionWorkspaceEnvironmentSelection(existing.companyId, existing.id), - ]); + assertCanAccessInstanceEnvironments(req); + const companyIds = await instanceSettings.listCompanyIds(); + await Promise.all( + companyIds.flatMap((companyId) => [ + executionWorkspaces.clearEnvironmentSelection(companyId, existing.id), + issues.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id), + projects.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id), + ]), + ); const removed = await svc.remove(existing.id); if (!removed) { res.status(404).json({ error: "Environment not found" }); @@ -389,14 +419,9 @@ export function environmentRoutes( await secrets.remove(secretId); } const actor = getActorInfo(req); - await logActivity(db, { - companyId: existing.companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, + await logInstanceEnvironmentActivity({ + actor, action: "environment.deleted", - entityType: "environment", entityId: removed.id, details: { name: removed.name, @@ -413,19 +438,24 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } - await assertCanMutateEnvironments(req, environment.companyId); + assertCanAccessInstanceEnvironments(req); const actor = getActorInfo(req); + const companyIdForSecrets = await resolveEnvironmentSecretContextCompanyId(req, environment.id, { required: false }); + if (!companyIdForSecrets) { + const secretRefs = await collectEnvironmentSecretRefs({ db, environment }); + if (secretRefs.length > 0) { + throw unprocessable( + "Environment probe requires an explicit companyId to resolve secret-backed config for this environment.", + ); + } + } const probe = await probeEnvironment(db, environment, { + companyId: companyIdForSecrets, pluginWorkerManager: options.pluginWorkerManager, }); - await logActivity(db, { - companyId: environment.companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, + await logInstanceEnvironmentActivity({ + actor, action: "environment.probed", - entityType: "environment", entityId: environment.id, details: { driver: environment.driver, @@ -441,7 +471,7 @@ export function environmentRoutes( validate(probeEnvironmentConfigSchema), async (req, res) => { const companyId = req.params.companyId as string; - await assertCanMutateEnvironments(req, companyId); + assertCanAccessInstanceEnvironments(req); if (req.body.driver === "sandbox") { // Draft sandbox probes can resolve unbound secret refs, so require // the same company-scoped secret-read capability before normalization. @@ -469,25 +499,22 @@ export function environmentRoutes( driver: req.body.driver, status: "active" as const, config: normalizedConfig, + envVars: {}, metadata: req.body.metadata ?? null, createdAt: new Date(), updatedAt: new Date(), }; const probe = await probeEnvironment(db, environment, { + companyId, pluginWorkerManager: options.pluginWorkerManager, resolvedConfig: { driver: req.body.driver, config: normalizedConfig, } as ParsedEnvironmentConfig, }); - await logActivity(db, { - companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, + await logInstanceEnvironmentActivity({ + actor, action: "environment.probed_unsaved", - entityType: "environment", entityId: "unsaved", details: { driver: environment.driver, diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 0d9acaf9..3e63de6a 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -2,12 +2,15 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { issueGraphLivenessAutoRecoveryRequestSchema, + patchInstanceSettingsSchema, patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { heartbeatService, instanceSettingsService, logActivity } from "../services/index.js"; +import { environmentService } from "../services/environments.js"; +import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; function assertCanManageInstanceSettings(req: Request) { @@ -23,8 +26,51 @@ function assertCanManageInstanceSettings(req: Request) { export function instanceSettingsRoutes(db: Db) { const router = Router(); const svc = instanceSettingsService(db); + const environments = environmentService(db); const heartbeat = heartbeatService(db); + router.get("/instance/settings", async (req, res) => { + assertBoardOrgAccess(req); + res.json(await svc.get()); + }); + + router.patch( + "/instance/settings", + validate(patchInstanceSettingsSchema), + async (req, res) => { + assertCanManageInstanceSettings(req); + if (Object.prototype.hasOwnProperty.call(req.body, "defaultEnvironmentId")) { + await assertEnvironmentSelectionForCompany( + environments, + "instance", + typeof req.body.defaultEnvironmentId === "string" ? req.body.defaultEnvironmentId : null, + ); + } + const updated = await svc.update(req.body); + const actor = getActorInfo(req); + const companyIds = await svc.listCompanyIds(); + await Promise.all( + companyIds.map((companyId) => + logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "instance.settings.updated", + entityType: "instance_settings", + entityId: updated.id, + details: { + defaultEnvironmentId: updated.defaultEnvironmentId, + changedKeys: Object.keys(req.body).sort(), + }, + }), + ), + ); + res.json(updated); + }, + ); + router.get("/instance/settings/general", async (req, res) => { // General settings (e.g. keyboardShortcuts) are readable by any // authenticated org member or instance admin. Only PATCH requires instance-admin. diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 4225cb17..ccf38a30 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -110,6 +110,7 @@ import { // Instance settings patchInstanceGeneralSettingsSchema, patchInstanceExperimentalSettingsSchema, + patchInstanceSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, // Resource memberships updateResourceMembershipSchema, @@ -2458,6 +2459,23 @@ registry.registerPath({ // ─── Instance settings ──────────────────────────────────────────────────────── +registry.registerPath({ + method: "get", + path: "/api/instance/settings", + tags: ["instance"], + summary: "Get instance settings", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/instance/settings", + tags: ["instance"], + summary: "Update instance settings", + request: { body: jsonBody(patchInstanceSettingsSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + registry.registerPath({ method: "get", path: "/api/instance/settings/general", diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index 83c88182..8c0e39b3 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -2,6 +2,7 @@ import type { Environment, EnvironmentProbeResult } from "@paperclipai/shared"; import type { Db } from "@paperclipai/db"; import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh"; import { + parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime, type ParsedEnvironmentConfig, } from "./environment-config.js"; @@ -13,9 +14,18 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js"; export async function probeEnvironment( db: Db, environment: Environment, - options: { pluginWorkerManager?: PluginWorkerManager; resolvedConfig?: ParsedEnvironmentConfig } = {}, + options: { + companyId?: string | null; + pluginWorkerManager?: PluginWorkerManager; + resolvedConfig?: ParsedEnvironmentConfig; + } = {}, ): Promise { - const parsed = options.resolvedConfig ?? await resolveEnvironmentDriverConfigForRuntime(db, environment.companyId, environment); + const resolvedCompanyId = options.companyId ?? null; + const parsed = options.resolvedConfig ?? ( + resolvedCompanyId + ? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment) + : parseEnvironmentDriverConfig(environment) + ); if (parsed.driver === "local") { return { @@ -44,7 +54,7 @@ export async function probeEnvironment( return await probePluginSandboxProviderDriver({ db, workerManager: options.pluginWorkerManager, - companyId: environment.companyId, + companyId: resolvedCompanyId ?? "instance", environmentId: environment.id, provider: parsed.config.provider, config: parsed.config as unknown as Record, @@ -68,7 +78,7 @@ export async function probeEnvironment( return await probePluginEnvironmentDriver({ db, workerManager: options.pluginWorkerManager, - companyId: environment.companyId, + companyId: resolvedCompanyId ?? "instance", environmentId: environment.id, config: parsed.config, }); diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 693b57c6..1add1a2b 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -159,20 +159,19 @@ export function environmentRunOrchestrator( }); /** - * Resolve the selected environment for a run. Ensures a local default - * exists and resolves the priority chain: - * execution workspace config > issue settings > project policy > agent default > company default + * Resolve the selected environment for a run. The caller passes the concrete + * selected environment id plus the built-in local fallback id used to lazily + * ensure the local environment row exists. */ async function resolveEnvironment(input: { companyId: string; selectedEnvironmentId: string; - defaultEnvironmentId: string; + localEnvironmentId: string; }): Promise { - const environmentId = - input.selectedEnvironmentId || input.defaultEnvironmentId; + const environmentId = input.selectedEnvironmentId || input.localEnvironmentId; const environment = - environmentId === input.defaultEnvironmentId + environmentId === input.localEnvironmentId ? await environmentsSvc.ensureLocalEnvironment(input.companyId) : await environmentsSvc.getById(environmentId); @@ -182,12 +181,6 @@ export function environmentRunOrchestrator( }); } - if (environment.companyId !== input.companyId) { - throw new EnvironmentRunError("environment_not_found", `Environment "${environmentId}" does not belong to this company.`, { - environmentId, - }); - } - if (environment.status !== "active") { throw new EnvironmentRunError("environment_inactive", `Environment "${environment.name}" is not active (status: ${environment.status}).`, { environmentId: environment.id, @@ -206,6 +199,7 @@ export function environmentRunOrchestrator( companyId: string; environment: Environment; issueId: string | null; + agentId: string; heartbeatRunId: string; persistedExecutionWorkspace: Pick | null; adapterType: string | null; @@ -262,7 +256,7 @@ export function environmentRunOrchestrator( async function acquireForRun(input: { companyId: string; selectedEnvironmentId: string; - defaultEnvironmentId: string; + localEnvironmentId: string; adapterType: string; issueId: string | null; heartbeatRunId: string; @@ -273,7 +267,7 @@ export function environmentRunOrchestrator( const environment = await resolveEnvironment({ companyId: input.companyId, selectedEnvironmentId: input.selectedEnvironmentId, - defaultEnvironmentId: input.defaultEnvironmentId, + localEnvironmentId: input.localEnvironmentId, }); // Step 2: Acquire lease @@ -281,6 +275,7 @@ export function environmentRunOrchestrator( companyId: input.companyId, environment, issueId: input.issueId, + agentId: input.agentId, heartbeatRunId: input.heartbeatRunId, persistedExecutionWorkspace: input.persistedExecutionWorkspace, adapterType: input.adapterType ?? null, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 08451082..496c85c2 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -103,6 +103,7 @@ export interface EnvironmentDriverAcquireInput { companyId: string; environment: Environment; issueId: string | null; + agentId: string | null; /** * UUID of the owning heartbeat run, or null for ad-hoc invocations * (e.g. operator-initiated `Test` probes) that are not tied to a run. @@ -219,6 +220,7 @@ function createLocalEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { leasePolicy: "ephemeral", provider: "local", metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, }, @@ -272,6 +274,7 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { provider: "ssh", providerLeaseId: `ssh://${parsed.config.username}@${parsed.config.host}:${parsed.config.port}${remoteCwd}`, metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, host: parsed.config.host, @@ -452,11 +455,23 @@ function createSandboxEnvironmentDriver( // We also filter out leases whose policy is not reuse_by_environment // so any non-reusable lease (including ad-hoc test leases that // landed in the table from older code paths) cannot be matched. - const reusableExistingLeases = parsed.config.reuseLease && input.heartbeatRunId !== null + const reusableExistingLeases = + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null ? (await environmentsSvc.listLeases(input.environment.id)) - .filter((lease) => lease.leasePolicy === "reuse_by_environment") + .filter((lease) => + lease.leasePolicy === "reuse_by_environment" && + lease.executionWorkspaceId === input.executionWorkspaceId && + lease.metadata?.agentId === input.agentId, + ) : []; - const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null + const reusableProviderLeaseId = + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null ? findReusableSandboxLeaseId({ config: storedConfig, leases: reusableExistingLeases }) : null; const reusableLease = reusableProviderLeaseId @@ -497,6 +512,8 @@ function createSandboxEnvironmentDriver( // a well-formed identifier. runId: input.heartbeatRunId ?? randomUUID(), workspaceMode: input.executionWorkspaceMode ?? undefined, + agentId: input.agentId ?? undefined, + executionWorkspaceId: input.executionWorkspaceId ?? undefined, // The agent's harness for THIS run, so the plugin picks the matching // runtime image (per-run adapter, mixed-harness environments). // NOTE: environment-runtime.ts has TWO drivers calling @@ -527,6 +544,7 @@ function createSandboxEnvironmentDriver( providerLeaseId: acquiredLease.providerLeaseId, expiresAt: acquiredLease.expiresAt ? new Date(acquiredLease.expiresAt) : undefined, metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, pluginId: pluginProvider.resolved.plugin.id, @@ -546,13 +564,21 @@ function createSandboxEnvironmentDriver( // provider lease, or releasing the test lease will terminate the live // heartbeat run that shares it. Filter to leases whose policy is // reuse_by_environment so non-reusable rows can never be matched. - const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null + const reusableProviderLeaseId = + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null ? (await environmentsSvc .listLeases(input.environment.id) .then((leases) => findReusableSandboxLeaseId({ config: parsed.config, - leases: leases.filter((lease) => lease.leasePolicy === "reuse_by_environment"), + leases: leases.filter((lease) => + lease.leasePolicy === "reuse_by_environment" && + lease.executionWorkspaceId === input.executionWorkspaceId && + lease.metadata?.agentId === input.agentId, + ), }), )) : null; @@ -562,6 +588,8 @@ function createSandboxEnvironmentDriver( environmentId: input.environment.id, heartbeatRunId: input.heartbeatRunId ?? randomUUID(), issueId: input.issueId, + agentId: input.agentId, + executionWorkspaceId: input.executionWorkspaceId, reusableProviderLeaseId, }); @@ -581,6 +609,7 @@ function createSandboxEnvironmentDriver( provider: parsed.config.provider, providerLeaseId: providerLease.providerLeaseId, metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, ...providerLease.metadata, @@ -913,6 +942,8 @@ function createPluginEnvironmentDriver( config: parsed.config.driverConfig, runId: input.heartbeatRunId ?? randomUUID(), workspaceMode: input.executionWorkspaceMode ?? undefined, + agentId: input.agentId ?? undefined, + executionWorkspaceId: input.executionWorkspaceId ?? undefined, adapterType: input.adapterType ?? undefined, }); @@ -927,6 +958,7 @@ function createPluginEnvironmentDriver( providerLeaseId: providerLease.providerLeaseId, expiresAt: parseExpiresAt(providerLease.expiresAt), metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), providerMetadata: providerLease.metadata ?? {}, driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, @@ -1126,6 +1158,7 @@ export function environmentRuntimeService( companyId: string; environment: Environment; issueId: string | null; + agentId?: string | null; /** Null for ad-hoc invocations (e.g. operator-initiated `Test` probes). */ heartbeatRunId: string | null; persistedExecutionWorkspace: Pick | null; @@ -1144,6 +1177,7 @@ export function environmentRuntimeService( companyId: input.companyId, environment: input.environment, issueId: input.issueId, + agentId: input.agentId ?? null, heartbeatRunId: input.heartbeatRunId, executionWorkspaceId: leaseContext.executionWorkspaceId, executionWorkspaceMode: leaseContext.executionWorkspaceMode, diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 69aaa5c8..c746389a 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -15,6 +15,7 @@ import { type EnvironmentLeaseStatus, type UpdateEnvironment, } from "@paperclipai/shared"; +import { conflict } from "../errors.js"; type EnvironmentRow = typeof environments.$inferSelect; type EnvironmentLeaseRow = typeof environmentLeases.$inferSelect; @@ -70,19 +71,68 @@ function readEnum(value: string | null, allowed: readonly T[], throw new Error(`Unexpected ${fieldName} value: ${value}`); } +function hasConstraintName(error: unknown, constraintName: string): boolean { + if (typeof error !== "object" || error === null) return false; + const candidate = error as { + constraint?: unknown; + constraint_name?: unknown; + cause?: unknown; + }; + return candidate.constraint === constraintName + || candidate.constraint_name === constraintName + || hasConstraintName(candidate.cause, constraintName); +} + function toEnvironment(row: EnvironmentRow): Environment { return { id: row.id, - companyId: row.companyId, name: row.name, description: row.description ?? null, driver: readEnum(row.driver, ENVIRONMENT_DRIVERS, "environment driver") ?? "local", status: readEnum(row.status, ENVIRONMENT_STATUSES, "environment status") ?? "active", config: cloneRecord(row.config, {}) ?? {}, + envVars: cloneRecord(row.envVars, {}) ?? {}, metadata: cloneRecord(row.metadata), createdAt: row.createdAt, updatedAt: row.updatedAt, - }; + } as Environment; +} + +type EnvironmentListFilters = { + status?: string; + driver?: string; +}; + +function resolveListFilters( + companyIdOrFilters?: string | EnvironmentListFilters, + maybeFilters?: EnvironmentListFilters, +): EnvironmentListFilters { + if (typeof companyIdOrFilters === "string") { + return maybeFilters ?? {}; + } + return companyIdOrFilters ?? {}; +} + +function resolveCreateInput( + companyIdOrInput: string | CreateEnvironment, + maybeInput?: CreateEnvironment, +): CreateEnvironment { + if (typeof companyIdOrInput === "string") { + if (!maybeInput) throw new Error("Create environment input is required"); + return maybeInput; + } + return companyIdOrInput; +} + +function resolveKubernetesConfig( + companyIdOrConfig: string | KubernetesEnvironmentConfigInput, + maybeConfig?: KubernetesEnvironmentConfigInput, +): KubernetesEnvironmentConfigInput { + if (typeof companyIdOrConfig === "string") { + if (!maybeConfig) throw new Error("Kubernetes environment config is required"); + return maybeConfig; + } + return companyIdOrConfig; } function toEnvironmentLease(row: EnvironmentLeaseRow): EnvironmentLease { @@ -116,19 +166,17 @@ function toEnvironmentLease(row: EnvironmentLeaseRow): EnvironmentLease { export function environmentService(db: Db) { return { list: async ( - companyId: string, - filters: { - status?: string; - driver?: string; - } = {}, + companyIdOrFilters?: string | EnvironmentListFilters, + maybeFilters?: EnvironmentListFilters, ): Promise => { - const conditions = [eq(environments.companyId, companyId)]; + const filters = resolveListFilters(companyIdOrFilters, maybeFilters); + const conditions = []; if (filters.status) conditions.push(eq(environments.status, filters.status)); if (filters.driver) conditions.push(eq(environments.driver, filters.driver)); const rows = await db .select() .from(environments) - .where(and(...conditions)) + .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(environments.updatedAt), desc(environments.createdAt)); return rows.map(toEnvironment); }, @@ -147,26 +195,26 @@ export function environmentService(db: Db) { return row ? toEnvironmentLease(row) : null; }, - ensureLocalEnvironment: async (companyId: string): Promise => { + ensureLocalEnvironment: async (_companyId?: string): Promise => { const now = new Date(); const row = await db .insert(environments) .values({ - companyId, name: DEFAULT_LOCAL_ENVIRONMENT_NAME, description: DEFAULT_LOCAL_ENVIRONMENT_DESCRIPTION, driver: "local", status: "active", config: {}, + envVars: {}, metadata: { managedByPaperclip: true, - defaultForCompany: true, + defaultForInstance: true, }, createdAt: now, updatedAt: now, }) .onConflictDoNothing({ - target: [environments.companyId, environments.driver], + target: [environments.driver], where: sql`${environments.driver} = 'local'`, }) .returning() @@ -176,7 +224,7 @@ export function environmentService(db: Db) { const existing = await db .select() .from(environments) - .where(and(eq(environments.companyId, companyId), eq(environments.driver, "local"))) + .where(eq(environments.driver, "local")) .then((rows) => rows[0] ?? null); if (!existing) { throw new Error("Failed to ensure local environment"); @@ -186,7 +234,7 @@ export function environmentService(db: Db) { /** * Idempotently ensure a managed Kubernetes sandbox environment exists for a - * company, configured from instance/operator-supplied config. Mirrors + * instance, configured from instance/operator-supplied config. Mirrors * `ensureLocalEnvironment`, but there is no DB unique index for sandbox * drivers, so idempotency is by metadata marker + driver lookup. * @@ -196,9 +244,10 @@ export function environmentService(db: Db) { * update egress/runtimeClass via gitops without recreating the row). */ ensureKubernetesEnvironment: async ( - companyId: string, - config: KubernetesEnvironmentConfigInput, + companyIdOrConfig: string | KubernetesEnvironmentConfigInput, + maybeConfig?: KubernetesEnvironmentConfigInput, ): Promise => { + const config = resolveKubernetesConfig(companyIdOrConfig, maybeConfig); const desiredConfig: Record = { ...config, provider: KUBERNETES_PROVIDER_KEY, @@ -211,7 +260,7 @@ export function environmentService(db: Db) { const existing = await db .select() .from(environments) - .where(and(eq(environments.companyId, companyId), eq(environments.driver, "sandbox"))) + .where(eq(environments.driver, "sandbox")) .then((rows) => rows.find( (row) => @@ -235,36 +284,45 @@ export function environmentService(db: Db) { return toEnvironment(updated); } - // The partial unique index `environments_company_managed_sandbox_idx` - // (added in migration 0102) enforces "at most one Paperclip-managed - // sandbox row per company" at the DB level. Use ON CONFLICT DO NOTHING - // keyed on that index so concurrent callers can race the INSERT — only - // one succeeds; losers re-read the surviving row. + // The partial unique index `environments_managed_sandbox_idx` enforces + // "at most one Paperclip-managed sandbox row per instance" at the DB + // level. Use ON CONFLICT DO NOTHING keyed on that index so concurrent + // callers can race the INSERT; losers re-read the surviving row. const inserted = await db .insert(environments) .values({ - companyId, name: DEFAULT_KUBERNETES_ENVIRONMENT_NAME, description: DEFAULT_KUBERNETES_ENVIRONMENT_DESCRIPTION, driver: "sandbox", status: "active", config: desiredConfig, + envVars: {}, metadata: desiredMetadata, createdAt: now, updatedAt: now, }) .onConflictDoNothing({ - target: [environments.companyId], - where: sql`${environments.driver} = 'sandbox' AND (${environments.metadata} ->> 'managedByPaperclip')::boolean = true`, + target: [environments.driver], + where: + sql`${environments.driver} = 'sandbox' AND (${environments.metadata} ->> 'managedByPaperclip')::boolean = true`, }) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error) => { + if ( + hasConstraintName(error, "environments_name_idx") + || hasConstraintName(error, "environments_managed_sandbox_idx") + ) { + return null; + } + throw error; + }); if (inserted) return toEnvironment(inserted); const winner = await db .select() .from(environments) - .where(and(eq(environments.companyId, companyId), eq(environments.driver, "sandbox"))) + .where(eq(environments.driver, "sandbox")) .then( (rows) => rows.find( @@ -281,17 +339,16 @@ export function environmentService(db: Db) { }, /** - * Find an active Kubernetes sandbox environment for a company, if one + * Find the active managed Kubernetes sandbox environment, if one * exists. Read-only counterpart to `ensureKubernetesEnvironment` used by the * per-run execution guard (which must not silently create config-less envs). */ - findKubernetesEnvironment: async (companyId: string): Promise => { + findKubernetesEnvironment: async (_companyId?: string): Promise => { const rows = await db .select() .from(environments) .where( and( - eq(environments.companyId, companyId), eq(environments.driver, "sandbox"), eq(environments.status, "active"), ), @@ -304,23 +361,36 @@ export function environmentService(db: Db) { return match ? toEnvironment(match) : null; }, - create: async (companyId: string, input: CreateEnvironment): Promise => { + create: async ( + companyIdOrInput: string | CreateEnvironment, + maybeInput?: CreateEnvironment, + ): Promise => { + const input = resolveCreateInput(companyIdOrInput, maybeInput); const now = new Date(); const row = await db .insert(environments) .values({ - companyId, name: input.name, description: input.description ?? null, driver: input.driver, status: input.status ?? "active", config: input.config ?? {}, + envVars: (input as CreateEnvironment & { envVars?: Record }).envVars ?? {}, metadata: input.metadata ?? null, createdAt: now, updatedAt: now, }) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error) => { + if (hasConstraintName(error, "environments_name_idx")) { + throw conflict(`An environment named "${input.name}" already exists for this instance.`); + } + if (hasConstraintName(error, "environments_local_driver_idx")) { + throw conflict("A local environment already exists for this instance."); + } + throw error; + }); if (!row) { throw new Error("Failed to create environment"); } @@ -336,6 +406,9 @@ export function environmentService(db: Db) { if (patch.driver !== undefined) values.driver = patch.driver; if (patch.status !== undefined) values.status = patch.status; if (patch.config !== undefined) values.config = patch.config; + if ("envVars" in patch && patch.envVars !== undefined) { + values.envVars = (patch.envVars ?? {}) as Record; + } if (patch.metadata !== undefined) values.metadata = patch.metadata ?? null; const row = await db @@ -343,7 +416,16 @@ export function environmentService(db: Db) { .set(values) .where(eq(environments.id, id)) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error) => { + if (hasConstraintName(error, "environments_name_idx")) { + throw conflict(`An environment named "${patch.name}" already exists for this instance.`); + } + if (hasConstraintName(error, "environments_local_driver_idx")) { + throw conflict("A local environment already exists for this instance."); + } + throw error; + }); return row ? toEnvironment(row) : null; }, diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 706acb3f..5cece4e5 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -38,7 +38,6 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu const defaultMode = asString(parsed.defaultMode, ""); const defaultProjectWorkspaceId = typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined; - const environmentId = typeof parsed.environmentId === "string" ? parsed.environmentId : undefined; const allowIssueOverride = typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined; const normalizedDefaultMode = (() => { @@ -59,7 +58,6 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu ...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}), ...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}), ...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}), - ...(environmentId !== undefined ? { environmentId } : {}), ...(workspaceStrategy ? { workspaceStrategy } : {}), ...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime) ? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record) } } @@ -114,7 +112,6 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti ...(normalizedMode ? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] } : {}), - ...(typeof parsed.environmentId === "string" ? { environmentId: parsed.environmentId } : {}), ...(workspaceStrategy ? { workspaceStrategy } : {}), ...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime) ? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record) } } @@ -123,125 +120,36 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti } export type ExecutionWorkspaceEnvironmentSource = - | "workspace" - | "issue" - | "project" | "agent" + | "instance" | "default"; -export type ExecutionWorkspaceEnvironmentConflict = { - reason: "reused_workspace_environment_mismatch"; - workspaceEnvironmentId: string; - assigneeIntendedEnvironmentId: string; - assigneeIntendedSource: Exclude; -}; - export type ExecutionWorkspaceEnvironmentResolution = { environmentId: string; source: ExecutionWorkspaceEnvironmentSource; - conflict: ExecutionWorkspaceEnvironmentConflict | null; }; -function resolveAssigneeIntendedExecutionWorkspaceEnvironment(input: { - projectPolicy: ProjectExecutionWorkspacePolicy | null; - issueSettings: IssueExecutionWorkspaceSettings | null; - agentDefaultEnvironmentId: string | null; - defaultEnvironmentId: string; -}): { - environmentId: string; - source: Exclude; -} { - // Explicit issue-level env override always wins, even for null-default - // (local-only) agents. An operator who deliberately set - // `executionWorkspaceSettings.environmentId` on this specific issue (see the - // issues-service contract preserved in issues.ts:4243) chose that env for - // this assignment and should not be silently downgraded to the local default - // (PAPA-430 review fix). Inherited issue envs from - // `inheritExecutionWorkspaceFromIssueId` are stripped before this point in - // `resolveExecutionWorkspaceEnvironmentId`. - if (input.issueSettings?.environmentId !== undefined) { - return { - environmentId: input.issueSettings.environmentId ?? input.defaultEnvironmentId, - source: "issue", - }; - } - // A null defaultEnvironmentId on the agent means it is deliberately scoped to - // the local default (e.g. Manual QA today). Project policy must not promote - // such an agent off of local — only an explicit issue-level override above - // can move the assignee away from the local default. - if (input.agentDefaultEnvironmentId === null) { - return { environmentId: input.defaultEnvironmentId, source: "default" }; - } - if (input.projectPolicy?.environmentId !== undefined) { - return { - environmentId: input.projectPolicy.environmentId ?? input.defaultEnvironmentId, - source: "project", - }; - } - return { environmentId: input.agentDefaultEnvironmentId, source: "agent" }; -} - export function resolveExecutionWorkspaceEnvironmentId(input: { - projectPolicy: ProjectExecutionWorkspacePolicy | null; - issueSettings: IssueExecutionWorkspaceSettings | null; - workspaceConfig: { environmentId?: string | null } | null; agentDefaultEnvironmentId: string | null; - defaultEnvironmentId: string; + instanceDefaultEnvironmentId: string | null; + localDefaultEnvironmentId: string; }): ExecutionWorkspaceEnvironmentResolution { - // PAPA-431 companion: when the assignee has no explicit defaultEnvironmentId - // (deliberately local-only, e.g. Manual QA) AND the issue settings env exactly - // matches the reused workspace env, treat the issue env as a promoted artifact - // from `inheritExecutionWorkspaceFromIssueId` rather than a deliberate - // operator choice. Strip it so the resolver falls back to the local default - // and the workspace-vs-intended conflict check forces a fresh realization. - // A genuine operator override (via PATCH on the issue) reaches this code path - // either with no reused workspace (workspaceConfig === null) or against a - // workspace whose persisted env does not match the new override; both keep - // the issue setting in place. - const inheritedIssueEnvOnNullDefaultAssignee = - input.agentDefaultEnvironmentId === null && - input.workspaceConfig?.environmentId !== undefined && - input.workspaceConfig?.environmentId !== null && - input.issueSettings?.environmentId !== undefined && - input.issueSettings.environmentId === input.workspaceConfig.environmentId; - let issueSettingsForResolution = input.issueSettings; - if (inheritedIssueEnvOnNullDefaultAssignee && input.issueSettings) { - const { environmentId: _droppedInheritedEnv, ...rest } = input.issueSettings; - void _droppedInheritedEnv; - issueSettingsForResolution = rest as IssueExecutionWorkspaceSettings; + if (input.agentDefaultEnvironmentId) { + return { + environmentId: input.agentDefaultEnvironmentId, + source: "agent", + }; } - - const assigneeIntended = resolveAssigneeIntendedExecutionWorkspaceEnvironment({ - projectPolicy: input.projectPolicy, - issueSettings: issueSettingsForResolution, - agentDefaultEnvironmentId: input.agentDefaultEnvironmentId, - defaultEnvironmentId: input.defaultEnvironmentId, - }); - - if (input.workspaceConfig?.environmentId !== undefined) { - const workspaceEnvironmentId = - input.workspaceConfig.environmentId ?? input.defaultEnvironmentId; - // PAPA-380 / PAPA-431: a reused workspace's persisted environmentId must - // never silently shadow the current assignee's environment identity. - // When they disagree, refuse the silent reuse: return the assignee's - // intended env and surface a conflict signal so the caller forces a fresh - // workspace realization (or otherwise alerts the operator) instead of - // running the agent on someone else's environment. - if (workspaceEnvironmentId !== assigneeIntended.environmentId) { - return { - environmentId: assigneeIntended.environmentId, - source: assigneeIntended.source, - conflict: { - reason: "reused_workspace_environment_mismatch", - workspaceEnvironmentId, - assigneeIntendedEnvironmentId: assigneeIntended.environmentId, - assigneeIntendedSource: assigneeIntended.source, - }, - }; - } - return { environmentId: workspaceEnvironmentId, source: "workspace", conflict: null }; + if (input.instanceDefaultEnvironmentId) { + return { + environmentId: input.instanceDefaultEnvironmentId, + source: "instance", + }; } - return { environmentId: assigneeIntended.environmentId, source: assigneeIntended.source, conflict: null }; + return { + environmentId: input.localDefaultEnvironmentId, + source: "default", + }; } export function defaultIssueExecutionWorkspaceSettingsForProject( diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 72423127..91dab042 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -457,6 +457,8 @@ export async function resolveExecutionRunAdapterConfig(input: { agentId?: string | null; issueId?: string | null; heartbeatRunId?: string | null; + environmentId?: string | null; + environmentEnv?: unknown; projectId?: string | null; routineId?: string | null; executionRunConfig: Record; @@ -466,12 +468,14 @@ export async function resolveExecutionRunAdapterConfig(input: { trustPreset?: TrustPresetResolution; }) { const executionRunConfig = stripPaperclipRuntimeEnvFromAdapterConfig(input.executionRunConfig); + const environmentEnv = stripPaperclipRuntimeEnvBindings(input.environmentEnv); const projectEnv = stripPaperclipRuntimeEnvBindings(input.projectEnv); const routineEnv = stripPaperclipRuntimeEnvBindings(input.routineEnv); const lowTrustAllowedBindingIds = input.trustPreset?.kind === "low_trust_review" ? input.trustPreset.boundary.allowedSecretBindingIds ?? [] : undefined; if (input.trustPreset?.kind === "low_trust_review") { + assertLowTrustEnvConfigAllowed(environmentEnv, "environment.env"); assertLowTrustEnvConfigAllowed(executionRunConfig.env, "agent.env"); assertLowTrustEnvConfigAllowed(projectEnv, "project.env"); assertLowTrustEnvConfigAllowed(routineEnv, "routine.env"); @@ -482,6 +486,15 @@ export async function resolveExecutionRunAdapterConfig(input: { // dispatched-then-failed run (which previously surfaced as opaque setup_failed). if (typeof input.secretsSvc.collectMissingRuntimeBindings === "function") { const missingBindings: MissingRuntimeBinding[] = []; + if (environmentEnv && input.environmentId) { + missingBindings.push( + ...(await input.secretsSvc.collectMissingRuntimeBindings( + input.companyId, + environmentEnv, + { consumerType: "environment", consumerId: input.environmentId }, + )), + ); + } if (input.agentId) { missingBindings.push( ...(await input.secretsSvc.collectMissingRuntimeBindings( @@ -524,6 +537,23 @@ export async function resolveExecutionRunAdapterConfig(input: { }); } } + const environmentEnvResolution = environmentEnv + ? await input.secretsSvc.resolveEnvBindings( + input.companyId, + environmentEnv, + input.environmentId + ? { + consumerType: "environment", + consumerId: input.environmentId, + actorType: "agent", + actorId: input.agentId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + ...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}), + } + : undefined, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime( input.companyId, executionRunConfig, @@ -539,6 +569,15 @@ export async function resolveExecutionRunAdapterConfig(input: { } : undefined, ); + if (Object.keys(environmentEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...environmentEnvResolution.env, + ...parseObject(resolvedConfig.env), + }; + for (const key of environmentEnvResolution.secretKeys) { + secretKeys.add(key); + } + } const projectEnvResolution = projectEnv ? await input.secretsSvc.resolveEnvBindings( input.companyId, @@ -595,6 +634,7 @@ export async function resolveExecutionRunAdapterConfig(input: { resolvedConfig, secretKeys, secretManifest: [ + ...(environmentEnvResolution.manifest ?? []), ...(manifest ?? []), ...(projectEnvResolution.manifest ?? []), ...(routineEnvResolution.manifest ?? []), @@ -8417,37 +8457,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting ? existingExecutionWorkspace?.config ?? null : null; - const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId); + const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId); + const resolvedInstanceSettings = await instanceSettings.get(); const environmentResolution = resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: projectExecutionWorkspacePolicy, - issueSettings: issueExecutionWorkspaceSettings, - workspaceConfig: requestedReusableExecutionWorkspaceConfig, agentDefaultEnvironmentId: agent.defaultEnvironmentId, - defaultEnvironmentId: defaultEnvironment.id, + instanceDefaultEnvironmentId: resolvedInstanceSettings.defaultEnvironmentId ?? null, + localDefaultEnvironmentId: localEnvironment.id, }); - // PAPA-380 / PAPA-431: when the resolver refuses silent reuse of the - // persisted workspace environment, also force a fresh workspace - // realization on the assignee's intended env. Reusing the on-disk - // workspace while swapping the env underneath it would mismatch the cwd's - // runtime expectations (e.g. an SSH-targeted worktree running on the - // local default driver). - if (environmentResolution.conflict) { - logger.warn( - { - runId: run.id, - issueId, - agentId: agent.id, - adapterType: agent.adapterType, - existingExecutionWorkspaceId: existingExecutionWorkspace?.id ?? null, - workspaceEnvironmentId: environmentResolution.conflict.workspaceEnvironmentId, - assigneeIntendedEnvironmentId: - environmentResolution.conflict.assigneeIntendedEnvironmentId, - assigneeIntendedSource: environmentResolution.conflict.assigneeIntendedSource, - }, - "Refusing silent reuse of execution workspace whose environment does not match the assignee's intended environment; forcing fresh realization", - ); - } - const shouldReuseExisting = requestedShouldReuseExisting && !environmentResolution.conflict; + const shouldReuseExisting = requestedShouldReuseExisting; const reusableExecutionWorkspaceConfig = shouldReuseExisting ? requestedReusableExecutionWorkspaceConfig : null; @@ -8545,7 +8562,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const preflightEnvironment = await envOrchestrator.resolveEnvironment({ companyId: agent.companyId, selectedEnvironmentId, - defaultEnvironmentId: defaultEnvironment.id, + localEnvironmentId: localEnvironment.id, }); return preflightEnvironment.driver; }, @@ -8609,11 +8626,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }); const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId); const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); + const selectedEnvironmentForConfig = selectedEnvironmentId === localEnvironment.id + ? localEnvironment + : selectedEnvironmentId + ? await environmentsSvc.getById(selectedEnvironmentId) + : null; const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ companyId: agent.companyId, agentId: agent.id, issueId, heartbeatRunId: run.id, + environmentId: selectedEnvironmentForConfig?.id ?? null, + environmentEnv: selectedEnvironmentForConfig?.envVars ?? null, projectId: projectContext?.id ?? null, routineId: routineEnvContext.routineId, executionRunConfig, @@ -8849,17 +8873,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }) .where(eq(heartbeatRuns.id, run.id)); } - // When execution is forced to Kubernetes, `selectedEnvironmentId` is already - // pinned to the managed k8s environment above; ignore any persisted workspace - // environmentId (which could point at a stale local/ssh env) so a reused - // workspace can never downgrade us off the sandbox. - const persistedEnvironmentId = isExecutionForcedToKubernetes(executionPolicy) - ? selectedEnvironmentId - : persistedExecutionWorkspace?.config?.environmentId ?? selectedEnvironmentId; const acquiredEnvironment = await envOrchestrator.acquireForRun({ companyId: agent.companyId, - selectedEnvironmentId: persistedEnvironmentId, - defaultEnvironmentId: defaultEnvironment.id, + selectedEnvironmentId, + localEnvironmentId: localEnvironment.id, adapterType: agent.adapterType, issueId: issueId ?? null, heartbeatRunId: run.id, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 3260d16a..d18a5bbd 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -10,6 +10,7 @@ import { type InstanceExperimentalSettings, type PatchInstanceGeneralSettings, type InstanceSettings, + type PatchInstanceSettings, type PatchInstanceExperimentalSettings, } from "@paperclipai/shared"; import { eq } from "drizzle-orm"; @@ -63,9 +64,9 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: false, enableConferenceRoomChat: false, + enableTaskWatchdogs: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, - enableTaskWatchdogs: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, @@ -77,11 +78,12 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings { return { id: row.id, + defaultEnvironmentId: row.defaultEnvironmentId ?? null, general: normalizeGeneralSettings(row.general), experimental: normalizeExperimentalSettings(row.experimental), createdAt: row.createdAt, updatedAt: row.updatedAt, - }; + } as InstanceSettings; } export function instanceSettingsService(db: Db) { @@ -126,6 +128,22 @@ export function instanceSettingsService(db: Db) { return { get: async (): Promise => toInstanceSettings(await getOrCreateRow()), + update: async (patch: PatchInstanceSettings): Promise => { + const current = await getOrCreateRow(); + const now = new Date(); + const [updated] = await db + .update(instanceSettings) + .set({ + ...(Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") + ? { defaultEnvironmentId: patch.defaultEnvironmentId ?? null } + : {}), + updatedAt: now, + }) + .where(eq(instanceSettings.id, current.id)) + .returning(); + return toInstanceSettings(updated ?? current); + }, + getGeneral: async (): Promise => { const row = await getOrCreateRow(); return normalizeGeneralSettings(row.general); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 43c86e7b..5768d09c 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -4975,9 +4975,8 @@ export function issueService(db: Db) { issueData.projectId = workspace.projectId; } const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); - // Cache the project policy lookup for this insert. Both the - // default-settings block and the assignee-environment-promotion block - // need the same row; without caching they'd issue two round-trips. + // Cache the project policy lookup for this insert so the default + // workspace-settings block does not re-query the project row. let projectPolicyCached: ReturnType | null = null; let projectPolicyLoaded = false; const loadProjectPolicyOnce = async () => { @@ -5006,37 +5005,6 @@ export function issueService(db: Db) { ), ) as Record | null; } - if (data.assigneeAgentId && isolatedWorkspacesEnabled) { - const currentWorkspaceSettings = executionWorkspaceSettings == null - ? {} - : parseObject(executionWorkspaceSettings); - const issueHasEnvironmentSelection = - Object.prototype.hasOwnProperty.call(currentWorkspaceSettings, "environmentId"); - // Don't promote the assignee agent's defaultEnvironmentId if either - // the issue or the project policy already specifies an environment. - // resolveExecutionWorkspaceEnvironmentId treats issue settings as - // higher priority than project policy, so promoting the agent's - // default to issue settings would invert the documented priority - // (project policy must win over agent default when explicitly set). - let projectHasEnvironmentSelection = false; - if (!issueHasEnvironmentSelection && issueData.projectId) { - const projectPolicy = await loadProjectPolicyOnce(); - projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined; - } - if (!issueHasEnvironmentSelection && !projectHasEnvironmentSelection) { - const assigneeAgent = await tx - .select({ defaultEnvironmentId: agents.defaultEnvironmentId }) - .from(agents) - .where(and(eq(agents.id, data.assigneeAgentId), eq(agents.companyId, companyId))) - .then((rows) => rows[0] ?? null); - if (typeof assigneeAgent?.defaultEnvironmentId === "string" && assigneeAgent.defaultEnvironmentId.length > 0) { - executionWorkspaceSettings = { - ...currentWorkspaceSettings, - environmentId: assigneeAgent.defaultEnvironmentId, - }; - } - } - } if (!projectWorkspaceId && issueData.projectId) { const project = await tx .select({ @@ -5236,6 +5204,11 @@ export function issueService(db: Db) { issueData.executionWorkspaceSettings !== undefined ? parseIssueExecutionWorkspaceSettings(issueData.executionWorkspaceSettings) : parseIssueExecutionWorkspaceSettings(existing.executionWorkspaceSettings); + if (issueData.executionWorkspaceSettings !== undefined) { + patch.executionWorkspaceSettings = nextExecutionWorkspaceSettings + ? { ...nextExecutionWorkspaceSettings } + : null; + } let validatedProjectWorkspace: { projectId: string } | null = null; let validatedExecutionWorkspace: { projectId: string } | null = null; if (!nextProjectId && nextProjectWorkspaceId) { @@ -5295,93 +5268,6 @@ export function issueService(db: Db) { ), ]); - // Mirror the create() path: when the assignee changes to a non-null - // agent, default the issue's executionWorkspaceSettings.environmentId - // to the new agent's defaultEnvironmentId. Skip when: - // - this update explicitly sets executionWorkspaceSettings.environmentId - // (caller is making a deliberate override; respect it), OR - // - the project policy already specifies an environmentId (project - // policy must win over agent default per the documented priority - // order in resolveExecutionWorkspaceEnvironmentId), OR - // - the issue already has an environmentId that was *not* the prior - // assignee's default (i.e., the operator set it explicitly in an - // earlier update; preserve their choice). When the existing - // environmentId matches the prior assignee's default, treat it as - // auto-promoted and refresh it to the new assignee's default. - const assigneeChanged = - issueData.assigneeAgentId !== undefined && - issueData.assigneeAgentId !== null && - issueData.assigneeAgentId !== existing.assigneeAgentId; - const explicitEnvInThisUpdate = - issueData.executionWorkspaceSettings !== undefined && - Object.prototype.hasOwnProperty.call( - parseObject(issueData.executionWorkspaceSettings), - "environmentId", - ); - if (assigneeChanged && isolatedWorkspacesEnabled && !explicitEnvInThisUpdate) { - let projectHasEnvironmentSelection = false; - if (nextProjectId) { - const projectRow = await tx - .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) - .from(projects) - .where(and(eq(projects.id, nextProjectId), eq(projects.companyId, existing.companyId))) - .then((rows: Array<{ executionWorkspacePolicy: unknown }>) => rows[0] ?? null); - const projectPolicy = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy); - projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined; - } - if (!projectHasEnvironmentSelection) { - const baseSettings = nextExecutionWorkspaceSettings == null - ? {} - : parseObject(nextExecutionWorkspaceSettings); - const existingEnvId = typeof baseSettings.environmentId === "string" - ? baseSettings.environmentId - : null; - - // Look up both the prior assignee (to detect auto-promoted env) - // and the new assignee in a single query. - type AgentRow = { id: string; defaultEnvironmentId: string | null }; - const agentRows: AgentRow[] = await tx - .select({ id: agents.id, defaultEnvironmentId: agents.defaultEnvironmentId }) - .from(agents) - .where( - and( - eq(agents.companyId, existing.companyId), - inArray( - agents.id, - [issueData.assigneeAgentId!, existing.assigneeAgentId].filter( - (value): value is string => typeof value === "string", - ), - ), - ), - ); - - const newAssignee = agentRows.find((row: AgentRow) => row.id === issueData.assigneeAgentId); - const previousAssignee = existing.assigneeAgentId - ? agentRows.find((row: AgentRow) => row.id === existing.assigneeAgentId) - : null; - - const newDefaultEnvId = - typeof newAssignee?.defaultEnvironmentId === "string" && newAssignee.defaultEnvironmentId.length > 0 - ? newAssignee.defaultEnvironmentId - : null; - const previousDefaultEnvId = - typeof previousAssignee?.defaultEnvironmentId === "string" && previousAssignee.defaultEnvironmentId.length > 0 - ? previousAssignee.defaultEnvironmentId - : null; - - const existingEnvWasAutoPromoted = - existingEnvId === null || - (previousDefaultEnvId !== null && existingEnvId === previousDefaultEnvId); - - if (newDefaultEnvId && existingEnvWasAutoPromoted) { - patch.executionWorkspaceSettings = { - ...baseSettings, - environmentId: newDefaultEnvId, - }; - } - } - } - patch.goalId = resolveNextIssueGoalId({ currentProjectId: existing.projectId, currentGoalId: existing.goalId, diff --git a/server/src/services/sandbox-provider-runtime.ts b/server/src/services/sandbox-provider-runtime.ts index 42b48199..e664fe00 100644 --- a/server/src/services/sandbox-provider-runtime.ts +++ b/server/src/services/sandbox-provider-runtime.ts @@ -18,6 +18,8 @@ export interface AcquireSandboxLeaseInput { environmentId: string; heartbeatRunId: string; issueId: string | null; + agentId?: string | null; + executionWorkspaceId?: string | null; } export interface ResumeSandboxLeaseInput { @@ -137,7 +139,7 @@ class FakeSandboxProvider implements SandboxProvider { async acquireLease(input: AcquireSandboxLeaseInput): Promise { assertProviderConfig(this.provider, input.config); const providerLeaseId = input.config.reuseLease - ? `sandbox://fake/${input.environmentId}` + ? `sandbox://fake/${input.environmentId}/${input.executionWorkspaceId ?? "workspace"}/${input.agentId ?? "agent"}` : `sandbox://fake/${input.heartbeatRunId}/${randomUUID()}`; return { @@ -329,6 +331,8 @@ export async function acquireSandboxProviderLease(input: { environmentId: string; heartbeatRunId: string; issueId: string | null; + agentId?: string | null; + executionWorkspaceId?: string | null; reusableProviderLeaseId?: string | null; }): Promise { const provider = requireSandboxProvider(input.config.provider); @@ -347,6 +351,8 @@ export async function acquireSandboxProviderLease(input: { environmentId: input.environmentId, heartbeatRunId: input.heartbeatRunId, issueId: input.issueId, + agentId: input.agentId, + executionWorkspaceId: input.executionWorkspaceId, }); } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index a8605115..c8de22cf 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -865,13 +865,13 @@ export function secretService(db: Db) { status: environments.status, }) .from(environments) - .where(and(eq(environments.companyId, companyId), inArray(environments.id, environmentIds))); + .where(inArray(environments.id, environmentIds)); for (const row of rows) { setTarget({ type: "environment", id: row.id, label: row.name, - href: "/company/settings/environments", + href: "/company/settings/instance/environments", status: row.status, }); } @@ -2181,6 +2181,20 @@ export function secretService(db: Db) { return normalizedRefs; }, + listBindingCompanyIdsForTarget: async ( + target: { targetType: SecretBindingTargetType; targetId: string }, + ): Promise => + db + .select({ companyId: companySecretBindings.companyId }) + .from(companySecretBindings) + .where( + and( + eq(companySecretBindings.targetType, target.targetType), + eq(companySecretBindings.targetId, target.targetId), + ), + ) + .then((rows) => [...new Set(rows.map((row) => row.companyId))]), + syncEnvBindingsForTarget: async ( companyId: string, target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ba6e9e08..07dfeddc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -79,7 +79,7 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -91,6 +91,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/instanceSettings.ts b/ui/src/api/instanceSettings.ts index 4a8544b8..ca8b7841 100644 --- a/ui/src/api/instanceSettings.ts +++ b/ui/src/api/instanceSettings.ts @@ -1,13 +1,19 @@ import type { InstanceExperimentalSettings, InstanceGeneralSettings, + InstanceSettings, IssueGraphLivenessAutoRecoveryPreview, + PatchInstanceSettings, PatchInstanceGeneralSettings, PatchInstanceExperimentalSettings, } from "@paperclipai/shared"; import { api } from "./client"; export const instanceSettingsApi = { + get: () => + api.get("/instance/settings"), + update: (patch: PatchInstanceSettings) => + api.patch("/instance/settings", patch), getGeneral: () => api.get("/instance/settings/general"), updateGeneral: (patch: PatchInstanceGeneralSettings) => diff --git a/ui/src/components/AgentConfigForm.test.ts b/ui/src/components/AgentConfigForm.test.ts index a17b05ae..51e2e107 100644 --- a/ui/src/components/AgentConfigForm.test.ts +++ b/ui/src/components/AgentConfigForm.test.ts @@ -78,12 +78,12 @@ describe("agent config test action", () => { function makeEnvironment(overrides: Partial): Environment { return { id: "env-1", - companyId: "co-1", name: "Env", description: null, driver: "local", status: "active", config: {}, + envVars: {}, metadata: null, createdAt: new Date(0), updatedAt: new Date(0), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 977673e1..5355d2f1 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -249,6 +249,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { queryFn: () => instanceSettingsApi.getGeneral(), retry: false, }); + const { data: instanceSettings } = useQuery({ + queryKey: queryKeys.instance.settings, + queryFn: () => instanceSettingsApi.get(), + retry: false, + }); const { data: environments = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"], @@ -368,13 +373,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const set = isCreate ? (patch: Partial) => props.onChange(patch) : null; - const currentDefaultEnvironmentId = isCreate + const rawCurrentDefaultEnvironmentId = isCreate ? val!.defaultEnvironmentId ?? "" : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); + const currentDefaultEnvironmentId = useMemo(() => { + if (!rawCurrentDefaultEnvironmentId) return ""; + const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null; + return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId; + }, [environments, rawCurrentDefaultEnvironmentId]); const currentDefaultEnvironment = useMemo( () => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null, [currentDefaultEnvironmentId, environments], ); + const instanceDefaultEnvironmentId = useMemo(() => { + const environmentId = instanceSettings?.defaultEnvironmentId ?? null; + if (!environmentId) return ""; + const selected = environments.find((environment) => environment.id === environmentId) ?? null; + return selected?.driver === "local" ? "" : environmentId; + }, [environments, instanceSettings?.defaultEnvironmentId]); + const instanceDefaultEnvironment = useMemo( + () => environments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null, + [environments, instanceDefaultEnvironmentId], + ); // When the instance forces Kubernetes execution, new agents must default to the // managed Kubernetes sandbox environment (never the implicit local default). @@ -389,12 +409,21 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const runnableEnvironments = useMemo( () => environments.filter((environment) => { if (!supportedEnvironmentDrivers.has(environment.driver)) return false; + if (environment.driver === "local") return false; if (environment.driver !== "sandbox") return true; const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; return provider !== null && provider !== "fake"; }), [environments, supportedEnvironmentDrivers], ); + const showEnvironmentOverrideControl = environmentsEnabled && ( + forcedKubernetes || + currentDefaultEnvironmentId.length > 0 || + runnableEnvironments.length > 1 + ); + const inheritedEnvironmentLabel = instanceDefaultEnvironment + ? `${instanceDefaultEnvironment.name} (${instanceDefaultEnvironment.driver})` + : "Local"; // Fetch adapter models for the effective adapter type const modelQueryKey = selectedCompanyId @@ -897,7 +926,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { - ) : environmentsEnabled ? ( + ) : showEnvironmentOverrideControl ? (
{cards ?

Execution

@@ -905,28 +934,35 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }
- +
+
+ {currentDefaultEnvironment + ? `Overriding the instance default with ${currentDefaultEnvironment.name}.` + : `Inheriting the instance default: ${inheritedEnvironmentLabel}.`} +
+ +
diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index d69dd9ae..ea052667 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -169,7 +169,7 @@ describe("CompanySettingsSidebar", () => { ); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ - to: "/company/settings/environments", + to: "/company/settings/instance/environments", label: "Environments", end: true, }), diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index aab43ec8..a7243a74 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -105,12 +105,6 @@ export function CompanySettingsSidebar() {
- {showCloudUpstream ? ( + + diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index 3133db0c..aeb99865 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -135,7 +135,7 @@ describe("IssueWorkspaceCard", () => { container.remove(); }); - it("locks the environment selector and clears the issue override when reusing a workspace", () => { + it("clears the legacy issue environment override when reusing a workspace", () => { const root = createRoot(container); const onUpdate = vi.fn(); const reusableWorkspace = createExecutionWorkspace(); @@ -180,12 +180,7 @@ describe("IssueWorkspaceCard", () => { }); const selects = container.querySelectorAll("select"); - expect(selects).toHaveLength(3); - - const environmentSelect = selects[2] as HTMLSelectElement; - expect(environmentSelect.disabled).toBe(true); - expect(environmentSelect.value).toBe("env-workspace"); - expect(container.textContent).toContain("Environment selection is locked while reusing an existing workspace."); + expect(selects).toHaveLength(2); const saveButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Save")); expect(saveButton).not.toBeUndefined(); diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index f8e1a035..f5a001b5 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -262,7 +262,6 @@ export function IssueWorkspaceCard({ const [draftSelection, setDraftSelection] = useState(currentSelection); const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? ""); - const [draftEnvironmentId, setDraftEnvironmentId] = useState(issue.executionWorkspaceSettings?.environmentId ?? ""); const projectEnvironmentId = environmentsEnabled ? project?.executionWorkspacePolicy?.environmentId ?? null : null; @@ -271,7 +270,6 @@ export function IssueWorkspaceCard({ ? ( (currentSelection === "reuse_existing" && currentReusableEnvironmentId) ?? workspace?.config?.environmentId - ?? issue.executionWorkspaceSettings?.environmentId ?? projectEnvironmentId ) : null; @@ -283,8 +281,7 @@ export function IssueWorkspaceCard({ if (editing) return; setDraftSelection(currentSelection); setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); - setDraftEnvironmentId(issue.executionWorkspaceSettings?.environmentId ?? ""); - }, [currentSelection, editing, issue.executionWorkspaceId, issue.executionWorkspaceSettings?.environmentId]); + }, [currentSelection, editing, issue.executionWorkspaceId]); const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace"); @@ -304,17 +301,6 @@ export function IssueWorkspaceCard({ }); const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0; - const reuseExistingSelection = draftSelection === "reuse_existing"; - const selectedReusableEnvironmentId = configuredReusableWorkspace?.config?.environmentId ?? ""; - const runSelectableEnvironments = useMemo( - () => environmentsEnabled ? (environments ?? []).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"; - }) : [], - [environments, environmentsEnabled], - ); const draftWorkspaceBranchName = draftSelection === "reuse_existing" && configuredReusableWorkspace?.mode !== "shared_workspace" ? configuredReusableWorkspace?.branchName ?? null @@ -328,11 +314,10 @@ export function IssueWorkspaceCard({ draftSelection === "reuse_existing" ? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode) : draftSelection, - environmentId: draftSelection === "reuse_existing" ? null : draftEnvironmentId || null, + environmentId: null, }, }), [ configuredReusableWorkspace?.mode, - draftEnvironmentId, draftExecutionWorkspaceId, draftSelection, ]); @@ -358,9 +343,8 @@ export function IssueWorkspaceCard({ const handleCancel = useCallback(() => { setDraftSelection(currentSelection); setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); - setDraftEnvironmentId(issue.executionWorkspaceSettings?.environmentId ?? ""); setEditing(false); - }, [currentSelection, issue.executionWorkspaceId, issue.executionWorkspaceSettings?.environmentId]); + }, [currentSelection, issue.executionWorkspaceId]); if (!policyEnabled || !project) return null; @@ -520,42 +504,6 @@ export function IssueWorkspaceCard({ )} - {environmentsEnabled ? ( - <> - - {reuseExistingSelection && ( -
- {configuredReusableWorkspace - ? "Environment selection is locked while reusing an existing workspace. The next run will use that workspace's persisted environment config." - : "Choose an existing workspace first. Its persisted environment config will determine the next run."} -
- )} - - ) : null} - {/* Current workspace summary when editing */} {workspace && (
diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 5a4967b3..968b93ee 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -442,14 +442,15 @@ describe("Layout", () => { const selector = container.querySelector("select"); expect(selector).not.toBeNull(); expect(selector?.value).toBe("secrets"); - expect(selector?.textContent).toContain("General"); - expect(selector?.textContent).toContain("Environments"); - expect(selector?.textContent).toContain("Cloud upstream"); - expect(selector?.textContent).toContain("Members"); - expect(selector?.textContent).toContain("Invites"); - expect(selector?.textContent).toContain("Secrets"); - expect(selector?.textContent).toContain("Instance general"); - expect(selector?.textContent).toContain("Instance plugins"); + const selectorText = selector?.textContent?.toLowerCase() ?? ""; + expect(selectorText).toContain("general"); + expect(selectorText).toContain("cloud upstream"); + expect(selectorText).toContain("members"); + expect(selectorText).toContain("invites"); + expect(selectorText).toContain("secrets"); + expect(selectorText).toContain("instance general"); + expect(selectorText).toContain("instance environments"); + expect(selectorText).toContain("instance plugins"); await act(async () => { root.unmount(); diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index c4f7b31b..b2d159e7 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -308,6 +308,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; return provider !== null && provider !== "fake"; }); + const showExecutionWorkspaceEnvironmentControl = environmentsEnabled && runSelectableEnvironments.length > 1; const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); @@ -1000,7 +1001,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
Host-managed implementation: Git worktree
- {environmentsEnabled ? ( + {showExecutionWorkspaceEnvironmentControl ? (