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
+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">
+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),