Skip to content
24 changes: 24 additions & 0 deletions packages/agent/src/adapters/codex-app-server/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion packages/agent/src/adapters/codex-app-server/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function tomlInlineTable(entries: Record<string, string>): string {

export function buildAppServerArgs(
options: CodexAppServerProcessOptions,
environment: NodeJS.ProcessEnv = process.env,
): string[] {
const args: string[] = ["app-server"];

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4697,6 +4697,34 @@ export class PostHogAPIClient {
return (await response.json()) as SandboxCustomImage;
}

async updateSandboxCustomImage(
id: string,
input: { name?: string; description?: string },
): Promise<SandboxCustomImage> {
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<SandboxCustomImage> {
Expand Down
35 changes: 35 additions & 0 deletions packages/api-client/src/sandbox-custom-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export function CloudEnvironmentsSettings() {
buildMutation,
builderTaskMutation,
deleteMutation: deleteImageMutation,
updateMutation: updateImageMutation,
} = useSandboxCustomImages();
const handleOpenTask = useHandleOpenTask();
const repoPickerProps = useCloudRepoPicker();
Expand All @@ -313,6 +314,10 @@ export function CloudEnvironmentsSettings() {
const [specDraft, setSpecDraft] = useState("");
const [deleteConfirmImage, setDeleteConfirmImage] =
useState<SandboxCustomImage | null>(null);
const [renameImage, setRenameImage] = useState<SandboxCustomImage | null>(
null,
);
const [renameName, setRenameName] = useState("");
const [viewingLogImageId, setViewingLogImageId] = useState<string | null>(
null,
);
Expand Down Expand Up @@ -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 (
<Flex direction="column" gap="4">
Expand Down Expand Up @@ -1000,6 +1029,18 @@ export function CloudEnvironmentsSettings() {
>
Save & build
</Button>
<Button
size="1"
variant="ghost"
color="gray"
onClick={() => openRenameImage(image)}
disabled={
deleteImageMutation.isPending ||
updateImageMutation.isPending
}
>
<PencilSimple size={14} />
</Button>
<Button
size="1"
variant="ghost"
Expand Down Expand Up @@ -1090,6 +1131,59 @@ export function CloudEnvironmentsSettings() {
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root
open={renameImage !== null}
onOpenChange={(open) => {
if (!open) setRenameImage(null);
}}
>
<AlertDialog.Content maxWidth="420px" size="1">
<AlertDialog.Title className="text-sm">
Rename custom image
</AlertDialog.Title>
<AlertDialog.Description>
<Text color="gray" className="text-[13px]">
Updates the display name. The build spec and status are
unchanged.
</Text>
</AlertDialog.Description>
<Flex direction="column" gap="1" mt="3">
<Text className="font-medium text-[13px]">Name</Text>
<TextField.Root
size="2"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder="Image name"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !updateImageMutation.isPending) {
e.preventDefault();
void handleRenameImage();
}
}}
/>
</Flex>
<Flex justify="end" gap="3" mt="3">
<AlertDialog.Cancel>
<Button variant="soft" color="gray" size="1">
Cancel
</Button>
</AlertDialog.Cancel>
<Button
variant="solid"
size="1"
onClick={() => void handleRenameImage()}
loading={updateImageMutation.isPending}
disabled={
!renameName.trim() ||
renameName.trim() === renameImage?.name
}
>
Save
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
</>
)}
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -152,5 +183,6 @@ export function useSandboxCustomImages() {
buildMutation,
builderTaskMutation,
deleteMutation,
updateMutation,
};
}
25 changes: 0 additions & 25 deletions packages/ui/src/features/sidebar/components/TaskListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand All @@ -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}
Expand Down
Loading
Loading