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 .changeset/cli-version-pin-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ The CLI now runs only as the version a project records, and only on rules reconc
- **`check` and `verify` refuse rules reconciled against a different ast-grep or Vale**, with `RULES_ENGINE_MISMATCH`, when `rules.engines` names an engine version this CLI does not bundle and the project has rules for that engine. Until now they ran the new engine against rules nobody had migrated. A rule still using a renamed kind could leave `check` reporting no ast-grep findings at all. When the rules are behind, run `update` and then `update --rules`, which applies the rule rewrites and records the bundled engines. A project that never recorded its engines is not affected.
- `--allow-version-mismatches` skips both refusals, and `TASKLESS_ALLOW_VERSION_MISMATCHES=1` does the same from the environment.
- npx is now only for installing, upgrading, and logging in (`auth` runs on any version). The `.taskless/README.md` written for new projects runs `check` through the launcher and names `init` for npx and `pnpm dlx`. `init`'s closing line says to run `./.taskless/bin/cli.mjs update` rather than the npx invocation that ran it, and the bare, non-interactive notice sends its reader to the launcher for recipes when the project has one.
- **An agent can now log a user in.** `auth login --json` is a step an agent repeats until it finishes. The first call returns `{ ok: true, status: "pending", verificationUrl, userCode, expiresAt }` at once. Each later call waits at most 45 seconds, then returns `pending` again, `authenticated`, or an error. A code that expires fails with the new `AUTH_EXPIRED` code, and running it again starts a new login. `auth login --json` used to block silently until approval, which kept the URL from an agent whose tool shows output only on exit. It now prints a success envelope, including `already_logged_in` with its `source`, where it printed nothing. A started login is saved to `.taskless/.auth-pending.json`, gitignored and readable only by its owner, and `auth logout` discards it. `auth login` without `--json` is unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-21
25 changes: 25 additions & 0 deletions openspec/changes/archive/2026-10-21-auth-agent-login/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Context

See proposal.md for why. The device flow already lives behind `DeviceFlowProvider` (`requestDeviceCode`, `pollForToken`), so the change is where the polling state lives. Today it is in one process's loop. Here it moves to a file, so polling can span several short processes.

## Goals / Non-Goals

**Goals:** an agent in any harness can complete a login with ordinary, short, synchronous tool calls; the person approving sees the URL as soon as it exists.

**Non-Goals:** opening a browser for the user; changing the interactive (non-`--json`) flow; changing the service's device-flow endpoints or token lifetime.

## Decisions

- **One re-runnable command, not `--start`/`--wait` flags.** `auth login --json` decides from local state whether to start or continue. The agent's loop is "call until not `pending`", with nothing to choose between calls. Separate flags would make the agent pick the right one each time, and picking `--start` mid-flow would invalidate the URL the user is already on. Because every `pending` response repeats the URL and code, a caller that lost track can always show the right one.
- **The first call returns at once, without polling.** Polling before returning would delay the URL, which is the thing the person is waiting for.
- **Each call waits at most 45 seconds.** That is well under the two-minute default Bash timeout in Claude Code, and leaves margin for slower harnesses, while keeping the number of calls small. The service's `interval` and `slow_down` are honoured within that window as the current loop does. A call ends early if the code expires.
- **`AUTH_EXPIRED` is a new code.** Expiry is reported as `NETWORK_ERROR` today, which sends an agent to check connectivity. The remedy is different: call again and show the new URL.
- **A code found expired when a call starts is discarded silently, and a new login begins.** That covers a login abandoned in an earlier session. Reporting `AUTH_EXPIRED` there would fail an attempt this caller may never have made. Expiry _during_ a call is reported, because that caller was waiting on it.
- **The pending file sits beside the token file, with the same protections.** It is `.taskless/.auth-pending.json`, mode `0600`, and listed in `.taskless/.gitignore` through the same helper. It holds the `device_code`, `user_code`, verification URL, `interval`, and an absolute `expires_at`. It is deleted on success, denial, expiry, logout, and a successful interactive login.
- **The already-logged-in checks run first, unchanged.** A `TASKLESS_TOKEN`, or a saved token the service accepts, returns `already_logged_in` before any pending code is read or created.

## Risks / Trade-offs

- [A device code on disk can be redeemed by anyone who reads it before it expires] → `0600`, gitignored, deleted on every terminal state, and short-lived by the service's own `expires_in`. The token it yields is stored with exactly the same protections.
- [Two agents logging in to the same checkout at once share one pending code] → Both show the same URL and both see the same result, which is the right outcome for one repository's login.
- [A harness whose timeout is under 45 seconds] → The wait is a constant. Lower it if one turns up, since the cost of lowering it is only more calls.
35 changes: 35 additions & 0 deletions openspec/changes/archive/2026-10-21-auth-agent-login/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

An agent cannot log a user in today. `auth login` prints a URL and code, then blocks while it polls, and agent harnesses return a command's output only when it exits. So the URL the user needs is held behind a wait for that same user, and a tool timeout (two minutes by default in Claude Code) usually ends the call first. Under `--json`, the mode an agent would reach for, the URL is not printed at all. The `auth` recipe tells the agent to run `auth login` and relay the URL, which it cannot do.

The version-pin gate makes this more pressing. `auth` is one of the few commands that runs on any CLI version, and the recipes send agents to it whenever a command needs a token.

## What Changes

- `auth login --json` becomes a step an agent repeats until it finishes, and never blocks for longer than a bounded wait:
- The first call requests a device code, saves it locally, and returns at once with `{ ok: true, status: "pending", verificationUrl, userCode, expiresAt }`.
- Each later call polls that saved code for at most 45 seconds. It returns `pending` again (same URL and code), `{ ok: true, status: "authenticated" }` once approved, or an error.
- A code that expires during a call fails with a new `AUTH_EXPIRED` error code. A denial fails with `AUTH_REQUIRED`, as today. Either one discards the saved code, so the next call starts over.
- A saved code found already expired is discarded, and the call starts a new login rather than failing on an attempt the caller may never have seen.
- **BREAKING (output)**: `auth login --json` now prints a success envelope where it printed nothing. "Already logged in" becomes `{ ok: true, status: "already_logged_in", source: "saved" | "environment" }`. Exit codes are unchanged, and the package is pre-1.0.
- The pending code is saved to `.taskless/.auth-pending.json`, mode `0600`, and added to `.taskless/.gitignore` the same way the token file is. A device code is a credential until it expires: whoever holds it receives the token the user approves.
- `auth logout` and a successful interactive login also discard a pending code.
- Without `--json`, `auth login` is unchanged: one blocking call for a person at a terminal.
- The `auth` recipe's login branch runs `auth login --json` in a loop: show the URL and code from the first `pending`, keep calling until the status is not `pending`, and show the new URL if the code changes.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-auth`: `auth login --json` prints a success envelope, and completes across repeated, bounded calls.

## Impact

- `packages/cli/src/commands/auth.ts`, `packages/cli/src/auth/login-interactive.ts` (or a new sibling for the stepped flow), `packages/cli/src/auth/token.ts` (pending-code storage beside the token), `packages/cli/src/types/errors.ts` (`AUTH_EXPIRED`).
- `packages/cli/src/agent/auth.md`, with a topic version bump.
- No service change: the CLI already polls the device-flow token endpoint, and this only moves the polling across process boundaries.
- Delivery shape: **stacked on #562 (`cli-version-pin-gate`), merging forward**. #562 lands first and is safe on its own. This change is one PR above it: one command's JSON mode, its storage, and its recipe, which only work together. It stacks because both edit `auth.md`, and #562's topic v4 note on running `auth` from any version is the context this recipe builds on.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
## MODIFIED Requirements

### Requirement: Auth login initiates Device Flow

`taskless auth login` initiates the device-code flow per the existing requirement. The new `--anonymous` flag (per the `cli` capability) SHALL NOT be accepted on this command — invocation with `--anonymous` SHALL exit with code 1 and an error message stating "auth commands cannot be anonymous".

When a token is already present, `auth login` SHALL decide by where it comes from:

- A token from `TASKLESS_TOKEN` SHALL be kept without contacting the service, since a saved login would not be used while the variable is set. The CLI SHALL name `TASKLESS_TOKEN` and SHALL NOT direct the user to `auth logout`, which cannot remove it.
- A saved token SHALL be checked with the service. If the service rejects it (`401`), the CLI SHALL run the device-code flow and replace the saved token. If the service accepts it, or cannot be reached, the CLI SHALL keep the token and report that the user is already logged in, since an unreachable service says nothing about the token.

Keeping a token SHALL NOT be an error: the command exits 0, and under `--json` prints `{ "ok": true, "status": "already_logged_in", "source": "saved" }`, or `"source": "environment"` for `TASKLESS_TOKEN`.

#### Scenario: Standard login still works

- **WHEN** a user runs `taskless auth login`
- **THEN** the CLI SHALL initiate the device-code flow per the existing behavior

#### Scenario: Login rejects --anonymous

- **WHEN** a user runs `taskless auth login --anonymous`
- **THEN** the CLI SHALL exit with code 1
- **AND** SHALL print "auth commands cannot be anonymous" (or similar)

#### Scenario: A rejected saved token is replaced

- **WHEN** `.taskless/.env.local.json` holds a token the service answers with `401`, and `TASKLESS_TOKEN` is not set
- **AND** a user runs `taskless auth login`
- **THEN** the CLI SHALL run the device-code flow
- **AND** on approval SHALL overwrite the saved token with the new one

#### Scenario: An accepted saved token is kept

- **WHEN** the saved token is accepted by the service
- **AND** a user runs `taskless auth login`
- **THEN** the CLI SHALL NOT start the device-code flow
- **AND** SHALL report that the user is already logged in and name `auth logout` as the way to re-authenticate

#### Scenario: An unreachable service keeps the saved token

- **WHEN** the service cannot be reached to check the saved token
- **AND** a user runs `taskless auth login`
- **THEN** the CLI SHALL keep the saved token and SHALL NOT start the device-code flow

#### Scenario: A token from TASKLESS_TOKEN is named

- **WHEN** `TASKLESS_TOKEN` is set
- **AND** a user runs `taskless auth login`
- **THEN** the CLI SHALL NOT contact the service
- **AND** SHALL name `TASKLESS_TOKEN` as the token in use
- **AND** SHALL NOT direct the user to `auth logout`

#### Scenario: Keeping a token under --json is reported

- **WHEN** the saved token is accepted by the service
- **AND** a user runs `taskless auth login --json`
- **THEN** stdout SHALL contain `{ "ok": true, "status": "already_logged_in", "source": "saved" }`
- **AND** the exit code SHALL be 0

## ADDED Requirements

### Requirement: Auth login under --json completes across repeated bounded calls

`taskless auth login --json` SHALL never block for longer than 45 seconds, so that an agent whose tool returns output only on exit can relay the verification URL and complete a login. It SHALL decide from a pending code saved in the repository whether to start a login or continue one:

- With no pending code, it SHALL request a device code, save it, and return at once with `{ "ok": true, "status": "pending", "verificationUrl": "...", "userCode": "...", "expiresAt": "<ISO 8601>" }`.
- With a pending code, it SHALL poll for at most 45 seconds. It SHALL print `{ "ok": true, "status": "authenticated" }` and save the token when the user approves, and the same `pending` envelope, with the same URL and code, when the wait ends first.
- A denial SHALL fail with `AUTH_REQUIRED`. A code that expires while the call waits SHALL fail with `AUTH_EXPIRED`. Both SHALL discard the pending code.
- A pending code that has already expired when the call starts SHALL be discarded, and the call SHALL start a new login.

The pending code SHALL be saved to `.taskless/.auth-pending.json` with mode `0600` and listed in `.taskless/.gitignore`. It SHALL be removed on approval, denial, expiry, `auth logout`, and a successful login without `--json`. The checks for an existing token SHALL run before any pending code is read or created. Without `--json`, `auth login` SHALL keep its single blocking flow.

#### Scenario: The first call returns the URL at once

- **WHEN** no token is present and no login is pending
- **AND** an agent runs `taskless auth login --json`
- **THEN** stdout SHALL contain a `pending` envelope with `verificationUrl`, `userCode`, and `expiresAt`
- **AND** the command SHALL exit 0 without polling
- **AND** `.taskless/.auth-pending.json` SHALL exist with mode `0600` and be listed in `.taskless/.gitignore`

#### Scenario: A later call completes the login

- **WHEN** a login is pending and the user approves it during the next call
- **THEN** stdout SHALL contain `{ "ok": true, "status": "authenticated" }`
- **AND** the token SHALL be saved to `.taskless/.env.local.json`
- **AND** `.taskless/.auth-pending.json` SHALL no longer exist

#### Scenario: A call that outlasts its wait repeats the pending envelope

- **WHEN** a login is pending and the user has not approved it within 45 seconds
- **THEN** the call SHALL return within 45 seconds plus one poll
- **AND** stdout SHALL contain the `pending` envelope with the same `verificationUrl` and `userCode` as the first call

#### Scenario: Expiry during a call is reported

- **WHEN** a login is pending and its code expires while a call is polling
- **THEN** stdout SHALL contain `{ "ok": false, "code": "AUTH_EXPIRED", "message": "..." }`
- **AND** the exit code SHALL be non-zero
- **AND** `.taskless/.auth-pending.json` SHALL no longer exist

#### Scenario: A denial is reported

- **WHEN** a login is pending and the user denies it
- **THEN** stdout SHALL contain `{ "ok": false, "code": "AUTH_REQUIRED", "message": "..." }`
- **AND** `.taskless/.auth-pending.json` SHALL no longer exist

#### Scenario: An abandoned login is replaced, not reported

- **WHEN** `.taskless/.auth-pending.json` holds a code whose expiry has already passed
- **AND** an agent runs `taskless auth login --json`
- **THEN** the CLI SHALL request a new device code
- **AND** stdout SHALL contain a `pending` envelope with the new code

#### Scenario: Logout discards a pending login

- **WHEN** a login is pending and a user runs `taskless auth logout`
- **THEN** `.taskless/.auth-pending.json` SHALL no longer exist

#### Scenario: The interactive login is unchanged

- **WHEN** a user runs `taskless auth login` without `--json`
- **THEN** the CLI SHALL print the URL and code and poll until approval, denial, or expiry in one call
19 changes: 19 additions & 0 deletions openspec/changes/archive/2026-10-21-auth-agent-login/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## 1. Pending login state

- [x] 1.1 Add read/save/clear for `.taskless/.auth-pending.json` beside the token helpers (mode `0600`, added to `.taskless/.gitignore`, `expires_at` absolute); unit-test save → read → clear, and that an expired file reads as absent
- [x] 1.2 Add `AUTH_EXPIRED` to the error code enum; typecheck passes

## 2. Stepped login

- [x] 2.1 Implement the stepped flow against `DeviceFlowProvider`: start (request, save, return pending), continue (poll ≤45s honouring `interval`/`slow_down`, then authenticated / pending / denied / expired), silent restart on a code found expired; unit-test every branch with a fake provider and an injected clock
- [x] 2.2 Route `auth login --json` through it and print the success envelopes (`pending`, `authenticated`, `already_logged_in` with `source`); keep the interactive path unchanged; tests assert each envelope and exit code
- [x] 2.3 Clear a pending code on `auth logout` and on a successful interactive login; tests cover both

## 3. Recipe

- [x] 3.1 Rewrite the login branch of `src/agent/auth.md` to loop on `auth login --json`, add `AUTH_EXPIRED` to its error table, and bump the topic version from #562's v4 to v5; recipe tests pass

## 4. Verification

- [x] 4.1 `pnpm typecheck`, `pnpm lint`, and the CLI suite pass
- [x] 4.2 A real login against the service, driven only by repeated `auth login --json` calls, ends in `authenticated` and leaves no `.auth-pending.json` behind
Loading
Loading