Skip to content

Commit e4caf2a

Browse files
committed
feat(cloud): custom sandbox base images
1 parent 2ecc6b6 commit e4caf2a

25 files changed

Lines changed: 1538 additions & 24 deletions

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,9 @@ function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
183183
}),
184184
...(gateway?.openaiBaseUrl && { OPENAI_BASE_URL: gateway.openaiBaseUrl }),
185185
...(gateway?.openaiApiKey && { OPENAI_API_KEY: gateway.openaiApiKey }),
186-
ELECTRON_RUN_AS_NODE: "1",
186+
...((process.versions.electron || process.env.ELECTRON_RUN_AS_NODE) && {
187+
ELECTRON_RUN_AS_NODE: "1",
188+
}),
187189
CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true",
188190
// Offload all MCP tools by default
189191
ENABLE_TOOL_SEARCH: "auto:0",

packages/api-client/src/posthog-client.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import type {
5454
PriorityJudgmentArtefact,
5555
RepoSelectionArtefact,
5656
SafetyJudgmentArtefact,
57+
SandboxCustomImage,
5758
SandboxEnvironment,
5859
SandboxEnvironmentInput,
5960
Signal,
@@ -122,6 +123,13 @@ export class SeatPaymentFailedError extends Error {
122123
}
123124
}
124125

126+
export class SandboxCustomImagesDisabledError extends Error {
127+
constructor(message?: string) {
128+
super(message ?? "Custom sandbox images are not enabled");
129+
this.name = "SandboxCustomImagesDisabledError";
130+
}
131+
}
132+
125133
export type UsageLimitType = "burst" | "sustained" | null;
126134

127135
// Stable message so callers recognize this after a saga reduces the error to a string.
@@ -478,6 +486,7 @@ interface CloudRunOptions {
478486
model?: string;
479487
reasoningLevel?: string;
480488
sandboxEnvironmentId?: string;
489+
customImageId?: string;
481490
prAuthorshipMode?: PrAuthorshipMode;
482491
autoPublish?: boolean;
483492
runSource?: CloudRunSource;
@@ -550,6 +559,9 @@ function buildCloudRunRequestBody(
550559
if (options?.sandboxEnvironmentId) {
551560
body.sandbox_environment_id = options.sandboxEnvironmentId;
552561
}
562+
if (options?.customImageId) {
563+
body.custom_image_id = options.customImageId;
564+
}
553565
if (options?.prAuthorshipMode) {
554566
body.pr_authorship_mode = options.prAuthorshipMode;
555567
}
@@ -4463,6 +4475,155 @@ export class PostHogAPIClient {
44634475
}
44644476
}
44654477

4478+
async listSandboxCustomImages(): Promise<SandboxCustomImage[]> {
4479+
const teamId = await this.getTeamId();
4480+
const url = new URL(
4481+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`,
4482+
);
4483+
const response = await this.api.fetcher.fetch({
4484+
method: "get",
4485+
url,
4486+
path: `/api/projects/${teamId}/sandbox_custom_images/`,
4487+
});
4488+
if (!response.ok) {
4489+
if (response.status === 403) {
4490+
const errorData = (await response.json().catch(() => ({}))) as {
4491+
detail?: string;
4492+
};
4493+
throw new SandboxCustomImagesDisabledError(errorData.detail);
4494+
}
4495+
throw new Error(
4496+
`Failed to fetch sandbox custom images: ${response.statusText}`,
4497+
);
4498+
}
4499+
const data = (await response.json()) as {
4500+
results?: SandboxCustomImage[];
4501+
};
4502+
return data.results ?? [];
4503+
}
4504+
4505+
async createSandboxCustomImage(input: {
4506+
name: string;
4507+
description?: string;
4508+
repository?: string | null;
4509+
private?: boolean;
4510+
}): Promise<SandboxCustomImage> {
4511+
const teamId = await this.getTeamId();
4512+
const url = new URL(
4513+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`,
4514+
);
4515+
const response = await this.api.fetcher.fetch({
4516+
method: "post",
4517+
url,
4518+
path: `/api/projects/${teamId}/sandbox_custom_images/`,
4519+
overrides: {
4520+
body: JSON.stringify(input),
4521+
},
4522+
});
4523+
if (!response.ok) {
4524+
const errorData = (await response.json().catch(() => ({}))) as {
4525+
detail?: string;
4526+
};
4527+
throw new Error(
4528+
errorData.detail ??
4529+
`Failed to create sandbox custom image: ${response.statusText}`,
4530+
);
4531+
}
4532+
return (await response.json()) as SandboxCustomImage;
4533+
}
4534+
4535+
async getSandboxCustomImage(id: string): Promise<SandboxCustomImage> {
4536+
const teamId = await this.getTeamId();
4537+
const url = new URL(
4538+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4539+
);
4540+
const response = await this.api.fetcher.fetch({
4541+
method: "get",
4542+
url,
4543+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4544+
});
4545+
if (!response.ok) {
4546+
throw new Error(
4547+
`Failed to fetch sandbox custom image: ${response.statusText}`,
4548+
);
4549+
}
4550+
return (await response.json()) as SandboxCustomImage;
4551+
}
4552+
4553+
async ensureSandboxCustomImageBuilderTask(
4554+
id: string,
4555+
): Promise<SandboxCustomImage> {
4556+
const teamId = await this.getTeamId();
4557+
const url = new URL(
4558+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`,
4559+
);
4560+
const response = await this.api.fetcher.fetch({
4561+
method: "post",
4562+
url,
4563+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`,
4564+
overrides: {
4565+
body: JSON.stringify({}),
4566+
},
4567+
});
4568+
if (!response.ok) {
4569+
const errorData = (await response.json().catch(() => ({}))) as {
4570+
detail?: string;
4571+
};
4572+
throw new Error(
4573+
errorData.detail ??
4574+
`Failed to open image builder session: ${response.statusText}`,
4575+
);
4576+
}
4577+
return (await response.json()) as SandboxCustomImage;
4578+
}
4579+
4580+
async buildSandboxCustomImage(
4581+
id: string,
4582+
specYaml?: string | null,
4583+
): Promise<SandboxCustomImage> {
4584+
const teamId = await this.getTeamId();
4585+
const url = new URL(
4586+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/build/`,
4587+
);
4588+
const response = await this.api.fetcher.fetch({
4589+
method: "post",
4590+
url,
4591+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/build/`,
4592+
overrides: {
4593+
body: JSON.stringify(
4594+
specYaml === undefined ? {} : { spec_yaml: specYaml },
4595+
),
4596+
},
4597+
});
4598+
if (!response.ok) {
4599+
const errorData = (await response.json().catch(() => ({}))) as {
4600+
detail?: string;
4601+
};
4602+
throw new Error(
4603+
errorData.detail ??
4604+
`Failed to build sandbox custom image: ${response.statusText}`,
4605+
);
4606+
}
4607+
return (await response.json()) as SandboxCustomImage;
4608+
}
4609+
4610+
async deleteSandboxCustomImage(id: string): Promise<void> {
4611+
const teamId = await this.getTeamId();
4612+
const url = new URL(
4613+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4614+
);
4615+
const response = await this.api.fetcher.fetch({
4616+
method: "delete",
4617+
url,
4618+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4619+
});
4620+
if (!response.ok) {
4621+
throw new Error(
4622+
`Failed to delete sandbox custom image: ${response.statusText}`,
4623+
);
4624+
}
4625+
}
4626+
44664627
/** Find an exported asset by session recording ID. */
44674628
async findExportBySessionRecordingId(
44684629
projectId: number,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
PostHogAPIClient,
4+
SandboxCustomImagesDisabledError,
5+
} from "./posthog-client";
6+
7+
function makeClient(fetch: ReturnType<typeof vi.fn>): PostHogAPIClient {
8+
const client = new PostHogAPIClient(
9+
"http://localhost:8000",
10+
async () => "token",
11+
async () => "token",
12+
123,
13+
);
14+
(
15+
client as unknown as {
16+
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
17+
}
18+
).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } };
19+
return client;
20+
}
21+
22+
describe("PostHogAPIClient sandbox custom images", () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
27+
it("maps a 403 on list to SandboxCustomImagesDisabledError", async () => {
28+
const fetch = vi.fn().mockResolvedValue({
29+
ok: false,
30+
status: 403,
31+
statusText: "Forbidden",
32+
json: async () => ({ detail: "not enabled" }),
33+
});
34+
await expect(
35+
makeClient(fetch).listSandboxCustomImages(),
36+
).rejects.toBeInstanceOf(SandboxCustomImagesDisabledError);
37+
});
38+
39+
it("does not map a non-403 list error to the disabled error", async () => {
40+
const fetch = vi.fn().mockResolvedValue({
41+
ok: false,
42+
status: 500,
43+
statusText: "Server Error",
44+
json: async () => ({ detail: "boom" }),
45+
});
46+
const promise = makeClient(fetch).listSandboxCustomImages();
47+
await expect(promise).rejects.toThrow();
48+
await expect(promise).rejects.not.toBeInstanceOf(
49+
SandboxCustomImagesDisabledError,
50+
);
51+
});
52+
53+
it.each([
54+
["undefined spec", undefined, {}],
55+
["null spec", null, { spec_yaml: null }],
56+
["string spec", "version: 1", { spec_yaml: "version: 1" }],
57+
])("builds with %s body", async (_name, specYaml, expectedBody) => {
58+
const fetch = vi.fn().mockResolvedValue({
59+
ok: true,
60+
status: 200,
61+
json: async () => ({ id: "im-1" }),
62+
});
63+
64+
await makeClient(fetch).buildSandboxCustomImage("im-1", specYaml);
65+
66+
expect(fetch).toHaveBeenCalledWith(
67+
expect.objectContaining({
68+
overrides: { body: JSON.stringify(expectedBody) },
69+
}),
70+
);
71+
});
72+
});

packages/core/src/settings/sandboxEnvironmentForm.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface SandboxEnvironmentFormState {
1515
include_default_domains: boolean;
1616
environment_variables_text: string;
1717
private: boolean;
18+
custom_image_id: string | null;
1819
}
1920

2021
export function isValidDomain(domain: string): boolean {
@@ -71,6 +72,7 @@ export function emptyForm(): SandboxEnvironmentFormState {
7172
include_default_domains: true,
7273
environment_variables_text: "",
7374
private: true,
75+
custom_image_id: null,
7476
};
7577
}
7678

@@ -84,6 +86,7 @@ export function formFromEnv(
8486
include_default_domains: env.include_default_domains,
8587
environment_variables_text: "",
8688
private: env.private,
89+
custom_image_id: env.custom_image_id ?? null,
8790
};
8891
}
8992

@@ -100,6 +103,7 @@ export function buildSandboxEnvironmentInput(
100103
include_default_domains: isCustom ? form.include_default_domains : false,
101104
private: form.private,
102105
repositories: [],
106+
custom_image_id: form.custom_image_id,
103107
...(form.environment_variables_text.trim()
104108
? { environment_variables: envVars }
105109
: {}),

packages/core/src/sidebar/groupTasks.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,37 @@ describe("groupByRepository", () => {
220220
expect(groups[0]?.name).toBe("Other");
221221
});
222222

223+
it("routes image-builder tasks to a pinned 'Custom images' group above 'Other'", () => {
224+
const tasks: TestTask[] = [
225+
{ id: "t1", repository: null, originProduct: "image_builder" },
226+
{
227+
id: "t2",
228+
repository: {
229+
fullPath: "posthog/code",
230+
name: "code",
231+
organization: "PostHog",
232+
},
233+
originProduct: "image_builder",
234+
},
235+
task("t3"),
236+
task("t4", {
237+
fullPath: "posthog/posthog",
238+
name: "posthog",
239+
organization: "PostHog",
240+
}),
241+
];
242+
243+
const groups = groupByRepository(tasks, []);
244+
245+
expect(groups.map((g) => g.id)).toEqual([
246+
"posthog/posthog",
247+
"custom-images",
248+
"other",
249+
]);
250+
expect(groups[1]?.name).toBe("Custom images");
251+
expect(groups[1]?.tasks.map((t) => t.id)).toEqual(["t1", "t2"]);
252+
});
253+
223254
it("keeps the bare name for a group without an organization when others collide", () => {
224255
const tasks: TestTask[] = [
225256
task("t1", {

0 commit comments

Comments
 (0)