From 08447ca15a1508a3b0866a457f2486885d291f8d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 7 Sep 2026 18:20:54 -0700 Subject: [PATCH 1/2] fix(cli): clarify login method selection --- apps/docs/content/docs/cli/authentication.mdx | 50 +++++--- apps/docs/content/docs/cli/commands.mdx | 5 +- apps/docs/content/docs/cli/reference.mdx | 5 +- packages/sim-cli/README.md | 17 ++- packages/sim-cli/src/auth/device-flow.test.ts | 15 ++- packages/sim-cli/src/auth/device-flow.ts | 5 +- packages/sim-cli/src/auth/oauth-flow.test.ts | 4 +- packages/sim-cli/src/auth/oauth-flow.ts | 8 +- packages/sim-cli/src/commands/auth.test.ts | 115 +++++++++++++++--- packages/sim-cli/src/commands/auth.ts | 81 ++++++------ 10 files changed, 197 insertions(+), 108 deletions(-) diff --git a/apps/docs/content/docs/cli/authentication.mdx b/apps/docs/content/docs/cli/authentication.mdx index 977f71d5f23..accd6544e3b 100644 --- a/apps/docs/content/docs/cli/authentication.mdx +++ b/apps/docs/content/docs/cli/authentication.mdx @@ -5,9 +5,10 @@ description: Sign in from the terminal, authenticate CI with an API key, and kee import { Callout } from 'fumadocs-ui/components/callout' -`sim login` signs you in through your browser and stores a short-lived login -that renews itself and can be revoked at any time. In CI you supply an API key -through the environment instead. +`sim login` signs you in through your browser. It prefers OAuth, which stores a +short-lived login that renews itself, and selects API-key pairing for remote +terminals or servers without OAuth support. In CI you supply an existing API +key through the environment instead. ## Signing in @@ -15,7 +16,21 @@ through the environment instead. sim login ``` -The CLI opens your browser on Sim's sign-in page, then on a consent page that +Choose a method explicitly when the credential type matters: + +```bash +sim login --method oauth +sim login --method api-key +``` + +`--method oauth` requires OAuth support and never falls back to an API key. The +server must enable `OAUTH_PROVIDER_ENABLED=true`. Explicit OAuth selection also +overrides SSH/headless detection; your browser still needs to reach the CLI's +local callback. `--method api-key` uses pairing-code approval to create a new +permanent API key. To supply an existing key, use the global `--api-key ` +option or `SIM_API_KEY` instead. + +OAuth login opens your browser on Sim's sign-in page, then on a consent page that names the Sim CLI and what it will be able to do. Approve, and the browser hands control back to the terminal: @@ -46,21 +61,21 @@ your login. | Option | What it does | | --- | --- | -| `--no-browser` | Print the URL instead of opening a browser | -| `--browserless` | Use the pairing-code handoff instead (see below) | +| `--method ` | `oauth` requires OAuth login; `api-key` creates a permanent key through pairing. Auto-selects when omitted | +| `--no-browser` | Print the approval URL without opening it; works with either method | | `--read-only` | Ask only for permission to read, never to change anything | | `--callback-port ` | Pin the loopback callback port, primarily for an SSH session that forwards the same fixed port | -| `--scope ` | Key space for the pairing-code handoff. Only `copilot` changes anything, and it forces that flow | | `-y, --yes` | Overwrite an existing API-key profile without prompting | ### Over SSH or in a container -The browser login needs your browser to reach a listener on the machine running +OAuth login needs your browser to reach a listener on the machine running `sim`. When it cannot — an SSH session, a dev container, a remote box — use the -pairing-code handoff, which the CLI selects automatically in an SSH session: +API-key pairing flow. The CLI selects it automatically in an SSH session when +no method or callback port is specified: ```bash -sim login --browserless +sim login --method api-key --no-browser ``` The terminal prints a pairing code and a URL you can open on any device: @@ -86,15 +101,16 @@ approving. That check is what binds the approval to your terminal. The handoff issues a permanent personal API key rather than a renewing login, so revoke it under **Settings → API keys** when you are done with that machine. It is also the path for a deployment that predates OAuth sign-in, or one with -the provider switched off; the CLI detects that and falls back on its own. +the provider switched off; the CLI detects that and selects API-key login when +`--method` is omitted. An explicit `--method oauth` fails in that case. -`--read-only` and `--callback-port` belong to the browser login and have no +`--read-only` and `--callback-port` belong to OAuth login and have no meaning here, so combining either with the handoff stops the login rather than storing a credential you did not ask for. If your SSH session forwards a port -from the remote loopback interface to the browser's machine, pass that same -`--callback-port ` on its own. An ordinary container port publication +from the remote loopback interface to the browser's machine, use +`--method oauth --callback-port ` with that port. An ordinary container port publication cannot reach a listener bound to the container's own loopback interface; use -`--browserless` there. +`--method api-key` there. ### Picking a workspace @@ -160,7 +176,7 @@ authentication profile entirely is refused until its workspace profiles are removed, so it cannot leave dangling references. -For a login created with `--browserless`, `sim logout` removes the API key from +For a login created with `--method api-key`, `sim logout` removes the API key from disk but does **not** revoke it. Revoke keys under **Settings → API keys**. @@ -279,5 +295,5 @@ a copied token from surviving an ambiguous refresh. ## Organization audit logs `sim audit-logs` requires a **personal** credential — an OAuth login, or the -personal API key `sim login --browserless` issues. A workspace-scoped key cannot +personal API key `sim login --method api-key` issues. A workspace-scoped key cannot read organization-level audit logs. diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index c55d7e89044..a108204a7c3 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -64,9 +64,8 @@ sim login [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Key space for the pairing-code handoff; only "copilot" changes anything, and it forces that flow. Defaults to `platform`. | -| `--no-browser` | No | Print the URL instead of opening a browser. | -| `--browserless` | No | Use the pairing-code handoff for a terminal whose browser cannot reach it (SSH, containers). | +| `--method ` | No | Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. | +| `--no-browser` | No | Print the approval URL without opening it (either login method). | | `--read-only` | No | Ask only for permission to read, never to change anything. | | `--callback-port ` | No | Pin the local port the browser returns to. | | `-y, --yes` | No | Overwrite an existing API-key profile without prompting. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 792ec073437..6b61ccc2022 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -38,9 +38,8 @@ sim login [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Key space for the pairing-code handoff; only "copilot" changes anything, and it forces that flow. Defaults to `platform`. | -| `--no-browser` | No | Print the URL instead of opening a browser. | -| `--browserless` | No | Use the pairing-code handoff for a terminal whose browser cannot reach it (SSH, containers). | +| `--method ` | No | Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. | +| `--no-browser` | No | Print the approval URL without opening it (either login method). | | `--read-only` | No | Ask only for permission to read, never to change anything. | | `--callback-port ` | No | Pin the local port the browser returns to. | | `-y, --yes` | No | Overwrite an existing API-key profile without prompting. | diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index d9b230edbcd..f8a154b588e 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -33,16 +33,23 @@ Sign in to the default profile: sim login ``` -The CLI opens Sim in your browser, asks you to approve the requested access, +With no `--method`, the CLI prefers OAuth when the server offers it and a local +browser callback is possible. It selects API-key pairing for remote terminals +or servers without OAuth. Use `sim login --method oauth` to require OAuth; +if the server does not offer it, login fails without creating an API key. + +OAuth login opens Sim in your browser, asks you to approve the requested access, and receives the one-time authorization code on a loopback callback. It stores a short-lived OAuth login that renews automatically and can be revoked under **Settings → General → Authorized apps**. Choose a default workspace afterward with `sim configure --set-workspace `. -Use `sim login --no-browser` to print the OAuth URL without opening it. The -browser must still be able to reach the CLI's loopback callback. Over SSH or in -a container without port forwarding, use `sim login --browserless`; that -pairing-code fallback creates a permanent personal API key instead. +Use `--no-browser` with either method to print the approval URL without opening +it. OAuth still needs the browser to reach the CLI's loopback callback. Over SSH +or in a container without port forwarding, use +`sim login --method api-key --no-browser` to approve from another device and +create a permanent personal API key. `--method api-key` creates a new key; +the global `--api-key ` option supplies an existing one. Check the active profile and verify that its endpoint, credential, and workspace work together: diff --git a/packages/sim-cli/src/auth/device-flow.test.ts b/packages/sim-cli/src/auth/device-flow.test.ts index be21ce0e346..5eea4972c44 100644 --- a/packages/sim-cli/src/auth/device-flow.test.ts +++ b/packages/sim-cli/src/auth/device-flow.test.ts @@ -190,9 +190,7 @@ describe('createAuthRequest', () => { const prefixed = 'https://host.test/sim' const auth = createAuthRequest() - expect(buildApprovalUrl(prefixed, auth, 'platform')).toMatch( - /^https:\/\/host\.test\/sim\/cli\/auth\?/ - ) + expect(buildApprovalUrl(prefixed, auth)).toMatch(/^https:\/\/host\.test\/sim\/cli\/auth\?/) // `spyOn`, like the rest of this file: `restoreAllMocks` in teardown undoes // it, whereas a `stubGlobal` would outlive the test and leak this @@ -210,13 +208,18 @@ describe('createAuthRequest', () => { it('omits an absent workspace rather than sending it blank', () => { const auth = createAuthRequest() - expect(buildApprovalUrl(ENDPOINT, auth, 'platform')).not.toContain('workspace=') - expect(buildApprovalUrl(ENDPOINT, auth, 'platform', 'ws_1')).toContain('workspace=ws_1') + expect(buildApprovalUrl(ENDPOINT, auth)).not.toContain('workspace=') + expect(buildApprovalUrl(ENDPOINT, auth, 'ws_1')).toContain('workspace=ws_1') + }) + + it('always requests a platform API key', () => { + const url = new URL(buildApprovalUrl(ENDPOINT, createAuthRequest())) + expect(url.searchParams.get('scope')).toBe('platform') }) it('never puts the poll secret in the browser URL', () => { const auth = createAuthRequest() - const url = buildApprovalUrl(ENDPOINT, auth, 'platform', 'ws_1') + const url = buildApprovalUrl(ENDPOINT, auth, 'ws_1') expect(url).toContain(encodeURIComponent(auth.challenge)) expect(url).not.toContain(auth.pollSecret) }) diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts index b8e9ee7a041..5e319905a67 100644 --- a/packages/sim-cli/src/auth/device-flow.ts +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -54,7 +54,7 @@ const RETRYABLE_POLL_STATUSES = new Set([409, 429, 500, 502, 503, 504]) */ const TRANSPORT_FAILURES_BEFORE_WARNING = 3 -export type CliAuthScope = 'copilot' | 'platform' +type CliAuthScope = 'copilot' | 'platform' export interface AuthRequest { /** Semi-public rendezvous handle; travels in the browser URL. */ @@ -103,14 +103,13 @@ export function createAuthRequest(): AuthRequest { export function buildApprovalUrl( endpoint: string, auth: AuthRequest, - scope: CliAuthScope, workspaceId?: string ): string { return buildUrl(endpoint, APPROVAL_PATH, { request: auth.request, challenge: auth.challenge, pairing: auth.pairing, - scope, + scope: 'platform', workspace: workspaceId, }) } diff --git a/packages/sim-cli/src/auth/oauth-flow.test.ts b/packages/sim-cli/src/auth/oauth-flow.test.ts index b3e122ee83d..8322ab06464 100644 --- a/packages/sim-cli/src/auth/oauth-flow.test.ts +++ b/packages/sim-cli/src/auth/oauth-flow.test.ts @@ -300,7 +300,7 @@ describe('loginWithBrowser', () => { expect(response.headers['cache-control']).toBe('no-store') }) - it('gives up after the timeout with the browserless fallback named', async () => { + it('gives up after the timeout with an explicit API-key login alternative', async () => { vi.stubGlobal('fetch', vi.fn()) await expect( loginWithBrowser(ENDPOINT, { @@ -308,7 +308,7 @@ describe('loginWithBrowser', () => { onAuthorizeUrl: () => {}, timeoutMs: 20, }) - ).rejects.toThrow('--browserless') + ).rejects.toThrow('--method api-key') }) }) diff --git a/packages/sim-cli/src/auth/oauth-flow.ts b/packages/sim-cli/src/auth/oauth-flow.ts index 6e28ecb29e0..391081631be 100644 --- a/packages/sim-cli/src/auth/oauth-flow.ts +++ b/packages/sim-cli/src/auth/oauth-flow.ts @@ -432,7 +432,7 @@ function listenForCallback( finish({ ok: false, error: new SimApiError( - `Timed out after ${Math.round(timeoutMs / 60000)} minutes waiting for the browser. Run sim login again, or use --browserless if this terminal's browser cannot reach it.`, + `Timed out after ${Math.round(timeoutMs / 60000)} minutes waiting for the browser. Run sim login again, or use --method api-key if this terminal's browser cannot reach it.`, 0 ), }), @@ -567,9 +567,9 @@ export async function loginWithBrowser( /** * Whether this terminal's browser is unlikely to reach a loopback listener on - * this machine: an SSH session, or a Linux box with no display. The signals - * Railway and Stripe use to auto-select their pairing flows; `--browserless` - * forces it and `--callback-port` overrides the guess. + * this machine: an SSH session, or a Linux box with no display. An explicit + * `--method` selects the flow without this guess; `--callback-port` opts into + * OAuth when a forwarded port makes the loopback listener reachable. */ export function isLikelyRemoteSession( env: NodeJS.ProcessEnv = process.env, diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 8da68b7054d..673e3e6fc2d 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -1375,45 +1375,120 @@ describe('login command — OAuth', () => { await expect(login('--callback-port', '70000')).rejects.toThrow('Invalid --callback-port') }) - it('falls back to the pairing code under --browserless', async () => { + it('creates an API key without OAuth discovery when that method is selected', async () => { setInteractive(false) - await login('--browserless') + await login('--method', 'api-key') expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.discoverOAuthProvider).not.toHaveBeenCalled() expect(mocks.pollForKey).toHaveBeenCalledOnce() + expect(mocks.writeCredentialsProfile).toHaveBeenCalledWith('default', { + kind: 'api_key', + apiKey: 'sim-key', + }) }) - it('falls back to the pairing code in a remote session', async () => { + it.each([false, true])('honors explicit OAuth with remote detection %s', async (remote) => { setInteractive(false) - mocks.isLikelyRemoteSession.mockReturnValue(true) - await login() + mocks.isLikelyRemoteSession.mockReturnValue(remote) - expect(mocks.loginWithBrowser).not.toHaveBeenCalled() - expect(mocks.pollForKey).toHaveBeenCalledOnce() + await login('--method', 'oauth') + + expect(mocks.loginWithBrowser).toHaveBeenCalledOnce() + expect(mocks.pollForKey).not.toHaveBeenCalled() }) - it('uses the pairing code for a copilot-scope key, which only the handoff mints', async () => { + it.each([false, true])( + 'refuses unavailable explicit OAuth with remote detection %s without minting a key', + async (remote) => { + setInteractive(false) + mocks.isLikelyRemoteSession.mockReturnValue(remote) + mocks.discoverOAuthProvider.mockResolvedValue('unavailable') + + await expect(login('--method', 'oauth')).rejects.toThrow('does not offer OAuth sign-in') + + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + expect(mocks.pollForKey).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + } + ) + + it.each([ + { args: ['--read-only'], message: 'API-key login cannot issue a read-only login' }, + { + args: ['--callback-port', '8976'], + message: 'API-key login has no local callback', + }, + ])('rejects OAuth-only options for API-key login: $args', async ({ args, message }) => { setInteractive(false) - mocks.pollForKey.mockResolvedValue({ - apiKey: 'sim-key', - scope: 'copilot', - workspaceBound: false, - workspaceId: undefined, - }) - await login('--scope', 'copilot') - expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + await expect(login('--method', 'api-key', ...args)).rejects.toThrow(message) + expect(mocks.discoverOAuthProvider).not.toHaveBeenCalled() + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.pollForKey).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() }) - it('refuses an unreachable endpoint rather than guessing it lacks the provider', async () => { - setInteractive(false) - mocks.discoverOAuthProvider.mockResolvedValue('unreachable') + it.each([ + { args: ['--method', 'password'], code: 'commander.invalidArgument' }, + { args: ['--method'], code: 'commander.optionMissingArgument' }, + { args: ['--browserless'], code: 'commander.unknownOption' }, + { args: ['--scope', 'copilot'], code: 'commander.unknownOption' }, + { args: ['--scope', 'platform'], code: 'commander.unknownOption' }, + ])('rejects invalid login options before authentication: $args', async ({ args, code }) => { + const command = loginCommand() + .exitOverride() + .configureOutput({ writeErr: () => {} }) + + await expect(command.parseAsync(args, { from: 'user' })).rejects.toMatchObject({ code }) - await expect(login()).rejects.toThrow('Could not reach https://sim.ai') + expect(mocks.profileFrom).not.toHaveBeenCalled() + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() expect(mocks.pollForKey).not.toHaveBeenCalled() }) + it('falls back to the pairing code in a remote session', async () => { + setInteractive(false) + mocks.isLikelyRemoteSession.mockReturnValue(true) + await login() + + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.pollForKey).toHaveBeenCalledOnce() + }) + + it.each([{ args: [] }, { args: ['--method', 'api-key'] }])( + 'refuses to store a copilot key returned by the server with method args $args', + async ({ args }) => { + setInteractive(false) + mocks.discoverOAuthProvider.mockResolvedValue('unavailable') + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'copilot', + workspaceBound: false, + workspaceId: undefined, + }) + await expect(login(...args)).rejects.toThrow('the CLI requires a platform API key') + + expect(mocks.loginWithBrowser).not.toHaveBeenCalled() + expect(mocks.pollForKey).toHaveBeenCalledOnce() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + } + ) + + it.each([{ args: [] }, { args: ['--method', 'oauth'] }])( + 'refuses an unreachable endpoint with method args $args without minting a key', + async ({ args }) => { + setInteractive(false) + mocks.discoverOAuthProvider.mockResolvedValue('unreachable') + + await expect(login(...args)).rejects.toThrow('Could not reach https://sim.ai') + expect(mocks.pollForKey).not.toHaveBeenCalled() + } + ) + it('requires logout before replacing a stored OAuth login', async () => { setInteractive(false) mocks.readCredentialsProfile.mockReturnValue({ diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 7436f4da1bc..70dc81646c3 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -3,13 +3,8 @@ import { randomBytes } from 'node:crypto' import { createInterface } from 'node:readline/promises' import { getErrorMessage } from '@sim/utils/errors' import chalk from 'chalk' -import { Command } from 'commander' -import { - buildApprovalUrl, - type CliAuthScope, - createAuthRequest, - pollForKey, -} from '../auth/device-flow' +import { Command, Option } from 'commander' +import { buildApprovalUrl, createAuthRequest, pollForKey } from '../auth/device-flow' import { discoverOAuthProvider, grantsWriteAccess, @@ -367,9 +362,8 @@ function addProfileCommand(): Command { } interface LoginOptions { - scope: string browser: boolean - browserless?: boolean + method?: 'oauth' | 'api-key' readOnly?: boolean callbackPort?: string yes?: boolean @@ -378,29 +372,29 @@ interface LoginOptions { /** * Which login to run. * - * OAuth is the default: it leaves a short-lived, revocable login instead of a - * permanent key. The pairing-code handoff remains for the cases OAuth's - * loopback redirect cannot serve — a terminal whose browser is on another - * machine (`--browserless`, or an SSH session detected), a copilot-scope key - * (which only the handoff mints), and a server without the provider (an older - * Sim, or one with it switched off), which discovery reports before the browser - * opens. An unreachable server is an error, not a fallback: a typo'd endpoint - * must not be mistaken for one that lacks the feature. + * An explicit method selects the credential type: OAuth never falls back to + * an API key, and API-key login skips OAuth discovery. With no method selected, + * OAuth is preferred, with the pairing-code handoff used for remote terminals + * or a server without the provider. An unreachable server remains + * an error rather than being mistaken for one that lacks the feature. * * `--callback-port` overrides the remote-session guess, because naming the port * is how someone with an SSH tunnel says the loopback redirect does reach them. */ async function chooseLoginFlow( profile: ResolvedProfile, - options: LoginOptions, - scope: CliAuthScope + options: LoginOptions ): Promise<'oauth' | 'handoff'> { requireSecureEndpoint(profile.endpoint) - if (options.browserless || scope === 'copilot') return 'handoff' - if (isLikelyRemoteSession() && options.callbackPort === undefined) { + if (options.method === 'api-key') return 'handoff' + if ( + options.method === undefined && + isLikelyRemoteSession() && + options.callbackPort === undefined + ) { console.log( chalk.dim( - 'This looks like a remote session, so the browser on this machine cannot finish an OAuth login; using the pairing code instead. Forward a port and pass --callback-port to sign in through the browser anyway.\n' + 'This looks like a remote session; using the pairing code to create a personal API key. To use OAuth, forward a port and pass --method oauth --callback-port .\n' ) ) return 'handoff' @@ -410,9 +404,15 @@ async function chooseLoginFlow( throw new SimApiError(`Could not reach ${profile.endpoint}. Check the endpoint.`, 0) } if (status === 'unavailable') { + if (options.method === 'oauth') { + throw new SimApiError( + `${profile.endpoint} does not offer OAuth sign-in. Enable OAuth on the server or explicitly choose sim login --method api-key.`, + 0 + ) + } console.log( chalk.dim( - `${profile.endpoint} does not offer OAuth sign-in; using the pairing code instead.\n` + `${profile.endpoint} does not offer OAuth sign-in; using the pairing code to create a personal API key.\n` ) ) return 'handoff' @@ -532,16 +532,13 @@ async function loginWithOAuth( export function loginCommand(): Command { return new Command('login') .description('Sign in through the browser and store the login for the profile') - .option( - '--scope ', - 'Key space for the pairing-code handoff; only "copilot" changes anything, and it forces that flow', - 'platform' - ) - .option('--no-browser', 'Print the URL instead of opening a browser') - .option( - '--browserless', - 'Use the pairing-code handoff for a terminal whose browser cannot reach it (SSH, containers)' + .addOption( + new Option( + '--method ', + 'Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted)' + ).choices(['oauth', 'api-key']) ) + .option('--no-browser', 'Print the approval URL without opening it (either login method)') .option('--read-only', 'Ask only for permission to read, never to change anything') .option('--callback-port ', 'Pin the local port the browser returns to') .option('-y, --yes', 'Overwrite an existing API-key profile without prompting') @@ -557,11 +554,6 @@ export function loginCommand(): Command { ) } - if (options.scope !== 'platform' && options.scope !== 'copilot') { - throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) - } - const scope = options.scope as CliAuthScope - const snapshot = readLoginProfileSnapshot(profile.name) const storedCredential = snapshot.credential if (storedCredential?.kind === 'oauth') { @@ -581,7 +573,7 @@ export function loginCommand(): Command { /** Validate a pinned port before opening a browser or starting either flow. */ const callbackPort = parseCallbackPort(options.callbackPort) - const loginFlow = await chooseLoginFlow(profile, options, scope) + const loginFlow = await chooseLoginFlow(profile, options) /** * Neither flag has a meaning in the handoff: it mints a permanent, * full-power API key on the server and never opens a local listener. @@ -592,13 +584,13 @@ export function loginCommand(): Command { if (loginFlow === 'handoff') { if (options.readOnly) { throw new SimApiError( - 'The pairing-code handoff cannot issue a read-only login; it mints a full API key. Drop --read-only, or sign in through the browser.', + 'API-key login cannot issue a read-only login. Use --method oauth, or drop --read-only.', 0 ) } if (callbackPort !== undefined) { throw new SimApiError( - 'The pairing-code handoff has no local callback, so --callback-port does not apply. Drop it, or sign in through the browser.', + 'API-key login has no local callback, so --callback-port does not apply. Use --method oauth, or drop --callback-port.', 0 ) } @@ -609,7 +601,7 @@ export function loginCommand(): Command { await loginWithOAuth(profile, options, callbackPort, snapshot) return } - await loginWithHandoff(profile, options, scope, snapshot) + await loginWithHandoff(profile, options, snapshot) }) }) } @@ -618,11 +610,10 @@ export function loginCommand(): Command { async function loginWithHandoff( profile: ResolvedProfile, options: LoginOptions, - scope: CliAuthScope, expected: LoginProfileSnapshot ): Promise { const auth = createAuthRequest() - const url = buildApprovalUrl(profile.endpoint, auth, scope, profile.workspaceId ?? undefined) + const url = buildApprovalUrl(profile.endpoint, auth, profile.workspaceId ?? undefined) console.log( `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(safeOneLine(profile.name))}` @@ -636,9 +627,9 @@ async function loginWithHandoff( const key = await pollForKey(profile.endpoint, auth) try { - if (key.scope !== scope) { + if (key.scope !== 'platform') { throw new SimApiError( - `Server issued a ${key.scope} key but this profile needs a ${scope} key. Update the Sim deployment, or run: sim login --scope ${key.scope}`, + `Server issued a ${key.scope} key, but the CLI requires a platform API key. Update the Sim deployment and try again.`, 0 ) } From 7d8a9c618e22ea23521fb924a02556150e8a7ef3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 7 Sep 2026 18:53:06 -0700 Subject: [PATCH 2/2] fix(cli): correct API-key authentication guidance --- apps/docs/content/docs/cli/authentication.mdx | 14 +++++++++----- packages/sim-cli/README.md | 6 +++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/docs/cli/authentication.mdx b/apps/docs/content/docs/cli/authentication.mdx index accd6544e3b..bba047f69b5 100644 --- a/apps/docs/content/docs/cli/authentication.mdx +++ b/apps/docs/content/docs/cli/authentication.mdx @@ -27,8 +27,11 @@ sim login --method api-key server must enable `OAUTH_PROVIDER_ENABLED=true`. Explicit OAuth selection also overrides SSH/headless detection; your browser still needs to reach the CLI's local callback. `--method api-key` uses pairing-code approval to create a new -permanent API key. To supply an existing key, use the global `--api-key ` -option or `SIM_API_KEY` instead. +permanent API key. To supply an existing key, set `SIM_API_KEY` instead. + +API-key pairing requires a server that supports `platform` API keys. Upgrade +older deployments that only issue `copilot` keys before starting login; those +keys cannot authenticate the platform CLI. OAuth login opens your browser on Sim's sign-in page, then on a consent page that names the Sim CLI and what it will be able to do. Approve, and the browser hands @@ -100,9 +103,10 @@ approving. That check is what binds the approval to your terminal. The handoff issues a permanent personal API key rather than a renewing login, so revoke it under **Settings → API keys** when you are done with that machine. -It is also the path for a deployment that predates OAuth sign-in, or one with -the provider switched off; the CLI detects that and selects API-key login when -`--method` is omitted. An explicit `--method oauth` fails in that case. +It also works when OAuth is unavailable or switched off, provided the server +supports platform API-key pairing. When `--method` is omitted, the CLI checks +OAuth availability and selects pairing if unavailable; that discovery does not +verify pairing compatibility. An explicit `--method oauth` fails in that case. `--read-only` and `--callback-port` belong to OAuth login and have no meaning here, so combining either with the handoff stops the login rather than diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index f8a154b588e..89f6b938b62 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -49,7 +49,11 @@ it. OAuth still needs the browser to reach the CLI's loopback callback. Over SSH or in a container without port forwarding, use `sim login --method api-key --no-browser` to approve from another device and create a permanent personal API key. `--method api-key` creates a new key; -the global `--api-key ` option supplies an existing one. +set `SIM_API_KEY` to supply an existing one. + +Pairing requires a server that supports `platform` API keys. Upgrade older +deployments that only issue `copilot` keys before login; they are not compatible +with the platform CLI. OAuth discovery does not check pairing compatibility. Check the active profile and verify that its endpoint, credential, and workspace work together: