From 4016619bfcd8f2d1e536fcc63a64f4094764bdf3 Mon Sep 17 00:00:00 2001 From: John W Date: Thu, 24 Sep 2026 10:58:16 -0700 Subject: [PATCH] feat(e2e): mint a gateway token locally with one browser login Headless and e2e runs need an already-issued AI gateway token in WIZARD_CI_GATEWAY_TOKEN_FILE, and the only way to get one locally was to borrow CI's. `pnpm mint-gateway-token` runs the same OAuth login and mint the interactive wizard uses, then writes the token plus a sidecar (program, verified project, gateway URL, refresh time, token SHA-256) so a runner such as wizard-workbench can reuse it until it expires. The mint writes only after the token's team matches PROJECT_ID, and only through 0600 temp files renamed into a directory chain that no other account can redirect, because the file holds a live bearer. Constraint: the mint endpoint accepts only a wizard-app OAuth token, so one browser login per mint is unavoidable Rejected: phs_ project secret key as the gateway token | gateway answers 402 admission rejected Confidence: medium Scope-risk: narrow Not-tested: the last review round's fix (full ancestor-chain check) had no review after it; exercised by a live mint on the default path --- docs/local-dev.md | 11 +++ package.json | 3 +- scripts/mint-gateway-token.no-jest.ts | 136 ++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 scripts/mint-gateway-token.no-jest.ts diff --git a/docs/local-dev.md b/docs/local-dev.md index 93f949cdd..8b8c89d33 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -20,6 +20,17 @@ file outside the repo, restrict its permissions (`chmod 600`), and supply a valid token for the gateway being used. Missing, expired, or rejected tokens fail the run. +To get a token on your machine, mint one for the program you will run. This +opens the browser once for the OAuth login, then writes the token (`chmod 600`) +and a `.json` sidecar holding the program, project, gateway URL and +refresh time: + +```bash +PROGRAM=posthog-integration PROJECT_ID=12345 \ + TOKEN_FILE="$HOME/.config/posthog/wizard-gateway-token" \ + pnpm mint-gateway-token +``` + With your personal API key already exported and gateway token saved locally: ```bash diff --git a/package.json b/package.json index 81603121d..ba1b7c727 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,8 @@ "prepare": "husky", "screens:check": "tsx scripts/check-screens.tsx", "wizard-ci-explore": "tsx scripts/wizard-ci-explore.no-jest.ts", - "wizard-ci-replay": "tsx scripts/tui-replay.no-jest.ts" + "wizard-ci-replay": "tsx scripts/tui-replay.no-jest.ts", + "mint-gateway-token": "tsx scripts/mint-gateway-token.no-jest.ts" }, "lint-staged": { ".claude/settings.json": "sh -c 'printf \"\\n\\033[31mDo not commit .claude/settings.json — use .claude/settings.local.json (gitignored).\\nUnstage with: git restore --staged .claude/settings.json\\033[0m\\n\\n\" >&2 && exit 1'", diff --git a/scripts/mint-gateway-token.no-jest.ts b/scripts/mint-gateway-token.no-jest.ts new file mode 100644 index 000000000..81cb7ea49 --- /dev/null +++ b/scripts/mint-gateway-token.no-jest.ts @@ -0,0 +1,136 @@ +import fs from 'fs'; +import path from 'path'; +import { createHash, randomBytes } from 'crypto'; +import { gatewayAuth } from '@agent/gateway-session'; +import { HostResolution } from '@shared/host-resolution'; +import { getOAuthScopesForProgram } from '@lib/oauth/program-scopes'; +import type { ProgramId } from '@lib/programs/program-registry'; +import { performOAuthFlow } from '@utils/oauth'; + +const program = process.env.PROGRAM as ProgramId | undefined; +const projectId = Number(process.env.PROJECT_ID); +const tokenFile = process.env.TOKEN_FILE + ? path.resolve(process.env.TOKEN_FILE) + : undefined; + +if ( + !program || + !Number.isSafeInteger(projectId) || + projectId <= 0 || + !tokenFile +) { + console.error('PROGRAM, PROJECT_ID, and TOKEN_FILE are required.'); + process.exit(2); +} + +function refuseNonRegularDestination(destinationPath: string): void { + const destinationStat = fs.lstatSync(destinationPath, { + throwIfNoEntry: false, + }); + if (destinationStat && !destinationStat.isFile()) { + throw new Error(`Refusing non-regular destination: ${destinationPath}`); + } +} + +function refuseUnsafeParentDirectory(directoryPath: string): void { + const directoryStat = fs.lstatSync(directoryPath); + const isOwnedByCurrentUser = directoryStat.uid === process.getuid?.(); + const isWritableByOthers = (directoryStat.mode & 0o022) !== 0; + if ( + !directoryStat.isDirectory() || + !isOwnedByCurrentUser || + isWritableByOthers + ) { + throw new Error(`Refusing unsafe destination directory: ${directoryPath}`); + } +} + +function refuseUnsafeAncestorDirectory(directoryPath: string): void { + const directoryStat = fs.lstatSync(directoryPath); + const isOwnedByTrustedAccount = + directoryStat.uid === process.getuid?.() || directoryStat.uid === 0; + const isWritableByOthers = (directoryStat.mode & 0o022) !== 0; + const hasStickyBit = (directoryStat.mode & 0o1000) !== 0; + if ( + !directoryStat.isDirectory() || + !isOwnedByTrustedAccount || + (isWritableByOthers && !hasStickyBit) + ) { + throw new Error(`Refusing unsafe ancestor directory: ${directoryPath}`); + } +} + +function refuseUnsafeDirectoryChain(parentDirectoryPath: string): void { + refuseUnsafeParentDirectory(parentDirectoryPath); + let childPath = parentDirectoryPath; + let ancestorPath = path.dirname(parentDirectoryPath); + while (ancestorPath !== childPath) { + refuseUnsafeAncestorDirectory(ancestorPath); + childPath = ancestorPath; + ancestorPath = path.dirname(ancestorPath); + } +} + +function writePrivateFileAtomically( + destinationPath: string, + contents: string, +): void { + const tempPath = `${destinationPath}.tmp-${process.pid}-${randomBytes( + 6, + ).toString('hex')}`; + const fileDescriptor = fs.openSync( + tempPath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW, + 0o600, + ); + try { + fs.writeSync(fileDescriptor, contents, null, 'utf8'); + fs.fchmodSync(fileDescriptor, 0o600); + fs.closeSync(fileDescriptor); + fs.renameSync(tempPath, destinationPath); + } catch (writeError) { + fs.rmSync(tempPath, { force: true }); + throw writeError; + } +} + +const tokenResponse = await performOAuthFlow({ + scopes: [...getOAuthScopesForProgram(program)], + projectId, +}); +const host = await HostResolution.fromAccessToken(tokenResponse.access_token, { + region: tokenResponse.posthog_region, +}); +const auth = await gatewayAuth(host, tokenResponse.access_token, program); + +if (auth.teamId !== projectId) { + console.error( + `Requested project ${projectId}, but the token was minted for project ${ + auth.teamId ?? 'unknown' + }. Nothing was written.`, + ); + process.exit(1); +} + +const sidecarFile = `${tokenFile}.json`; +const tokenDirectory = path.dirname(tokenFile); +fs.mkdirSync(tokenDirectory, { recursive: true, mode: 0o700 }); +refuseUnsafeDirectoryChain(tokenDirectory); +refuseNonRegularDestination(tokenFile); +refuseNonRegularDestination(sidecarFile); +writePrivateFileAtomically(tokenFile, auth.token); +writePrivateFileAtomically( + sidecarFile, + JSON.stringify({ + program, + projectId: auth.teamId, + gatewayUrl: auth.gatewayUrl, + refreshAtMs: auth.refreshAtMs, + tokenSha256: createHash('sha256').update(auth.token, 'utf8').digest('hex'), + }), +); +console.log(`minted gateway token for ${program} -> ${tokenFile}`); +process.exit(0);