From bdffd26ad3d19304f66985578e36d9af91d126d2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 7 Jul 2026 12:02:27 -0700 Subject: [PATCH] fix(ui): pass company context to custom image setup (#9028) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environment settings let board users configure sandbox execution targets for agent runs > - Sandbox custom-image setup is company-scoped because the backend resolves provider secrets and access through company context > - The browser UI already knows the selected company, but the custom-image setup calls were not passing it to routes whose OpenAPI contract includes `companyId` > - In multi-company authenticated deployments, the backend cannot safely infer company context and returns a `companyId query parameter is required` error > - This pull request passes the selected company id through the custom-image overview, setup, rollback, and disable UI paths > - The benefit is that custom-image setup works consistently in multi-company instances while preserving backend company-boundary checks ## Linked Issues or Issue Description - No public issue is filed for this regression. - Related prior work: #8911 - Bug summary: after the browser SSH terminal custom-image setup flow shipped, opening custom-image setup in an authenticated multi-company instance could show `companyId query parameter is required for environment customImage setup` instead of starting the setup session. - Expected behavior: the environment settings UI should send the selected company context to company-scoped custom-image endpoints. - Reproduction shape: run Paperclip in authenticated/private mode with more than one company, open a sandbox environment's edit dialog, and use the custom-image setup controls. ## What Changed - Added company-id query construction for custom-image overview, setup, rollback, and disable calls in the UI environment API wrapper. - Passed the selected company id into the custom-image panel used by the environment edit dialog. - Updated the environment page tests so the regression fails if company context is dropped again. ## Verification - `pnpm exec vitest run ui/src/pages/CompanyEnvironments.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - `git diff --check` - Also applied the same patch to a local dev checkout serving port 3100 and confirmed `/api/health` still returns `ok` after the dev watcher reload. ## Risks - Low risk: this only adds the selected company id to UI calls for endpoints that already declare or require company context. - If the selected company id is stale or invalid, the existing server-side company access checks still reject the request. ## Model Used - OpenAI Codex CLI using GPT-5, with tool-enabled repository inspection, editing, shell command execution, and local test execution. Context window size is not exposed in this environment. ## 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 - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/api/environments.ts | 20 ++++--- ui/src/pages/CompanyEnvironments.test.tsx | 63 ++++++++++++++++++++++- ui/src/pages/CompanyEnvironments.tsx | 20 ++++--- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index 4740427eb4..ab3cf09aaa 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -41,6 +41,10 @@ export interface EnvironmentCustomImageRollbackResult { supersededTemplate: EnvironmentCustomImageTemplate; } +function customImageCompanyQuery(companyId: string): string { + return `companyId=${encodeURIComponent(companyId)}`; +} + export const environmentsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/environments`), capabilities: (companyId: string) => @@ -69,14 +73,17 @@ export const environmentsApi = { config?: Record; metadata?: Record | null; }) => api.post(`/companies/${companyId}/environments/probe-config`, body), - customImageTemplate: (environmentId: string) => - api.get(`/environments/${environmentId}/custom-image-template`), + customImageTemplate: (environmentId: string, companyId: string) => + api.get( + `/environments/${environmentId}/custom-image-template?${customImageCompanyQuery(companyId)}`, + ), startCustomImageSetupSession: ( environmentId: string, + companyId: string, body: StartEnvironmentCustomImageSetupSession = {}, ) => api.post( - `/environments/${environmentId}/custom-image-setup-sessions`, + `/environments/${environmentId}/custom-image-setup-sessions?${customImageCompanyQuery(companyId)}`, body, ), customImageSetupSession: (sessionId: string) => @@ -107,16 +114,17 @@ export const environmentsApi = { `/environment-custom-image-setup-sessions/${sessionId}/cancel`, body, ), - rollbackCustomImageTemplate: (environmentId: string) => + rollbackCustomImageTemplate: (environmentId: string, companyId: string) => api.post( - `/environments/${environmentId}/custom-image-template/rollback`, + `/environments/${environmentId}/custom-image-template/rollback?${customImageCompanyQuery(companyId)}`, {}, ), disableCustomImageTemplate: ( environmentId: string, + companyId: string, options: { deleteProviderTemplate?: boolean } = {}, ) => api.delete( - `/environments/${environmentId}/custom-image-template?deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`, + `/environments/${environmentId}/custom-image-template?${customImageCompanyQuery(companyId)}&deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`, ), }; diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 7003642314..c787bea861 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -673,7 +673,7 @@ describe("CompanyEnvironments — test provider button", () => { await waitForAssertion(() => { expect(getOpenDialog()?.textContent).toContain("Configure image"); }); - expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1"); + expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); await act(async () => click(findButton(document.body, "Cancel"))); await waitForAssertion(() => expect(getOpenDialog()).toBeNull()); @@ -1037,6 +1037,7 @@ describe("CompanyEnvironments — test provider button", () => { expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith( "env-1", + "company-1", { templateId: "template-active" }, ); await waitForAssertion(() => { @@ -1046,4 +1047,64 @@ describe("CompanyEnvironments — test provider button", () => { expect(FakeWebSocket.instances).toHaveLength(1); }); }); + + it("passes company context when rolling back and disabling an active template", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue({ + adapters: [], + drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" }, + sandboxProviders: { + daytona: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + supportsTemplateDelete: true, + displayName: "Daytona", + }, + }, + }); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-active" }), + activeSession: null, + latestSession: null, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getOpenDialog(); + expect(dialog?.textContent).toContain("Active template"); + expect(findButton(dialog!, "Rollback")).toBeTruthy(); + expect(findButton(dialog!, "Disable")).toBeTruthy(); + }); + + await act(async () => click(findButton(getOpenDialog()!, "Rollback"))); + await waitForAssertion(() => { + expect(mockEnvironmentsApi.rollbackCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + }); + + await act(async () => click(findButton(getOpenDialog()!, "Disable"))); + await waitForAssertion(() => { + expect(mockEnvironmentsApi.disableCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + }); + }); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index c19881491b..444db4358c 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -679,10 +679,12 @@ function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"]) function EnvironmentImageTemplatePanel({ environment, + companyId, providerCapability, providerDisplayName, }: { environment: Environment; + companyId: string; providerCapability: EnvironmentProviderCapability | null | undefined; providerDisplayName: string; }) { @@ -693,7 +695,7 @@ function EnvironmentImageTemplatePanel({ const overviewQuery = useQuery({ queryKey: overviewKey, - queryFn: () => environmentsApi.customImageTemplate(environment.id), + queryFn: () => environmentsApi.customImageTemplate(environment.id, companyId), enabled: state.kind === "supported", retry: false, }); @@ -721,9 +723,11 @@ function EnvironmentImageTemplatePanel({ const startSetupMutation = useMutation({ mutationFn: (input: { templateId?: string | null } = {}) => - environmentsApi.startCustomImageSetupSession(environment.id, { - templateId: input.templateId ?? null, - }), + environmentsApi.startCustomImageSetupSession( + environment.id, + companyId, + { templateId: input.templateId ?? null }, + ), onSuccess: (result) => { queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ activeTemplate: current?.activeTemplate ?? null, @@ -798,7 +802,7 @@ function EnvironmentImageTemplatePanel({ }); const rollbackTemplateMutation = useMutation({ - mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id), + mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id, companyId), onSuccess: (result) => { queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ activeTemplate: result.activeTemplate, @@ -822,7 +826,7 @@ function EnvironmentImageTemplatePanel({ }); const disableTemplateMutation = useMutation({ - mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id), + mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId), onSuccess: (template) => { queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ activeTemplate: null, @@ -1697,7 +1701,8 @@ export function CompanyEnvironments() { {editingEnvironment && editingEnvironment.driver === "sandbox" && - environmentForm.driver === "sandbox" ? ( + environmentForm.driver === "sandbox" && + selectedCompanyId ? (
Custom image
@@ -1706,6 +1711,7 @@ export function CompanyEnvironments() {