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, 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, }; } 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}