Move environments into instance settings

This commit is contained in:
Devin Foley committed 2026-06-19 22:54:12 -07:00
1 parent 20853c196a
commit cb40d9afea
36 files changed
+397 -132

No files matched your search

@@ -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
@@ -903,12 +903,14 @@ export {
type PatchInstanceGeneralSettings,
instanceExperimentalSettingsSchema,
patchInstanceExperimentalSettingsSchema,
patchInstanceSettingsSchema,
issueGraphLivenessAutoRecoveryRequestSchema,
trustPresetSchema,
lowTrustBoundarySchema,
lowTrustReviewPresetPolicySchema,
trustAuthorizationPolicySchema,
type PatchInstanceExperimentalSettings,
type PatchInstanceSettings,
type IssueGraphLivenessAutoRecoveryRequest,
type TrustPresetInput,
type LowTrustBoundaryInput,
+1
View File
@@ -49,6 +49,7 @@ export interface InstanceExperimentalSettings {
enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean;
enableConferenceRoomChat: boolean;
enableTaskWatchdogs: boolean;
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
enableTaskWatchdogs: boolean;
@@ -162,6 +162,7 @@ export interface ProjectExecutionWorkspacePolicy {
export interface IssueExecutionWorkspaceSettings {
mode?: ExecutionWorkspaceMode;
environmentId?: string | null;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
}
+2
View File
@@ -6,9 +6,11 @@ export {
type PatchInstanceGeneralSettings,
instanceExperimentalSettingsSchema,
patchInstanceExperimentalSettingsSchema,
patchInstanceSettingsSchema,
issueGraphLivenessAutoRecoveryRequestSchema,
type InstanceExperimentalSettings,
type PatchInstanceExperimentalSettings,
type PatchInstanceSettings,
type IssueGraphLivenessAutoRecoveryRequest,
} from "./instance.js";
@@ -43,6 +43,7 @@ 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),
@@ -59,6 +60,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 +77,7 @@ 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
>;
+1
View File
@@ -117,6 +117,7 @@ const executionWorkspaceStrategySchema = z
export const issueExecutionWorkspaceSettingsSchema = z
.object({
mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(),
environmentId: z.string().uuid().optional().nullable(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
})
@@ -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(),
@@ -48,14 +50,37 @@ 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();
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 +97,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: {
@@ -151,6 +197,29 @@ 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("allows local board users to update guarded dev-server auto-restart", async () => {
const app = await createApp({
type: "board",
+36
View File
@@ -2,6 +2,7 @@ import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
issueGraphLivenessAutoRecoveryRequestSchema,
patchInstanceSettingsSchema,
patchInstanceExperimentalSettingsSchema,
patchInstanceGeneralSettingsSchema,
} from "@paperclipai/shared";
@@ -25,6 +26,41 @@ export function instanceSettingsRoutes(db: Db) {
const svc = instanceSettingsService(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);
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",
+17
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";
@@ -129,6 +130,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);
+1 -1
View File
@@ -871,7 +871,7 @@ export function secretService(db: Db) {
type: "environment",
id: row.id,
label: row.name,
href: "/company/settings/environments",
href: "/company/settings/instance/environments",
status: row.status,
});
}
+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),
+59 -23
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,28 +934,35 @@ 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."
>
<select
className={inputClass}
value={currentDefaultEnvironmentId}
onChange={(event) => {
const nextValue = event.target.value;
if (isCreate) {
set!({ defaultEnvironmentId: nextValue });
return;
}
mark("identity", "defaultEnvironmentId", nextValue || null);
}}
>
<option value="">Company default (Local)</option>
{runnableEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environment.name} · {environment.driver}
</option>
))}
</select>
<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}
onChange={(event) => {
const nextValue = event.target.value;
if (isCreate) {
set!({ defaultEnvironmentId: nextValue });
return;
}
mark("identity", "defaultEnvironmentId", nextValue || null);
}}
>
<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">
+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();
@@ -169,11 +187,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,
@@ -198,6 +222,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) => {
@@ -230,6 +264,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),
onSuccess: (probe, environmentId) => {
@@ -306,6 +360,7 @@ export function CompanyEnvironments() {
sshPrivateKeySecretId: ssh.privateKeySecretId,
sshKnownHosts: ssh.knownHosts,
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
envVars: environment.envVars ?? {},
});
return;
}
@@ -319,6 +374,7 @@ export function CompanyEnvironments() {
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
envVars: environment.envVars ?? {},
});
return;
}
@@ -328,6 +384,7 @@ export function CompanyEnvironments() {
name: environment.name,
description: environment.description ?? "",
driver: "local",
envVars: environment.envVars ?? {},
});
}
@@ -396,8 +453,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) {
@@ -405,28 +471,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
@@ -485,10 +582,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 (
@@ -754,6 +851,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),