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:
parent
5356b7d737
commit
bdffd26ad3
|
|
@ -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"}`,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue