From 9e056151a2a2285fb391efda7bb5e0c4e33fc3fa Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 21 Jul 2026 13:19:13 +0100 Subject: [PATCH 1/3] feat(cloud): rename custom sandbox env images Add a rename action to the custom sandbox image list so users can update an image's display name without deleting and rebuilding it. Wired through a new updateSandboxCustomImage API client method that PATCHes the existing custom-image detail endpoint, plus an updateMutation hook and a rename AlertDialog mirroring the existing delete confirmation flow. The rename handler swallows the mutation rejection (onError shows the toast), the Enter key respects the pending guard, and the update mutation invalidates the detail cache rather than overwriting it with the PATCH response, so a partial response cannot drop cached fields. --- packages/api-client/src/posthog-client.ts | 28 ++++++ .../src/sandbox-custom-images.test.ts | 35 +++++++ .../CloudEnvironmentsSettings.tsx | 94 +++++++++++++++++++ .../environments/useSandboxCustomImages.ts | 32 +++++++ 4 files changed, 189 insertions(+) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 29c687a4fb..bc33a43596 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4697,6 +4697,34 @@ export class PostHogAPIClient { return (await response.json()) as SandboxCustomImage; } + async updateSandboxCustomImage( + id: string, + input: { name?: string; description?: string }, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`, + overrides: { + body: JSON.stringify(input), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to update sandbox custom image: ${response.statusText}`, + ); + } + return (await response.json()) as SandboxCustomImage; + } + async ensureSandboxCustomImageBuilderTask( id: string, ): Promise { diff --git a/packages/api-client/src/sandbox-custom-images.test.ts b/packages/api-client/src/sandbox-custom-images.test.ts index 22c97e771b..269bec2716 100644 --- a/packages/api-client/src/sandbox-custom-images.test.ts +++ b/packages/api-client/src/sandbox-custom-images.test.ts @@ -69,4 +69,39 @@ describe("PostHogAPIClient sandbox custom images", () => { }), ); }); + + it.each([ + ["name only", { name: "renamed" }], + ["description only", { description: "updated" }], + ["both", { name: "renamed", description: "updated" }], + ])("patches %s via updateSandboxCustomImage", async (_name, input) => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: "im-1", ...input }), + }); + + await makeClient(fetch).updateSandboxCustomImage("im-1", input); + + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "patch", + path: `/api/projects/123/sandbox_custom_images/im-1/`, + overrides: { body: JSON.stringify(input) }, + }), + ); + }); + + it("throws the backend detail message when update fails", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + json: async () => ({ detail: "Name cannot be blank." }), + }); + + await expect( + makeClient(fetch).updateSandboxCustomImage("im-1", { name: " " }), + ).rejects.toThrow("Name cannot be blank."); + }); }); diff --git a/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx b/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx index 3ed5b5c390..2252998d4c 100644 --- a/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx +++ b/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx @@ -296,6 +296,7 @@ export function CloudEnvironmentsSettings() { buildMutation, builderTaskMutation, deleteMutation: deleteImageMutation, + updateMutation: updateImageMutation, } = useSandboxCustomImages(); const handleOpenTask = useHandleOpenTask(); const repoPickerProps = useCloudRepoPicker(); @@ -313,6 +314,10 @@ export function CloudEnvironmentsSettings() { const [specDraft, setSpecDraft] = useState(""); const [deleteConfirmImage, setDeleteConfirmImage] = useState(null); + const [renameImage, setRenameImage] = useState( + null, + ); + const [renameName, setRenameName] = useState(""); const [viewingLogImageId, setViewingLogImageId] = useState( null, ); @@ -456,6 +461,30 @@ export function CloudEnvironmentsSettings() { setDeleteConfirmImage(null); }, [deleteConfirmImage, deleteImageMutation]); + const openRenameImage = useCallback((image: SandboxCustomImage) => { + setRenameImage(image); + setRenameName(image.name); + }, []); + + const handleRenameImage = useCallback(async () => { + if (!renameImage) return; + const name = renameName.trim(); + if (!name) { + toast.error("Name cannot be blank"); + return; + } + if (name === renameImage.name) { + setRenameImage(null); + return; + } + try { + await updateImageMutation.mutateAsync({ id: renameImage.id, name }); + setRenameImage(null); + } catch { + // The mutation's onError callback displays the failure toast. + } + }, [renameImage, renameName, updateImageMutation]); + if (isFormOpen) { return ( @@ -1000,6 +1029,18 @@ export function CloudEnvironmentsSettings() { > Save & build + + + + + + )} diff --git a/packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts b/packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts index c780e55b8d..aa17d0d308 100644 --- a/packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts +++ b/packages/ui/src/features/settings/sections/environments/useSandboxCustomImages.ts @@ -143,6 +143,37 @@ export function useSandboxCustomImages() { }, ); + const updateMutation = useAuthenticatedMutation( + ( + client, + { + id, + ...input + }: { id: string } & { + name?: string; + description?: string; + }, + ) => client.updateSandboxCustomImage(id, input), + { + onSuccess: (image) => { + toast.success("Custom image updated"); + // Invalidate rather than setQueryData: the PATCH response may not carry + // every field a detail view depends on, so force a refetch to repopulate + // the full image instead of overwriting the cache with a partial object. + queryClient.invalidateQueries({ + queryKey: sandboxCustomImageKeys.detail(image.id), + }); + queryClient.invalidateQueries({ + queryKey: sandboxCustomImageKeys.list, + }); + queryClient.invalidateQueries({ queryKey: sandboxEnvKeys.list }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update custom image"); + }, + }, + ); + return { images: images ?? [], isLoading, @@ -152,5 +183,6 @@ export function useSandboxCustomImages() { buildMutation, builderTaskMutation, deleteMutation, + updateMutation, }; } From 3bfedf627c3e073a7b4dbb5d385d1dffe86566bc Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 21 Jul 2026 13:43:36 +0100 Subject: [PATCH 2/3] fix(agent): keep cloud credentials in secondary checkouts --- .../adapters/codex-app-server/spawn.test.ts | 24 +++++++++++++++++++ .../src/adapters/codex-app-server/spawn.ts | 13 +++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/adapters/codex-app-server/spawn.test.ts b/packages/agent/src/adapters/codex-app-server/spawn.test.ts index 9e9f23f7f2..6794bef2dd 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.test.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.test.ts @@ -126,6 +126,30 @@ describe("buildAppServerArgs", () => { expect(args).toContain('model_verbosity="low"'); }); + it("pins the cloud BASH_ENV into tool shells for secondary checkouts", () => { + const args = buildAppServerArgs( + { binaryPath: "/bundle/codex" }, + { IS_SANDBOX: "1", BASH_ENV: "/tmp/agentsh-bash-env.sh" }, + ); + + expect(args).toContain( + 'shell_environment_policy.set.BASH_ENV="/tmp/agentsh-bash-env.sh"', + ); + }); + + it("does not override BASH_ENV outside a managed sandbox", () => { + const args = buildAppServerArgs( + { binaryPath: "/bundle/codex" }, + { BASH_ENV: "/Users/example/.bash-env" }, + ); + + expect( + args.some((arg) => + arg.startsWith("shell_environment_policy.set.BASH_ENV="), + ), + ).toBe(false); + }); + it("does not set instructions at spawn (developer_instructions are per-thread)", () => { const args = buildAppServerArgs({ binaryPath: "/bundle/codex", diff --git a/packages/agent/src/adapters/codex-app-server/spawn.ts b/packages/agent/src/adapters/codex-app-server/spawn.ts index 6d25686200..81deb7a4aa 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.ts @@ -91,6 +91,7 @@ function tomlInlineTable(entries: Record): string { export function buildAppServerArgs( options: CodexAppServerProcessOptions, + environment: NodeJS.ProcessEnv = process.env, ): string[] { const args: string[] = ["app-server"]; @@ -130,6 +131,16 @@ export function buildAppServerArgs( // still override it via configOverrides, which the trailing loop appends last. args.push("-c", `approvals_reviewer="user"`); + // Codex snapshots shell state only for the thread's initial cwd. Cloud tasks + // can work in additional checkouts, so pin the backend-controlled BASH_ENV + // path into every tool shell instead of relying on snapshot restoration. + if (environment.IS_SANDBOX && environment.BASH_ENV) { + args.push( + "-c", + `shell_environment_policy.set.BASH_ENV=${tomlBasicString(environment.BASH_ENV)}`, + ); + } + // Disable the user's ambient ~/.codex MCP servers so the adapter only exposes // MCP servers PostHog injects per-thread; otherwise codex fails connecting to them. for (const name of new CodexSettingsManager( @@ -199,7 +210,7 @@ export function spawnCodexAppServerProcess( } env.PATH = `${dirname(options.binaryPath)}${delimiter}${env.PATH ?? ""}`; - const args = buildAppServerArgs(options); + const args = buildAppServerArgs(options, env); logger.info("Spawning codex app-server process", { command: options.binaryPath, From dd1e715356e2deb0ad78b61275ebe6dd9075503f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 16:31:04 +0300 Subject: [PATCH 3/3] fix(sidebar): remove worktree name chip from task items The per-task worktree/branch name chip took up sidebar space without earning it. Remove the WorktreeChip and its data wiring. Generated-By: PostHog Code Task-Id: b4f4207f-df3f-4db7-b815-b5194fc8efe3 --- .../sidebar/components/TaskListView.tsx | 25 --------------- .../sidebar/components/items/TaskItem.tsx | 31 ++----------------- 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/TaskListView.tsx b/packages/ui/src/features/sidebar/components/TaskListView.tsx index 6124cf8115..f21d0b11fc 100644 --- a/packages/ui/src/features/sidebar/components/TaskListView.tsx +++ b/packages/ui/src/features/sidebar/components/TaskListView.tsx @@ -12,7 +12,6 @@ import type { TaskGroup, } from "@posthog/core/sidebar/sidebarData.types"; import { MenuLabel } from "@posthog/quill"; -import { getFileName } from "@posthog/shared"; import { builderHog } from "@posthog/ui/assets/hedgehogs"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; @@ -92,32 +91,10 @@ function TaskRow({ depth?: number; }) { const workspace = useWorkspace(task.id); - const { folders } = useFolders(); const effectiveMode = workspace?.mode ?? (task.taskRunEnvironment === "cloud" ? "cloud" : undefined); - // Chip identifying the checkout the task runs in: app-managed worktrees - // (worktree mode) and local tasks whose registered folder is itself a - // linked worktree of the group's main clone. - const worktreeCheckout = useMemo(() => { - if (workspace?.worktreePath) { - return { - name: workspace.worktreeName ?? getFileName(workspace.worktreePath), - path: workspace.worktreePath, - }; - } - if (workspace?.mode === "local" && workspace.folderPath) { - const folder = folders.find((f) => f.path === workspace.folderPath); - if (folder?.mainRepoPath) { - return { - name: getFileName(workspace.folderPath), - path: workspace.folderPath, - }; - } - } - return null; - }, [workspace, folders]); const { prState, hasDiff } = useTaskPrStatus(task); const isArchiving = useArchivingTasksStore((s) => s.archivingTaskIds.has(task.id), @@ -134,8 +111,6 @@ function TaskRow({ hideHoverActions={hideHoverActions} isEditing={isEditing} workspaceMode={effectiveMode} - worktreeName={worktreeCheckout?.name} - worktreePath={worktreeCheckout?.path} isSuspended={task.isSuspended} isGenerating={task.isGenerating} isUnread={task.isUnread} diff --git a/packages/ui/src/features/sidebar/components/items/TaskItem.tsx b/packages/ui/src/features/sidebar/components/items/TaskItem.tsx index 1d71be612a..3d68b86474 100644 --- a/packages/ui/src/features/sidebar/components/items/TaskItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/TaskItem.tsx @@ -1,9 +1,4 @@ -import { - Archive, - GitFork, - GitPullRequest, - PushPin, -} from "@phosphor-icons/react"; +import { Archive, GitPullRequest, PushPin } from "@phosphor-icons/react"; import { parseGithubUrl } from "@posthog/git/utils"; import type { WorkspaceMode } from "@posthog/shared"; import { formatRelativeTimeShort } from "@posthog/shared"; @@ -34,17 +29,6 @@ function PrBadge({ url, number }: { url: string; number: number }) { ); } -function WorktreeChip({ name, path }: { name: string; path?: string }) { - return ( - - - - {name} - - - ); -} - interface TaskItemProps { depth?: number; taskId: string; @@ -55,10 +39,6 @@ interface TaskItemProps { isArchiving?: boolean; hideHoverActions?: boolean; workspaceMode?: WorkspaceMode; - /** Checkout name shown as a chip when the task runs in a git worktree. */ - worktreeName?: string; - /** Full path of that checkout, surfaced in the chip's tooltip. */ - worktreePath?: string; isGenerating?: boolean; isUnread?: boolean; isPinned?: boolean; @@ -131,8 +111,6 @@ export function TaskItem({ isArchiving = false, hideHoverActions = false, workspaceMode, - worktreeName, - worktreePath, isSuspended = false, isGenerating, isUnread, @@ -195,14 +173,9 @@ export function TaskItem({ /> ) : null; - const worktreeChip = worktreeName ? ( - - ) : null; - const endContent = - worktreeChip || prBadge || timestampNode || toolbar ? ( + prBadge || timestampNode || toolbar ? ( <> - {worktreeChip} {prBadge} {timestampNode} {toolbar}