Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { gitRouter } from "@posthog/host-router/routers/git.router";
import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router";
import { githubReleasesRouter } from "@posthog/host-router/routers/github-releases.router";
import { handoffRouter } from "@posthog/host-router/routers/handoff.router";
import { integrationRouter } from "@posthog/host-router/routers/integration.router";
import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router";
import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router";
import { localMcpRouter } from "@posthog/host-router/routers/local-mcp.router";
Expand Down Expand Up @@ -87,6 +88,7 @@ export const trpcRouter = router({
githubIntegration: githubIntegrationRouter,
githubReleases: githubReleasesRouter,
handoff: handoffRouter,
integration: integrationRouter,
linearIntegration: linearIntegrationRouter,
llmGateway: llmGatewayRouter,
localMcp: localMcpRouter,
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/features/tasks/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ describe("runTaskInCloud", () => {
expect(init.body).toBeUndefined();
});

it("forwards the selected sandbox environment and custom image", async () => {
await runTaskInCloud("task-1", {
sandboxEnvironmentId: "environment-123",
customImageId: "image-123",
});

expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({
sandbox_environment_id: "environment-123",
custom_image_id: "image-123",
});
});

it("omits the sandbox environment and custom image when unset", async () => {
await runTaskInCloud("task-1", {
model: "claude-opus-4-8",
sandboxEnvironmentId: null,
customImageId: null,
});

const body = bodyOf(mockFetch.mock.calls[0]);
expect(body).not.toHaveProperty("sandbox_environment_id");
expect(body).not.toHaveProperty("custom_image_id");
});

it("sends rtk_enabled=false when the run opts out", async () => {
await runTaskInCloud("task-1", { rtkEnabled: false });

Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ export async function warmTask(options: {
runtime_adapter?: string | null;
model?: string | null;
reasoning_effort?: string | null;
sandbox_environment_id?: string | null;
custom_image_id?: string | null;
}): Promise<{ task_id: string; run_id: string } | null> {
const baseUrl = getBaseUrl();
const projectId = getProjectId();
Expand All @@ -329,6 +331,12 @@ export async function warmTask(options: {
runtime_adapter: options.runtime_adapter ?? null,
model: options.model ?? null,
reasoning_effort: options.reasoning_effort ?? null,
...(options.sandbox_environment_id
? { sandbox_environment_id: options.sandbox_environment_id }
: {}),
...(options.custom_image_id
? { custom_image_id: options.custom_image_id }
: {}),
}),
},
);
Expand Down Expand Up @@ -431,6 +439,10 @@ export interface RunTaskInCloudOptions {
model?: string;
/** Reasoning effort: "low" | "medium" | "high" (model-dependent). */
reasoningEffort?: string;
/** Sandbox environment / custom base image to run on. Sent so a reused warm
* sandbox matches the selection instead of a mismatched default. */
sandboxEnvironmentId?: string | null;
customImageId?: string | null;
/** Permission mode: "default" | "acceptEdits" | "plan" | "auto". */
initialPermissionMode?: string;
/** Source that triggered this run. */
Expand Down Expand Up @@ -463,6 +475,8 @@ export async function runTaskInCloud(
options.runtimeAdapter !== undefined ||
options.model !== undefined ||
options.reasoningEffort !== undefined ||
options.sandboxEnvironmentId !== undefined ||
options.customImageId !== undefined ||
options.initialPermissionMode !== undefined ||
options.runSource !== undefined ||
options.signalReportId !== undefined ||
Expand All @@ -488,6 +502,12 @@ export async function runTaskInCloud(
payload.reasoning_effort = options.reasoningEffort;
}
}
if (options?.sandboxEnvironmentId) {
payload.sandbox_environment_id = options.sandboxEnvironmentId;
}
if (options?.customImageId) {
payload.custom_image_id = options.customImageId;
}
if (options?.initialPermissionMode) {
payload.initial_permission_mode = options.initialPermissionMode;
}
Expand Down
52 changes: 52 additions & 0 deletions apps/mobile/src/features/tasks/api.warm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,58 @@ describe("warmTask", () => {
);
});

it("forwards the selected sandbox environment and custom image", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), {
status: 200,
}),
);

await warmTask({
repository: "posthog/posthog",
github_integration: 7,
branch: "main",
sandbox_environment_id: "environment-123",
custom_image_id: "image-123",
});

expect(mockFetch).toHaveBeenCalledWith(
"https://app.posthog.test/api/projects/42/tasks/warm/",
expect.objectContaining({
body: JSON.stringify({
repository: "posthog/posthog",
github_integration: 7,
branch: "main",
runtime_adapter: null,
model: null,
reasoning_effort: null,
sandbox_environment_id: "environment-123",
custom_image_id: "image-123",
}),
}),
);
});

it("omits the sandbox environment and custom image when unset", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), {
status: 200,
}),
);

await warmTask({
repository: "posthog/posthog",
github_integration: 7,
sandbox_environment_id: null,
custom_image_id: null,
});

const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body).not.toHaveProperty("sandbox_environment_id");
expect(body).not.toHaveProperty("custom_image_id");
});

it("serializes a missing branch as null", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), {
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ interface Props {
runtimeAdapter?: string | null;
model?: string | null;
reasoningEffort?: string | null;
sandboxEnvironmentId?: string | null;
customImageId?: string | null;
}

const composing: Props = {
Expand Down Expand Up @@ -198,6 +200,42 @@ describe("useWarmTask", () => {
},
);

it("forwards the sandbox environment and custom image", async () => {
render({
...composing,
sandboxEnvironmentId: "environment-123",
customImageId: "image-123",
});
await flushDebounce();

expect(mockWarmTask).toHaveBeenCalledWith({
repository: "acme/repo",
github_integration: 42,
branch: "main",
...NULL_RUNTIME,
sandbox_environment_id: "environment-123",
custom_image_id: "image-123",
});
});

it("re-warms when the custom image changes", async () => {
const { rerender } = render({ ...composing, customImageId: "image-123" });
await flushDebounce();
expect(mockWarmTask).toHaveBeenCalledOnce();

rerender({ ...composing, customImageId: "image-456" });
await flushDebounce();

expect(mockWarmTask).toHaveBeenCalledTimes(2);
expect(mockWarmTask).toHaveBeenLastCalledWith({
repository: "acme/repo",
github_integration: 42,
branch: "main",
...NULL_RUNTIME,
custom_image_id: "image-456",
});
});

it("warms again for a new selection after a failed warm", async () => {
mockWarmTask.mockRejectedValueOnce(new Error("boom"));
const { rerender } = render(composing);
Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/features/tasks/hooks/useWarmTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface UseWarmTaskOptions {
runtimeAdapter?: string | null;
model?: string | null;
reasoningEffort?: string | null;
sandboxEnvironmentId?: string | null;
customImageId?: string | null;
}

export function useWarmTask({
Expand All @@ -26,6 +28,8 @@ export function useWarmTask({
runtimeAdapter,
model,
reasoningEffort,
sandboxEnvironmentId,
customImageId,
}: UseWarmTaskOptions): void {
const enabled = useFeatureFlag(TASKS_PREWARM_SANDBOX_FLAG);

Expand All @@ -36,14 +40,16 @@ export function useWarmTask({
const normalizedRuntimeAdapter = runtimeAdapter ?? null;
const normalizedModel = model ?? null;
const normalizedReasoningEffort = reasoningEffort ?? null;
const normalizedSandboxEnvironmentId = sandboxEnvironmentId ?? null;
const normalizedCustomImageId = customImageId ?? null;
const eligible =
!!enabled &&
!!repository &&
githubIntegrationId != null &&
!composerIsEmpty;
const key =
repository && githubIntegrationId != null
? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}`
? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}:${normalizedSandboxEnvironmentId ?? ""}:${normalizedCustomImageId ?? ""}`
: null;

useEffect(() => {
Expand All @@ -68,6 +74,8 @@ export function useWarmTask({
const warmRuntimeAdapter = normalizedRuntimeAdapter;
const warmModel = normalizedModel;
const warmReasoningEffort = normalizedReasoningEffort;
const warmSandboxEnvironmentId = normalizedSandboxEnvironmentId;
const warmCustomImageId = normalizedCustomImageId;
debounceRef.current = setTimeout(() => {
debounceRef.current = null;
lastWarmedKeyRef.current = key;
Expand All @@ -78,6 +86,10 @@ export function useWarmTask({
runtime_adapter: warmRuntimeAdapter,
model: warmModel,
reasoning_effort: warmReasoningEffort,
...(warmSandboxEnvironmentId
? { sandbox_environment_id: warmSandboxEnvironmentId }
: {}),
...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}),
}).catch((error) => {
lastWarmedKeyRef.current = null;
log.warn("Failed to warm task", error);
Expand All @@ -94,5 +106,7 @@ export function useWarmTask({
normalizedRuntimeAdapter,
normalizedModel,
normalizedReasoningEffort,
normalizedSandboxEnvironmentId,
normalizedCustomImageId,
]);
}
4 changes: 4 additions & 0 deletions packages/core/src/integrations/identifiers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export const INTEGRATION_SERVICE = Symbol.for(
"posthog.core.integrationService",
);

export const GITHUB_INTEGRATION_SERVICE = Symbol.for(
"posthog.core.githubIntegrationService",
);
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/integrations/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
import { IntegrationService } from "./integration";

function createService() {
const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) };
const service = new IntegrationService(urlLauncher as never);
return { service, urlLauncher };
}

describe("IntegrationService.startFlow", () => {
it("launches an authorize URL for the given kind scoped to the project", async () => {
const { service, urlLauncher } = createService();

const result = await service.startFlow("intercom", "us", 42);

expect(result).toEqual({ success: true });
const launched = urlLauncher.launch.mock.calls[0][0];
expect(launched).toContain("/api/environments/42/integrations/authorize/");
expect(launched).toContain("kind=intercom");
});

it("url-encodes the kind", async () => {
const { service, urlLauncher } = createService();

await service.startFlow("rapid7_insightvm", "eu", 7);

expect(urlLauncher.launch.mock.calls[0][0]).toContain(
"kind=rapid7_insightvm",
);
});

it("returns a failure result when launching the browser throws", async () => {
const { service, urlLauncher } = createService();
urlLauncher.launch.mockRejectedValue(new Error("no browser"));

expect(await service.startFlow("hubspot", "us", 42)).toEqual({
success: false,
error: "no browser",
});
});
});
44 changes: 44 additions & 0 deletions packages/core/src/integrations/integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
type IUrlLauncher,
URL_LAUNCHER_SERVICE,
} from "@posthog/platform/url-launcher";
import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared";
import { inject, injectable } from "inversify";
import type { StartIntegrationFlowOutput } from "./schemas";

/**
* Generic OAuth integration flow starter. PostHog's
* `…/integrations/authorize/?kind=<kind>` endpoint is generic over the integration kind, so a
* single service starts the flow for any supported OAuth provider (linear, intercom, hubspot,
* salesforce, …) — no per-kind service or router required. The OAuth grant, callback, and token
* storage all happen on PostHog Cloud; the caller then polls the integrations list for the new
* integration of this `kind`.
*/
@injectable()
export class IntegrationService {
constructor(
@inject(URL_LAUNCHER_SERVICE)
private readonly urlLauncher: IUrlLauncher,
) {}

public async startFlow(
kind: string,
region: CloudRegion,
projectId: number,
): Promise<StartIntegrationFlowOutput> {
try {
const cloudUrl = getCloudUrlFromRegion(region);
const next = `${cloudUrl}/project/${projectId}`;
const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=${encodeURIComponent(kind)}&next=${encodeURIComponent(next)}`;

await this.urlLauncher.launch(authorizeUrl);

return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
3 changes: 3 additions & 0 deletions packages/core/src/integrations/integrations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { ContainerModule } from "inversify";
import { GitHubIntegrationService } from "./github";
import {
GITHUB_INTEGRATION_SERVICE,
INTEGRATION_SERVICE,
LINEAR_INTEGRATION_SERVICE,
SLACK_INTEGRATION_SERVICE,
} from "./identifiers";
import { IntegrationService } from "./integration";
import { LinearIntegrationService } from "./linear";
import { SlackIntegrationService } from "./slack";

export const integrationsModule = new ContainerModule(({ bind }) => {
bind(INTEGRATION_SERVICE).to(IntegrationService).inSingletonScope();
bind(GITHUB_INTEGRATION_SERVICE)
.to(GitHubIntegrationService)
.inSingletonScope();
Expand Down
Loading
Loading