From e5eb33397f068e86c3deb3cf786b1d3257b0166b Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" <29069505+gewenyu99@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:15:35 -0400 Subject: [PATCH] refactor(host): route #746's host resolution through HostResolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce HostResolution — a frozen, resolve-once snapshot of every PostHog origin (api/app/asset/gateway/mcp), a façade over the @utils/urls resolvers that carries the --base-url override through every field. Point #746's own host resolution at it: the auth-time region+host resolution (setup-utils), the LLM gateway (agent-interface, mcp-prompt-streaming), and the region->app-url derivations (linear, audit, events-audit, posthog-integration, AiOptIn) now go through HostResolution instead of getHost/getCloudUrl/ getLlmGatewayUrl/detectRegion. Credentials.host stays a string here, so the single-field sites construct a throwaway HostResolution to read one origin — each marked TODO: clean up in #755, where the Credentials.host flip lets them read off the resolved object. Meant to merge into #746. --- src/lib/__tests__/host-resolution.test.ts | 78 +++++++++ src/lib/agent/agent-interface.ts | 7 +- src/lib/agent/mcp-prompt-streaming.ts | 5 +- src/lib/agent/runner/linear.ts | 12 +- src/lib/host-resolution.ts | 159 ++++++++++++++++++ src/lib/programs/audit/index.ts | 6 +- src/lib/programs/events-audit/index.ts | 6 +- src/lib/programs/posthog-integration/index.ts | 8 +- src/ui/tui/screens/AiOptInRequiredScreen.tsx | 5 +- src/utils/setup-utils.ts | 25 ++- 10 files changed, 285 insertions(+), 26 deletions(-) create mode 100644 src/lib/__tests__/host-resolution.test.ts create mode 100644 src/lib/host-resolution.ts diff --git a/src/lib/__tests__/host-resolution.test.ts b/src/lib/__tests__/host-resolution.test.ts new file mode 100644 index 00000000..af7dc7c4 --- /dev/null +++ b/src/lib/__tests__/host-resolution.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { HostResolution } from '@lib/host-resolution'; + +/** + * Contract test for HostResolution — the durable record of what the host family + * guarantees. `fromApiHost` is environment-independent (it never consults + * IS_DEV), so it locks down the prod region→host mapping even though the test + * runner sets NODE_ENV=test (which makes `fromRegion` resolve to localhost). + */ +describe('HostResolution.fromApiHost', () => { + it('derives the full US host family from the US ingestion host', () => { + const h = HostResolution.fromApiHost('https://us.i.posthog.com'); + expect(h.region).toBe('us'); + expect(h.apiHost).toBe('https://us.i.posthog.com'); + expect(h.appHost).toBe('https://us.posthog.com'); + expect(h.assetHost).toBe('https://us-assets.i.posthog.com'); + expect(h.gatewayUrl).toBe('https://gateway.us.posthog.com/wizard'); + }); + + it('derives the full EU host family from the EU ingestion host', () => { + const h = HostResolution.fromApiHost('https://eu.i.posthog.com'); + expect(h.region).toBe('eu'); + expect(h.apiHost).toBe('https://eu.i.posthog.com'); + expect(h.appHost).toBe('https://eu.posthog.com'); + expect(h.assetHost).toBe('https://eu-assets.i.posthog.com'); + expect(h.gatewayUrl).toBe('https://gateway.eu.posthog.com/wizard'); + }); + + it('preserves the given apiHost verbatim (provisioning may return a non-canonical host)', () => { + // Trailing slash and all — the task-stream destination relies on this so its + // own normalization is exercised. + const h = HostResolution.fromApiHost('https://us.posthog.com/'); + expect(h.apiHost).toBe('https://us.posthog.com/'); + }); + + it('points everything at the local stack for a localhost host', () => { + const h = HostResolution.fromApiHost('http://localhost:8010'); + expect(h.region).toBe('us'); + expect(h.apiHost).toBe('http://localhost:8010'); + expect(h.appHost).toBe('http://localhost:8010'); + expect(h.gatewayUrl).toBe('http://localhost:3308/wizard'); + }); +}); + +describe('HostResolution mcpUrl', () => { + it('defaults to the region-independent prod MCP url', () => { + expect(HostResolution.fromApiHost('https://us.i.posthog.com').mcpUrl).toBe( + 'https://mcp.posthog.com/mcp', + ); + expect(HostResolution.fromApiHost('https://eu.i.posthog.com').mcpUrl).toBe( + 'https://mcp.posthog.com/mcp', + ); + }); + + it('points at the local MCP server when localMcp is set', () => { + const h = HostResolution.fromApiHost('https://us.i.posthog.com', { + localMcp: true, + }); + expect(h.mcpUrl).toBe('http://localhost:8787/mcp'); + }); +}); + +describe('HostResolution immutability', () => { + it('is frozen and rejects mutation', () => { + const h = HostResolution.fromApiHost('https://us.i.posthog.com'); + expect(Object.isFrozen(h)).toBe(true); + expect(() => { + (h as unknown as { apiHost: string }).apiHost = 'https://evil.example'; + }).toThrow(); + }); +}); + +describe('HostResolution.fromAccessToken', () => { + it('short-circuits to us in dev/test without a network probe', async () => { + const h = await HostResolution.fromAccessToken('any-token'); + expect(h.region).toBe('us'); + }); +}); diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 84d7f7f9..4b383608 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -25,7 +25,7 @@ import { } from '@lib/wizard-session'; import { wizardAbort, WizardError } from '@utils/wizard-abort'; import { createCustomHeaders } from '@utils/custom-headers'; -import { getLlmGatewayUrl } from '@utils/urls'; +import { HostResolution } from '@lib/host-resolution'; import { LINTING_TOOLS } from '@lib/safe-tools'; import { createWizardToolsServer, WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { @@ -580,7 +580,10 @@ export async function initializeAgent( logToFile('Install directory:', options.installDir); try { - const gatewayUrl = getLlmGatewayUrl(config.posthogApiHost); + // TODO: clean up in #755 + const gatewayUrl = HostResolution.fromApiHost( + config.posthogApiHost, + ).gatewayUrl; // Configure model routing (inherited by the SDK subprocess). All model // calls route through the PostHog LLM gateway, authed with the user's diff --git a/src/lib/agent/mcp-prompt-streaming.ts b/src/lib/agent/mcp-prompt-streaming.ts index a712be31..1af675f8 100644 --- a/src/lib/agent/mcp-prompt-streaming.ts +++ b/src/lib/agent/mcp-prompt-streaming.ts @@ -15,7 +15,7 @@ import type { AgentChunk } from '@ui/tui/services/mcp-suggested-prompts-services'; import type { Credentials } from '@lib/wizard-session'; import { WIZARD_USER_AGENT } from '@lib/constants'; -import { getLlmGatewayUrl } from '@utils/urls'; +import { HostResolution } from '@lib/host-resolution'; import { runtimeEnv } from '@env'; import { logToFile } from '@utils/debug'; import { buildAgentEnv } from '@lib/agent/agent-interface'; @@ -197,7 +197,8 @@ export async function* runMcpPromptViaSdk(args: { process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true'; // Route through the PostHog LLM gateway, authed with the user's OAuth token. - const gatewayUrl = getLlmGatewayUrl(credentials.host); + // TODO: clean up in #755 + const gatewayUrl = HostResolution.fromApiHost(credentials.host).gatewayUrl; process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = credentials.accessToken; process.env.CLAUDE_CODE_OAUTH_TOKEN = credentials.accessToken; diff --git a/src/lib/agent/runner/linear.ts b/src/lib/agent/runner/linear.ts index 8aef4118..d6575416 100644 --- a/src/lib/agent/runner/linear.ts +++ b/src/lib/agent/runner/linear.ts @@ -15,7 +15,7 @@ import { AgentSignals, } from '../agent-interface'; import { restoreClaudeSettings } from '../claude-settings'; -import { getCloudUrl } from '../../../utils/urls'; +import { HostResolution } from '@lib/host-resolution'; import { logToFile, getLogFilePath } from '../../../utils/debug'; import { createBenchmarkPipeline } from '../../middleware/benchmark'; import { @@ -290,11 +290,13 @@ export async function runLinearProgram( message: config.successMessage, reportFile: config.reportFile, docsUrl: config.docsUrl, + // TODO: clean up in #755 continueUrl: session.signup - ? `${getCloudUrl( - cloudRegion, - session.baseUrl, - )}/products?source=wizard` + ? `${ + HostResolution.fromRegion(cloudRegion, { + baseUrl: session.baseUrl, + }).appHost + }/products?source=wizard` : undefined, }; if (outroData) { diff --git a/src/lib/host-resolution.ts b/src/lib/host-resolution.ts new file mode 100644 index 00000000..9f28af12 --- /dev/null +++ b/src/lib/host-resolution.ts @@ -0,0 +1,159 @@ +/** + * HostResolution — the single, immutable snapshot of where the wizard talks to + * PostHog for one run. + * + * One cloud region (or a pinned `--base-url`) implies a whole family of hosts: + * the event-ingestion/API host, the user-facing web app host, the CDN asset + * host, the LLM gateway, and the MCP server. Rather than re-deriving each of + * these at every call site and threading `region` + `baseUrl` around as loose + * params, resolve once at auth time and pass this frozen object around — read + * the property you need. + * + * The actual region→URL math (and the `--base-url` override) lives in + * `@utils/urls`; this class is a thin, immutable façade over those resolvers so + * the override flows through every field without callers having to know about + * it. The OAuth-server URL stays in `@utils/urls` (`getOAuthUrl`) because it is + * needed *before* a region is known, so it can't come from this post-auth object. + */ + +import { + getHost, + getCloudUrl, + getLlmGatewayUrl, + getUiHostFromHost, + detectRegion, + resolveBaseUrl, +} from '@utils/urls'; +import { runtimeEnv } from '@env'; +import type { CloudRegion } from '@utils/types'; + +const LOCAL_MCP_URL = 'http://localhost:8787/mcp'; +const PROD_MCP_URL = 'https://mcp.posthog.com/mcp'; + +/** Construction-time inputs that aren't implied by the region. */ +export interface HostResolutionOptions { + /** `--local-mcp`: point the agent's MCP url at the local dev server. */ + localMcp?: boolean; + /** `--base-url`: pin every PostHog origin to one URL, bypassing region resolution. */ + baseUrl?: string; +} + +function assetHostFor(region: CloudRegion, baseUrl?: string): string { + const override = resolveBaseUrl(baseUrl); + if (override) return override; + return region === 'eu' + ? 'https://eu-assets.i.posthog.com' + : 'https://us-assets.i.posthog.com'; +} + +function assetHostFromApiHost(apiHost: string): string { + if (apiHost.includes('us.i.posthog.com')) { + return 'https://us-assets.i.posthog.com'; + } + if (apiHost.includes('eu.i.posthog.com')) { + return 'https://eu-assets.i.posthog.com'; + } + return apiHost; +} + +function mcpUrlFor(localMcp: boolean): string { + if (localMcp) return LOCAL_MCP_URL; + return runtimeEnv('MCP_URL') || PROD_MCP_URL; +} + +export class HostResolution { + /** The resolved cloud region. `'us'` when a base URL is pinned. */ + readonly region: CloudRegion; + /** + * Event-ingestion / REST API host (e.g. `https://us.i.posthog.com`, or the + * pinned `--base-url`). The SDK `host` written into the user's `.env`, the + * base for the wizard-session REST calls, and the host shown to the agent. + */ + readonly apiHost: string; + /** + * User-facing web app host (e.g. `https://us.posthog.com`). Use for any link + * we hand to the user or open in their browser (dashboards, settings, inbox, + * deep-link base). + */ + readonly appHost: string; + /** CDN asset host (e.g. `https://us-assets.i.posthog.com`). */ + readonly assetHost: string; + /** PostHog LLM gateway URL the agent SDK authenticates its model calls against. */ + readonly gatewayUrl: string; + /** + * PostHog MCP server URL the agent connects to. Region-independent — the + * server resolves the user's region from the bearer token — so this is driven + * only by `--local-mcp` and the `MCP_URL` override, not by region/base-url. + */ + readonly mcpUrl: string; + + private constructor(fields: { + region: CloudRegion; + apiHost: string; + appHost: string; + assetHost: string; + gatewayUrl: string; + mcpUrl: string; + }) { + this.region = fields.region; + this.apiHost = fields.apiHost; + this.appHost = fields.appHost; + this.assetHost = fields.assetHost; + this.gatewayUrl = fields.gatewayUrl; + this.mcpUrl = fields.mcpUrl; + Object.freeze(this); + } + + /** + * Canonical path: build the host family from a resolved region. A pinned + * `--base-url` (via `opts.baseUrl`) wins for every origin; otherwise the + * region's standard hosts are used. Honors IS_DEV through `resolveBaseUrl`. + */ + static fromRegion( + region: CloudRegion, + opts: HostResolutionOptions = {}, + ): HostResolution { + const apiHost = getHost(region, opts.baseUrl); + return new HostResolution({ + region, + apiHost, + appHost: getCloudUrl(region, opts.baseUrl), + assetHost: assetHostFor(region, opts.baseUrl), + gatewayUrl: getLlmGatewayUrl(apiHost), + mcpUrl: mcpUrlFor(opts.localMcp ?? false), + }); + } + + /** + * Build from an ingestion host string (the provisioning API returns one). The + * given host is preserved verbatim as `apiHost`; the region and the derived + * app/asset/gateway hosts are inferred from it. + */ + static fromApiHost( + apiHost: string, + opts: Pick = {}, + ): HostResolution { + const region: CloudRegion = apiHost.includes('eu.') ? 'eu' : 'us'; + return new HostResolution({ + region, + apiHost, + appHost: getUiHostFromHost(apiHost), + assetHost: assetHostFromApiHost(apiHost), + gatewayUrl: getLlmGatewayUrl(apiHost), + mcpUrl: mcpUrlFor(opts.localMcp ?? false), + }); + } + + /** + * Resolve the region from an access token (the us/eu probe, skipped when a + * base URL is pinned), then build the host family. Used after OAuth and after + * CI-mode API-key auth. + */ + static async fromAccessToken( + accessToken: string, + opts: HostResolutionOptions = {}, + ): Promise { + const region = await detectRegion(accessToken, opts.baseUrl); + return HostResolution.fromRegion(region, opts); + } +} diff --git a/src/lib/programs/audit/index.ts b/src/lib/programs/audit/index.ts index 9b6ced68..2964e0c0 100644 --- a/src/lib/programs/audit/index.ts +++ b/src/lib/programs/audit/index.ts @@ -7,7 +7,7 @@ import type { ProgramRun } from '@lib/agent/agent-runner'; import type { WizardSession } from '@lib/wizard-session'; import { OutroKind } from '@lib/wizard-session'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; -import { getCloudUrl } from '@utils/urls'; +import { HostResolution } from '@lib/host-resolution'; import { AUDIT_ABORT_CASES } from './detect.js'; import { AUDIT_CHECKS_KEY, AUDIT_REPORT_FILE } from './types.js'; import { AUDIT_SEED_CHECKS, seedAuditLedger } from './seed.js'; @@ -69,8 +69,10 @@ const auditRun = async (session: WizardSession): Promise => { // agent emits via `[DASHBOARD_URL]` / `[NOTEBOOK_URL]` are surfaced // on the post-run screen. buildOutroData: (session, _credentials, cloudRegion) => { + // TODO: clean up in #755 const cloudUrl = cloudRegion - ? getCloudUrl(cloudRegion, session.baseUrl) + ? HostResolution.fromRegion(cloudRegion, { baseUrl: session.baseUrl }) + .appHost : undefined; const continueUrl = session.signup && cloudUrl diff --git a/src/lib/programs/events-audit/index.ts b/src/lib/programs/events-audit/index.ts index 3bc9da0c..ae704453 100644 --- a/src/lib/programs/events-audit/index.ts +++ b/src/lib/programs/events-audit/index.ts @@ -4,7 +4,7 @@ import type { WizardSession } from '@lib/wizard-session'; import { OutroKind } from '@lib/wizard-session'; import { SPINNER_MESSAGE } from '@lib/framework-config'; import { isUsingTypeScript } from '@utils/setup-utils'; -import { getCloudUrl } from '@utils/urls'; +import { HostResolution } from '@lib/host-resolution'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { EVENTS_AUDIT_PROGRAM } from './steps.js'; import { AUDIT_CHECKS_KEY } from '@lib/programs/audit/types'; @@ -67,8 +67,10 @@ Project context: `, buildOutroData: (sess, _credentials, cloudRegion) => { + // TODO: clean up in #755 const cloudUrl = cloudRegion - ? getCloudUrl(cloudRegion, sess.baseUrl) + ? HostResolution.fromRegion(cloudRegion, { baseUrl: sess.baseUrl }) + .appHost : undefined; const continueUrl = sess.signup && cloudUrl diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index 1013c8a6..11c85559 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -15,7 +15,7 @@ import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { wizardAbort } from '@utils/wizard-abort'; import { WIZARD_INTERACTION_EVENT_NAME } from '@lib/constants'; import { getUI } from '@ui/index'; -import { getCloudUrl } from '@utils/urls'; +import { HostResolution } from '@lib/host-resolution'; import { requestDeepLink } from '@utils/provisioning'; import { openTrackedLink, withUtm } from '@utils/links'; import type { CloudRegion } from '@utils/types'; @@ -33,8 +33,12 @@ function resolveContinueUrl( if (!session.signup) return undefined; if (typeof deepLink === 'string' && deepLink) return deepLink; if (cloudRegion) + // TODO: clean up in #755 return withUtm( - `${getCloudUrl(cloudRegion, session.baseUrl)}/products?source=wizard`, + `${ + HostResolution.fromRegion(cloudRegion, { baseUrl: session.baseUrl }) + .appHost + }/products?source=wizard`, 'outro-continue', ); return undefined; diff --git a/src/ui/tui/screens/AiOptInRequiredScreen.tsx b/src/ui/tui/screens/AiOptInRequiredScreen.tsx index 899a98fc..c1053f0f 100644 --- a/src/ui/tui/screens/AiOptInRequiredScreen.tsx +++ b/src/ui/tui/screens/AiOptInRequiredScreen.tsx @@ -21,7 +21,7 @@ import { useKeyBindings } from '@ui/tui/hooks/useKeyBindings'; import { Colors } from '@ui/tui/styles'; import { useSkillEntry } from '@ui/tui/screens/SkillSourceInfo'; import { fetchUserData } from '@lib/api'; -import { getCloudUrl } from '@utils/urls'; +import { HostResolution } from '@lib/host-resolution'; import { CONTEXT_MILL_RELEASES_URL, POSTHOG_APP_URL } from '@lib/constants'; import { analytics } from '@utils/analytics'; import { LoadingBox } from '@ui/tui/primitives/index'; @@ -98,7 +98,8 @@ export const AiOptInRequiredScreen = ({ } setRetrying(true); setRetryError(null); - void fetchUserData(accessToken, getCloudUrl(region, session.baseUrl)) + // TODO: clean up in #755 + void fetchUserData(accessToken, HostResolution.fromRegion(region, { baseUrl: session.baseUrl }).appHost) .then((user) => { store.setApiUser(user); }) diff --git a/src/utils/setup-utils.ts b/src/utils/setup-utils.ts index a1c2e9f9..ab0e6ea3 100644 --- a/src/utils/setup-utils.ts +++ b/src/utils/setup-utils.ts @@ -23,7 +23,7 @@ import { getOAuthScopesForProgram } from '@lib/oauth/program-scopes'; import type { ProgramId } from '@lib/programs/program-registry'; import { analytics } from './analytics'; import { getUI } from '@ui'; -import { getCloudUrl, getHost, detectRegion } from './urls'; +import { HostResolution } from '@lib/host-resolution'; import { performOAuthFlow } from './oauth'; import { resolveGrantedProject } from './project-resolution'; import { provisionNewAccount } from './provisioning'; @@ -419,9 +419,12 @@ export async function getOrAskForProjectData( if (_options.ci && _options.apiKey) { getUI().log.info('Using provided API key (CI mode - OAuth bypassed)'); - const cloudRegion = await detectRegion(_options.apiKey, _options.baseUrl); - const host = getHost(cloudRegion, _options.baseUrl); - const cloudUrl = getCloudUrl(cloudRegion, _options.baseUrl); + const hosts = await HostResolution.fromAccessToken(_options.apiKey, { + baseUrl: _options.baseUrl, + }); + const cloudRegion = hosts.region; + const host = hosts.apiHost; + const cloudUrl = hosts.appHost; const projectData = _options.projectId != null @@ -478,7 +481,10 @@ export async function getOrAskForProjectData( ); if (!projectApiKey) { - const cloudUrl = getCloudUrl(cloudRegion, _options.baseUrl); + // TODO: clean up in #755 + const cloudUrl = HostResolution.fromRegion(cloudRegion, { + baseUrl: _options.baseUrl, + }).appHost; getUI().log.error(`Didn't receive a project token. This shouldn't happen :( Please let us know if you think this is a bug in the wizard: @@ -599,12 +605,13 @@ async function askForWizardLogin(options: { await abort(); } - const cloudRegion = await detectRegion( + const hosts = await HostResolution.fromAccessToken( tokenResponse.access_token, - options.baseUrl, + { baseUrl: options.baseUrl }, ); - const cloudUrl = getCloudUrl(cloudRegion, options.baseUrl); - const host = getHost(cloudRegion, options.baseUrl); + const cloudRegion = hosts.region; + const cloudUrl = hosts.appHost; + const host = hosts.apiHost; const projectData = await fetchProjectData( tokenResponse.access_token,