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
6 changes: 6 additions & 0 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,19 @@ jobs:
[ -n "$API_KEY" ] || { echo "::error::GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_PERSONAL_KEY is not set"; exit 1; }
[ -n "$PROJECT_ID" ] || { echo "::error::GH_APP_POSTHOG_WIZARD_CI_BOT_TARGET_PROJECT_ID is not set"; exit 1; }

- name: Write gateway token
run: printf '%s' "$GATEWAY_TOKEN" > "$RUNNER_TEMP/gateway-token"
env:
GATEWAY_TOKEN: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_GATEWAY_TOKEN }}

- name: Run smoke test
env:
POSTHOG_PERSONAL_API_KEY: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_PERSONAL_KEY }}
# Required — without it the wizard falls back to the key's default
# team and 403s. Same secret name wizard-workbench's wizard-ci.yml uses.
POSTHOG_WIZARD_PROJECT_ID: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_TARGET_PROJECT_ID }}
POSTHOG_WIZARD_REGION: us
WIZARD_CI_GATEWAY_TOKEN_FILE: ${{ runner.temp }}/gateway-token

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low: Gateway bearer exposed to dependency lifecycle scripts

This variable is inherited by smoke-test-ci.sh, which runs pnpm install or npm install for the fixture and installs the wizard package before launching the wizard. A compromised lifecycle script can read the predictable token file and use the bearer; create and expose the file only immediately before the wizard invocation, then remove it after configureGatewayFromCIEnvironment reads it.

WIZARD_WORKBENCH_ROOT: ${{ github.workspace }}/wizard-workbench
SMOKE_TEST_APP: ${{ inputs.app || 'basic-integration/next-js/15-app-router-todo' }}
run: ./scripts/smoke-test-ci.sh "$SMOKE_TEST_APP"
Expand Down
7 changes: 7 additions & 0 deletions e2e-harness/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ The direct host reads `APP_DIR`, `PROJECT_ID`, and either
Detection-only MCP runs omit the key and stop at `auth`. The internal socket's
`set_credentials` command is not exposed as an MCP tool.

Agent runs require `WIZARD_CI_GATEWAY_TOKEN_FILE` containing an already-issued
gateway bearer. CI uses it directly and never mints or refreshes it; missing or
rejected credentials fail the run. `WIZARD_CI_GATEWAY_URL` optionally overrides
`https://ai-gateway.<region>.posthog.com`. `POSTHOG_KEY_FILE` /
`POSTHOG_PERSONAL_API_KEY` remain separate credentials for PostHog API and MCP.
The same gateway settings apply to development `--ci` runs.

## The two routes

- **CI snapshots** — `tui-snapshots.no-jest.ts` spawns `tui-host` (`MODE=fixed`)
Expand Down
9 changes: 9 additions & 0 deletions scripts/tui-host.no-jest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '@lib/programs/program-registry';
import type { Harness, Sequence } from '@lib/constants';
import { buildSession } from '@lib/wizard-session';
import { configureGatewayFromCIEnvironment } from '@lib/gateway-session';
import { runAgent } from '@lib/agent/agent-runner';
import { authenticate } from '@lib/agent/runner/shared/authenticate';
import { getOrAskForProjectData } from '@utils/setup-utils';
Expand Down Expand Up @@ -247,7 +248,15 @@ async function main() {
// Pass the pre-run gates and run the program's real agent. The auth and run
// screens never advance on their own; this is what moves them. Mirrors
// run-wizard's flow, including in-program run phases.
let gatewayConfigured = false;
const runProgram = async () => {
if (!gatewayConfigured) {
configureGatewayFromCIEnvironment(
Number(projectId),
store.session.region ?? 'us',
);
gatewayConfigured = true;
}
await store.getGate('intro');
await store.getGate('integration-check');
await store.getGate('health-check');
Expand Down
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ type RuntimeEnvKey =
// would claim it as an unknown CLI option and strict-reject the run.
| 'WIZARD_CI_FLAG_OVERRIDES'
| 'WIZARD_CI_EXCLUDE_TASKS'
| 'WIZARD_CI_GATEWAY_TOKEN_FILE'
| 'WIZARD_CI_GATEWAY_URL'
// Wizard CLI configuration (yargs POSTHOG_WIZARD_ prefix)
| 'POSTHOG_WIZARD_BENCHMARK_CONFIG'
| 'POSTHOG_WIZARD_BENCHMARK_FILE'
Expand Down
80 changes: 80 additions & 0 deletions src/lib/__tests__/gateway-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
GatewayMintFailed,
GatewayMintRefused,
buildWizardPropertiesBlob,
configureGatewayCredentialsForCI,
configureGatewayFromCIEnvironment,
gatewayAuth,
isPastRefresh,
isTrustedGatewayUrl,
Expand Down Expand Up @@ -67,6 +69,84 @@ describe('gatewayAuth', () => {
vi.unstubAllGlobals();
});

it('uses the supplied CI bearer across programs and time without minting', async () => {
configureGatewayCredentialsForCI(
' opaque-ci-token ',
42,
'https://ai-gateway.us.posthog.com/',
);
const auth = {
token: 'opaque-ci-token',
teamId: 42,
gatewayUrl: 'https://ai-gateway.us.posthog.com',
refreshAtMs: Infinity,
};
const results = await Promise.all(
['integration', 'audit', undefined].map((program) =>
gatewayAuth(host, 'phx_project', program),
),
);
expect(results).toEqual([auth, auth, auth]);
const clock = vi
.spyOn(Date, 'now')
.mockReturnValue(Number.MAX_SAFE_INTEGER);
try {
expect(await gatewayAuth(host, 'phx_project', 'integration')).toEqual(
auth,
);
expect(isPastRefresh(auth)).toBe(false);
} finally {
clock.mockRestore();
}
expect(fetchMock).not.toHaveBeenCalled();
});

it.each([
['', 42, 'https://ai-gateway.us.posthog.com'],
['token', 0, 'https://ai-gateway.us.posthog.com'],
['token', 1.5, 'https://ai-gateway.us.posthog.com'],
['token', NaN, 'https://ai-gateway.us.posthog.com'],
['token', 42, 'https://untrusted.example'],
['token', 42, 'https://ai-gateway.us.posthog.com/v1'],
['token', 42, 'ftp://localhost'],
] as const)(
'rejects invalid CI gateway configuration',
(token, projectId, url) => {
expect(() =>
configureGatewayCredentialsForCI(token, projectId, url),
).toThrow();
},
);

it('rejects direct CI gateway auth in production builds', async () => {
vi.stubEnv('NODE_ENV', 'production');
vi.resetModules();
try {
const prod = await import('@lib/gateway-session');
expect(() =>
prod.configureGatewayCredentialsForCI(
'token',
42,
'https://ai-gateway.us.posthog.com',
),
).toThrow('non-production');
} finally {
vi.unstubAllEnvs();
vi.resetModules();
}
});

it('requires an explicit gateway token file for CI', () => {
vi.stubEnv('WIZARD_CI_GATEWAY_TOKEN_FILE', '');
try {
expect(() => configureGatewayFromCIEnvironment(42, 'us')).toThrow(
'WIZARD_CI_GATEWAY_TOKEN_FILE is required',
);
} finally {
vi.unstubAllEnvs();
}
});

it('resolves auth from a mint response and caches it', async () => {
fetchMock.mockResolvedValue({
ok: true,
Expand Down
54 changes: 52 additions & 2 deletions src/lib/gateway-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* legacy-gateway.ts.
*/

import { readFileSync } from 'node:fs';
import { logToFile } from '@utils/debug';
import { analytics } from '@utils/analytics';
import { WizardError } from '@utils/wizard-abort';
Expand All @@ -15,13 +16,15 @@ import type { HostResolution } from '@lib/host-resolution';
import { checkLlmGatewayHealth } from '@lib/health-checks/endpoints';
import { ServiceHealthStatus } from '@lib/health-checks/types';
import { legacyGatewayAuth } from '@lib/legacy-gateway';
import { IS_PRODUCTION_BUILD, runtimeEnv } from '@env';
import type { CloudRegion } from '@utils/types';

export interface GatewayAuth {
/** Base URL for model calls (no `/v1`; transports append their route). */
gatewayUrl: string;
/** Bearer for the gateway: the minted `phe_`. */
/** Gateway bearer, minted normally or supplied directly by CI. */
token: string;
/** The team the mint verified; rides the blob so dashboards keep a breakdown. */
/** Team verified by the mint, or explicitly supplied for CI attribution. */
teamId?: number;
/** Set only by the CI fallback in legacy-gateway.ts. */
legacy?: boolean;
Expand All @@ -46,6 +49,51 @@ let cached: CachedAuth | null = null;
* task at once, and each would otherwise take its own token and its own cap.
*/
let inFlight: { key: string; promise: Promise<GatewayAuth> } | null = null;
let ciAuth: GatewayAuth | null = null;

// Snapshot CI supplies a gateway bearer without minting or re-minting.
export function configureGatewayCredentialsForCI(
token: string,
projectId: number,
gatewayUrl: string,
): void {
if (IS_PRODUCTION_BUILD)
throw new Error('CI gateway auth requires a non-production build');
if (!token.trim() || !Number.isSafeInteger(projectId) || projectId <= 0) {
throw new Error('CI gateway auth requires a token and valid project ID');
}
if (
!/^https?:\/\//.test(gatewayUrl) ||
!isTrustedGatewayUrl(gatewayUrl, '')
) {
throw new Error('CI gateway auth requires a trusted gateway origin');
}
resetGatewaySession();
ciAuth = {
token: token.trim(),
teamId: projectId,
gatewayUrl: gatewayUrl.replace(/\/+$/, ''),
refreshAtMs: Infinity,
};
}

export function configureGatewayFromCIEnvironment(
projectId: number,
region: CloudRegion,
): void {
if (IS_PRODUCTION_BUILD)
throw new Error('CI gateway auth requires a non-production build');
const path = runtimeEnv('WIZARD_CI_GATEWAY_TOKEN_FILE');
if (!path) throw new Error('WIZARD_CI_GATEWAY_TOKEN_FILE is required for CI');
const token = readFileSync(path, 'utf8');
delete process.env.WIZARD_CI_GATEWAY_TOKEN_FILE;
configureGatewayCredentialsForCI(
token,
projectId,
runtimeEnv('WIZARD_CI_GATEWAY_URL') ||
`https://ai-gateway.${region}.posthog.com`,
);
}

/**
* Adoption floor. The anthropic subprocess holds its credential until a 401
Expand All @@ -68,6 +116,7 @@ export async function gatewayAuth(
accessToken: string,
program: string | undefined,
): Promise<GatewayAuth> {
if (ciAuth) return ciAuth;
// Keyed by program: a token pins `wizard:<program>`, so reusing one across
// programs bills the wrong budget.
const key = `${host.apiHost}\n${accessToken}\n${program ?? ''}`;
Expand Down Expand Up @@ -153,6 +202,7 @@ async function resolveGatewayAuth(
export function resetGatewaySession(): void {
cached = null;
inFlight = null;
ciAuth = null;
}

/** Whether a 401 on this bearer may be age (past its refresh instant) rather than a bad credential. */
Expand Down
9 changes: 9 additions & 0 deletions src/lib/runners/run-non-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,15 @@ export function runNonInteractive(
};

try {
if (mode === 'ci') {
const { configureGatewayFromCIEnvironment } = await import(
'@lib/gateway-session'
);
configureGatewayFromCIEnvironment(
Number(session.projectId),
session.region ?? 'us',
);
}
if (config.ciPreRun) {
await config.ciPreRun(session);
} else {
Expand Down
Loading