From 048e461fd1271334468949920d1d8ca784dd02dc Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Wed, 26 Aug 2026 17:06:48 -0700 Subject: [PATCH] feat(auth): add auth bind-run to attribute installer runs on already signed-in machines An install run only reached POST /auth/onboarding_runs/{runId}/bind when a fresh OAuth login carried ?run=. A machine with existing credentials skips the login entirely, so the run never binds and the install is invisible to the activation funnel. `polylane auth bind-run [runId]` resolves the run id (env, then ~/.polylane/onboarding-run), requires existing credentials without prompting, posts the bind with the stored credential, and consumes the one-shot run file on success. No run or no credentials is a quiet exit 0 so the installer can always call it; a server error is surfaced and keeps the file. The API-key login path binds the same way, since it never touches the console URLs that carry the run id. Co-Authored-By: Claude Fable 5 --- src/auth/onboarding-run.ts | 4 +- src/commands/auth/bind-run.ts | 79 ++++++++++++ src/commands/auth/index.ts | 2 + src/commands/auth/login.ts | 16 ++- src/main.ts | 1 + test/auth-bind-run.test.ts | 223 ++++++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 src/commands/auth/bind-run.ts create mode 100644 test/auth-bind-run.test.ts diff --git a/src/auth/onboarding-run.ts b/src/auth/onboarding-run.ts index d1172cb..8bd43a6 100644 --- a/src/auth/onboarding-run.ts +++ b/src/auth/onboarding-run.ts @@ -13,7 +13,7 @@ const ONBOARDING_RUN_ID_PATTERN = export const ONBOARDING_RUN_QUERY_PARAM = 'run'; -function sanitize(value: string | undefined | null): string | null { +export function sanitizeOnboardingRunId(value: string | undefined | null): string | null { const trimmed = value?.trim(); if (!trimmed || !ONBOARDING_RUN_ID_PATTERN.test(trimmed)) return null; return trimmed.toLowerCase(); @@ -33,7 +33,7 @@ export function resolveOnboardingRunId(): string | null { // and always resolves at runtime. That exclusion is the only guarantee — the // bracket access is not a fallback, since esbuild bakes indexed reads // identically to dotted ones when the key is in the define map. - return sanitize(process.env['POLYLANE_ONBOARDING_RUN']) ?? sanitize(readOnboardingRunFile()); + return sanitizeOnboardingRunId(process.env['POLYLANE_ONBOARDING_RUN']) ?? sanitizeOnboardingRunId(readOnboardingRunFile()); } // The onboarding run funnel join is one-shot: once an auth flow has carried the diff --git a/src/commands/auth/bind-run.ts b/src/commands/auth/bind-run.ts new file mode 100644 index 0000000..731de3f --- /dev/null +++ b/src/commands/auth/bind-run.ts @@ -0,0 +1,79 @@ +import type { Command } from '../../command'; +import type { Config } from '../../config/schema'; +import { tryResolveCredential } from '../../auth/resolver'; +import { + consumeOnboardingRunFile, + resolveOnboardingRunId, + sanitizeOnboardingRunId, +} from '../../auth/onboarding-run'; +import { requestJson } from '../../client/http'; +import { CLIError } from '../../errors/base'; +import { ExitCode } from '../../errors/codes'; +import { formatOutput } from '../../output/formatter'; +import { getPositional } from '../helpers'; + +export interface BindRunResult { + bound: boolean; + runId: string | null; +} + +// Binds an installer-minted onboarding run to the account already signed in on +// this machine. The fresh-login flows carry the run id on the OAuth URLs / signup +// body, but a machine with existing credentials never enters those flows, so the +// installer (and a bare re-login) call this instead. Never a login prompt: the +// caller decides whether being signed out is a problem. +// +// `apiKey` pins the request to that key instead of whatever `resolveCredential` +// would pick: stored OAuth credentials outrank a config-file or prompted key +// there, so an API-key login on a machine with an older OAuth session would +// otherwise attribute the run to the OAuth account, not the key just validated. +export async function bindOnboardingRun( + config: Config, + runId: string | null, + apiKey?: string +): Promise { + if (!runId) { + process.stderr.write('No onboarding run to bind (POLYLANE_ONBOARDING_RUN unset, no ~/.polylane/onboarding-run).\n'); + return { bound: false, runId: null }; + } + if (!apiKey && !(await tryResolveCredential(config))) { + process.stderr.write(`Not signed in; onboarding run ${runId} left unbound.\n`); + return { bound: false, runId }; + } + const result = await requestJson<{ bound: boolean }>(config, { + method: 'POST', + url: `/v1/auth/onboarding_runs/${runId}/bind`, + ...(apiKey ? { headers: { 'x-api-key': apiKey }, noAuth: true } : {}), + }); + const bound = result.bound === true; + // The file is one-shot: spent once the server has joined the run to the account. + // Under --dry-run nothing was sent, so it must survive for the real call. + if (bound && !config.dryRun) consumeOnboardingRunFile(); + return { bound, runId }; +} + +export const authBindRunCommand: Command = { + name: 'auth bind-run', + description: 'Bind an installer onboarding run to the signed-in account', + operationId: 'auth.bindOnboardingRun', + positional: [{ name: 'run-id', description: 'Onboarding run UUID (default: POLYLANE_ONBOARDING_RUN, then ~/.polylane/onboarding-run)' }], + examples: ['polylane auth bind-run', 'polylane auth bind-run 5f0c9a4e-2b7d-4f11-9c3a-8e6b2d1a7c4f'], + async execute(config: Config, _flags, args: Record): Promise { + const explicit = getPositional(args, 0); + let runId: string | null; + if (explicit !== undefined) { + runId = sanitizeOnboardingRunId(explicit); + if (!runId) { + throw new CLIError(`Onboarding run id must be a UUID: ${explicit}`, ExitCode.USAGE, 'polylane auth bind-run '); + } + } else { + runId = resolveOnboardingRunId(); + } + const result = await bindOnboardingRun(config, runId); + if (config.output === 'json') { + formatOutput(config, result); + return; + } + if (result.bound) process.stdout.write(`Bound onboarding run ${result.runId} to the signed-in account\n`); + }, +}; diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index 1c7b263..0f37e50 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -1,3 +1,4 @@ +import { authBindRunCommand } from './bind-run'; import { authLoginCommand } from './login'; import { authLogoutCommand } from './logout'; import { authStatusCommand } from './status'; @@ -12,4 +13,5 @@ export const authCommands = [ authRefreshCommand, authSignupCommand, authWhoamiCommand, + authBindRunCommand, ]; diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 5f51087..173557e 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -5,7 +5,8 @@ import { promptPassword, promptSelect, outro } from '../../utils/prompt'; import { isInteractive } from '../../utils/env'; import { oauthBrowserFlow, oauthDeviceCodeFlow, type BrowserFlowOptions } from '../../auth/oauth'; import { emailSignup } from './signup'; -import { consumeOnboardingRunFile } from '../../auth/onboarding-run'; +import { consumeOnboardingRunFile, resolveOnboardingRunId } from '../../auth/onboarding-run'; +import { bindOnboardingRun } from './bind-run'; import { writeCredentials } from '../../auth/credentials'; import type { OAuthCredential } from '../../auth/types'; import { writeConfigFile } from '../../config/loader'; @@ -127,6 +128,19 @@ async function apiKeyLogin(config: Config, key: string): Promise { ...(wsId ? { workspace_id: wsId } : {}), }); + // An API-key login never touches the console, so nothing upstream carried the + // installer's run id: bind it here with the key itself, not the resolver's + // pick (stored OAuth credentials would win there). Best effort — the sign-in + // already succeeded, and attribution must never fail it. + const runId = resolveOnboardingRunId(); + if (runId) { + try { + await bindOnboardingRun(configWithKey, runId, key); + } catch { + // non-fatal + } + } + outro(`API key saved to ~/.polylane/config.json`); } diff --git a/src/main.ts b/src/main.ts index b9144f9..06b716b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,7 @@ const NO_AUTH_COMMANDS = new Set([ 'auth logout', 'auth status', 'auth signup', + 'auth bind-run', 'config show', 'config set', 'telemetry status', diff --git a/test/auth-bind-run.test.ts b/test/auth-bind-run.test.ts new file mode 100644 index 0000000..6efa3e2 --- /dev/null +++ b/test/auth-bind-run.test.ts @@ -0,0 +1,223 @@ +import { describe, it, before, beforeEach, after, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { GlobalFlags } from '../src/types/flags'; + +const tempHome = mkdtempSync(join(tmpdir(), 'polylane-bind-run-test-')); +process.env.HOME = tempHome; +delete process.env.POLYLANE_ONBOARDING_RUN; +delete process.env.POLYLANE_API_KEY; + +const configDir = join(tempHome, '.polylane'); +mkdirSync(configDir, { recursive: true }); + +const RUN_FILE = join(configDir, 'onboarding-run'); +const CREDENTIALS_FILE = join(configDir, 'credentials.json'); +const ENV_RUN = '11111111-2222-3333-4444-555555555555'; +const FILE_RUN = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + +// `auth login` prints clack chrome (outro) to stdout; stub it so the JSON +// assertions only see command output. ?real keeps Node 20's mock.module from +// being inert against an already-loaded canonical module. +const realPrompt = (await import('../src/utils/prompt.ts?real' as string)) as typeof import('../src/utils/prompt'); +mock.module('../src/utils/prompt', { + namedExports: { ...realPrompt, outro: (): void => {} }, +}); + +const { authBindRunCommand } = await import('../src/commands/auth/bind-run'); +const { authLoginCommand } = await import('../src/commands/auth/login'); +const { mockConfig } = await import('./helpers/config'); +const { ApiError } = await import('../src/errors/api'); +const { ExitCode } = await import('../src/errors/codes'); + +function writeCredentialsFile(): void { + writeFileSync( + CREDENTIALS_FILE, + JSON.stringify({ + access_token: 'tok_access', + refresh_token: 'tok_refresh', + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + token_type: 'Bearer', + scope: 'read', + }), + { mode: 0o600 } + ); +} + +describe('auth bind-run', () => { + const originalFetch = globalThis.fetch; + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + let stdout = ''; + let stderr = ''; + let bindCalls: { url: string; auth: string | undefined }[] = []; + let bindStatus = 200; + + before(() => { + process.stdout.write = ((chunk: unknown): boolean => { + stdout += String(chunk); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown): boolean => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + }); + + beforeEach(() => { + stdout = ''; + stderr = ''; + bindCalls = []; + bindStatus = 200; + delete process.env.POLYLANE_ONBOARDING_RUN; + rmSync(RUN_FILE, { force: true }); + rmSync(CREDENTIALS_FILE, { force: true }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes('/v1/auth/whoami')) { + return Response.json({ success: true, error: null, result: { id: 'user_1', email: 'dev@acme.com' } }); + } + if (url.includes('/v1/workspaces')) { + return Response.json({ success: true, error: null, result: { items: [], count: 0 } }); + } + if (url.includes('/v1/auth/onboarding_runs/')) { + const headers = new Headers(init?.headers); + bindCalls.push({ url, auth: headers.get('authorization') ?? headers.get('x-api-key') ?? undefined }); + if (bindStatus === 401) { + return Response.json( + { success: false, error: { message: 'Unauthorized', detail: 'Session expired' }, result: null }, + { status: 401 } + ); + } + return Response.json({ success: true, error: null, result: { bound: true } }); + } + throw new Error(`Unexpected request in test: ${url}`); + }) as typeof fetch; + }); + + after(() => { + globalThis.fetch = originalFetch; + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + rmSync(tempHome, { recursive: true, force: true }); + }); + + async function run(args: Record = {}, config = mockConfig()): Promise { + await authBindRunCommand.execute(config, {} as GlobalFlags, args); + } + + it('binds the env run id with the stored credential and consumes the file', async () => { + writeCredentialsFile(); + process.env.POLYLANE_ONBOARDING_RUN = ENV_RUN; + writeFileSync(RUN_FILE, FILE_RUN); + await run(); + assert.equal(bindCalls.length, 1); + assert.ok(bindCalls[0]!.url.endsWith(`/v1/auth/onboarding_runs/${ENV_RUN}/bind`)); + assert.equal(bindCalls[0]!.auth, 'Bearer tok_access'); + assert.equal(existsSync(RUN_FILE), false); + assert.deepEqual(JSON.parse(stdout), { bound: true, runId: ENV_RUN }); + }); + + it('binds the file run id when the env var is unset', async () => { + writeCredentialsFile(); + writeFileSync(RUN_FILE, `${FILE_RUN}\n`); + await run(); + assert.equal(bindCalls.length, 1); + assert.ok(bindCalls[0]!.url.endsWith(`/v1/auth/onboarding_runs/${FILE_RUN}/bind`)); + assert.equal(existsSync(RUN_FILE), false); + assert.deepEqual(JSON.parse(stdout), { bound: true, runId: FILE_RUN }); + }); + + it('prefers an explicit positional run id over env and file', async () => { + writeCredentialsFile(); + process.env.POLYLANE_ONBOARDING_RUN = ENV_RUN; + const explicit = '99999999-8888-7777-6666-555555555555'; + await run({ _: [explicit.toUpperCase()] }); + assert.ok(bindCalls[0]!.url.endsWith(`/v1/auth/onboarding_runs/${explicit}/bind`)); + }); + + it('is a no-op that exits 0 when no run id resolves', async () => { + writeCredentialsFile(); + await run(); + assert.equal(bindCalls.length, 0); + assert.deepEqual(JSON.parse(stdout), { bound: false, runId: null }); + assert.match(stderr, /no onboarding run/i); + }); + + it('is a no-op that exits 0 and leaves the file when there are no credentials', async () => { + writeFileSync(RUN_FILE, FILE_RUN); + await run(); + assert.equal(bindCalls.length, 0); + assert.equal(existsSync(RUN_FILE), true); + assert.deepEqual(JSON.parse(stdout), { bound: false, runId: FILE_RUN }); + assert.match(stderr, /not signed in/i); + }); + + it('surfaces a server 401 and keeps the file', async () => { + writeCredentialsFile(); + writeFileSync(RUN_FILE, FILE_RUN); + bindStatus = 401; + await assert.rejects(run(), (err: unknown) => { + assert.ok(err instanceof ApiError); + assert.equal(err.status, 401); + assert.equal(err.exitCode, ExitCode.AUTH); + return true; + }); + assert.equal(bindCalls.length, 1); + assert.equal(existsSync(RUN_FILE), true); + assert.equal(stdout, ''); + }); + + it('rejects a malformed positional run id', async () => { + writeCredentialsFile(); + await assert.rejects(run({ _: ['not-a-uuid'] }), (err: unknown) => { + assert.ok(err instanceof Error); + assert.match(err.message, /uuid/i); + return true; + }); + assert.equal(bindCalls.length, 0); + }); + + it('prints a text line instead of JSON in text mode', async () => { + writeCredentialsFile(); + process.env.POLYLANE_ONBOARDING_RUN = ENV_RUN; + await run({}, mockConfig({ output: 'text' })); + assert.match(stdout, new RegExp(`Bound onboarding run ${ENV_RUN}`)); + }); + + describe('via auth login --api-key', () => { + it('binds the resolved run with the key and consumes the file', async () => { + writeFileSync(RUN_FILE, FILE_RUN); + await authLoginCommand.execute(mockConfig(), {} as GlobalFlags, { apiKey: 'sk_test' }); + assert.equal(bindCalls.length, 1); + assert.ok(bindCalls[0]!.url.endsWith(`/v1/auth/onboarding_runs/${FILE_RUN}/bind`)); + assert.equal(bindCalls[0]!.auth, 'sk_test'); + assert.equal(existsSync(RUN_FILE), false); + }); + + it('binds with the key even when stored OAuth credentials would outrank it', async () => { + writeCredentialsFile(); + writeFileSync(RUN_FILE, FILE_RUN); + await authLoginCommand.execute(mockConfig(), {} as GlobalFlags, { apiKey: 'sk_test' }); + assert.equal(bindCalls.length, 1); + assert.equal(bindCalls[0]!.auth, 'sk_test'); + assert.equal(existsSync(RUN_FILE), false); + }); + + it('skips the bind call when no run resolves', async () => { + await authLoginCommand.execute(mockConfig(), {} as GlobalFlags, { apiKey: 'sk_test' }); + assert.equal(bindCalls.length, 0); + }); + + it('still signs in when the bind is rejected, and keeps the file', async () => { + writeFileSync(RUN_FILE, FILE_RUN); + bindStatus = 401; + await authLoginCommand.execute(mockConfig(), {} as GlobalFlags, { apiKey: 'sk_test' }); + assert.equal(bindCalls.length, 1); + assert.equal(existsSync(RUN_FILE), true); + assert.equal(existsSync(join(configDir, 'config.json')), true); + }); + }); +});