Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <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 <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 <op>` if you really need them from the CLI, but they're not first-class commands.

## Telemetry
Expand Down
11 changes: 6 additions & 5 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email> --password <pw> # bootstrap an account (emails a 6-digit code)
polylane auth signup --email <email> # bootstrap an account: generates a strong password, shown once (emails a 6-digit code)
polylane auth signup --email <email> --code <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 <op>` if you must.

Expand Down Expand Up @@ -119,8 +119,9 @@ The best way to learn a command is `polylane <resource> <verb> --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 <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 <code>`)
polylane auth signup --email you@example.com
# or: polylane auth login

Expand Down Expand Up @@ -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 |
|---|---|
Expand Down
34 changes: 13 additions & 21 deletions src/agents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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' };
}

Expand All @@ -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' };
}

Expand Down Expand Up @@ -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' };
}

Expand All @@ -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' };
}

Expand Down Expand Up @@ -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');
Expand All @@ -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' };
}

Expand Down
16 changes: 10 additions & 6 deletions src/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Credential> {
// 1. Flag-provided api key
if (process.argv.includes('--api-key') || process.argv.some((a) => a.startsWith('--api-key='))) {
Expand All @@ -13,7 +17,12 @@ export async function resolveCredential(config: Config): Promise<Credential> {
}
}

// 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)) {
Expand All @@ -27,11 +36,6 @@ export async function resolveCredential(config: Config): Promise<Credential> {
}
}

// 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' };
Expand Down
38 changes: 38 additions & 0 deletions src/auth/signup-helpers.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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');
}
Loading
Loading