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 src/auth/onboarding-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
79 changes: 79 additions & 0 deletions src/commands/auth/bind-run.ts
Original file line number Diff line number Diff line change
@@ -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<BindRunResult> {
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<string, unknown>): Promise<void> {
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 <uuid>');
}
} 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`);
},
};
2 changes: 2 additions & 0 deletions src/commands/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { authBindRunCommand } from './bind-run';
import { authLoginCommand } from './login';
import { authLogoutCommand } from './logout';
import { authStatusCommand } from './status';
Expand All @@ -12,4 +13,5 @@ export const authCommands = [
authRefreshCommand,
authSignupCommand,
authWhoamiCommand,
authBindRunCommand,
];
16 changes: 15 additions & 1 deletion src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -127,6 +128,19 @@ async function apiKeyLogin(config: Config, key: string): Promise<void> {
...(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`);
}

Expand Down
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
223 changes: 223 additions & 0 deletions test/auth-bind-run.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}, config = mockConfig()): Promise<void> {
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);
});
});
});
Loading