From 07355aaed3c2f085779155ea495a5b6e0377190b Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 13:34:05 -0400 Subject: [PATCH 1/3] feat(gateway): code and report a refused mint Co-Authored-By: Claude Fable 5.1 --- README.md | 46 +++++----- src/__tests__/wizard-abort.test.ts | 18 ++++ src/lib/__tests__/gateway-session.test.ts | 88 ++++++++++++++++++++ src/lib/errors/__tests__/codes.test.ts | 13 +++ src/lib/errors/__tests__/run-failure.test.ts | 52 ++++++++++++ src/lib/errors/catalog.ts | 13 +++ src/lib/errors/codes.ts | 4 + src/lib/errors/index.ts | 1 + src/lib/errors/run-failure.ts | 23 +++++ src/lib/errors/types.ts | 1 + src/lib/gateway-session.ts | 80 +++++++++++++----- src/lib/runners/run-non-interactive.ts | 18 +++- src/lib/runners/run-wizard.ts | 22 +++-- 13 files changed, 322 insertions(+), 57 deletions(-) create mode 100644 src/lib/errors/__tests__/run-failure.test.ts create mode 100644 src/lib/errors/run-failure.ts diff --git a/README.md b/README.md index aa5e75003..49e39cfeb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The PostHog wizard helps you quickly add PostHog to your project using AI. To use the wizard, you can run it directly using: ```bash -npx @posthog/wizard +npx @posthog/wizard@latest ``` Currently the wizard can be used for over 16+ frameworks for frontend, backend, and mobile applications. If you have other integrations you would like the wizard to @@ -43,10 +43,10 @@ Protocol) servers: ```bash # Install PostHog MCP server to supported clients -npx @posthog/wizard mcp add +npx @posthog/wizard@latest mcp add # Remove PostHog MCP server from supported clients -npx @posthog/wizard mcp remove +npx @posthog/wizard@latest mcp remove ``` ## Wizard programs @@ -58,7 +58,7 @@ The wizard's commands are grouped into **programs** — self-contained agentic j Running the wizard with no arguments installs PostHog into your project. It detects your framework, wires up initialization, instruments a starter set of events, and walks you through a first dashboard: ```bash -npx @posthog/wizard +npx @posthog/wizard@latest ``` Powered by the `posthog-integration` program. Most other programs below build on it (they declare `requires: ['posthog-integration']`) and will offer to run it first if PostHog isn't already set up. @@ -68,7 +68,7 @@ Powered by the `posthog-integration` program. Most other programs below build on Autonomously sets up PostHog self-driving end-to-end. It connects GitHub, enables Session Replay and Error Tracking, wires up signal sources, and configures a Signals scout troop that watches your project for you. ```bash -npx @posthog/wizard self-driving +npx @posthog/wizard@latest self-driving ``` If PostHog isn't already installed, the wizard runs the default integration first (composed run) before starting the self-driving setup. @@ -81,16 +81,16 @@ audit (the default); pass a subcommand to run a specific one: ```bash # Runs the events audit (the default) — no subcommand needed -npx @posthog/wizard audit +npx @posthog/wizard@latest audit # Or run a specific audit directly -npx @posthog/wizard audit events # event capture quality + cost (default) -npx @posthog/wizard audit all # comprehensive audit across every area -npx @posthog/wizard audit autocapture # autocapture setup + cost -npx @posthog/wizard audit feature-flags # feature flag usage + cost -npx @posthog/wizard audit identify # your $identify implementation -npx @posthog/wizard audit session-replay # session replay setup -npx @posthog/wizard audit web-analytics # web analytics setup +npx @posthog/wizard@latest audit events # event capture quality + cost (default) +npx @posthog/wizard@latest audit all # comprehensive audit across every area +npx @posthog/wizard@latest audit autocapture # autocapture setup + cost +npx @posthog/wizard@latest audit feature-flags # feature flag usage + cost +npx @posthog/wizard@latest audit identify # your $identify implementation +npx @posthog/wizard@latest audit session-replay # session replay setup +npx @posthog/wizard@latest audit web-analytics # web analytics setup ``` Most audit subcommands resolve at runtime from the published skill registry, so @@ -108,7 +108,7 @@ new audits appear without a wizard release (`web-analytics` is wizard-native). Wire up an existing PostHog + Stripe project for revenue analytics: ```bash -npx @posthog/wizard revenue-analytics +npx @posthog/wizard@latest revenue-analytics ``` Requires PostHog and Stripe SDKs already installed. Supports `--ci` with the @@ -120,7 +120,7 @@ Detect data sources your project already uses (Postgres, MySQL, MongoDB, Snowflake, BigQuery, Stripe, …) and connect them to PostHog's data warehouse: ```bash -npx @posthog/wizard warehouse +npx @posthog/wizard@latest warehouse ``` The wizard scans your dependencies and `.env` key names (never the values) to @@ -132,7 +132,7 @@ OAuth sources open the PostHog app's new-source flow in your browser. Upload JavaScript source maps to PostHog error tracking so stack traces are symbolicated back to your original code: ```bash -npx @posthog/wizard upload-source-maps +npx @posthog/wizard@latest upload-source-maps ``` ### Run skill @@ -141,8 +141,8 @@ Run any context-mill skill directly by name, even if it isn't exposed as its own command: ```bash -npx @posthog/wizard skill list # list every available skill -npx @posthog/wizard skill # run one by name +npx @posthog/wizard@latest skill list # list every available skill +npx @posthog/wizard@latest skill # run one by name ``` ## Wizard ownership @@ -181,7 +181,7 @@ account, uses the returned personal API key to run the normal CI install, and wires PostHog into the project at `--install-dir`: ```bash -npx @posthog/wizard --ci --signup \ +npx @posthog/wizard@latest --ci --signup \ --email you@example.com \ --install-dir . ``` @@ -197,10 +197,10 @@ PostHog yourself — use the `provision` subcommand, which emits a structured ```bash # Human-readable (when stdout is a TTY) -npx @posthog/wizard provision --email user@example.com --region us +npx @posthog/wizard@latest provision --email user@example.com --region us # Machine-readable — auto when stdout is piped, or force with --json -npx @posthog/wizard provision --email user@example.com --region eu --json +npx @posthog/wizard@latest provision --email user@example.com --region eu --json ``` Success prints the full `ProvisioningResult` (`projectApiKey`, `host`, @@ -235,13 +235,13 @@ The following CLI arguments are available: > gateway doesn't yet grant the scopes the wizard needs to personal API keys > for most users, so non-interactive `--ci` runs fail at the gateway. The flag > is disabled in the published package and exits with an error — run the wizard -> in an interactive terminal instead (`npx @posthog/wizard`). The notes below +> in an interactive terminal instead (`npx @posthog/wizard@latest`). The notes below > describe CI mode as it works in development builds. Run the wizard non-interactive executions with `--ci`: ```bash -npx @posthog/wizard --ci --api-key $POSTHOG_PERSONAL_API_KEY --install-dir . +npx @posthog/wizard@latest --ci --api-key $POSTHOG_PERSONAL_API_KEY --install-dir . ``` When running in CI mode (`--ci`): diff --git a/src/__tests__/wizard-abort.test.ts b/src/__tests__/wizard-abort.test.ts index 8a72e0cf1..8699354c6 100644 --- a/src/__tests__/wizard-abort.test.ts +++ b/src/__tests__/wizard-abort.test.ts @@ -7,6 +7,7 @@ import { runCleanups, } from '@utils/wizard-abort'; import { analytics } from '@utils/analytics'; +import { ErrorCodes } from '@lib/errors'; import { getUI } from '../ui'; vi.mock('../utils/analytics'); @@ -132,6 +133,23 @@ describe('wizardAbort', () => { }); }); + it('resolves the code from a coded WizardError when the caller passes none', async () => { + // A mint refusal reaches wizardAbort as the error alone; its code must + // still land on the captured exception. + const error = new WizardError( + 'refused', + { status: 403 }, + ErrorCodes.GatewayMintRefused, + ); + + await expect(wizardAbort({ error })).rejects.toThrow('process.exit called'); + + expect(mockAnalytics.captureException).toHaveBeenCalledWith(error, { + status: 403, + error_code: ErrorCodes.GatewayMintRefused, + }); + }); + it('runs registered cleanup functions before analytics and display', async () => { const callOrder: string[] = []; diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index f5064331c..26e775877 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -8,8 +8,15 @@ import { resetGatewaySession, } from '@lib/gateway-session'; import type { HostResolution } from '@lib/host-resolution'; +import { ErrorCodes } from '@lib/errors'; +import { WizardError } from '@utils/wizard-abort'; +import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; +vi.mock('@utils/analytics', () => ({ + analytics: { wizardCapture: 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 @@ -37,6 +44,7 @@ describe('gatewayAuth', () => { beforeEach(() => { resetGatewaySession(); fetchMock.mockReset(); + vi.mocked(analytics.wizardCapture).mockClear(); vi.mocked(logToFile).mockClear(); vi.stubGlobal('fetch', fetchMock); }); @@ -288,6 +296,86 @@ describe('gatewayAuth', () => { }, ); + it('captures a refusal with its status, outcome and program', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + detail: 'This account is blocked.', + outcome: 'blocked', + }), + }); + // The backend's own denial event has no run id, so this client event is + // what joins a refusal to the session. + const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch( + (e: unknown) => e, + ); + expect(analytics.wizardCapture).toHaveBeenCalledTimes(1); + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'gateway mint refused', + { status: 403, outcome: 'blocked', program: 'audit' }, + ); + expect((err as GatewayMintRefused).outcome).toBe('blocked'); + }); + + it.each([ + ['absent', () => Promise.resolve({ detail: 'Limit reached.' })], + ['not a string', () => Promise.resolve({ outcome: 429 })], + ['oversized', () => Promise.resolve({ outcome: 'x'.repeat(65) })], + ['unparseable', () => Promise.reject(new SyntaxError('bad json'))], + ])( + 'captures a refusal with no outcome when the body has one that is %s', + async (_label, json) => { + fetchMock.mockResolvedValue({ ok: false, status: 429, json }); + await expect( + gatewayAuth(host, 'pha_oauth', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintRefused); + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'gateway mint refused', + { status: 429, outcome: undefined, program: 'integration' }, + ); + }, + ); + + it('does not capture a mint failure as a refusal', async () => { + // A 5xx is the mint being unavailable, not a decision about this run. + fetchMock.mockResolvedValue({ ok: false, status: 503 }); + await expect( + gatewayAuth(host, 'pha_oauth', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintFailed); + expect(analytics.wizardCapture).not.toHaveBeenCalled(); + }); + + it('throws coded WizardErrors so the runners can name the failure', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 403, + json: () => Promise.resolve({ outcome: 'blocked' }), + }); + const refused: unknown = await gatewayAuth( + host, + 'pha_oauth', + 'integration', + ).catch((e: unknown) => e); + expect(refused).toBeInstanceOf(WizardError); + expect((refused as WizardError).code).toBe(ErrorCodes.GatewayMintRefused); + // The context is what wizardAbort attaches to the captured exception. + expect((refused as WizardError).context).toEqual({ + status: 403, + outcome: 'blocked', + }); + + fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }); + const failed: unknown = await gatewayAuth( + host, + 'pha_oauth', + 'integration', + ).catch((e: unknown) => e); + expect(failed).toBeInstanceOf(WizardError); + expect((failed as WizardError).code).toBe(ErrorCodes.GatewayMintFailed); + }); + it('surfaces a refusal through the transport catch', async () => { // The refusal is thrown from inside the try that wraps fetch, so a catch that // treats every throw as a transport failure would silently restore fallback. diff --git a/src/lib/errors/__tests__/codes.test.ts b/src/lib/errors/__tests__/codes.test.ts index b7e451e86..0a49a4ace 100644 --- a/src/lib/errors/__tests__/codes.test.ts +++ b/src/lib/errors/__tests__/codes.test.ts @@ -41,6 +41,19 @@ describe('error catalog', () => { } }); + it('files the mint codes under the gateway group', () => { + // A refusal is a per-run decision the user can sometimes act on; a failure + // is the mint being unavailable and clears on its own. + expect(ERROR_CATALOG[ErrorCodes.GatewayMintRefused]).toMatchObject({ + group: 'gateway', + retry: 'case-by-case', + }); + expect(ERROR_CATALOG[ErrorCodes.GatewayMintFailed]).toMatchObject({ + group: 'gateway', + retry: 'yes', + }); + }); + it('every entry carries a group, retry advice, and a description', () => { for (const [code, entry] of Object.entries(ERROR_CATALOG)) { expect(entry.group, `${code} group`).toBeTruthy(); diff --git a/src/lib/errors/__tests__/run-failure.test.ts b/src/lib/errors/__tests__/run-failure.test.ts new file mode 100644 index 000000000..3d9c30091 --- /dev/null +++ b/src/lib/errors/__tests__/run-failure.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { classifyRunFailure } from '../run-failure'; +import { ErrorCodes } from '../codes'; +import { WizardError } from '@utils/wizard-abort'; +import { GatewayMintRefused } from '@lib/gateway-session'; + +vi.mock('@utils/analytics', () => ({ + analytics: { wizardCapture: vi.fn(), captureException: vi.fn() }, +})); + +describe('classifyRunFailure', () => { + it('keeps a mint refusal as its own code and message', () => { + // The runners print this message alone, without the unhandled framing. + const failure = classifyRunFailure( + new GatewayMintRefused(403, 'This account is blocked.', 'blocked'), + ); + expect(failure).toEqual({ + code: ErrorCodes.GatewayMintRefused, + message: 'This account is blocked.', + coded: true, + }); + }); + + it('treats an uncoded WizardError as unhandled', () => { + const failure = classifyRunFailure(new WizardError('no code', {})); + expect(failure.code).toBe(ErrorCodes.InternalUnhandled); + expect(failure.coded).toBe(false); + }); + + it('treats a plain error as unhandled', () => { + expect(classifyRunFailure(new Error('boom'))).toEqual({ + code: ErrorCodes.InternalUnhandled, + message: 'boom', + coded: false, + }); + }); + + it('ignores a code that is not in the catalog', () => { + // A third-party error with its own `code` field (ENOENT, say) is not a + // wizard decision. + const err = Object.assign(new Error('missing'), { code: 'ENOENT' }); + expect(classifyRunFailure(err).code).toBe(ErrorCodes.InternalUnhandled); + }); + + it('stringifies a non-error throw', () => { + expect(classifyRunFailure('nope')).toEqual({ + code: ErrorCodes.InternalUnhandled, + message: 'nope', + coded: false, + }); + }); +}); diff --git a/src/lib/errors/catalog.ts b/src/lib/errors/catalog.ts index 7d2c026d5..e49be2929 100644 --- a/src/lib/errors/catalog.ts +++ b/src/lib/errors/catalog.ts @@ -232,6 +232,19 @@ export const ERROR_CATALOG: Record = { retry: 'no', description: 'The orchestrator plan failed the sink coverage invariant.', }, + [ErrorCodes.GatewayMintRefused]: { + group: 'gateway', + // 429 clears with the daily window, 401 with a fresh login, 403 never. + retry: 'case-by-case', + description: + 'The PostHog backend refused to mint a gateway token for this run.', + }, + [ErrorCodes.GatewayMintFailed]: { + group: 'gateway', + retry: 'yes', + description: + 'The PostHog backend could not mint a gateway token: unreachable, a 5xx, or an unusable response.', + }, [ErrorCodes.SettingsUnfixableConflict]: { group: 'settings', retry: 'no', diff --git a/src/lib/errors/codes.ts b/src/lib/errors/codes.ts index 6342b79b0..a4d5f04dc 100644 --- a/src/lib/errors/codes.ts +++ b/src/lib/errors/codes.ts @@ -45,6 +45,10 @@ export const ErrorCodes = { AgentOrchestratorTasksFailed: 'PHW_AGENT_ORCHESTRATOR_TASKS_FAILED', AgentOrchestratorHollowRun: 'PHW_AGENT_ORCHESTRATOR_HOLLOW_RUN', AgentOrchestratorSinkInvariant: 'PHW_AGENT_ORCHESTRATOR_SINK_INVARIANT', + /** The backend answered the mint with a deliberate refusal. */ + GatewayMintRefused: 'PHW_GATEWAY_MINT_REFUSED', + /** The mint was unreachable, errored, or returned an unusable token. */ + GatewayMintFailed: 'PHW_GATEWAY_MINT_FAILED', SettingsUnfixableConflict: 'PHW_SETTINGS_UNFIXABLE_CONFLICT', InternalUnhandled: 'PHW_INTERNAL_UNHANDLED', } as const; diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index 669ea257b..5558c7942 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -17,3 +17,4 @@ export { type WizardErrorLine, } from './emit'; export { sanitizeErrorDetail } from './sanitize'; +export { classifyRunFailure, type RunFailure } from './run-failure'; diff --git a/src/lib/errors/run-failure.ts b/src/lib/errors/run-failure.ts new file mode 100644 index 000000000..8ad3b0f20 --- /dev/null +++ b/src/lib/errors/run-failure.ts @@ -0,0 +1,23 @@ +import { ErrorCodes, isErrorCode, type ErrorCode } from './codes'; + +export interface RunFailure { + code: ErrorCode; + message: string; + /** True when the error carried its own code: a decision, not a crash. */ + coded: boolean; +} + +/** + * How a run's terminal error is reported. A coded WizardError (a mint refusal, + * say) keeps its own message and code; anything else is unhandled and gets the + * generic framing. Duck-typed on `code` so callers need not load wizard-abort. + */ +export function classifyRunFailure(err: unknown): RunFailure { + const message = err instanceof Error ? err.message : String(err); + const code = + err instanceof Error ? (err as { code?: unknown }).code : undefined; + if (typeof code === 'string' && isErrorCode(code)) { + return { code, message, coded: true }; + } + return { code: ErrorCodes.InternalUnhandled, message, coded: false }; +} diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index f71ac37d4..43e9ce898 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -6,6 +6,7 @@ export type ErrorGroup = | 'detect' | 'skill' | 'agent' + | 'gateway' | 'settings' | 'internal'; diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 7bf698b61..d0ab74428 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -7,6 +7,9 @@ */ import { logToFile } from '@utils/debug'; +import { analytics } from '@utils/analytics'; +import { WizardError } from '@utils/wizard-abort'; +import { ErrorCodes } from '@lib/errors'; import type { HostResolution } from '@lib/host-resolution'; export interface GatewayAuth { @@ -44,6 +47,8 @@ const REFRESH_AT_FRACTION = 0.8; const MINT_TIMEOUT_MS = 20_000; /** Longer than any refusal the mint writes; a body past this is not a message. */ const MAX_REFUSAL_DETAIL_LENGTH = 500; +/** Outcomes are short snake_case labels; anything longer is not one. */ +const MAX_REFUSAL_OUTCOME_LENGTH = 64; /** Resolve this run's gateway auth, minting and re-minting near expiry. */ export async function gatewayAuth( @@ -165,15 +170,19 @@ interface MintedToken { /** * A deliberate refusal from the mint endpoint, as opposed to the mint being * unavailable. Thrown so the run stops instead of proceeding without the - * controls the refusal was enforcing. + * controls the refusal was enforcing. A WizardError, so the runners print its + * message as-is and `wizardAbort` resolves its code. */ -export class GatewayMintRefused extends Error { +export class GatewayMintRefused extends WizardError { readonly status: number; + /** The backend's refusal outcome (`blocked`, `throttled`, ...), when it sent one. */ + readonly outcome?: string; - constructor(status: number, message: string) { - super(message); + constructor(status: number, message: string, outcome?: string) { + super(message, { status, outcome }, ErrorCodes.GatewayMintRefused); this.name = 'GatewayMintRefused'; this.status = status; + this.outcome = outcome; } } @@ -181,9 +190,9 @@ export class GatewayMintRefused extends Error { * The mint could not produce a usable credential: unreachable, a 5xx, or a * response the client cannot use. */ -export class GatewayMintFailed extends Error { +export class GatewayMintFailed extends WizardError { constructor(message: string) { - super(message); + super(message, undefined, ErrorCodes.GatewayMintFailed); this.name = 'GatewayMintFailed'; } } @@ -204,25 +213,43 @@ function isMintRefusal(status: number): boolean { ); } +interface MintRefusal { + detail?: string; + outcome?: string; +} + /** * The server's own reason for a refusal, when it sent one. DRF answers every * refusal as `{"detail": "..."}`; the blocklist's detail names the contact - * address, which the fixed messages below cannot. + * address, which the fixed messages below cannot. `outcome` is the backend's + * own label for the refusal and rides the client event. */ -async function readRefusalDetail(resp: Response): Promise { +function cleanRefusalText(value: unknown): string { + if (typeof value !== 'string') return ''; + // Not because the server sends escapes, but because this string is printed + // straight to a terminal: sanitizing at the boundary means no later message + // can move the cursor or repaint the screen. + // eslint-disable-next-line no-control-regex + return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(); +} + +async function readRefusal(resp: Response): Promise { try { - const body = (await resp.json()) as { detail?: unknown }; - const raw = typeof body?.detail === 'string' ? body.detail : ''; - // Not because the server sends escapes, but because this string is printed - // straight to a terminal: sanitizing at the boundary means no later message - // can move the cursor or repaint the screen. - // eslint-disable-next-line no-control-regex - const detail = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(); - return detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH - ? detail - : undefined; + const body = (await resp.json()) as { detail?: unknown; outcome?: unknown }; + const detail = cleanRefusalText(body?.detail); + const outcome = cleanRefusalText(body?.outcome); + return { + detail: + detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH + ? detail + : undefined, + outcome: + outcome.length > 0 && outcome.length <= MAX_REFUSAL_OUTCOME_LENGTH + ? outcome + : undefined, + }; } catch { - return undefined; + return {}; } } @@ -265,12 +292,23 @@ async function mintGatewayToken( }); if (!resp.ok) { if (isMintRefusal(resp.status)) { + const refusal = await readRefusal(resp); logToFile( - `[gateway] mint refused with HTTP ${resp.status}; failing the run`, + `[gateway] mint refused with HTTP ${resp.status} (${ + refusal.outcome ?? 'no outcome' + }); failing the run`, ); + // The terminal denial event for this run. The backend's own event has + // no run id, so this is what joins a refusal to the session. + analytics.wizardCapture('gateway mint refused', { + status: resp.status, + outcome: refusal.outcome, + program, + }); throw new GatewayMintRefused( resp.status, - mintRefusalMessage(resp.status, await readRefusalDetail(resp)), + mintRefusalMessage(resp.status, refusal.detail), + refusal.outcome, ); } logToFile( diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index a26263dbb..54c2b98f0 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -13,7 +13,12 @@ import { resolveNoTelemetry } from './resolve-no-telemetry'; import type { WizardStore } from '@ui/tui/store'; import type { TaskStreamPush } from '@lib/task-stream/task-stream-push'; import { join } from 'node:path'; -import { ErrorCodes, detectErrorCode, emitWizardError } from '@lib/errors'; +import { + ErrorCodes, + classifyRunFailure, + detectErrorCode, + emitWizardError, +} from '@lib/errors'; import type { OutroData, RunPhase as RunPhaseT } from '@lib/wizard-session'; /** @@ -315,14 +320,19 @@ export function runNonInteractive( session.frameworkConfig?.metadata.docsUrl ?? runDef?.docsUrl ?? POSTHOG_DOCS_URL; + // A coded failure is a decision with its own message; anything else is + // unexpected and gets the generic framing. + const failure = classifyRunFailure(error); await settleStream(RunPhase.Error, { kind: OutroKind.Error, message: errorMessage, - errorCode: ErrorCodes.InternalUnhandled, + errorCode: failure.code, }); await wizardAbort({ - code: ErrorCodes.InternalUnhandled, - message: `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${docsUrl} to set up manually.${debugInfo}`, + code: failure.code, + message: failure.coded + ? `${errorMessage}${debugInfo}` + : `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${docsUrl} to set up manually.${debugInfo}`, error: error as Error, }); } diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index b2d915553..66b6b5f55 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -12,8 +12,7 @@ import type { TaskStreamPush as TaskStreamPushClass } from '@lib/task-stream/tas import { resolveNoTelemetry } from './resolve-no-telemetry'; import { checkLocalServices, getLocalDev } from '@lib/local-dev'; import { runCleanups } from '@utils/wizard-abort'; -import { ErrorCodes } from '@lib/errors'; -import { emitWizardError } from '@lib/errors'; +import { classifyRunFailure, emitWizardError } from '@lib/errors'; import { join } from 'node:path'; const WIZARD_VERSION = VERSION; @@ -277,15 +276,20 @@ export function runWizard( // ignore } } - // Print after unmount — anything printed into the alt screen is wiped. - // eslint-disable-next-line no-console - console.error('Wizard run failed:', err); + // Print after unmount: anything printed into the alt screen is wiped. + // A coded failure is a decision with its own message; anything else is + // unexpected and goes out whole. + const failure = classifyRunFailure(err); + if (failure.coded) { + // eslint-disable-next-line no-console + console.error(failure.message); + } else { + // eslint-disable-next-line no-console + console.error('Wizard run failed:', err); + } // eslint-disable-next-line no-console console.error(`Full logs: ${getLogFilePath()}`); - emitWizardError({ - code: ErrorCodes.InternalUnhandled, - message: err instanceof Error ? err.message : String(err), - }); + emitWizardError({ code: failure.code, message: failure.message }); process.exit(1); } })(); From 037c1c2cf6d045c65b44068ea6671e76d7ad10d4 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 14:08:53 -0400 Subject: [PATCH 2/3] fix(gateway): read the mint refusal outcome from the DRF body code Co-Authored-By: Claude Fable 5.1 --- src/lib/__tests__/gateway-session.test.ts | 28 +++++++++++++++++++++++ src/lib/gateway-session.ts | 18 +++++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 26e775877..227ba2d53 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -296,6 +296,33 @@ describe('gatewayAuth', () => { }, ); + it('reads the outcome from the DRF body code and shows its detail', async () => { + // The exact shape the backend's exception handler writes for a refusal. + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + type: 'permission_denied', + code: 'blocked', + detail: 'This account is blocked. Contact wizard@posthog.com.', + attr: null, + }), + }); + const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(GatewayMintRefused); + expect((err as GatewayMintRefused).outcome).toBe('blocked'); + expect((err as GatewayMintRefused).message).toContain( + 'Contact wizard@posthog.com', + ); + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'gateway mint refused', + { status: 403, outcome: 'blocked', program: 'audit' }, + ); + }); + it('captures a refusal with its status, outcome and program', async () => { fetchMock.mockResolvedValue({ ok: false, @@ -322,6 +349,7 @@ describe('gatewayAuth', () => { it.each([ ['absent', () => Promise.resolve({ detail: 'Limit reached.' })], ['not a string', () => Promise.resolve({ outcome: 429 })], + ['a non-string code', () => Promise.resolve({ code: 403 })], ['oversized', () => Promise.resolve({ outcome: 'x'.repeat(65) })], ['unparseable', () => Promise.reject(new SyntaxError('bad json'))], ])( diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index d0ab74428..4cb9d7137 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -220,9 +220,10 @@ interface MintRefusal { /** * The server's own reason for a refusal, when it sent one. DRF answers every - * refusal as `{"detail": "..."}`; the blocklist's detail names the contact - * address, which the fixed messages below cannot. `outcome` is the backend's - * own label for the refusal and rides the client event. + * refusal as `{"detail": "...", "code": ""}`; the blocklist's detail + * names the contact address, which the fixed messages below cannot. `code` is + * the backend's own label for the refusal (`outcome` on older backends) and + * rides the client event. */ function cleanRefusalText(value: unknown): string { if (typeof value !== 'string') return ''; @@ -235,9 +236,16 @@ function cleanRefusalText(value: unknown): string { async function readRefusal(resp: Response): Promise { try { - const body = (await resp.json()) as { detail?: unknown; outcome?: unknown }; + const body = (await resp.json()) as { + detail?: unknown; + code?: unknown; + outcome?: unknown; + }; const detail = cleanRefusalText(body?.detail); - const outcome = cleanRefusalText(body?.outcome); + // The DRF handler flattens a dict detail, so the outcome rides as `code`. + const outcome = cleanRefusalText( + typeof body?.code === 'string' ? body.code : body?.outcome, + ); return { detail: detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH From 186db96a8faf1d50afa669f06996bc5ae61d1407 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 16:18:40 -0400 Subject: [PATCH 3/3] fix(gateway): stop an empty code shadowing a usable outcome typeof body.code === 'string' is true for the empty string, so a refusal carrying both keys lost its outcome whenever the code cleaned to nothing. The catalog doc gains the group this PR adds, and the precedence has fixtures that carry one key each, so swapping the arms goes red. Co-Authored-By: Claude Opus 5 (1M context) --- docs/error-catalog.md | 4 ++- src/lib/__tests__/gateway-session.test.ts | 33 +++++++++++++++++++++++ src/lib/gateway-session.ts | 6 ++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docs/error-catalog.md b/docs/error-catalog.md index 7e90f99d5..dc1d90bdb 100644 --- a/docs/error-catalog.md +++ b/docs/error-catalog.md @@ -22,7 +22,7 @@ as an API: backends may branch on it. New codes follow the pattern `PHW__` (see `ERROR_CODE_PATTERN` in `codes.ts`). Groups are lowercase module prefixes (`cli`, `args`, `auth`, `env`, -`detect`, `skill`, `agent`, `settings`, `internal`). +`detect`, `skill`, `agent`, `settings`, `gateway`, `internal`). ## How codes propagate @@ -107,6 +107,8 @@ screen, debug log) keep the full detail. | `PHW_AGENT_ORCHESTRATOR_SINK_INVARIANT` | agent | orchestrator plan violates sink coverage invariant | no | | `PHW_SETTINGS_UNFIXABLE_CONFLICT` | settings | Claude settings conflict that cannot be auto-neutralized (managed/unwritable) | no | | `PHW_INTERNAL_UNHANDLED` | internal | catch-all: an unexpected error escaped the pipeline | yes | +| `PHW_GATEWAY_MINT_REFUSED` | gateway | the gateway-token mint refused this run (blocked, throttled, unlisted program, rollout off); the server's reason is shown | no | +| `PHW_GATEWAY_MINT_FAILED` | gateway | the gateway-token mint could not be reached or answered unusably | yes | Retry advice is guidance for automated hosts (sandbox re-run policies), not a guarantee. diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 227ba2d53..a2cfb977f 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -323,6 +323,39 @@ describe('gatewayAuth', () => { ); }); + it.each([ + [ + 'code wins over outcome', + { code: 'blocked', outcome: 'throttled' }, + 'blocked', + ], + [ + 'outcome carries it when code is absent', + { outcome: 'throttled' }, + 'throttled', + ], + [ + 'an empty code does not shadow outcome', + { code: ' ', outcome: 'throttled' }, + 'throttled', + ], + [ + 'a control-only code does not shadow outcome', + { code: '\u0007', outcome: 'throttled' }, + 'throttled', + ], + ])('resolves the outcome when %s', async (_label, body, want) => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve(body), + }); + const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch( + (e: unknown) => e, + ); + expect((err as GatewayMintRefused).outcome).toBe(want); + }); + it('captures a refusal with its status, outcome and program', async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 4cb9d7137..647492a26 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -243,9 +243,9 @@ async function readRefusal(resp: Response): Promise { }; const detail = cleanRefusalText(body?.detail); // The DRF handler flattens a dict detail, so the outcome rides as `code`. - const outcome = cleanRefusalText( - typeof body?.code === 'string' ? body.code : body?.outcome, - ); + // A `code` that cleans to nothing does not shadow a usable `outcome`. + const outcome = + cleanRefusalText(body?.code) || cleanRefusalText(body?.outcome); return { detail: detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH