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 <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-07 12:02:27 -07:00 committed by GitHub
parent 5356b7d737
commit bdffd26ad3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 89 additions and 14 deletions

View File

@ -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<Environment[]>(`/companies/${companyId}/environments`),
capabilities: (companyId: string) =>
@ -69,14 +73,17 @@ export const environmentsApi = {
config?: Record<string, unknown>;
metadata?: Record<string, unknown> | null;
}) => api.post<EnvironmentProbeResult>(`/companies/${companyId}/environments/probe-config`, body),
customImageTemplate: (environmentId: string) =>
api.get<EnvironmentCustomImageOverview>(`/environments/${environmentId}/custom-image-template`),
customImageTemplate: (environmentId: string, companyId: string) =>
api.get<EnvironmentCustomImageOverview>(
`/environments/${environmentId}/custom-image-template?${customImageCompanyQuery(companyId)}`,
),
startCustomImageSetupSession: (
environmentId: string,
companyId: string,
body: StartEnvironmentCustomImageSetupSession = {},
) =>
api.post<EnvironmentCustomImageSetupSessionResult>(
`/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<EnvironmentCustomImageRollbackResult>(
`/environments/${environmentId}/custom-image-template/rollback`,
`/environments/${environmentId}/custom-image-template/rollback?${customImageCompanyQuery(companyId)}`,
{},
),
disableCustomImageTemplate: (
environmentId: string,
companyId: string,
options: { deleteProviderTemplate?: boolean } = {},
) =>
api.delete<EnvironmentCustomImageTemplate>(
`/environments/${environmentId}/custom-image-template?deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`,
`/environments/${environmentId}/custom-image-template?${customImageCompanyQuery(companyId)}&deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`,
),
};

View File

@ -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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
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");
});
});
});

View File

@ -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 ? (
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="text-sm font-medium">Custom image</div>
<div className="text-xs text-muted-foreground">
@ -1706,6 +1711,7 @@ export function CompanyEnvironments() {
</div>
<EnvironmentImageTemplatePanel
environment={editingEnvironment}
companyId={selectedCompanyId}
providerCapability={editingSandboxCapability}
providerDisplayName={editingSandboxDisplayName}
/>