Skip to content

Commit dedd747

Browse files
committed
feat(cloud): custom sandbox base images
1 parent 4e1ce7e commit dedd747

21 files changed

Lines changed: 1368 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
@@ -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
@@ -52,6 +52,7 @@ import type {
5252
PriorityJudgmentArtefact,
5353
RepoSelectionArtefact,
5454
SafetyJudgmentArtefact,
55+
SandboxCustomImage,
5556
SandboxEnvironment,
5657
SandboxEnvironmentInput,
5758
Signal,
@@ -119,6 +120,13 @@ export class SeatPaymentFailedError extends Error {
119120
}
120121
}
121122

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

124132
// Stable message so callers recognize this after a saga reduces the error to a string.
@@ -477,6 +485,7 @@ interface CloudRunOptions {
477485
model?: string;
478486
reasoningLevel?: string;
479487
sandboxEnvironmentId?: string;
488+
customImageId?: string;
480489
prAuthorshipMode?: PrAuthorshipMode;
481490
runSource?: CloudRunSource;
482491
signalReportId?: string;
@@ -548,6 +557,9 @@ function buildCloudRunRequestBody(
548557
if (options?.sandboxEnvironmentId) {
549558
body.sandbox_environment_id = options.sandboxEnvironmentId;
550559
}
560+
if (options?.customImageId) {
561+
body.custom_image_id = options.customImageId;
562+
}
551563
if (options?.prAuthorshipMode) {
552564
body.pr_authorship_mode = options.prAuthorshipMode;
553565
}
@@ -4402,6 +4414,155 @@ export class PostHogAPIClient {
44024414
}
44034415
}
44044416

4417+
async listSandboxCustomImages(): Promise<SandboxCustomImage[]> {
4418+
const teamId = await this.getTeamId();
4419+
const url = new URL(
4420+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`,
4421+
);
4422+
const response = await this.api.fetcher.fetch({
4423+
method: "get",
4424+
url,
4425+
path: `/api/projects/${teamId}/sandbox_custom_images/`,
4426+
});
4427+
if (!response.ok) {
4428+
if (response.status === 403) {
4429+
const errorData = (await response.json().catch(() => ({}))) as {
4430+
detail?: string;
4431+
};
4432+
throw new SandboxCustomImagesDisabledError(errorData.detail);
4433+
}
4434+
throw new Error(
4435+
`Failed to fetch sandbox custom images: ${response.statusText}`,
4436+
);
4437+
}
4438+
const data = (await response.json()) as {
4439+
results?: SandboxCustomImage[];
4440+
};
4441+
return data.results ?? [];
4442+
}
4443+
4444+
async createSandboxCustomImage(input: {
4445+
name: string;
4446+
description?: string;
4447+
repository?: string | null;
4448+
private?: boolean;
4449+
}): Promise<SandboxCustomImage> {
4450+
const teamId = await this.getTeamId();
4451+
const url = new URL(
4452+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/`,
4453+
);
4454+
const response = await this.api.fetcher.fetch({
4455+
method: "post",
4456+
url,
4457+
path: `/api/projects/${teamId}/sandbox_custom_images/`,
4458+
overrides: {
4459+
body: JSON.stringify(input),
4460+
},
4461+
});
4462+
if (!response.ok) {
4463+
const errorData = (await response.json().catch(() => ({}))) as {
4464+
detail?: string;
4465+
};
4466+
throw new Error(
4467+
errorData.detail ??
4468+
`Failed to create sandbox custom image: ${response.statusText}`,
4469+
);
4470+
}
4471+
return (await response.json()) as SandboxCustomImage;
4472+
}
4473+
4474+
async getSandboxCustomImage(id: string): Promise<SandboxCustomImage> {
4475+
const teamId = await this.getTeamId();
4476+
const url = new URL(
4477+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4478+
);
4479+
const response = await this.api.fetcher.fetch({
4480+
method: "get",
4481+
url,
4482+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4483+
});
4484+
if (!response.ok) {
4485+
throw new Error(
4486+
`Failed to fetch sandbox custom image: ${response.statusText}`,
4487+
);
4488+
}
4489+
return (await response.json()) as SandboxCustomImage;
4490+
}
4491+
4492+
async ensureSandboxCustomImageBuilderTask(
4493+
id: string,
4494+
): Promise<SandboxCustomImage> {
4495+
const teamId = await this.getTeamId();
4496+
const url = new URL(
4497+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`,
4498+
);
4499+
const response = await this.api.fetcher.fetch({
4500+
method: "post",
4501+
url,
4502+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/builder_task/`,
4503+
overrides: {
4504+
body: JSON.stringify({}),
4505+
},
4506+
});
4507+
if (!response.ok) {
4508+
const errorData = (await response.json().catch(() => ({}))) as {
4509+
detail?: string;
4510+
};
4511+
throw new Error(
4512+
errorData.detail ??
4513+
`Failed to open image builder session: ${response.statusText}`,
4514+
);
4515+
}
4516+
return (await response.json()) as SandboxCustomImage;
4517+
}
4518+
4519+
async buildSandboxCustomImage(
4520+
id: string,
4521+
specYaml?: string | null,
4522+
): Promise<SandboxCustomImage> {
4523+
const teamId = await this.getTeamId();
4524+
const url = new URL(
4525+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/build/`,
4526+
);
4527+
const response = await this.api.fetcher.fetch({
4528+
method: "post",
4529+
url,
4530+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/build/`,
4531+
overrides: {
4532+
body: JSON.stringify(
4533+
specYaml === undefined ? {} : { spec_yaml: specYaml },
4534+
),
4535+
},
4536+
});
4537+
if (!response.ok) {
4538+
const errorData = (await response.json().catch(() => ({}))) as {
4539+
detail?: string;
4540+
};
4541+
throw new Error(
4542+
errorData.detail ??
4543+
`Failed to build sandbox custom image: ${response.statusText}`,
4544+
);
4545+
}
4546+
return (await response.json()) as SandboxCustomImage;
4547+
}
4548+
4549+
async deleteSandboxCustomImage(id: string): Promise<void> {
4550+
const teamId = await this.getTeamId();
4551+
const url = new URL(
4552+
`${this.api.baseUrl}/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4553+
);
4554+
const response = await this.api.fetcher.fetch({
4555+
method: "delete",
4556+
url,
4557+
path: `/api/projects/${teamId}/sandbox_custom_images/${id}/`,
4558+
});
4559+
if (!response.ok) {
4560+
throw new Error(
4561+
`Failed to delete sandbox custom image: ${response.statusText}`,
4562+
);
4563+
}
4564+
}
4565+
44054566
/** Find an exported asset by session recording ID. */
44064567
async findExportBySessionRecordingId(
44074568
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;
@@ -69,8 +72,13 @@ export function groupByRepository<T extends GroupableTask>(
6972

7073
for (const task of tasks) {
7174
const repository = task.repository;
72-
const groupId = repository?.fullPath ?? "other";
73-
const groupName = repository?.name ?? "Other";
75+
const isImageBuilder = task.originProduct === "image_builder";
76+
const groupId = isImageBuilder
77+
? CUSTOM_IMAGES_GROUP_ID
78+
: (repository?.fullPath ?? "other");
79+
const groupName = isImageBuilder
80+
? "Custom images"
81+
: (repository?.name ?? "Other");
7482

7583
let group = groupMap.get(groupId);
7684
if (!group) {
@@ -106,25 +114,27 @@ export function groupByRepository<T extends GroupableTask>(
106114
}
107115
}
108116

109-
// The "other" group (tasks without a resolvable repository) always sorts to
110-
// the bottom, regardless of the alphabetical or persisted folder order.
111-
const pinOtherLast = (a: TaskGroup<T>, b: TaskGroup<T>): number | null => {
112-
const aOther = a.id === "other";
113-
const bOther = b.id === "other";
114-
if (aOther && bOther) return 0;
115-
if (aOther) return 1;
116-
if (bOther) return -1;
117-
return null;
117+
// Custom-images and "other" always sort last, in that order.
118+
const pinnedRank = (group: TaskGroup<T>): number => {
119+
if (group.id === CUSTOM_IMAGES_GROUP_ID) return 1;
120+
if (group.id === "other") return 2;
121+
return 0;
122+
};
123+
const pinSpecialLast = (a: TaskGroup<T>, b: TaskGroup<T>): number | null => {
124+
const aRank = pinnedRank(a);
125+
const bRank = pinnedRank(b);
126+
if (aRank === 0 && bRank === 0) return null;
127+
return aRank - bRank;
118128
};
119129

120130
if (folderOrder.length === 0) {
121131
return groups.sort(
122-
(a, b) => pinOtherLast(a, b) ?? a.name.localeCompare(b.name),
132+
(a, b) => pinSpecialLast(a, b) ?? a.name.localeCompare(b.name),
123133
);
124134
}
125135

126136
return groups.sort((a, b) => {
127-
const pinned = pinOtherLast(a, b);
137+
const pinned = pinSpecialLast(a, b);
128138
if (pinned !== null) return pinned;
129139
const aIndex = folderOrder.indexOf(a.id);
130140
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;
@@ -52,6 +53,7 @@ export function prepareTaskInput(
5253
reasoningLevel: options.reasoningLevel,
5354
environmentId: options.environmentId ?? undefined,
5455
sandboxEnvironmentId: options.sandboxEnvironmentId,
56+
customImageId: options.customImageId,
5557
cloudPrAuthorshipMode:
5658
options.signalReportId && isCloud ? "user" : undefined,
5759
cloudRunSource:

0 commit comments

Comments
 (0)