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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,7 @@ point is `evaluateWizardReadiness()`, which returns one of three values:
| --- | --- |
| `types.ts` | Enums, interfaces (`ServiceHealthStatus`, `AllServicesHealth`, etc.) |
| `statuspage.ts` | Statuspage.io v2 API helpers + checks for Anthropic, PostHog, GitHub, npm, Cloudflare |
| `endpoints.ts` | Direct endpoint checks for LLM Gateway (`/_liveness`), MCP (`/`), and the skills origins (`skill-menu.json` on GitHub Releases + the AWS mirror) |
| `endpoints.ts` | Direct endpoint checks for MCP (`/`) and the skills origins (`skill-menu.json` on GitHub Releases + the AWS mirror) |
| `readiness.ts` | `checkAllExternalServices`, `evaluateWizardReadiness`, readiness config |
| `index.ts` | Barrel re-export |
| `testme.md` | Test running instructions and endpoint reference |
Expand All @@ -632,7 +632,7 @@ two arrays:
### Current defaults

```ts
downBlocksRun: ['anthropic', 'npmOverall', 'llmGateway', 'mcp', 'skillsOrigin'],
downBlocksRun: ['anthropic', 'npmOverall', 'mcp', 'skillsOrigin'],
degradedBlocksRun: ['anthropic'],
```

Expand Down
41 changes: 15 additions & 26 deletions src/lib/__tests__/agent-interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ describe('runAgent', () => {
// would make either source pass.
gatewayUrl: 'https://gateway.test',
token: 'phe_run_scoped_token',
edition: 'legacy' as const,
},
};

Expand Down Expand Up @@ -598,31 +597,21 @@ describe('buildAgentEnv header shape', () => {
const metadata = { run_id: 'r1', integration: 'nextjs' };
const flags = { 'wizard-orchestrator': 'test' };

it('sends per-key headers and the bedrock opt-in on the legacy gateway', () => {
const encoded = buildAgentEnv(metadata, flags, {
gatewayUrl: 'https://gateway.us.posthog.com/wizard',
token: 'pha_oauth',
edition: 'legacy',
it('sends one properties blob and no per-key or bedrock headers', () => {
const encoded = buildAgentEnv(metadata, flags, 42);
const [name, json] = encoded.split(': ', 2);
expect(name).toBe('X-PostHog-Properties');
// Fallback is native in the gateway's routing chain, and the run tags ride
// the blob rather than per-key headers.
expect(JSON.parse(json)).toEqual({
ai_product: 'wizard',
team_id: 42,
run_id: 'r1',
integration: 'nextjs',
'wizard_flag_wizard-orchestrator': 'test',
});
expect(encoded).toContain('x-posthog-use-bedrock-fallback');
expect(encoded).toContain('X-POSTHOG-PROPERTY-run_id');
expect(encoded).not.toContain('X-PostHog-Properties');
});

it('sends one properties blob and no bedrock opt-in on the new gateway', () => {
const encoded = buildAgentEnv(metadata, flags, {
gatewayUrl: 'https://ai-gateway.us.posthog.com',
token: 'phe_minted',
edition: 'v2',
teamId: 42,
});
expect(encoded).toContain('X-PostHog-Properties');
expect(encoded).not.toContain('x-posthog-use-bedrock-fallback');
expect(encoded).not.toContain('X-POSTHOG-PROPERTY-run_id');
// Fallback is native in the new gateway's routing chain, and the run tags
// ride the blob rather than per-key headers.
expect(encoded).toContain('run_id');
expect(encoded).toContain('team_id');
expect(encoded).not.toContain('X-POSTHOG-PROPERTY-');
});
});

Expand All @@ -642,7 +631,6 @@ describe('subprocess gateway credentials', () => {
gatewayAuth: {
gatewayUrl: 'https://ai-gateway.us.posthog.com',
token: 'phe_run_scoped_token',
edition: 'v2' as const,
teamId: 42,
},
};
Expand Down Expand Up @@ -689,8 +677,9 @@ describe('subprocess gateway credentials', () => {
// The MCP token is the user's own OAuth key and must not be swapped for
// the gateway bearer.
expect(env.POSTHOG_MCP_TOKEN).toBe('phx_user_oauth_token');
// v2 carries one properties blob, not the per-key legacy headers.
// The run tags ride one properties blob, with the minted team on it.
expect(env.ANTHROPIC_CUSTOM_HEADERS).toContain('X-PostHog-Properties');
expect(env.ANTHROPIC_CUSTOM_HEADERS).toContain('"team_id":42');
});
});

Expand Down
135 changes: 83 additions & 52 deletions src/lib/__tests__/gateway-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,8 @@ import {
resetGatewaySession,
} from '@lib/gateway-session';
import type { HostResolution } from '@lib/host-resolution';
import { analytics } from '@utils/analytics';
import { logToFile } from '@utils/debug';

vi.mock('@utils/analytics', () => ({
analytics: { setTag: vi.fn(), captureException: vi.fn() },
}));

vi.mock('@utils/debug', () => ({ logToFile: vi.fn() }));

// logToFile is variadic, so a leak in any argument is a leak. Rendered every way the
Expand All @@ -34,18 +29,14 @@ const renderArg = (a: unknown): string => {
const loggedLines = () =>
vi.mocked(logToFile).mock.calls.map((call) => call.map(renderArg).join(' '));

const host = {
apiHost: 'https://us.posthog.com',
gatewayUrl: 'https://gateway.us.posthog.com/wizard',
} as unknown as HostResolution;
const host = { apiHost: 'https://us.posthog.com' } as unknown as HostResolution;

describe('gatewayAuth', () => {
const fetchMock = vi.fn();

beforeEach(() => {
resetGatewaySession();
fetchMock.mockReset();
vi.mocked(analytics.setTag).mockClear();
vi.mocked(logToFile).mockClear();
vi.stubGlobal('fetch', fetchMock);
});
Expand All @@ -54,7 +45,7 @@ describe('gatewayAuth', () => {
vi.unstubAllGlobals();
});

it('resolves the v2 posture from a mint response and caches it', async () => {
it('resolves auth from a mint response and caches it', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Expand All @@ -70,10 +61,8 @@ describe('gatewayAuth', () => {
expect(auth).toEqual({
gatewayUrl: 'https://gateway.us.posthog.com',
token: 'phe_minted',
edition: 'v2',
teamId: 42,
});
expect(analytics.setTag).toHaveBeenCalledWith('gateway_edition', 'v2');
expect(fetchMock).toHaveBeenCalledWith(
'https://us.posthog.com/api/wizard/gateway_token/',
expect.objectContaining({
Expand Down Expand Up @@ -155,14 +144,6 @@ describe('gatewayAuth', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('caches the legacy fallback instead of re-minting per caller', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });

await gatewayAuth(host, 'pha_oauth', 'integration');
await gatewayAuth(host, 'pha_oauth', 'integration');
expect(fetchMock).toHaveBeenCalledTimes(1);
});

// Every field except the one under test is valid, so the named guard is the
// sole reason the call fails. Filling the others with junk (an unparseable
// expiry, say) makes the TTL guard throw first and every case pass for the
Expand Down Expand Up @@ -209,31 +190,89 @@ describe('gatewayAuth', () => {
[429, 'daily run limit'],
[400, 'exactly one project'],
[403, 'access to this project'],
])('refuses the run on HTTP %i', async (status, fragment) => {
fetchMock.mockResolvedValue({ ok: false, status });
// A refusal is the mint enforcing a limit; the run must not proceed
// without it.
await expect(gatewayAuth(host, 'pha_oauth', 'integration')).rejects.toThrow(
new RegExp(String(fragment), 'i'),
);
});

it('shows the server detail on a refusal when it sends one', async () => {
// The blocklist's 403 names the contact address; the fixed message would
// tell a banned user to re-authenticate instead.
fetchMock.mockResolvedValue({
ok: false,
status: 403,
json: () =>
Promise.resolve({
detail: 'This account is blocked. Contact wizard@posthog.com.',
}),
});
await expect(gatewayAuth(host, 'pha_oauth', 'integration')).rejects.toThrow(
'Contact wizard@posthog.com',
);
});

it('keeps the fixed message when the detail is only control characters', async () => {
// Pins both the C1 arm and the trim running after the substitution: either
// one reverted leaves a run of spaces as the user-facing message.
fetchMock.mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: '\u0007\u009b\u001b' }),
});
await expect(gatewayAuth(host, 'pha_oauth', 'integration')).rejects.toThrow(
/access to this project/i,
);
});

it('strips control characters before the detail reaches the terminal', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 403,
json: () =>
Promise.resolve({ detail: 'Upgrade\u001b[2J\u0007 the wizard.' }),
});
await expect(gatewayAuth(host, 'pha_oauth', 'integration')).rejects.toThrow(
'Upgrade [2J the wizard.',
);
});

it.each([
['not an object', () => Promise.resolve('nope')],
['an empty detail', () => Promise.resolve({ detail: ' ' })],
['an unparseable body', () => Promise.reject(new SyntaxError('bad json'))],
['an oversized detail', () => Promise.resolve({ detail: 'x'.repeat(501) })],
])(
'refuses rather than falling back on HTTP %i',
async (status, fragment) => {
fetchMock.mockResolvedValue({ ok: false, status });
// Falling back would put the run on the legacy gateway, which enforces none
// of the limits these statuses represent.
'keeps the fixed message when the refusal body is %s',
async (_label, json) => {
fetchMock.mockResolvedValue({ ok: false, status: 403, json });
await expect(
gatewayAuth(host, 'pha_oauth', 'integration'),
).rejects.toThrow(new RegExp(String(fragment), 'i'));
).rejects.toThrow(/access to this project/i);
},
);

it.each([404, 401])(
'stays on the existing gateway on HTTP %i',
async (status) => {
it.each([
[401, /re-authenticate with `npx @posthog\/wizard@latest`/i],
[404, /does not issue gateway tokens/i],
])(
'refuses with a status-specific message on HTTP %i',
async (status, message) => {
fetchMock.mockResolvedValue({ ok: false, status });
// 404 is the staged-rollout switch, so removing it would make the flip
// all-or-nothing. 401 covers a credential the mint cannot authenticate,
// such as the API key CI runs with.
const auth = await gatewayAuth(host, 'pha_oauth', 'integration');
expect(auth.edition).toBe('legacy');
expect(analytics.setTag).toHaveBeenCalledWith(
'gateway_edition',
'legacy',
);
// Neither status has a fallback: 401 is a credential the mint does not
// accept, 404 an instance without the mint endpoint. A run that proceeded
// past either would be on a path enforcing none of the mint's limits.
const err: unknown = await gatewayAuth(
host,
'pha_oauth',
'integration',
).catch((e: unknown) => e);
expect(err).toBeInstanceOf(GatewayMintRefused);
expect((err as GatewayMintRefused).status).toBe(status);
expect((err as GatewayMintRefused).message).toMatch(message);
},
);

Expand Down Expand Up @@ -272,10 +311,13 @@ describe('gatewayAuth', () => {
await gatewayAuth(host, 'pha_oauth', 'audit');

// The backend pins `wizard:<program>` from this field; without it the mint
// has nothing to attribute the run to and refuses.
// has nothing to attribute the run to and refuses. The flag is what tells
// it this build reads a refusal rather than falling back on a 404.
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ program: 'audit' }) }),
expect.objectContaining({
body: JSON.stringify({ program: 'audit', reads_refusal_reason: true }),
}),
);
});

Expand Down Expand Up @@ -425,17 +467,6 @@ describe('gatewayAuth', () => {
).rejects.toBeInstanceOf(GatewayMintFailed);
});

it('falls back to the legacy posture when the backend does not mint', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });

const auth = await gatewayAuth(host, 'pha_oauth', 'integration');
expect(auth).toEqual({
gatewayUrl: host.gatewayUrl,
token: 'pha_oauth',
edition: 'legacy',
});
});

it('fails the run on a transport failure', async () => {
fetchMock.mockRejectedValue(new Error('network down'));

Expand Down
3 changes: 0 additions & 3 deletions src/lib/__tests__/host-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ describe('HostResolution.fromApiHost', () => {
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', () => {
Expand All @@ -23,7 +22,6 @@ describe('HostResolution.fromApiHost', () => {
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)', () => {
Expand All @@ -38,7 +36,6 @@ describe('HostResolution.fromApiHost', () => {
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');
});
});

Expand Down
Loading
Loading