refactor(environments): make execution environments instance-scoped (#8375)

## Thinking Path

> - Paperclip is the control plane for AI-agent companies, so execution
environment selection has to stay inspectable and predictable across
companies, agents, and runs.
> - The environment subsystem decides where an agent heartbeat actually
runs and how remote sandbox state is realized and restored.
> - That subsystem previously mixed company-scoped environment catalogs
with issue-level environment stamping, so a reassigned issue could keep
executing in the previous assignee's sandbox.
> - That behavior breaks the control-plane contract: changing the
assignee should change the executing agent/environment path unless there
is an explicit current override.
> - Fixing it cleanly required more than a narrow patch; the environment
model had to move to instance scope with a single inherited default and
per-agent override semantics.
> - This pull request rewires the schema, server/API surface, runtime
resolution, and UI around that model, then adds regression coverage for
cross-company inheritance and per-agent isolation.
> - The benefit is that environment choice now follows the approved
instance/agent configuration path instead of stale issue state, while
shared environments only need to be configured once per instance.

## Linked Issues or Issue Description

- No directly matching public GitHub issue or PR was found while
searching for this refactor.

### What happened?

Reassigning work between agents with different execution environments
could keep running in the previous sandbox because environment choice
was stamped onto the issue and outranked the current assignee. The same
subsystem also forced environment catalogs to be duplicated per company
even though the underlying execution environments were instance-wide
resources.

### Expected behavior

Execution should resolve through the current instance and agent
configuration path, with one instance-scoped environment catalog, one
instance default, optional per-agent override, and no stale issue-level
environment authority surviving reassignment.

### Steps to reproduce

1. Configure two agents to use different execution environments.
2. Assign an issue to the first agent so the issue records execution
state in that environment.
3. Reassign the same issue to the second agent and run another
heartbeat.
4. Observe that the pre-fix runtime can still sync or execute in the
original sandbox instead of the second agent's environment.

### Paperclip version or commit

Current `master` before this PR.

### Deployment mode

Self-hosted server.

### Installation method

Built from source (`pnpm dev` / `pnpm build`).

### Agent adapter(s) involved

- Claude Code
- Not adapter-specific (core bug in environment authority / resolution)

### Database mode

External Postgres.

### Access context

Both board reassignment and agent heartbeats were involved.

## What Changed

- Moved environments and their default selection contract to instance
scope in DB/shared types, including the migration that dedupes legacy
per-company environments and seeds the instance local default.
- Reworked environment CRUD/auth flows to use instance-scoped APIs and
added route/service coverage for instance-level environment management.
- Changed runtime resolution to prefer `agent default -> instance
default -> built-in local`, removed issue-level environment stamping
from the active execution path, and isolated sandbox/plugin leases by
`(executionWorkspaceId, agentId)`.
- Added environment env-var runtime precedence so environment-provided
values act as the baseline for agent execution.
- Moved the environment UI into instance settings and updated agent
configuration surfaces to reflect inherit/override behavior.
- Added regression coverage for instance-default inheritance across
companies and for the new runtime resolution behavior.
- Fixed a rebase-only duplicate `enableTaskWatchdogs` flag regression in
instance settings types/validators/services so the branch typechecks
cleanly on current `master`.
- Updated stale server tests so CI matches the shipped instance-scoped
environment contract.

## Verification

- `git diff --check`
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm exec vitest run
server/src/__tests__/environment-runtime-driver-contract.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/environment-routes.test.ts
server/src/__tests__/environment-instance-routes.test.ts
server/src/__tests__/execution-workspace-policy.test.ts
server/src/__tests__/heartbeat-plugin-environment.test.ts
server/src/__tests__/instance-settings-routes.test.ts`

## Risks

- The migration changes environment scope and dedupes existing rows, so
installs with unusual legacy environment combinations should be reviewed
carefully during upgrade.
- Remote execution behavior now depends on instance-default inheritance
semantics instead of issue-level stamping, so any remaining code paths
that still assume issue-scoped environment authority would surface as
follow-up bugs.
- This PR includes both server/runtime behavior and UI relocation, so
reviewers should watch for authorization edge cases around instance
settings and environment management.

> I checked [`ROADMAP.md`](ROADMAP.md). This work fits the existing
Cloud / Sandbox agents direction as a bug-fix/refactor to current
behavior, not a new parallel product surface.

## Model Used

- OpenAI Codex coding agent in this Paperclip/Codex session; GPT-5-class
tool-using model with code execution and shell access. The exact backend
model ID is not exposed to the session runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley
2026-06-20 09:42:53 -07:00
committed by GitHub
parent 950484d204
commit 547463d3a2
69 changed files with 2183 additions and 1001 deletions
@@ -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");
@@ -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
}
]
}
+7 -8
View File
@@ -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<Record<string, unknown>>().notNull().default({}),
envVars: jsonb("env_vars").$type<Record<string, unknown>>().notNull().default({}),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
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),
}),
);
@@ -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<Record<string, unknown>>().notNull().default({}),
experimental: jsonb("experimental").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+2
View File
@@ -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
+3
View File
@@ -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,
+2 -2
View File
@@ -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<string, unknown>;
envVars: AgentEnvConfig;
metadata: Record<string, unknown> | null;
createdAt: Date;
updatedAt: Date;
+2 -1
View File
@@ -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;
@@ -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<typeof updateEnvironmentSchema>;
@@ -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<typeof probeEnvironmentConfigSchema>;
+3
View File
@@ -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";
+15 -1
View File
@@ -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<typeof instanceGeneralSettingsSche
export type PatchInstanceGeneralSettings = z.infer<typeof patchInstanceGeneralSettingsSchema>;
export type InstanceExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>;
export type PatchInstanceExperimentalSettings = z.infer<typeof patchInstanceExperimentalSettingsSchema>;
export type PatchInstanceSettings = z.infer<typeof patchInstanceSettingsSchema>;
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();
@@ -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 () => {
@@ -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<string, unknown> = {}) {
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<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as typeof req & { actor: Record<string, unknown> }).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" });
});
});
@@ -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",
+156 -102
View File
@@ -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();
});
@@ -135,9 +135,16 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
},
};
}
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: environmentId,
companyId,
id: resolvedEnvironmentId,
name: `${input.driver} contract`,
driver: input.driver,
status: "active",
@@ -145,6 +152,9 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
createdAt: now,
updatedAt: now,
});
} else {
config = (existingLocal.config as Record<string, unknown> | 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,
+196 -17
View File
@@ -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();
@@ -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<string, unknown>)?.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);
});
});
@@ -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,
});
});
@@ -114,9 +114,17 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
await db.delete(documents);
await db.delete(agentTaskSessions);
await db.delete(executionWorkspaces);
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);
@@ -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");
@@ -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<string, unknown>) => {
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",
@@ -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) => {
@@ -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<string, unknown>) => ({
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 () => {
@@ -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",
@@ -19,6 +19,7 @@ describe("instance settings service", () => {
enableIsolatedWorkspaces: true,
enableStreamlinedLeftNavigation: false,
enableConferenceRoomChat: false,
enableTaskWatchdogs: false,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
+14 -21
View File
@@ -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,
@@ -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",
+4 -4
View File
@@ -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;
}
+2 -3
View File
@@ -4,13 +4,12 @@ export async function assertEnvironmentSelectionForCompany(
environmentsSvc: {
getById(environmentId: string): Promise<{
id: string;
companyId: string;
driver: string;
status?: string | null;
config: Record<string, unknown> | 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") {
+167 -140
View File
@@ -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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
@@ -52,79 +50,108 @@ export function environmentRoutes(
: {};
}
function canCreateAgents(agent: { permissions: Record<string, unknown> | null | undefined }) {
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
function assertCanAccessInstanceEnvironments(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Instance environment management is restricted to board operators");
}
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;
throw forbidden("Instance admin access required");
}
if (!req.actor.agentId) {
throw forbidden("Agent authentication required");
function assertCanReadInstanceEnvironments(req: Request) {
assertBoardOrgAccess(req);
}
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");
}
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<T extends {
config: Record<string, unknown>;
config: Record<string, unknown> | null;
envVars?: Record<string, unknown> | null;
metadata: Record<string, unknown> | 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<T extends {
config: Record<string, unknown> | null;
envVars?: Record<string, unknown> | null;
metadata: Record<string, unknown> | 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<typeof getActorInfo>;
action: string;
entityId: string;
details: Record<string, unknown>;
}) {
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<string | null> {
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<string, unknown>,
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<string, unknown>, 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,
+46
View File
@@ -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.
+18
View File
@@ -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",
+14 -4
View File
@@ -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<EnvironmentProbeResult> {
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<string, unknown>,
@@ -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,
});
@@ -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<Environment> {
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<ExecutionWorkspace, "id" | "mode"> | 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,
+39 -5
View File
@@ -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<ExecutionWorkspace, "id" | "mode"> | 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,
+116 -34
View File
@@ -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<T extends string>(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<Environment[]> => {
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<Environment> => {
ensureLocalEnvironment: async (_companyId?: string): Promise<Environment> => {
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<Environment> => {
const config = resolveKubernetesConfig(companyIdOrConfig, maybeConfig);
const desiredConfig: Record<string, unknown> = {
...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<Environment | null> => {
findKubernetesEnvironment: async (_companyId?: string): Promise<Environment | null> => {
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<Environment> => {
create: async (
companyIdOrInput: string | CreateEnvironment,
maybeInput?: CreateEnvironment,
): Promise<Environment> => {
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<string, unknown> }).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<string, unknown>;
}
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;
},
+15 -107
View File
@@ -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<string, unknown>) } }
@@ -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<string, unknown>) } }
@@ -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<ExecutionWorkspaceEnvironmentSource, "workspace">;
};
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<ExecutionWorkspaceEnvironmentSource, "workspace">;
} {
// 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;
}
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) {
if (input.agentDefaultEnvironmentId) {
return {
environmentId: assigneeIntended.environmentId,
source: assigneeIntended.source,
conflict: {
reason: "reused_workspace_environment_mismatch",
workspaceEnvironmentId,
assigneeIntendedEnvironmentId: assigneeIntended.environmentId,
assigneeIntendedSource: assigneeIntended.source,
},
environmentId: input.agentDefaultEnvironmentId,
source: "agent",
};
}
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(
+55 -38
View File
@@ -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<string, unknown>;
@@ -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<string>(), 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,
+20 -2
View File
@@ -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<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
update: async (patch: PatchInstanceSettings): Promise<InstanceSettings> => {
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<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
return normalizeGeneralSettings(row.general);
+7 -121
View File
@@ -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<typeof parseProjectExecutionWorkspacePolicy> | null = null;
let projectPolicyLoaded = false;
const loadProjectPolicyOnce = async () => {
@@ -5006,37 +5005,6 @@ export function issueService(db: Db) {
),
) as Record<string, unknown> | 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,
@@ -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<SandboxLeaseHandle> {
assertProviderConfig<FakeSandboxEnvironmentConfig>(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<SandboxLeaseHandle> {
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,
});
}
+16 -2
View File
@@ -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<string[]> =>
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 },
+2 -1
View File
@@ -79,7 +79,7 @@ function boardRoutes() {
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/environments" element={<Navigate to="/company/settings/instance/environments" replace />} />
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
<Route path="company/settings/members" element={<CompanyAccess />} />
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
@@ -91,6 +91,7 @@ function boardRoutes() {
<Route path="company/settings/instance" element={<Navigate to="general" replace />} />
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
<Route path="company/settings/instance/general" element={<InstanceGeneralSettings />} />
<Route path="company/settings/instance/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/instance/access" element={<InstanceAccess />} />
<Route path="company/settings/instance/heartbeats" element={<InstanceSettings />} />
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />
+6
View File
@@ -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<InstanceSettings>("/instance/settings"),
update: (patch: PatchInstanceSettings) =>
api.patch<InstanceSettings>("/instance/settings", patch),
getGeneral: () =>
api.get<InstanceGeneralSettings>("/instance/settings/general"),
updateGeneral: (patch: PatchInstanceGeneralSettings) =>
+1 -1
View File
@@ -78,12 +78,12 @@ describe("agent config test action", () => {
function makeEnvironment(overrides: Partial<Environment>): 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),
+41 -5
View File
@@ -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<Environment[]>({
queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"],
@@ -368,13 +373,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const set = isCreate
? (patch: Partial<CreateConfigValues>) => 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) {
</Field>
</div>
</div>
) : environmentsEnabled ? (
) : showEnvironmentOverrideControl ? (
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
{cards
? <h3 className="text-sm font-medium mb-3">Execution</h3>
@@ -905,9 +934,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field
label="Default environment"
hint="Agent-level default execution target. Project and task settings can still override this."
label="Environment override"
hint="Leave this unset to inherit the instance default. Agent-specific overrides only appear when there is a real alternative."
>
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{currentDefaultEnvironment
? `Overriding the instance default with ${currentDefaultEnvironment.name}.`
: `Inheriting the instance default: ${inheritedEnvironmentLabel}.`}
</div>
<select
className={inputClass}
value={currentDefaultEnvironmentId}
@@ -920,13 +955,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
mark("identity", "defaultEnvironmentId", nextValue || null);
}}
>
<option value="">Company default (Local)</option>
<option value="">Inherit instance default ({inheritedEnvironmentLabel})</option>
{runnableEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environment.name} · {environment.driver}
</option>
))}
</select>
</div>
</Field>
</div>
</div>
@@ -169,7 +169,7 @@ describe("CompanySettingsSidebar", () => {
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/environments",
to: "/company/settings/instance/environments",
label: "Environments",
end: true,
}),
+6 -6
View File
@@ -105,12 +105,6 @@ export function CompanySettingsSidebar() {
</div>
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem
to="/company/settings/environments"
label="Environments"
icon={MonitorCog}
end
/>
{showCloudUpstream ? (
<SidebarNavItem
to="/company/settings/cloud-upstream"
@@ -156,6 +150,12 @@ export function CompanySettingsSidebar() {
icon={SlidersHorizontal}
end
/>
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`}
label="Environments"
icon={MonitorCog}
end
/>
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`}
label="Access"
+2 -1
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
import { Clock3, Cpu, FlaskConical, MonitorCog, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
import type { PluginRecord } from "@paperclipai/shared";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
@@ -41,6 +41,7 @@ export function InstanceSidebar() {
<div className="flex flex-col gap-0.5">
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`} label="Profile" icon={UserRoundPen} end />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/general`} label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`} label="Environments" icon={MonitorCog} end />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`} label="Access" icon={Shield} end />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`} label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`} label="Experimental" icon={FlaskConical} />
@@ -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();
+3 -55
View File
@@ -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({
</select>
)}
{environmentsEnabled ? (
<>
<select
className={cn(
"w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none",
reuseExistingSelection && "cursor-not-allowed opacity-70",
)}
value={reuseExistingSelection ? selectedReusableEnvironmentId : draftEnvironmentId}
onChange={(e) => setDraftEnvironmentId(e.target.value)}
disabled={reuseExistingSelection}
>
<option value="">
{reuseExistingSelection
? configuredReusableWorkspace
? "No environment on reused workspace"
: "Select an existing workspace to inspect its environment"
: projectEnvironmentId
? "Project default environment"
: "No environment"}
</option>
{runSelectableEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environment.name} · {environment.driver}
</option>
))}
</select>
{reuseExistingSelection && (
<div className="text-[11px] text-muted-foreground">
{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."}
</div>
)}
</>
) : null}
{/* Current workspace summary when editing */}
{workspace && (
<div className="text-[11px] text-muted-foreground space-y-0.5 pt-1 border-t border-border/50">
+9 -8
View File
@@ -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();
+2 -1
View File
@@ -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
<div className="text-xs text-muted-foreground">
Host-managed implementation: <span className="text-foreground">Git worktree</span>
</div>
{environmentsEnabled ? (
{showExecutionWorkspaceEnvironmentControl ? (
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -66,8 +66,7 @@ describe("CompanySettingsNav", () => {
it("maps company settings routes to the expected shared tab value", () => {
expect(getCompanySettingsTab("/company/settings")).toBe("general");
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/company/settings/environments")).toBe("instance-environments");
expect(getCompanySettingsTab("/company/settings/cloud-upstream")).toBe("cloud-upstream");
expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
@@ -77,6 +76,7 @@ describe("CompanySettingsNav", () => {
expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets");
expect(getCompanySettingsTab("/company/settings/instance/profile")).toBe("instance-profile");
expect(getCompanySettingsTab("/PAP/company/settings/instance/general")).toBe("instance-general");
expect(getCompanySettingsTab("/company/settings/instance/environments")).toBe("instance-environments");
expect(getCompanySettingsTab("/company/settings/instance/access")).toBe("instance-access");
expect(getCompanySettingsTab("/company/settings/instance/heartbeats")).toBe("instance-heartbeats");
expect(getCompanySettingsTab("/company/settings/instance/experimental")).toBe("instance-experimental");
@@ -98,13 +98,13 @@ describe("CompanySettingsNav", () => {
value: "members",
items: [
{ value: "general", label: "General" },
{ value: "environments", label: "Environments" },
{ value: "cloud-upstream", label: "Cloud upstream" },
{ value: "members", label: "Members" },
{ value: "invites", label: "Invites" },
{ value: "secrets", label: "Secrets" },
{ value: "instance-profile", label: "Instance profile" },
{ value: "instance-general", label: "Instance general" },
{ value: "instance-environments", label: "Instance environments" },
{ value: "instance-access", label: "Instance access" },
{ value: "instance-heartbeats", label: "Instance heartbeats" },
{ value: "instance-experimental", label: "Instance experimental" },
@@ -5,13 +5,13 @@ import { useLocation, useNavigate } from "@/lib/router";
const items = [
{ value: "general", label: "General", href: "/company/settings" },
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
{ value: "cloud-upstream", label: "Cloud upstream", href: "/company/settings/cloud-upstream" },
{ value: "members", label: "Members", href: "/company/settings/members" },
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
{ value: "instance-profile", label: "Instance profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` },
{ value: "instance-general", label: "Instance general", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/general` },
{ value: "instance-environments", label: "Instance environments", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/environments` },
{ value: "instance-access", label: "Instance access", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/access` },
{ value: "instance-heartbeats", label: "Instance heartbeats", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats` },
{ value: "instance-experimental", label: "Instance experimental", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/experimental` },
@@ -30,6 +30,10 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
return "instance-access";
}
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`)) {
return "instance-environments";
}
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`)) {
return "instance-heartbeats";
}
@@ -51,7 +55,7 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
}
if (pathname.includes("/company/settings/environments")) {
return "environments";
return "instance-environments";
}
if (pathname.includes("/company/settings/cloud-upstream")) {
+6
View File
@@ -12,6 +12,9 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
"/company/settings/instance/experimental",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/environments")).toBe(
"/company/settings/instance/environments",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe(
"/company/settings/instance/plugins/example?tab=config#logs",
);
@@ -24,6 +27,9 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
expect(normalizeRememberedInstanceSettingsPath("/company/settings/instance/plugins/example?tab=config#logs")).toBe(
"/company/settings/instance/plugins/example?tab=config#logs",
);
expect(normalizeRememberedInstanceSettingsPath("/company/settings/environments")).toBe(
"/company/settings/instance/environments",
);
expect(normalizeRememberedInstanceSettingsPath("/settings/access?tab=users#admins")).toBe(
"/company/settings/instance/access?tab=users#admins",
);
+3
View File
@@ -31,6 +31,8 @@ function normalizePathForMatching(rawPath: string): { pathname: string; search:
}
function instanceSettingsSuffix(pathname: string): string | null {
if (pathname === "/company/settings/environments") return "/environments";
if (pathname === INSTANCE_SETTINGS_PATH_PREFIX) return "/general";
if (pathname.startsWith(`${INSTANCE_SETTINGS_PATH_PREFIX}/`)) {
return pathname.slice(INSTANCE_SETTINGS_PATH_PREFIX.length);
@@ -61,6 +63,7 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
if (
suffix === "/profile" ||
suffix === "/general" ||
suffix === "/environments" ||
suffix === "/access" ||
suffix === "/heartbeats" ||
suffix === "/plugins" ||
+1
View File
@@ -194,6 +194,7 @@ export const queryKeys = {
mine: (companyId: string) => ["resource-memberships", companyId, "me"] as const,
},
instance: {
settings: ["instance", "settings"] as const,
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const,
+122 -12
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AGENT_ADAPTER_TYPES,
getAdapterEnvironmentSupport,
type EnvBinding,
type Environment,
type EnvironmentProbeResult,
type JsonSchema,
@@ -12,6 +13,7 @@ import { environmentsApi } from "@/api/environments";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { secretsApi } from "@/api/secrets";
import { Button } from "@/components/ui/button";
import { EnvVarEditor } from "@/components/EnvVarEditor";
import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
@@ -37,6 +39,7 @@ type EnvironmentFormState = {
sshStrictHostKeyChecking: boolean;
sandboxProvider: string;
sandboxConfig: Record<string, unknown>;
envVars: Record<string, EnvBinding>;
};
const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({
@@ -49,6 +52,7 @@ function buildEnvironmentPayload(form: EnvironmentFormState) {
name: form.name.trim(),
description: form.description.trim() || null,
driver: form.driver,
envVars: form.envVars,
config:
form.driver === "ssh"
? {
@@ -88,9 +92,23 @@ function createEmptyEnvironmentForm(): EnvironmentFormState {
sshStrictHostKeyChecking: true,
sandboxProvider: "",
sandboxConfig: {},
envVars: {},
};
}
function isLocalEnvironment(environment: Environment | null | undefined) {
return environment?.driver === "local";
}
function normalizeNonLocalEnvironmentId(
environmentId: string | null | undefined,
environments: readonly Environment[],
): string {
if (!environmentId) return "";
const environment = environments.find((candidate) => candidate.id === environmentId) ?? null;
return isLocalEnvironment(environment) ? "" : environmentId;
}
function readSshConfig(environment: Environment) {
const config = environment.config ?? {};
return {
@@ -159,7 +177,7 @@ function SupportMark({ supported }: { supported: boolean }) {
}
export function CompanyEnvironments() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
@@ -170,11 +188,17 @@ export function CompanyEnvironments() {
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Instance settings", href: "/company/settings/instance/general" },
{ label: "Environments" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
}, [setBreadcrumbs]);
const { data: instanceSettings } = useQuery({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
retry: false,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
@@ -199,6 +223,16 @@ export function CompanyEnvironments() {
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: async () => {
if (!selectedCompanyId) return;
await queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const environmentMutation = useMutation({
mutationFn: async (form: EnvironmentFormState) => {
@@ -231,6 +265,26 @@ export function CompanyEnvironments() {
},
});
const defaultEnvironmentMutation = useMutation({
mutationFn: async (defaultEnvironmentId: string | null) =>
await instanceSettingsApi.update({ defaultEnvironmentId }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.settings });
pushToast({
title: "Default environment updated",
body: "Agent inheritance now follows the updated instance default.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to update default environment",
body: error instanceof Error ? error.message : "Default environment update failed.",
tone: "error",
});
},
});
const environmentProbeMutation = useMutation({
mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId),
onMutate: (environmentId) => {
@@ -314,6 +368,7 @@ export function CompanyEnvironments() {
sshPrivateKeySecretId: ssh.privateKeySecretId,
sshKnownHosts: ssh.knownHosts,
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
envVars: environment.envVars ?? {},
});
return;
}
@@ -327,6 +382,7 @@ export function CompanyEnvironments() {
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
envVars: environment.envVars ?? {},
});
return;
}
@@ -336,6 +392,7 @@ export function CompanyEnvironments() {
name: environment.name,
description: environment.description ?? "",
driver: "local",
envVars: environment.envVars ?? {},
});
}
@@ -404,8 +461,17 @@ export function CompanyEnvironments() {
environmentForm.sandboxProvider !== "fake" &&
Object.keys(sandboxConfigErrors).length === 0);
const savedEnvironments = environments ?? [];
const nonLocalEnvironments = savedEnvironments.filter((environment) => !isLocalEnvironment(environment));
const instanceDefaultEnvironmentId = normalizeNonLocalEnvironmentId(
instanceSettings?.defaultEnvironmentId ?? null,
savedEnvironments,
);
const instanceDefaultEnvironment =
nonLocalEnvironments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null;
if (!selectedCompanyId) {
return <div className="text-sm text-muted-foreground">Select a company to manage environments.</div>;
return <div className="text-sm text-muted-foreground">Select a company context to manage environment secrets and bindings.</div>;
}
if (!environmentsEnabled) {
@@ -413,28 +479,59 @@ export function CompanyEnvironments() {
<div className="max-w-3xl space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Environments</h1>
<h1 className="text-lg font-semibold">Environments</h1>
</div>
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
Enable Environments in instance experimental settings to manage company execution targets.
Enable Environments in instance experimental settings to manage shared execution targets.
</div>
</div>
);
}
return (
<div className="max-w-5xl space-y-6" data-testid="company-settings-environments-section">
<div className="max-w-5xl space-y-6" data-testid="instance-settings-environments-section">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Environments</h1>
<h1 className="text-lg font-semibold">Environments</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Define reusable execution targets for projects, task workspaces, and remote-capable adapters.
Define reusable execution targets for the instance. The built-in default is <span className="font-medium text-foreground">Local</span>; agents inherit that unless the instance default or an agent override points somewhere else.
</p>
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-medium">Instance default environment</div>
<div className="text-xs text-muted-foreground">
New agent configurations inherit this target unless they explicitly override it.
</div>
</div>
<div className="min-w-[18rem] flex-1">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={instanceDefaultEnvironmentId}
onChange={(event) =>
defaultEnvironmentMutation.mutate(event.target.value || null)}
disabled={defaultEnvironmentMutation.isPending}
>
<option value="">Local (built-in default)</option>
{nonLocalEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environment.name} · {environment.driver}
</option>
))}
</select>
<div className="mt-1 text-[11px] text-muted-foreground">
{instanceDefaultEnvironment
? `Agents currently inherit ${instanceDefaultEnvironment.name} unless they override it.`
: "Agents currently inherit the built-in Local environment unless they override it."}
</div>
</div>
</div>
</div>
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
Environment choices use the same adapter support matrix as agent defaults. SSH is always available for
remote-managed adapters, and sandbox environments appear only when a run-capable sandbox provider plugin is
@@ -493,10 +590,10 @@ export function CompanyEnvironments() {
</div>
<div className="space-y-3">
{(environments ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground">No environments saved for this company yet.</div>
{savedEnvironments.length === 0 ? (
<div className="text-sm text-muted-foreground">No saved environments yet. Local remains the default until you add another target.</div>
) : (
(environments ?? []).map((environment) => {
savedEnvironments.map((environment) => {
const probe = probeResults[environment.id] ?? null;
const isEditing = editingEnvironmentId === environment.id;
return (
@@ -762,6 +859,19 @@ export function CompanyEnvironments() {
</div>
) : null}
<Field
label="Environment variables"
hint="Injected into runs that resolve through this environment. Use plain values or company secrets."
>
<EnvVarEditor
value={environmentForm.envVars}
secrets={secrets ?? []}
onCreateSecret={async (name, value) => await createSecret.mutateAsync({ name, value })}
onChange={(env) =>
setEnvironmentForm((current) => ({ ...current, envVars: env ?? {} }))}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
+5 -5
View File
@@ -161,13 +161,13 @@ describe("PluginSettings", () => {
vi.clearAllMocks();
});
it("routes environment-provider plugins to company environments when they have no instance config", async () => {
it("routes environment-provider plugins to instance environments when they have no instance config", async () => {
const root = await renderSettings(container);
expect(container.textContent).toContain("Configure this plugin from Company Environments.");
expect(container.textContent).toContain("company-scoped instead of instance-global");
const link = container.querySelector('a[href="/company/settings/environments"]');
expect(link?.textContent).toContain("Open Company Environments");
expect(container.textContent).toContain("Configure this plugin from Instance Settings → Environments.");
expect(container.textContent).toContain("secret bindings still resolve through the selected company context");
const link = container.querySelector('a[href="/company/settings/instance/environments"]');
expect(link?.textContent).toContain("Open Environments");
await act(async () => {
root.unmount();
+5 -5
View File
@@ -254,14 +254,14 @@ export function PluginSettings() {
/>
) : environmentDrivers.length > 0 ? (
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm">
<p className="font-medium text-foreground">Configure this plugin from Company Environments.</p>
<p className="font-medium text-foreground">Configure this plugin from Instance Settings Environments.</p>
<p className="mt-1 text-muted-foreground">
{driverLabel || "This plugin"} registers environment runtime settings there so credentials stay
company-scoped instead of instance-global.
{driverLabel || "This plugin"} registers environment runtime settings there so the execution target
stays instance-scoped while secret bindings still resolve through the selected company context.
</p>
<div className="mt-3">
<Link to="/company/settings/environments">
<Button variant="outline" size="sm">Open Company Environments</Button>
<Link to="/company/settings/instance/environments">
<Button variant="outline" size="sm">Open Environments</Button>
</Link>
</div>
</div>
@@ -780,7 +780,6 @@ export const ManagementMatrix: Story = {};
const managedKubernetesEnvironment: Environment = {
id: "env-k8s-storybook",
companyId: COMPANY_ID,
name: "Kubernetes Sandbox",
description: "Managed Kubernetes sandbox environment for hosted tenant execution.",
driver: "sandbox",
@@ -792,6 +791,7 @@ const managedKubernetesEnvironment: Environment = {
runtimeClassName: "gvisor",
egressMode: "cilium",
},
envVars: {},
metadata: { managedByPaperclip: true, managedKubernetesSandbox: true },
createdAt: recent(2_000),
updatedAt: recent(60),