Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
};
}
Loading