diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f2209456c6..a8bcb3623d 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -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"; @@ -87,6 +88,7 @@ export const trpcRouter = router({ githubIntegration: githubIntegrationRouter, githubReleases: githubReleasesRouter, handoff: handoffRouter, + integration: integrationRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, localMcp: localMcpRouter, diff --git a/apps/mobile/src/features/tasks/api.test.ts b/apps/mobile/src/features/tasks/api.test.ts index 19e6f93dc1..2ac4f23d57 100644 --- a/apps/mobile/src/features/tasks/api.test.ts +++ b/apps/mobile/src/features/tasks/api.test.ts @@ -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 }); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 6952fde9f6..fa703b7401 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -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(); @@ -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 } + : {}), }), }, ); @@ -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. */ @@ -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 || @@ -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; } diff --git a/apps/mobile/src/features/tasks/api.warm.test.ts b/apps/mobile/src/features/tasks/api.warm.test.ts index d8e8f882ee..06c9d1aba0 100644 --- a/apps/mobile/src/features/tasks/api.warm.test.ts +++ b/apps/mobile/src/features/tasks/api.warm.test.ts @@ -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" }), { diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 8ed33df816..5b757d40fc 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -32,6 +32,8 @@ interface Props { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } const composing: Props = { @@ -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); diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts index e69ce6555c..935619f75f 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts @@ -16,6 +16,8 @@ interface UseWarmTaskOptions { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } export function useWarmTask({ @@ -26,6 +28,8 @@ export function useWarmTask({ runtimeAdapter, model, reasoningEffort, + sandboxEnvironmentId, + customImageId, }: UseWarmTaskOptions): void { const enabled = useFeatureFlag(TASKS_PREWARM_SANDBOX_FLAG); @@ -36,6 +40,8 @@ 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 && @@ -43,7 +49,7 @@ export function useWarmTask({ !composerIsEmpty; const key = repository && githubIntegrationId != null - ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}` + ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}:${normalizedSandboxEnvironmentId ?? ""}:${normalizedCustomImageId ?? ""}` : null; useEffect(() => { @@ -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; @@ -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); @@ -94,5 +106,7 @@ export function useWarmTask({ normalizedRuntimeAdapter, normalizedModel, normalizedReasoningEffort, + normalizedSandboxEnvironmentId, + normalizedCustomImageId, ]); } diff --git a/packages/core/src/integrations/identifiers.ts b/packages/core/src/integrations/identifiers.ts index a71c99eb94..d76d4a9b48 100644 --- a/packages/core/src/integrations/identifiers.ts +++ b/packages/core/src/integrations/identifiers.ts @@ -1,3 +1,7 @@ +export const INTEGRATION_SERVICE = Symbol.for( + "posthog.core.integrationService", +); + export const GITHUB_INTEGRATION_SERVICE = Symbol.for( "posthog.core.githubIntegrationService", ); diff --git a/packages/core/src/integrations/integration.test.ts b/packages/core/src/integrations/integration.test.ts new file mode 100644 index 0000000000..27a8a03805 --- /dev/null +++ b/packages/core/src/integrations/integration.test.ts @@ -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", + }); + }); +}); diff --git a/packages/core/src/integrations/integration.ts b/packages/core/src/integrations/integration.ts new file mode 100644 index 0000000000..0a3cb19c60 --- /dev/null +++ b/packages/core/src/integrations/integration.ts @@ -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=` 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 { + 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", + }; + } + } +} diff --git a/packages/core/src/integrations/integrations.module.ts b/packages/core/src/integrations/integrations.module.ts index bd57929177..3c3af8ab5f 100644 --- a/packages/core/src/integrations/integrations.module.ts +++ b/packages/core/src/integrations/integrations.module.ts @@ -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(); diff --git a/packages/core/src/integrations/schemas.ts b/packages/core/src/integrations/schemas.ts index f2d3220591..b58c263f68 100644 --- a/packages/core/src/integrations/schemas.ts +++ b/packages/core/src/integrations/schemas.ts @@ -11,6 +11,20 @@ export type StartIntegrationFlowInput = z.infer< typeof startIntegrationFlowInput >; +/** + * Generic integration flow input: any OAuth `kind` PostHog supports. The per-kind routers + * (linear/slack/github) keep the narrower input above; this one drives the generic starter so + * new OAuth sources need no dedicated router. + */ +export const startGenericIntegrationFlowInput = z.object({ + kind: z.string(), + region: cloudRegion, + projectId: z.number(), +}); +export type StartGenericIntegrationFlowInput = z.infer< + typeof startGenericIntegrationFlowInput +>; + export const startIntegrationFlowOutput = z.object({ success: z.boolean(), error: z.string().optional(), diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index a9582f4172..17d323b1eb 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -25,6 +25,7 @@ import { gitRouter } from "./routers/git.router"; import { githubIntegrationRouter } from "./routers/github-integration.router"; import { githubReleasesRouter } from "./routers/github-releases.router"; import { handoffRouter } from "./routers/handoff.router"; +import { integrationRouter } from "./routers/integration.router"; import { linearIntegrationRouter } from "./routers/linear-integration.router"; import { llmGatewayRouter } from "./routers/llm-gateway.router"; import { localMcpRouter } from "./routers/local-mcp.router"; @@ -76,6 +77,7 @@ export const hostRouter = router({ fs: fsRouter, git: gitRouter, handoff: handoffRouter, + integration: integrationRouter, githubIntegration: githubIntegrationRouter, githubReleases: githubReleasesRouter, linearIntegration: linearIntegrationRouter, diff --git a/packages/host-router/src/routers/integration.router.ts b/packages/host-router/src/routers/integration.router.ts new file mode 100644 index 0000000000..680980f01c --- /dev/null +++ b/packages/host-router/src/routers/integration.router.ts @@ -0,0 +1,23 @@ +import { INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers"; +import type { IntegrationService } from "@posthog/core/integrations/integration"; +import { + startGenericIntegrationFlowInput, + startIntegrationFlowOutput, +} from "@posthog/core/integrations/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +/** + * Generic OAuth integration flow starter, parameterized by `kind`. Replaces the need for a + * per-provider router when adding a new OAuth data source — the source's connect-form schema + * already carries the `kind`, so the UI passes it straight through. + */ +export const integrationRouter = router({ + startFlow: publicProcedure + .input(startGenericIntegrationFlowInput) + .output(startIntegrationFlowOutput) + .mutation(({ ctx, input }) => { + return ctx.container + .get(INTEGRATION_SERVICE) + .startFlow(input.kind, input.region, input.projectId); + }), +}); diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx index afe1e5c36d..c5d57650c4 100644 --- a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -399,9 +399,9 @@ function SourceField({ /** * Renders an `oauth` config field: a connect button that launches the provider's * OAuth flow, polls for the resulting integration, and writes its id into the - * form. Mirrors the previous bespoke Linear setup. Only providers with a wired - * flow starter (currently `linear`) can be connected here; others surface a - * message. + * form. The flow is started generically by the field's `kind` (PostHog's + * `…/integrations/authorize/?kind=…` endpoint is generic), so any OAuth source + * PostHog supports works here without provider-specific code. */ function OAuthSourceField({ field, @@ -418,8 +418,8 @@ function OAuthSourceField({ const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); const trpc = useHostTRPC(); - const startLinearFlow = useMutation( - trpc.linearIntegration.startFlow.mutationOptions(), + const startIntegrationFlow = useMutation( + trpc.integration.startFlow.mutationOptions(), ); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(null); @@ -441,13 +441,16 @@ function OAuthSourceField({ const connected = value !== undefined && value !== ""; const startFlow = useCallback(async () => { - if (field.kind === "linear") { - if (!region || !projectId) throw new Error("Missing project context"); - await startLinearFlow.mutateAsync({ region, projectId }); - return; + if (!region || !projectId) throw new Error("Missing project context"); + const result = await startIntegrationFlow.mutateAsync({ + kind: field.kind, + region, + projectId, + }); + if (!result.success) { + throw new Error(result.error ?? `Failed to connect ${providerName}`); } - throw new Error(`Connecting ${providerName} isn't supported here yet.`); - }, [field.kind, region, projectId, startLinearFlow, providerName]); + }, [field.kind, region, projectId, startIntegrationFlow, providerName]); const handleConnect = useCallback(async () => { if (!projectId || !client) return;