Skip to content

Commit bdd7ed4

Browse files
committed
feat(cloud): custom sandbox base images
1 parent 5cbbc21 commit bdd7ed4

21 files changed

Lines changed: 1338 additions & 20 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,9 @@ function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
182182
}),
183183
...(gateway?.openaiBaseUrl && { OPENAI_BASE_URL: gateway.openaiBaseUrl }),
184184
...(gateway?.openaiApiKey && { OPENAI_API_KEY: gateway.openaiApiKey }),
185-
ELECTRON_RUN_AS_NODE: "1",
185+
...((process.versions.electron || process.env.ELECTRON_RUN_AS_NODE) && {
186+
ELECTRON_RUN_AS_NODE: "1",
187+
}),
186188
CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true",
187189
// Offload all MCP tools by default
188190
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
@@ -52,6 +52,7 @@ import type {
5252
PriorityJudgmentArtefact,
5353
RepoSelectionArtefact,
5454
SafetyJudgmentArtefact,
55+
SandboxCustomImage,
5556
SandboxEnvironment,
5657
SandboxEnvironmentInput,
5758
Signal,
@@ -117,6 +118,13 @@ export class SeatPaymentFailedError extends Error {
117118
}
118119
}
119120

121+
export class SandboxCustomImagesDisabledError extends Error {
122+
constructor(message?: string) {
123+
super(message ?? "Custom sandbox images are not enabled");
124+
this.name = "SandboxCustomImagesDisabledError";
125+
}
126+
}
127+
120128
export type UsageLimitType = "burst" | "sustained" | null;
121129

122130
// Stable message so callers recognize this after a saga reduces the error to a string.
@@ -475,6 +483,7 @@ interface CloudRunOptions {
475483
model?: string;
476484
reasoningLevel?: string;
477485
sandboxEnvironmentId?: string;
486+
customImageId?: string;
478487
prAuthorshipMode?: PrAuthorshipMode;
479488
runSource?: CloudRunSource;
480489
signalReportId?: string;
@@ -546,6 +555,9 @@ function buildCloudRunRequestBody(
546555
if (options?.sandboxEnvironmentId) {
547556
body.sandbox_environment_id = options.sandboxEnvironmentId;
548557
}
558+
if (options?.customImageId) {
559+
body.custom_image_id = options.customImageId;
560+
}
549561
if (options?.prAuthorshipMode) {
550562
body.pr_authorship_mode = options.prAuthorshipMode;
551563
}
@@ -4279,6 +4291,155 @@ export class PostHogAPIClient {
42794291
}
42804292
}
42814293

4294+
async listSandboxCustomImages(): Promise<SandboxCustomImage[]> {
4295+
const teamId = await this.getTeamId();
4296+
const url = new URL(
4297+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`,
4298+
);
4299+
const response = await this.api.fetcher.fetch({
4300+
method: "get",
4301+
url,
4302+
path: `/api/projects/${teamId}/sandbox_custom_images/`,
4303+
});
4304+
if (!response.ok) {
4305+
if (response.status === 403) {
4306+
const errorData = (await response.json().catch(() => ({}))) as {
4307+
detail?: string;
4308+
};
4309+
throw new SandboxCustomImagesDisabledError(errorData.detail);
4310+
}
4311+
throw new Error(
4312+
`Failed to fetch sandbox custom images: ${response.statusText}`,
4313+
);
4314+
}
4315+
const data = (await response.json()) as {
4316+
results?: SandboxCustomImage[];
4317+
};
4318+
return data.results ?? [];
4319+
}
4320+
4321+
async createSandboxCustomImage(input: {
4322+
name: string;
4323+
description?: string;
4324+
repository?: string | null;
4325+
private?: boolean;
4326+
}): Promise<SandboxCustomImage> {
4327+
const teamId = await this.getTeamId();
4328+
const url = new URL(
4329+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`,
4330+
);
4331+
const response = await this.api.fetcher.fetch({
4332+
method: "post",
4333+
url,
4334+
path: `/api/projects/${teamId}/sandbox_custom_images/`,
4335+
overrides: {
4336+
body: JSON.stringify(input),
4337+
},
4338+
});
4339+
if (!response.ok) {
4340+
const errorData = (await response.json().catch(() => ({}))) as {
4341+
detail?: string;
4342+
};
4343+
throw new Error(
4344+
errorData.detail ??
4345+
`Failed to create sandbox custom image: ${response.statusText}`,
4346+
);
4347+
}
4348+
return (await response.json()) as SandboxCustomImage;
4349+
}
4350+
4351+
async getSandboxCustomImage(id: string): Promise<SandboxCustomImage> {
4352+
const teamId = await this.getTeamId();
4353+
const url = new URL(
4354+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4355+
);
4356+
const response = await this.api.fetcher.fetch({
4357+
method: "get",
4358+
url,
4359+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4360+
});
4361+
if (!response.ok) {
4362+
throw new Error(
4363+
`Failed to fetch sandbox custom image: ${response.statusText}`,
4364+
);
4365+
}
4366+
return (await response.json()) as SandboxCustomImage;
4367+
}
4368+
4369+
async ensureSandboxCustomImageBuilderTask(
4370+
id: string,
4371+
): Promise<SandboxCustomImage> {
4372+
const teamId = await this.getTeamId();
4373+
const url = new URL(
4374+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`,
4375+
);
4376+
const response = await this.api.fetcher.fetch({
4377+
method: "post",
4378+
url,
4379+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`,
4380+
overrides: {
4381+
body: JSON.stringify({}),
4382+
},
4383+
});
4384+
if (!response.ok) {
4385+
const errorData = (await response.json().catch(() => ({}))) as {
4386+
detail?: string;
4387+
};
4388+
throw new Error(
4389+
errorData.detail ??
4390+
`Failed to open image builder session: ${response.statusText}`,
4391+
);
4392+
}
4393+
return (await response.json()) as SandboxCustomImage;
4394+
}
4395+
4396+
async buildSandboxCustomImage(
4397+
id: string,
4398+
specYaml?: string | null,
4399+
): Promise<SandboxCustomImage> {
4400+
const teamId = await this.getTeamId();
4401+
const url = new URL(
4402+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/build/`,
4403+
);
4404+
const response = await this.api.fetcher.fetch({
4405+
method: "post",
4406+
url,
4407+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/build/`,
4408+
overrides: {
4409+
body: JSON.stringify(
4410+
specYaml === undefined ? {} : { spec_yaml: specYaml },
4411+
),
4412+
},
4413+
});
4414+
if (!response.ok) {
4415+
const errorData = (await response.json().catch(() => ({}))) as {
4416+
detail?: string;
4417+
};
4418+
throw new Error(
4419+
errorData.detail ??
4420+
`Failed to build sandbox custom image: ${response.statusText}`,
4421+
);
4422+
}
4423+
return (await response.json()) as SandboxCustomImage;
4424+
}
4425+
4426+
async deleteSandboxCustomImage(id: string): Promise<void> {
4427+
const teamId = await this.getTeamId();
4428+
const url = new URL(
4429+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4430+
);
4431+
const response = await this.api.fetcher.fetch({
4432+
method: "delete",
4433+
url,
4434+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4435+
});
4436+
if (!response.ok) {
4437+
throw new Error(
4438+
`Failed to delete sandbox custom image: ${response.statusText}`,
4439+
);
4440+
}
4441+
}
4442+
42824443
/** Find an exported asset by session recording ID. */
42834444
async findExportBySessionRecordingId(
42844445
projectId: number,

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", {

packages/core/src/sidebar/groupTasks.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ export interface TaskRepositoryInfo {
1313

1414
export interface GroupableTask {
1515
repository: TaskRepositoryInfo | null;
16+
originProduct?: string;
1617
}
1718

19+
export const CUSTOM_IMAGES_GROUP_ID = "custom-images";
20+
1821
export interface TaskGroup<T extends GroupableTask> {
1922
id: string;
2023
name: string;
@@ -58,8 +61,13 @@ export function groupByRepository<T extends GroupableTask>(
5861

5962
for (const task of tasks) {
6063
const repository = task.repository;
61-
const groupId = repository?.fullPath ?? "other";
62-
const groupName = repository?.name ?? "Other";
64+
const isImageBuilder = task.originProduct === "image_builder";
65+
const groupId = isImageBuilder
66+
? CUSTOM_IMAGES_GROUP_ID
67+
: (repository?.fullPath ?? "other");
68+
const groupName = isImageBuilder
69+
? "Custom images"
70+
: (repository?.name ?? "Other");
6371

6472
let group = groupMap.get(groupId);
6573
if (!group) {
@@ -88,25 +96,27 @@ export function groupByRepository<T extends GroupableTask>(
8896
}
8997
}
9098

91-
// The "other" group (tasks without a resolvable repository) always sorts to
92-
// the bottom, regardless of the alphabetical or persisted folder order.
93-
const pinOtherLast = (a: TaskGroup<T>, b: TaskGroup<T>): number | null => {
94-
const aOther = a.id === "other";
95-
const bOther = b.id === "other";
96-
if (aOther && bOther) return 0;
97-
if (aOther) return 1;
98-
if (bOther) return -1;
99-
return null;
99+
// Custom-images and "other" always sort last, in that order.
100+
const pinnedRank = (group: TaskGroup<T>): number => {
101+
if (group.id === CUSTOM_IMAGES_GROUP_ID) return 1;
102+
if (group.id === "other") return 2;
103+
return 0;
104+
};
105+
const pinSpecialLast = (a: TaskGroup<T>, b: TaskGroup<T>): number | null => {
106+
const aRank = pinnedRank(a);
107+
const bRank = pinnedRank(b);
108+
if (aRank === 0 && bRank === 0) return null;
109+
return aRank - bRank;
100110
};
101111

102112
if (folderOrder.length === 0) {
103113
return groups.sort(
104-
(a, b) => pinOtherLast(a, b) ?? a.name.localeCompare(b.name),
114+
(a, b) => pinSpecialLast(a, b) ?? a.name.localeCompare(b.name),
105115
);
106116
}
107117

108118
return groups.sort((a, b) => {
109-
const pinned = pinOtherLast(a, b);
119+
const pinned = pinSpecialLast(a, b);
110120
if (pinned !== null) return pinned;
111121
const aIndex = folderOrder.indexOf(a.id);
112122
const bIndex = folderOrder.indexOf(b.id);

packages/core/src/task-detail/taskCreationApiClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface CreateTaskRunClientOptions {
99
model?: string;
1010
reasoningLevel?: string;
1111
sandboxEnvironmentId?: string;
12+
customImageId?: string;
1213
prAuthorshipMode?: PrAuthorshipMode;
1314
runSource?: CloudRunSource;
1415
signalReportId?: string;

packages/core/src/task-detail/taskCreationSaga.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ export class TaskCreationSaga extends Saga<
357357
model: input.model,
358358
reasoningLevel: input.reasoningLevel,
359359
sandboxEnvironmentId: input.sandboxEnvironmentId,
360+
customImageId: input.customImageId,
360361
prAuthorshipMode,
361362
runSource: input.cloudRunSource ?? "manual",
362363
signalReportId: input.signalReportId,

packages/core/src/task-detail/taskInput.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface PrepareTaskInputOptions {
1717
reasoningLevel?: string;
1818
environmentId?: string | null;
1919
sandboxEnvironmentId?: string;
20+
customImageId?: string;
2021
signalReportId?: string;
2122
additionalDirectories?: string[];
2223
channelContext?: string;
@@ -51,6 +52,7 @@ export function prepareTaskInput(
5152
reasoningLevel: options.reasoningLevel,
5253
environmentId: options.environmentId ?? undefined,
5354
sandboxEnvironmentId: options.sandboxEnvironmentId,
55+
customImageId: options.customImageId,
5456
cloudPrAuthorshipMode:
5557
options.signalReportId && isCloud ? "user" : undefined,
5658
cloudRunSource:

0 commit comments

Comments
 (0)