diff --git a/ERRORS.md b/ERRORS.md index 44b6210..a6926be 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 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/README.md b/README.md index e114a97..45d9204 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,19 @@ 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. +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..b8219ce 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. **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. @@ -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 @@ -244,7 +245,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/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/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/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..31beea6 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,13 @@ interface VerifiedSession { const CODE_ATTEMPTS = 3; +// 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> = { google: 'Google', github: 'GitHub', @@ -71,6 +78,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 +274,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 +304,31 @@ 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)) { + // 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( + 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; + 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.' : CHALLENGE_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 +338,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 +346,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 +375,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 +413,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 +426,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/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/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/); + }); +}); 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); + }); +}); diff --git a/test/signup.test.ts b/test/signup.test.ts index f6618d5..e02461f 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -283,6 +283,132 @@ 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, /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; + } + ); + 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' } }) + ), + /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)', () => { before(() => { delete process.env.POLYLANE_API_KEY;