From 1c530adafd9e159048ad5cf8361c58aaf11dcc31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 02:49:59 +0000 Subject: [PATCH 1/4] fix(auth): let POLYLANE_API_KEY override the credentials file Credential precedence is now --api-key flag, POLYLANE_API_KEY, then ~/.polylane/credentials.json, then api_key in ~/.polylane/config.json. Previously the OAuth credentials file outranked the env var, so a stale token left on a CI runner silently won over an exported key. Adds test/resolver.test.ts pinning the order (env over a stale credentials.json in particular) and documents it in README and SKILL.md. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U8mbVueynsz2KwmBwiLzat --- README.md | 9 +++++ skill/SKILL.md | 4 +- src/auth/resolver.ts | 16 +++++--- test/resolver.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 test/resolver.test.ts diff --git a/README.md b/README.md index e114a97..6e1d3f4 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,15 @@ OAuth is the default way to connect — including for agents. Use an API key onl OAuth credentials live at `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. `polylane auth status` reports the active source. +Credential precedence (first match wins): + +1. `--api-key ` flag +2. `POLYLANE_API_KEY` environment variable +3. `~/.polylane/credentials.json` (OAuth, from `auth login` / `auth signup`) +4. `api_key` in `~/.polylane/config.json` (from `auth login --api-key`) + +The environment variable outranks the credentials file so that a key exported in CI is never silently overridden by a stale OAuth token left on the runner. + For account lifecycle operations beyond signup/login (reset password, update profile, delete account, notification settings) — use the web console. They're available via `polylane api call ` if you really need them from the CLI, but they're not first-class commands. ## Telemetry diff --git a/skill/SKILL.md b/skill/SKILL.md index 2f746b0..8febf97 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -31,7 +31,7 @@ polylane auth signup --email --code # finish signup with the polylane auth status ``` -**API key** persists to `~/.polylane/config.json`. **OAuth** credentials persist to `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. **Signup** emails a 6-digit verification code to the address; the account is unusable until the code is confirmed (interactively, or with `--code`). The confirmed session token is stored under the same OAuth credential shape — for long-lived agent access, create an API key right after signup and switch to it. +**API key** persists to `~/.polylane/config.json`. **OAuth** credentials persist to `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. Credential precedence: `--api-key` flag > `POLYLANE_API_KEY` > `~/.polylane/credentials.json` > `api_key` in `~/.polylane/config.json` (an exported key always beats a stale OAuth token). **Signup** emails a 6-digit verification code to the address; the account is unusable until the code is confirmed (interactively, or with `--code`). The confirmed session token is stored under the same OAuth credential shape — for long-lived agent access, create an API key right after signup and switch to it. Account-lifecycle operations beyond signup/login (reset password, update profile, delete account, notification settings) live in the web console. Reach them from the CLI via `polylane api call ` if you must. @@ -244,7 +244,7 @@ polylane issue list --quiet 2>/dev/null ## Configuration precedence -**CLI flags > environment variables > `~/.polylane/config.json` > defaults.** +**CLI flags > environment variables > `~/.polylane/config.json` > defaults.** For credentials specifically: `--api-key` > `POLYLANE_API_KEY` > `~/.polylane/credentials.json` (OAuth) > `api_key` in `~/.polylane/config.json`. | Variable | Purpose | |---|---| diff --git a/src/auth/resolver.ts b/src/auth/resolver.ts index 5165bae..628cfe8 100644 --- a/src/auth/resolver.ts +++ b/src/auth/resolver.ts @@ -5,6 +5,10 @@ import { isTokenExpiringSoon, refreshToken } from './refresh'; import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; +// Precedence: --api-key flag > POLYLANE_API_KEY > ~/.polylane/credentials.json +// (OAuth) > ~/.polylane/config.json api_key. The env var sits above the +// credentials file on purpose: a CI runner exporting POLYLANE_API_KEY must not +// be silently overridden by a stale OAuth token left on disk. export async function resolveCredential(config: Config): Promise { // 1. Flag-provided api key if (process.argv.includes('--api-key') || process.argv.some((a) => a.startsWith('--api-key='))) { @@ -13,7 +17,12 @@ export async function resolveCredential(config: Config): Promise { } } - // 2. OAuth credentials on disk + // 2. Env var + if (process.env.POLYLANE_API_KEY) { + return { type: 'api-key', key: process.env.POLYLANE_API_KEY, source: 'env' }; + } + + // 3. OAuth credentials on disk const stored = readCredentials(); if (stored) { if (isTokenExpiringSoon(stored)) { @@ -27,11 +36,6 @@ export async function resolveCredential(config: Config): Promise { } } - // 3. Env var - if (process.env.POLYLANE_API_KEY) { - return { type: 'api-key', key: process.env.POLYLANE_API_KEY, source: 'env' }; - } - // 4. Config file if (config.apiKey) { return { type: 'api-key', key: config.apiKey, source: 'config' }; diff --git a/test/resolver.test.ts b/test/resolver.test.ts new file mode 100644 index 0000000..302c4d3 --- /dev/null +++ b/test/resolver.test.ts @@ -0,0 +1,88 @@ +import { describe, it, beforeEach, afterEach, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// HOME must point at a temp dir before any source module loads so the +// resolver reads this test's credentials file, not the developer's. +const tempHome = mkdtempSync(join(tmpdir(), 'polylane-resolver-test-')); +process.env.HOME = tempHome; +after(() => rmSync(tempHome, { recursive: true, force: true })); + +const { resolveCredential } = await import('../src/auth/resolver'); +const { mockConfig } = await import('./helpers/config'); + +const configDir = join(tempHome, '.polylane'); +const credentialsFile = join(configDir, 'credentials.json'); + +// A valid, non-expiring OAuth credential: what a runner is left with after +// someone ran `polylane auth login` on it months ago. +function writeStaleCredentials(): void { + mkdirSync(configDir, { recursive: true }); + writeFileSync( + credentialsFile, + JSON.stringify({ + access_token: 'stale-oauth-token', + refresh_token: 'stale-refresh-token', + expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + token_type: 'Bearer', + scope: '', + }), + { mode: 0o600 } + ); +} + +describe('resolveCredential precedence', () => { + const originalArgv = [...process.argv]; + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.POLYLANE_API_KEY; + process.argv = originalArgv.filter((a) => !a.startsWith('--api-key')); + rmSync(credentialsFile, { force: true }); + }); + + afterEach(() => { + process.argv = [...originalArgv]; + process.env = { ...originalEnv, HOME: tempHome }; + }); + + it('POLYLANE_API_KEY wins over a stale credentials.json', async () => { + writeStaleCredentials(); + process.env.POLYLANE_API_KEY = 'sk_from_env'; + + const cred = await resolveCredential(mockConfig({ apiKey: 'sk_from_env' })); + assert.equal(cred.type, 'api-key'); + assert.equal(cred.type === 'api-key' && cred.key, 'sk_from_env'); + assert.equal(cred.type === 'api-key' && cred.source, 'env'); + }); + + it('--api-key wins over POLYLANE_API_KEY and credentials.json', async () => { + writeStaleCredentials(); + process.env.POLYLANE_API_KEY = 'sk_from_env'; + process.argv.push('--api-key', 'sk_from_flag'); + + const cred = await resolveCredential(mockConfig({ apiKey: 'sk_from_flag' })); + assert.equal(cred.type === 'api-key' && cred.key, 'sk_from_flag'); + assert.equal(cred.type === 'api-key' && cred.source, 'flag'); + }); + + it('credentials.json wins over the config file api_key', async () => { + writeStaleCredentials(); + + const cred = await resolveCredential(mockConfig({ apiKey: 'sk_from_config' })); + assert.equal(cred.type, 'oauth'); + assert.equal(cred.type === 'oauth' && cred.accessToken, 'stale-oauth-token'); + }); + + it('falls back to the config file api_key', async () => { + const cred = await resolveCredential(mockConfig({ apiKey: 'sk_from_config' })); + assert.equal(cred.type === 'api-key' && cred.key, 'sk_from_config'); + assert.equal(cred.type === 'api-key' && cred.source, 'config'); + }); + + it('fails with the sign-in hint when nothing is set', async () => { + await assert.rejects(resolveCredential(mockConfig()), /Not signed in/); + }); +}); From 021c89ca3f86c8bd537d399aec128e1a0dd3bac7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 02:54:15 +0000 Subject: [PATCH 2/4] feat(signup): generate a strong password when none is given The API edge challenges weak or known-leaked password values (Cloudflare leaked-credentials rule): it answers with an HTML challenge page instead of the JSON envelope, which `auth signup` surfaced as an opaque parse error. Random passwords pass. When --password is omitted (or left empty at the prompt) the CLI now generates a 32-character password with letters, digits and symbols, sends it, and prints it once on stderr after the server accepts it (plus `generated_password` in the JSON envelope for scripts), with a note that password reset changes it later. A supplied password that trips the challenge (`cf-mitigated: challenge`, or a 403 HTML body) fails with "The password was rejected as weak or known-leaked" and a hint to re-run without --password. The emailed verification-code step is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U8mbVueynsz2KwmBwiLzat --- ERRORS.md | 1 + README.md | 2 +- skill/SKILL.md | 9 +-- src/auth/signup-helpers.ts | 38 ++++++++++++ src/commands/auth/signup.ts | 71 ++++++++++++++++++---- test/signup.test.ts | 115 ++++++++++++++++++++++++++++++++++++ 6 files changed, 221 insertions(+), 15 deletions(-) diff --git a/ERRORS.md b/ERRORS.md index 44b6210..3408322 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -132,6 +132,7 @@ These are the non-obvious command-level behaviours an agent should know about. S ### Idempotent operations - `auth signup` is idempotent for an existing user with a matching password: it returns a fresh session token instead of an error. Agents can call it again to renew. +- `auth signup` without `--password` generates a strong random password and prints it once (stderr, and `generated_password` in JSON output). A supplied password that the API edge flags as weak or known-leaked exits `2` with `The password was rejected as weak or known-leaked` and a hint to re-run without `--password`; the raw challenge page is never shown. ### Partial-success responses diff --git a/README.md b/README.md index 6e1d3f4..45d9204 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ OAuth is the default way to connect — including for agents. Use an API key onl | `polylane auth login --no-browser` | OAuth device code (SSH / headless) | | `polylane auth login --api-key sk_...` | Scripts / CI / machines that cannot complete OAuth | | `polylane auth signup` | Create an account — Google/GitHub (one browser trip: signup + CLI OAuth) or email + password | -| `polylane auth signup --email … --password …` | Bootstrap a fresh account from an agent; finish with `--code ` from the verification email | +| `polylane auth signup --email …` | Bootstrap a fresh account from an agent: a strong random password is generated and shown once (pass `--password` to choose your own; weak or known-leaked values are rejected); finish with `--code ` from the verification email | OAuth credentials live at `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. `polylane auth status` reports the active source. diff --git a/skill/SKILL.md b/skill/SKILL.md index 8febf97..4a867b7 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -24,14 +24,14 @@ npm install -g @coreplane/polylane polylane auth login # OAuth browser (PKCE) — the default polylane auth login --no-browser # OAuth device code (SSH / headless) polylane auth login --api-key sk_xxxxx # API key — scripts / CI without OAuth -polylane auth signup --email --password # bootstrap an account (emails a 6-digit code) +polylane auth signup --email # bootstrap an account: generates a strong password, shown once (emails a 6-digit code) polylane auth signup --email --code # finish signup with the emailed code # Verify polylane auth status ``` -**API key** persists to `~/.polylane/config.json`. **OAuth** credentials persist to `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. Credential precedence: `--api-key` flag > `POLYLANE_API_KEY` > `~/.polylane/credentials.json` > `api_key` in `~/.polylane/config.json` (an exported key always beats a stale OAuth token). **Signup** emails a 6-digit verification code to the address; the account is unusable until the code is confirmed (interactively, or with `--code`). The confirmed session token is stored under the same OAuth credential shape — for long-lived agent access, create an API key right after signup and switch to it. +**API key** persists to `~/.polylane/config.json`. **OAuth** credentials persist to `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. Credential precedence: `--api-key` flag > `POLYLANE_API_KEY` > `~/.polylane/credentials.json` > `api_key` in `~/.polylane/config.json` (an exported key always beats a stale OAuth token). **Signup** generates a strong random password when `--password` is omitted and prints it once on stderr (also `generated_password` in JSON output); store it, or change it later via password reset. Weak or known-leaked passwords are rejected at the edge (exit `2`, "rejected as weak or known-leaked"): do not invent one, let the CLI generate it. Signup emails a 6-digit verification code to the address; the account is unusable until the code is confirmed (interactively, or with `--code`). The confirmed session token is stored under the same OAuth credential shape — for long-lived agent access, create an API key right after signup and switch to it. Account-lifecycle operations beyond signup/login (reset password, update profile, delete account, notification settings) live in the web console. Reach them from the CLI via `polylane api call ` if you must. @@ -119,8 +119,9 @@ The best way to learn a command is `polylane --help`. These wo ### Onboarding a new account ```bash -# 1. Account (a 6-digit verification code is emailed; enter it at the prompt -# or finish with `polylane auth signup --email you@example.com --code `) +# 1. Account (a strong password is generated and printed once; a 6-digit +# verification code is emailed; enter it at the prompt or finish with +# `polylane auth signup --email you@example.com --code `) polylane auth signup --email you@example.com # or: polylane auth login diff --git a/src/auth/signup-helpers.ts b/src/auth/signup-helpers.ts index a880186..bf716d4 100644 --- a/src/auth/signup-helpers.ts +++ b/src/auth/signup-helpers.ts @@ -1,3 +1,5 @@ +import { randomInt } from 'node:crypto'; + // Parses the `Expires=...` attribute from a Set-Cookie header into an ISO date // string. Returns null if the header is missing or unparseable. Used to record // the actual server-side session lifetime instead of guessing a TTL. @@ -8,3 +10,39 @@ export function parseSessionExpiresAt(setCookie: string | null): string | null { const d = new Date(match[1]!); return Number.isFinite(d.getTime()) ? d.toISOString() : null; } + +const PASSWORD_LENGTH = 32; +const LOWER = 'abcdefghijklmnopqrstuvwxyz'; +const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const DIGITS = '0123456789'; +// No quotes, backslash, `$`, backtick, or whitespace: the value must survive +// being pasted into a shell inside single quotes, and into JSON verbatim. +const SYMBOLS = '!@#%^*-_=+.,:;?~'; +const ALL = LOWER + UPPER + DIGITS + SYMBOLS; + +function pick(alphabet: string): string { + return alphabet[randomInt(alphabet.length)]!; +} + +// A random password that clears leaked-credential checks at the edge (the +// API's WAF challenges weak or known-leaked values). Always carries at least +// one character from each class. +export function generatePassword(length = PASSWORD_LENGTH): string { + const chars = [pick(LOWER), pick(UPPER), pick(DIGITS), pick(SYMBOLS)]; + while (chars.length < length) chars.push(pick(ALL)); + for (let i = chars.length - 1; i > 0; i--) { + const j = randomInt(i + 1); + [chars[i], chars[j]] = [chars[j]!, chars[i]!]; + } + return chars.join(''); +} + +// Cloudflare answers a request it wants to challenge with an HTML page and a +// `cf-mitigated: challenge` header instead of the API's JSON envelope. On the +// signup route that means the password value tripped the leaked-credentials +// rule, not that the API rejected the request. +export function isCloudflareChallenge(res: Response): boolean { + if (res.headers.get('cf-mitigated') === 'challenge') return true; + const contentType = res.headers.get('content-type') ?? ''; + return res.status === 403 && contentType.includes('text/html'); +} diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 0795da0..4205141 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -7,7 +7,7 @@ import { isInteractive } from '../../utils/env'; import { oauthLogin, selectWorkspace, type WhoamiResult, type WorkspaceItem } from './login'; import { writeCredentials } from '../../auth/credentials'; import { resolveOnboardingRunId, consumeOnboardingRunFile } from '../../auth/onboarding-run'; -import { parseSessionExpiresAt } from '../../auth/signup-helpers'; +import { generatePassword, isCloudflareChallenge, parseSessionExpiresAt } from '../../auth/signup-helpers'; import { readInstallRef } from '../../telemetry/environment'; import type { OAuthCredential } from '../../auth/types'; import { writeConfigFile } from '../../config/loader'; @@ -57,6 +57,10 @@ interface VerifiedSession { const CODE_ATTEMPTS = 3; +const WEAK_PASSWORD_ERROR = 'The password was rejected as weak or known-leaked'; +const WEAK_PASSWORD_HINT = + 'Use a random one: re-run without --password and the CLI generates a strong password (shown once).'; + const OAUTH_PROVIDER_LABELS: Record<'google' | 'github', string> = { google: 'Google', github: 'GitHub', @@ -71,6 +75,20 @@ const TERMS_NOTICE = [ ' https://polylane.com/privacy/', ].join('\n'); +// Shown exactly once, on stderr, right after the server accepted it. Password +// reset from the console sign-in page is the way to pick a different one. +function announceGeneratedPassword(config: Config, email: string, password: string): void { + const message = [ + `A strong password was generated for ${email}; it is shown only once:`, + ``, + ` ${password}`, + ``, + `Store it now. Change it later via "Forgot password" on the console sign-in page.`, + ].join('\n'); + if (isInteractive(config.nonInteractive)) note(message, 'Generated password'); + else process.stderr.write(`\n${message}\n\n`); +} + function writeSessionCredential(token: string, expiresAt: string, account: string): void { const cred: OAuthCredential = { type: 'oauth', @@ -253,9 +271,18 @@ export async function emailSignup(config: Config, args: Record) return; } - const passwordArg = getArgString(args, 'password'); - const password = - passwordArg ?? (await promptPassword({ nonInteractive: config.nonInteractive }, 'Password')); + // No --password means a generated one: the API's edge challenges weak or + // known-leaked values, so the CLI never asks a script to invent one. An + // interactive user may still type their own (or leave it empty to generate). + let password = getArgString(args, 'password'); + if (password === undefined && isInteractive(config.nonInteractive)) { + password = await promptPassword( + { nonInteractive: config.nonInteractive }, + 'Password (leave empty to generate a strong one)' + ); + } + const generated = !password; + if (!password) password = generatePassword(); // Need response headers (Set-Cookie -> session expiry) so call request() directly // rather than via the generated client which only exposes the body. @@ -274,10 +301,29 @@ export async function emailSignup(config: Config, args: Record) body: { email, password, ...(ref ? { ref } : {}), ...(run ? { run } : {}) }, noAuth: true, }); - const json = (await res.json()) as SignupEnvelope; + if (isCloudflareChallenge(res)) { + throw new CLIError( + WEAK_PASSWORD_ERROR, + ExitCode.USAGE, + generated ? 'Retry; a fresh password is generated on every run.' : WEAK_PASSWORD_HINT + ); + } + let json: SignupEnvelope; + try { + json = (await res.json()) as SignupEnvelope; + } catch { + throw new CLIError( + `Signup returned a non-JSON response (status ${res.status})`, + ExitCode.GENERAL, + generated ? 'Retry in a moment.' : WEAK_PASSWORD_HINT + ); + } if (!res.ok || !json.success) { throw new CLIError(json.error?.detail ?? json.error?.message ?? 'Signup did not complete', ExitCode.GENERAL); } + if (generated) announceGeneratedPassword(config, email, password); + // Scripts read stdout: a generated password rides the JSON envelope too. + const result = generated ? { ...json.result, generated_password: password } : json.result; // The run id (if any) rode this signup request and the server has bound it — // on both the created and existing-account paths. Consume the one-shot file so // it can't re-stamp future signups on this machine. Never under --dry-run: the @@ -287,7 +333,7 @@ export async function emailSignup(config: Config, args: Record) const { user, token } = json.result; if (!user) { // dry-run stub or unexpected server response - emitResult(config, json.result); + emitResult(config, result); outro('Account created, but no session returned. Run `polylane auth login`.'); return; } @@ -295,14 +341,14 @@ export async function emailSignup(config: Config, args: Record) if (user.emailVerified) { // Existing account re-authenticated: the session works immediately. if (!token) { - emitResult(config, json.result); + emitResult(config, result); outro('Account created, but no session returned. Run `polylane auth login`.'); return; } const expiresAt = parseSessionExpiresAt(res.headers.get('set-cookie')) ?? new Date().toISOString(); writeSessionCredential(token, expiresAt, user.email ?? user.id); await persistDefaultWorkspace(config); - emitResult(config, json.result); + emitResult(config, result); if (config.hints) note(nextSteps(), 'Next steps'); outro(`Signed in as ${user.email ?? user.id}.`); return; @@ -324,7 +370,7 @@ export async function emailSignup(config: Config, args: Record) } if (!isInteractive(config.nonInteractive)) { - emitResult(config, json.result); + emitResult(config, result); outro( `Check ${email} for a verification code, then run: polylane auth signup --email ${email} --code ` ); @@ -362,7 +408,11 @@ export const authSignupCommand: Command = { operationId: 'auth.signup', options: [ { flag: '--email ', description: 'Email address (implies email signup)', type: 'string' }, - { flag: '--password ', description: 'Password (prompted if omitted)', type: 'string' }, + { + flag: '--password ', + description: 'Password; when omitted a strong random one is generated and shown once (weak or known-leaked values are rejected)', + type: 'string', + }, { flag: '--code ', description: 'Verification code from the signup email (completes email signup)', @@ -371,6 +421,7 @@ export const authSignupCommand: Command = { ], examples: [ 'polylane auth signup', + 'polylane auth signup --email agent@example.com # generates a strong password, shown once', 'polylane auth signup --email agent@example.com --password "$PW"', 'polylane auth signup --email agent@example.com --code 123456 # finish verification', ], diff --git a/test/signup.test.ts b/test/signup.test.ts index f6618d5..8c1a2f0 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -283,6 +283,121 @@ describe('auth signup existing-account re-auth', () => { }); }); +describe('auth signup password handling', () => { + before(() => { + delete process.env.POLYLANE_API_KEY; + delete process.env.POLYLANE_WORKSPACE_ID; + delete process.env.POLYLANE_API_DOMAIN; + }); + + beforeEach(() => { + rmSync(CONFIG_FILE, { force: true }); + rmSync(CREDENTIALS_FILE, { force: true }); + delete process.env.POLYLANE_ONBOARDING_RUN; + }); + + // Unverified new account: the server emails a code, the CLI prints the + // completion command. No whoami / workspaces round-trip. + function unverifiedSignupResponse(): Response { + return jsonResponse({ + success: true, + error: null, + result: { + user: { id: 'user_1', email: 'dev@acme.com', emailVerified: false, created: new Date().toISOString() }, + token: 'tok_signup', + }, + }); + } + + function challengeResponse(): Response { + return new Response('Just a moment...', { + status: 403, + headers: { 'content-type': 'text/html; charset=UTF-8', 'cf-mitigated': 'challenge' }, + }); + } + + async function runCapturingBody( + args: Record, + respond: () => Response, + overrides: Parameters[0] = {} + ): Promise<{ password: string | undefined }> { + let sent: { password?: string } = {}; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes('/v1/auth/signup')) { + sent = JSON.parse(String(init?.body)) as { password?: string }; + return respond(); + } + throw new Error(`Unexpected request in test: ${url}`); + }) as typeof fetch; + captureOutput(); + try { + await authSignupCommand.execute(mockConfig({ telemetry: false, ...overrides }), {} as GlobalFlags, args); + } finally { + restoreOutput(); + } + return { password: sent.password }; + } + + it('generates a strong password when --password is omitted non-interactively and shows it once', async () => { + const { password } = await runCapturingBody({ email: 'dev@acme.com' }, unverifiedSignupResponse, { + output: 'text', + }); + assert.ok(password, 'a password was sent'); + assert.ok(password.length >= 24, `length ${password.length}`); + assert.match(password, /[a-z]/); + assert.match(password, /[A-Z]/); + assert.match(password, /[0-9]/); + assert.match(password, /[^A-Za-z0-9]/); + assert.equal(output.split(password).length - 1, 1, 'password printed exactly once'); + assert.ok(output.includes('shown only once')); + assert.ok(output.includes('Forgot password')); + assert.ok(output.includes('--code ')); + }); + + it('carries the generated password in the JSON envelope for scripts', async () => { + const { password } = await runCapturingBody({ email: 'dev@acme.com' }, unverifiedSignupResponse, { + output: 'json', + }); + assert.ok(password); + assert.ok(output.includes(`"generated_password": "${password}"`), output); + }); + + it('does not generate one when --password is given, and never prints it', async () => { + const { password } = await runCapturingBody( + { email: 'dev@acme.com', password: 'Xq7!vR2pLm9zTb4w-unique-8f3a' }, + unverifiedSignupResponse, + { output: 'text' } + ); + assert.equal(password, 'Xq7!vR2pLm9zTb4w-unique-8f3a'); + assert.ok(!output.includes('Xq7!vR2pLm9zTb4w-unique-8f3a')); + assert.ok(!output.includes('shown only once')); + }); + + it('turns a Cloudflare challenge on a supplied password into a clear error, not an HTML dump', async () => { + await assert.rejects( + runCapturingBody({ email: 'dev@acme.com', password: 'password123' }, challengeResponse), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.match(err.message, /rejected as weak or known-leaked/); + assert.match((err as { hint?: string }).hint ?? '', /without --password/); + return true; + } + ); + assert.ok(!output.includes(' { + await assert.rejects( + runCapturingBody({ email: 'dev@acme.com', password: 'password123' }, () => + new Response('Just a moment...', { status: 403, headers: { 'content-type': 'text/html' } }) + ), + /rejected as weak or known-leaked/ + ); + }); +}); + describe('auth signup --code (email verification)', () => { before(() => { delete process.env.POLYLANE_API_KEY; From 19200d6af7ebb269dc6c271c7db26dc774fac254 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 02:54:15 +0000 Subject: [PATCH 3/4] fix(setup): create agent MCP config files owner-only `polylane setup` writes only the MCP server URL into agent configs, but those same files receive the workspace API key from the installer right afterwards and hold the agents' own OAuth tokens. Files the CLI creates (JSON, JSONC, Codex TOML, Goose YAML) are now created 0600; a file that already exists keeps its mode, so another tool's config is never widened or tightened behind its back. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U8mbVueynsz2KwmBwiLzat --- src/agents/registry.ts | 34 +++++++++-------------- src/utils/fs.ts | 8 ++++++ test/setup.test.ts | 63 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/agents/registry.ts b/src/agents/registry.ts index bb8490f..00b58ad 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -4,7 +4,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from ' import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser'; -import { ensureDir } from '../utils/fs'; +import { ensureDir, writePrivateTextFile } from '../utils/fs'; import { SKILL_MD } from '../generated/skill'; export const MCP_SERVER_NAME = 'polylane'; @@ -41,6 +41,10 @@ export function writeSkillFile(path: string, dryRun = false): WriteOutcome { return { label, path, action: 'created' }; } +// Every MCP config writer below creates its file 0600: the installer hands +// these same files the workspace API key right after `polylane setup`, and the +// bare entry is where an agent stores its own OAuth tokens. A file that +// already exists keeps its mode. export function upsertJsonEntry( path: string, keyPath: string[], @@ -83,10 +87,7 @@ export function upsertJsonEntry( } node[leaf] = value; - if (!dryRun) { - ensureDir(dirname(path)); - writeFileSync(path, JSON.stringify(root, null, 2) + '\n', 'utf-8'); - } + if (!dryRun) writePrivateTextFile(path, JSON.stringify(root, null, 2) + '\n'); return { label, path, action: existed ? 'updated' : 'created' }; } @@ -111,10 +112,7 @@ export function upsertJsoncEntry( ): WriteOutcome { const label = 'MCP server'; if (!existsSync(path)) { - if (!dryRun) { - ensureDir(dirname(path)); - writeFileSync(path, JSON.stringify(nestEntry(keyPath, value), null, 2) + '\n', 'utf-8'); - } + if (!dryRun) writePrivateTextFile(path, JSON.stringify(nestEntry(keyPath, value), null, 2) + '\n'); return { label, path, action: 'created' }; } @@ -151,7 +149,7 @@ export function upsertJsoncEntry( formattingOptions: { insertSpaces: true, tabSize: 2 }, getInsertionIndex: () => 0, }); - if (!dryRun) writeFileSync(path, applyEdits(text, edits), 'utf-8'); + if (!dryRun) writePrivateTextFile(path, applyEdits(text, edits)); return { label, path, action: 'updated' }; } @@ -176,14 +174,11 @@ export function upsertTomlSection( } if (!dryRun) { const separator = current.endsWith('\n') || current === '' ? '' : '\n'; - writeFileSync(path, `${current}${separator}\n${sectionHeader}\n${sectionBody}`, 'utf-8'); + writePrivateTextFile(path, `${current}${separator}\n${sectionHeader}\n${sectionBody}`); } return { label, path, action: 'updated' }; } - if (!dryRun) { - ensureDir(dirname(path)); - writeFileSync(path, `${sectionHeader}\n${sectionBody}`, 'utf-8'); - } + if (!dryRun) writePrivateTextFile(path, `${sectionHeader}\n${sectionBody}`); return { label, path, action: 'created' }; } @@ -262,10 +257,7 @@ const GOOSE_EXTENSION_LINES = [ export function upsertGooseExtension(path: string, dryRun = false): WriteOutcome { const label = 'MCP server'; if (!existsSync(path)) { - if (!dryRun) { - ensureDir(dirname(path)); - writeFileSync(path, ['extensions:', ...GOOSE_EXTENSION_LINES, ''].join('\n'), 'utf-8'); - } + if (!dryRun) writePrivateTextFile(path, ['extensions:', ...GOOSE_EXTENSION_LINES, ''].join('\n')); return { label, path, action: 'created' }; } const current = readFileSync(path, 'utf-8'); @@ -276,14 +268,14 @@ export function upsertGooseExtension(path: string, dryRun = false): WriteOutcome const blockStart = lines.findIndex((l) => /^extensions:\s*$/.test(l)); if (blockStart >= 0) { lines.splice(blockStart + 1, 0, ...GOOSE_EXTENSION_LINES); - if (!dryRun) writeFileSync(path, lines.join('\n'), 'utf-8'); + if (!dryRun) writePrivateTextFile(path, lines.join('\n')); return { label, path, action: 'updated' }; } if (/^extensions:/m.test(current)) { return { label, path, action: 'skipped', detail: '`extensions:` is not a plain block; add the entry manually', needsManualStep: true }; } const separator = current.endsWith('\n') || current === '' ? '' : '\n'; - if (!dryRun) writeFileSync(path, `${current}${separator}\nextensions:\n${GOOSE_EXTENSION_LINES.join('\n')}\n`, 'utf-8'); + if (!dryRun) writePrivateTextFile(path, `${current}${separator}\nextensions:\n${GOOSE_EXTENSION_LINES.join('\n')}\n`); return { label, path, action: 'updated' }; } diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 68839b1..2636798 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -24,6 +24,14 @@ export function writeJsonFile(path: string, data: unknown, mode?: number): void } } +// For config files that hold, or will be handed, a credential. The mode only +// applies when the file is created: a file another tool already owns keeps +// whatever permissions it has, never widened, never tightened behind its back. +export function writePrivateTextFile(path: string, contents: string): void { + ensureDir(dirname(path)); + writeFileSync(path, contents, { encoding: 'utf-8', mode: 0o600 }); +} + export function fileExists(path: string): boolean { try { return existsSync(path); diff --git a/test/setup.test.ts b/test/setup.test.ts index 2a9f3be..e3dda34 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync, symlinkSync } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync, symlinkSync, statSync, chmodSync } from 'node:fs'; import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; @@ -788,3 +788,64 @@ describe('agent definitions', () => { assert.equal(mcp.action, 'skipped'); }); }); + +// MCP config files end up holding credentials: the installer writes the +// workspace API key into them right after setup, and agents store their own +// OAuth tokens there. Files the CLI creates are owner-only; files another tool +// already owns keep their mode. Windows has no POSIX modes to assert on. +describe('MCP config file modes', { skip: process.platform === 'win32' }, () => { + const mode = (path: string): number => statSync(path).mode & 0o777; + + it('creates every MCP config file 0600', () => { + const json = join(tempDir, 'mcp.json'); + upsertJsonEntry(json, ['mcpServers', MCP_SERVER_NAME], { url: MCP_SERVER_URL }); + assert.equal(mode(json), 0o600); + + const jsonc = join(tempDir, 'opencode.json'); + upsertJsoncEntry(jsonc, ['mcp', MCP_SERVER_NAME], { type: 'remote', url: MCP_SERVER_URL }); + assert.equal(mode(jsonc), 0o600); + + const toml = join(tempDir, 'config.toml'); + upsertTomlSection(toml, '[mcp_servers.polylane]', `url = "${MCP_SERVER_URL}"\n`); + assert.equal(mode(toml), 0o600); + + const yaml = join(tempDir, 'config.yaml'); + upsertGooseExtension(yaml); + assert.equal(mode(yaml), 0o600); + }); + + it('creates every user-level agent config file 0600', () => { + for (const definition of AGENTS) { + for (const outcome of definition.user(tempDir, false)) { + if (outcome.label !== 'MCP server' || outcome.action !== 'created') continue; + assert.equal(mode(outcome.path), 0o600, `${definition.id}: ${outcome.path}`); + } + } + }); + + it('keeps the existing mode of a file it edits in place', () => { + const json = join(tempDir, 'mcp.json'); + writeFileSync(json, '{"mcpServers":{"other":{"url":"https://other.example"}}}\n', 'utf-8'); + chmodSync(json, 0o644); + assert.equal(upsertJsonEntry(json, ['mcpServers', MCP_SERVER_NAME], { url: MCP_SERVER_URL }).action, 'updated'); + assert.equal(mode(json), 0o644); + + const jsonc = join(tempDir, 'opencode.jsonc'); + writeFileSync(jsonc, '// hi\n{ "mcp": {} }\n', 'utf-8'); + chmodSync(jsonc, 0o644); + assert.equal(upsertJsoncEntry(jsonc, ['mcp', MCP_SERVER_NAME], { type: 'remote', url: MCP_SERVER_URL }).action, 'updated'); + assert.equal(mode(jsonc), 0o644); + + const toml = join(tempDir, 'config.toml'); + writeFileSync(toml, 'model = "x"\n', 'utf-8'); + chmodSync(toml, 0o644); + assert.equal(upsertTomlSection(toml, '[mcp_servers.polylane]', `url = "${MCP_SERVER_URL}"\n`).action, 'updated'); + assert.equal(mode(toml), 0o644); + + const yaml = join(tempDir, 'config.yaml'); + writeFileSync(yaml, 'extensions:\n other:\n enabled: true\n', 'utf-8'); + chmodSync(yaml, 0o644); + assert.equal(upsertGooseExtension(yaml).action, 'updated'); + assert.equal(mode(yaml), 0o644); + }); +}); From a423aec964976924eea8a6e72bf09bbdf237516b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 03:00:22 +0000 Subject: [PATCH 4/4] fix(signup): hedge the edge-challenge error and exit 1 for generated passwords A 403 HTML / cf-mitigated challenge can also come from IP reputation or rate limiting, so the error now reads "The sign-up request was challenged by the edge, usually because the password is weak or known-leaked" while keeping the re-run-without---password hint. When the challenged password was generated by the CLI the caller made no usage error, so that branch exits GENERAL (1) instead of USAGE (2). Tests, ERRORS.md and SKILL.md follow the new wording. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U8mbVueynsz2KwmBwiLzat --- ERRORS.md | 2 +- skill/SKILL.md | 2 +- src/commands/auth/signup.ts | 17 +++++++++++------ test/signup.test.ts | 15 +++++++++++++-- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/ERRORS.md b/ERRORS.md index 3408322..a6926be 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -132,7 +132,7 @@ These are the non-obvious command-level behaviours an agent should know about. S ### Idempotent operations - `auth signup` is idempotent for an existing user with a matching password: it returns a fresh session token instead of an error. Agents can call it again to renew. -- `auth signup` without `--password` generates a strong random password and prints it once (stderr, and `generated_password` in JSON output). A supplied password that the API edge flags as weak or known-leaked exits `2` with `The password was rejected as weak or known-leaked` and a hint to re-run without `--password`; the raw challenge page is never shown. +- `auth signup` without `--password` generates a strong random password and prints it once (stderr, and `generated_password` in JSON output). A supplied password that the API edge challenges (usually because it is weak or known-leaked; IP reputation or rate limiting can also trigger it) exits `2` with `The sign-up request was challenged by the edge, usually because the password is weak or known-leaked` and a hint to re-run without `--password`; a challenge on a CLI-generated password exits `1` with a retry hint. The raw challenge page is never shown. ### Partial-success responses diff --git a/skill/SKILL.md b/skill/SKILL.md index 4a867b7..b8219ce 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -31,7 +31,7 @@ polylane auth signup --email --code # finish signup with the polylane auth status ``` -**API key** persists to `~/.polylane/config.json`. **OAuth** credentials persist to `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. Credential precedence: `--api-key` flag > `POLYLANE_API_KEY` > `~/.polylane/credentials.json` > `api_key` in `~/.polylane/config.json` (an exported key always beats a stale OAuth token). **Signup** generates a strong random password when `--password` is omitted and prints it once on stderr (also `generated_password` in JSON output); store it, or change it later via password reset. Weak or known-leaked passwords are rejected at the edge (exit `2`, "rejected as weak or known-leaked"): do not invent one, let the CLI generate it. Signup emails a 6-digit verification code to the address; the account is unusable until the code is confirmed (interactively, or with `--code`). The confirmed session token is stored under the same OAuth credential shape — for long-lived agent access, create an API key right after signup and switch to it. +**API key** persists to `~/.polylane/config.json`. **OAuth** credentials persist to `~/.polylane/credentials.json` (mode `0600`) and auto-refresh before expiry. Credential precedence: `--api-key` flag > `POLYLANE_API_KEY` > `~/.polylane/credentials.json` > `api_key` in `~/.polylane/config.json` (an exported key always beats a stale OAuth token). **Signup** generates a strong random password when `--password` is omitted and prints it once on stderr (also `generated_password` in JSON output); store it, or change it later via password reset. Weak or known-leaked passwords are challenged at the edge (exit `2`, "challenged by the edge, usually because the password is weak or known-leaked"): do not invent one, let the CLI generate it. Signup emails a 6-digit verification code to the address; the account is unusable until the code is confirmed (interactively, or with `--code`). The confirmed session token is stored under the same OAuth credential shape — for long-lived agent access, create an API key right after signup and switch to it. Account-lifecycle operations beyond signup/login (reset password, update profile, delete account, notification settings) live in the web console. Reach them from the CLI via `polylane api call ` if you must. diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 4205141..31beea6 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -57,8 +57,11 @@ interface VerifiedSession { const CODE_ATTEMPTS = 3; -const WEAK_PASSWORD_ERROR = 'The password was rejected as weak or known-leaked'; -const WEAK_PASSWORD_HINT = +// A challenge is not always about the password (IP reputation and rate +// limiting produce the same page), so the message hedges. +const CHALLENGE_ERROR = + 'The sign-up request was challenged by the edge, usually because the password is weak or known-leaked'; +const CHALLENGE_HINT = 'Use a random one: re-run without --password and the CLI generates a strong password (shown once).'; const OAUTH_PROVIDER_LABELS: Record<'google' | 'github', string> = { @@ -302,10 +305,12 @@ export async function emailSignup(config: Config, args: Record) noAuth: true, }); if (isCloudflareChallenge(res)) { + // A generated password is not the caller's mistake: that is a transient + // edge condition (exit 1), not a usage error (exit 2). throw new CLIError( - WEAK_PASSWORD_ERROR, - ExitCode.USAGE, - generated ? 'Retry; a fresh password is generated on every run.' : WEAK_PASSWORD_HINT + CHALLENGE_ERROR, + generated ? ExitCode.GENERAL : ExitCode.USAGE, + generated ? 'Retry in a moment; a fresh password is generated on every run.' : CHALLENGE_HINT ); } let json: SignupEnvelope; @@ -315,7 +320,7 @@ export async function emailSignup(config: Config, args: Record) throw new CLIError( `Signup returned a non-JSON response (status ${res.status})`, ExitCode.GENERAL, - generated ? 'Retry in a moment.' : WEAK_PASSWORD_HINT + generated ? 'Retry in a moment.' : CHALLENGE_HINT ); } if (!res.ok || !json.success) { diff --git a/test/signup.test.ts b/test/signup.test.ts index 8c1a2f0..e02461f 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -379,8 +379,9 @@ describe('auth signup password handling', () => { runCapturingBody({ email: 'dev@acme.com', password: 'password123' }, challengeResponse), (err: unknown) => { assert.ok(err instanceof Error); - assert.match(err.message, /rejected as weak or known-leaked/); + assert.match(err.message, /challenged by the edge, usually because the password is weak or known-leaked/); assert.match((err as { hint?: string }).hint ?? '', /without --password/); + assert.equal((err as { exitCode?: number }).exitCode, 2); return true; } ); @@ -393,9 +394,19 @@ describe('auth signup password handling', () => { runCapturingBody({ email: 'dev@acme.com', password: 'password123' }, () => new Response('Just a moment...', { status: 403, headers: { 'content-type': 'text/html' } }) ), - /rejected as weak or known-leaked/ + /challenged by the edge/ ); }); + + it('exits 1, not 2, when the challenged password was generated by the CLI', async () => { + await assert.rejects(runCapturingBody({ email: 'dev@acme.com' }, challengeResponse), (err: unknown) => { + assert.ok(err instanceof Error); + assert.match(err.message, /challenged by the edge/); + assert.equal((err as { exitCode?: number }).exitCode, 1); + assert.match((err as { hint?: string }).hint ?? '', /Retry/); + return true; + }); + }); }); describe('auth signup --code (email verification)', () => {