Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/lib/__tests__/host-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
7 changes: 5 additions & 2 deletions src/lib/agent/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/lib/agent/mcp-prompt-streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 7 additions & 5 deletions src/lib/agent/runner/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
159 changes: 159 additions & 0 deletions src/lib/host-resolution.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +65 to +66

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this should simply be null when a base URL is pinned? We don't even use this for a lot. Or maybe unknown.

/**
* 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: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

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<HostResolutionOptions, 'localMcp'> = {},
): 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<HostResolution> {
const region = await detectRegion(accessToken, opts.baseUrl);
return HostResolution.fromRegion(region, opts);
}
}
6 changes: 4 additions & 2 deletions src/lib/programs/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,8 +69,10 @@ const auditRun = async (session: WizardSession): Promise<ProgramRun> => {
// 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
Expand Down
6 changes: 4 additions & 2 deletions src/lib/programs/events-audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/lib/programs/posthog-integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/ui/tui/screens/AiOptInRequiredScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
})
Expand Down
Loading
Loading