diff --git a/.changeset/cli-version-pin-gate.md b/.changeset/cli-version-pin-gate.md index 4df6fd3c..398ac521 100644 --- a/.changeset/cli-version-pin-gate.md +++ b/.changeset/cli-version-pin-gate.md @@ -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. diff --git a/openspec/changes/archive/2026-10-21-auth-agent-login/.openspec.yaml b/openspec/changes/archive/2026-10-21-auth-agent-login/.openspec.yaml new file mode 100644 index 00000000..dc36fb1b --- /dev/null +++ b/openspec/changes/archive/2026-10-21-auth-agent-login/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-21 diff --git a/openspec/changes/archive/2026-10-21-auth-agent-login/design.md b/openspec/changes/archive/2026-10-21-auth-agent-login/design.md new file mode 100644 index 00000000..9640124f --- /dev/null +++ b/openspec/changes/archive/2026-10-21-auth-agent-login/design.md @@ -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. diff --git a/openspec/changes/archive/2026-10-21-auth-agent-login/proposal.md b/openspec/changes/archive/2026-10-21-auth-agent-login/proposal.md new file mode 100644 index 00000000..1e51f5a3 --- /dev/null +++ b/openspec/changes/archive/2026-10-21-auth-agent-login/proposal.md @@ -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. diff --git a/openspec/changes/archive/2026-10-21-auth-agent-login/specs/cli-auth/spec.md b/openspec/changes/archive/2026-10-21-auth-agent-login/specs/cli-auth/spec.md new file mode 100644 index 00000000..ca4bc3db --- /dev/null +++ b/openspec/changes/archive/2026-10-21-auth-agent-login/specs/cli-auth/spec.md @@ -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": "" }`. +- 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 diff --git a/openspec/changes/archive/2026-10-21-auth-agent-login/tasks.md b/openspec/changes/archive/2026-10-21-auth-agent-login/tasks.md new file mode 100644 index 00000000..ed222b06 --- /dev/null +++ b/openspec/changes/archive/2026-10-21-auth-agent-login/tasks.md @@ -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 diff --git a/openspec/specs/cli-auth/spec.md b/openspec/specs/cli-auth/spec.md index c84ba3e2..3227cfb4 100644 --- a/openspec/specs/cli-auth/spec.md +++ b/openspec/specs/cli-auth/spec.md @@ -24,7 +24,7 @@ When a token is already present, `auth login` SHALL decide by where it comes fro - 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 nothing. +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 @@ -65,6 +65,13 @@ Keeping a token SHALL NOT be an error: the command exits 0, and under `--json` p - **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 + ### Requirement: Auth logout removes saved token `taskless auth logout` removes the saved token per the existing requirement. The `--anonymous` flag SHALL be accepted as a no-op on this command (logout is already a local operation requiring no API state). @@ -288,3 +295,65 @@ This SHALL hold for every command that reaches the service with a token, includi - **WHEN** a user runs `taskless auth` and the service cannot be reached - **THEN** the output SHALL say identity could not be verified - **AND** SHALL NOT say the token is invalid or rejected + +### 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": "" }`. +- 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 diff --git a/packages/cli/src/agent/auth.md b/packages/cli/src/agent/auth.md index 62076248..a78d7ce4 100644 --- a/packages/cli/src/agent/auth.md +++ b/packages/cli/src/agent/auth.md @@ -1,4 +1,4 @@ -# Topic: auth (CLI v%(CLI_VERSION)s / topic v4) +# Topic: auth (CLI v%(CLI_VERSION)s / topic v5) %(ONE_COMMAND_PER_CALL)s @@ -29,25 +29,41 @@ Pick the branch matching the user's intent. ### Login +Log in with `--json`. Without it, `auth login` blocks until the user +approves, and you would not see the URL they need until then. With it, +each call is one short step you repeat: + 1. Run: ``` - %(TASKLESS_CLI)s auth login + %(TASKLESS_CLI)s auth login --json ``` -2. The CLI prints a URL and a device code. Tell the user to open the - URL and enter the code. -3. The CLI polls until the token is approved. On success, the token - is written to `.taskless/.env.local.json` and a confirmation is - printed. + It returns at once with + `{ "ok": true, "status": "pending", "verificationUrl", "userCode", "expiresAt" }`. +2. Tell the user to open `verificationUrl` and enter `userCode`. Do + this before the next call: the next call waits on their approval. +3. Run the same command again. Each call waits at most 45 seconds, + then prints one of: + - `"status": "pending"`: not approved yet. Run it again. If + `userCode` differs from the one you showed, the earlier code + expired and a new login started: show the new URL and code. + - `"status": "authenticated"`: done. The token is saved to + `.taskless/.env.local.json`. + - An error envelope: see **Errors** below. 4. Report success. Suggest `%(TASKLESS_CLI)s info` to verify identity. +Stop calling once the user says they will not approve, and run +`%(TASKLESS_CLI)s auth logout` to discard the pending login. + If a saved token is already present, `auth login` asks the service whether it is still accepted. A rejected token (revoked or expired) is replaced by a fresh login; an accepted one is kept and the CLI -prints "You are already logged in." Neither is an error. +prints `{ "ok": true, "status": "already_logged_in", "source": "saved" }`. +Neither is an error. If the token comes from the `TASKLESS_TOKEN` environment variable, -`auth login` does nothing: a saved login would not be used while the -variable is set. Tell the user to replace or unset `TASKLESS_TOKEN`. +`auth login` prints `"source": "environment"` and does nothing else: a +saved login would not be used while the variable is set. Tell the user +to replace or unset `TASKLESS_TOKEN`. The `--anonymous` flag is rejected on `auth login`, it errors with "auth commands cannot be anonymous". Don't pass it. @@ -84,17 +100,18 @@ The `--anonymous` flag is rejected on `auth login`, it errors with `auth login` and `auth logout` accept `--json`. On error, the standardized `{ ok: false, code, message }` envelope is written to -stdout (and human text on stderr is suppressed). On success in -`--json` mode, the commands exit 0 silently, no success envelope is -emitted. The status path (`%(TASKLESS_CLI)s auth` with no subcommand) accepts +stdout (and human text on stderr is suppressed). On success, +`auth login --json` prints the `ok: true` envelopes described under +**Login**, and `auth logout --json` exits 0 silently. The status path (`%(TASKLESS_CLI)s auth` with no subcommand) accepts `--json` for forward-compat but currently has no error paths to report. | code | meaning | fix | |-----------------|--------------------------------------------------------------------------------------|--------------------------------------| | `INVALID_INPUT` | `--anonymous` passed to `auth login` (rejected: auth commands cannot be anonymous) | Don't pass `--anonymous` | -| `NETWORK_ERROR` | Device flow / token endpoint unreachable, or the device code expired before approval | Check connectivity; retry | -| `AUTH_REQUIRED` | The user denied the authorization request in their browser | Re-run `%(TASKLESS_CLI)s auth login` | +| `NETWORK_ERROR` | Device flow / token endpoint unreachable. The pending login is kept | Check connectivity; run it again | +| `AUTH_EXPIRED` | The code expired before the user approved it | Run it again; show the new URL/code | +| `AUTH_REQUIRED` | The user denied the authorization request in their browser | Ask the user; run it again to retry | ## See Also diff --git a/packages/cli/src/auth/login-interactive.ts b/packages/cli/src/auth/login-interactive.ts index 213bc2ac..2c30c826 100644 --- a/packages/cli/src/auth/login-interactive.ts +++ b/packages/cli/src/auth/login-interactive.ts @@ -1,6 +1,7 @@ import process from "node:process"; import { deviceFlowProvider } from "./device-flow"; +import { clearPendingLogin } from "./pending-login"; import { getToken, isEnvironmentToken, saveToken } from "./token"; import { whoami } from "../api/v2"; import { resolveRepositoryUrl } from "../util/git-remote"; @@ -22,6 +23,28 @@ export interface LoginInteractiveOptions { err?: (line: string) => void; } +/** + * Where the token that makes a new login unnecessary comes from, or + * `undefined` when there is none to keep. + */ +export async function existingLoginSource( + cwd: string +): Promise<"environment" | "saved" | undefined> { + // Silent: this is only an "already logged in?" probe. Stderr warnings from + // legacy/tracked-token checks belong to the commands that use the token, + // not to the login flow that is about to replace it. + const existing = await getToken(cwd, { silent: true }); + if (!existing) return undefined; + // A token from TASKLESS_TOKEN outranks anything this flow could save, so + // logging in would change nothing the next command reads. + if (isEnvironmentToken()) return "environment"; + // A saved token the service rejects is the reason the user is here: every + // 401 message sends them to `auth login`. Only a definite rejection is + // replaced; an unreachable service says nothing about the token. + const verified = await whoami(existing); + return verified.status === "unauthorized" ? undefined : "saved"; +} + /** * Run the device-code login flow interactively. Writes the user prompt, * polls the Taskless auth endpoint, persists the token on success, and @@ -38,24 +61,8 @@ export async function loginInteractive( const out = options.out ?? ((line) => console.log(line)); const error_ = options.err ?? ((line) => console.error(line)); - // Silent: this is only an "already logged in?" probe. Stderr warnings from - // legacy/tracked-token checks belong to the commands that use the token, - // not to the login flow that is about to replace it. - const existing = await getToken(cwd, { silent: true }); - if (existing) { - // A token from TASKLESS_TOKEN outranks anything this flow could save, so - // logging in would change nothing the next command reads. - if (isEnvironmentToken()) { - return { status: "already_logged_in", source: "environment" }; - } - // A saved token the service rejects is the reason the user is here: every - // 401 message sends them to `auth login`. Only a definite rejection is - // replaced; an unreachable service says nothing about the token. - const verified = await whoami(existing); - if (verified.status !== "unauthorized") { - return { status: "already_logged_in", source: "saved" }; - } - } + const source = await existingLoginSource(cwd); + if (source !== undefined) return { status: "already_logged_in", source }; try { let repositoryUrl: string | undefined; @@ -93,6 +100,8 @@ export async function loginInteractive( switch (result.status) { case "success": { await saveToken(result.token, cwd); + // A login started under `--json` and finished here is finished. + await clearPendingLogin(cwd); out("Logged in successfully."); return { status: "ok" }; } diff --git a/packages/cli/src/auth/login-step.ts b/packages/cli/src/auth/login-step.ts new file mode 100644 index 00000000..1f8d301a --- /dev/null +++ b/packages/cli/src/auth/login-step.ts @@ -0,0 +1,164 @@ +import { deviceFlowProvider } from "./device-flow"; +import { existingLoginSource } from "./login-interactive"; +import { + clearPendingLogin, + type PendingLogin, + readPendingLogin, + savePendingLogin, +} from "./pending-login"; +import { saveToken } from "./token"; +import { resolveRepositoryUrl } from "../util/git-remote"; + +/** + * The longest one step waits for approval. Well under the two-minute default + * a Claude Code Bash call allows, so a step returns before an agent's tool + * gives up on it. Lowering it costs only more calls. + */ +export const LOGIN_STEP_WAIT_MS = 45_000; + +/** How much the service asks a poller to back off on `slow_down`. */ +const SLOW_DOWN_MS = 5000; + +export type LoginStepResult = + | { status: "already_logged_in"; source: "environment" | "saved" } + | { status: "authenticated" } + | { + status: "pending"; + verificationUrl: string; + userCode: string; + expiresAt: number; + } + | { + status: "failed"; + reason: "denied" | "expired" | "error"; + message?: string; + }; + +export interface LoginStepOptions { + cwd: string; + /** Defaults to {@link LOGIN_STEP_WAIT_MS}. */ + waitMs?: number; + now?: () => number; + sleep?: (ms: number) => Promise; +} + +function pendingResult(pending: PendingLogin): LoginStepResult { + return { + status: "pending", + verificationUrl: pending.verificationUrl, + userCode: pending.userCode, + expiresAt: pending.expiresAt, + }; +} + +/** + * One bounded step of a device-code login, for a caller that only sees a + * command's output once it exits. + * + * The first step starts a login and returns its URL at once. Each later step + * polls the saved login for at most {@link LOGIN_STEP_WAIT_MS} and reports + * where it got to, so a caller repeats the step until it is not `pending`. + * Every `pending` result carries the URL and code, so a caller that lost + * track can always show the right ones. + */ +export async function loginStep( + options: LoginStepOptions +): Promise { + const { cwd } = options; + const now = options.now ?? (() => Date.now()); + const sleep = + options.sleep ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const waitMs = options.waitMs ?? LOGIN_STEP_WAIT_MS; + + try { + const source = await existingLoginSource(cwd); + if (source !== undefined) return { status: "already_logged_in", source }; + + const saved = await readPendingLogin(cwd); + // A code that expired before this step began belongs to a login this + // caller may never have seen, so it is replaced rather than reported. + if (saved === undefined || saved.expiresAt <= now()) { + return pendingResult(await startLogin(cwd, now())); + } + + const pending = saved; + const deadline = now() + waitMs; + let interval = pending.interval * 1000; + // Poll only while a whole interval still fits, so the service's pacing + // is never cut short and a step ends within its wait. + while (now() + interval <= Math.min(deadline, pending.expiresAt)) { + await sleep(interval); + const result = await deviceFlowProvider.pollForToken(pending.deviceCode); + switch (result.status) { + case "success": { + await saveToken(result.token, cwd); + await clearPendingLogin(cwd); + return { status: "authenticated" }; + } + case "denied": { + await clearPendingLogin(cwd); + return { status: "failed", reason: "denied" }; + } + case "expired": { + await clearPendingLogin(cwd); + return { status: "failed", reason: "expired" }; + } + case "slow_down": { + interval += SLOW_DOWN_MS; + // Kept for the next step, which would otherwise poll too fast. + await savePendingLogin(cwd, { + ...pending, + interval: interval / 1000, + }); + break; + } + case "pending": { + break; + } + } + } + + // Expiry, not the wait, ended the polling: the code runs out inside this + // step's window. Wait it out so the report is never early, then say so to + // the caller that was waiting on it. A code that outlives the window is + // still pending, and the step that reaches its expiry reports it. + if (pending.expiresAt <= deadline) { + await sleep(Math.max(0, pending.expiresAt - now())); + await clearPendingLogin(cwd); + return { status: "failed", reason: "expired" }; + } + return pendingResult(pending); + } catch (error) { + // The pending login is kept: a failed request says nothing about it. + return { + status: "failed", + reason: "error", + message: + error instanceof Error ? error.message : "Authentication failed.", + }; + } +} + +async function startLogin( + cwd: string, + startedAt: number +): Promise { + let repositoryUrl: string | undefined; + try { + repositoryUrl = await resolveRepositoryUrl(cwd); + } catch { + // Not a git repo or no origin — proceed without hint + } + const deviceCode = await deviceFlowProvider.requestDeviceCode(repositoryUrl); + const pending: PendingLogin = { + deviceCode: deviceCode.device_code, + userCode: deviceCode.user_code, + verificationUrl: + deviceCode.verification_uri_complete ?? deviceCode.verification_uri, + interval: deviceCode.interval, + expiresAt: startedAt + deviceCode.expires_in * 1000, + }; + await savePendingLogin(cwd, pending); + return pending; +} diff --git a/packages/cli/src/auth/pending-login.ts b/packages/cli/src/auth/pending-login.ts new file mode 100644 index 00000000..aa55b9bd --- /dev/null +++ b/packages/cli/src/auth/pending-login.ts @@ -0,0 +1,76 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { addToGitignore } from "../filesystem/gitignore"; + +const PENDING_LOGIN_FILE = ".auth-pending.json"; + +/** + * A device-code login that has been started and not yet finished, saved so + * that `auth login --json` can continue it from a later process. + * + * The device code is a credential until it expires: whoever polls with it + * receives the token the user approves. It is kept beside the saved token, + * with the same mode and the same gitignore entry. + */ +export interface PendingLogin { + deviceCode: string; + userCode: string; + verificationUrl: string; + /** Seconds between polls, as the service last asked for. */ + interval: number; + /** Milliseconds since the epoch. */ + expiresAt: number; +} + +/** The pending login in `cwd`, or `undefined` when none is saved or it is unreadable. */ +export async function readPendingLogin( + cwd: string +): Promise { + let data: Partial; + try { + const raw = await readFile( + join(cwd, ".taskless", PENDING_LOGIN_FILE), + "utf8" + ); + data = JSON.parse(raw) as Partial; + } catch { + return undefined; + } + // A file this CLI did not write in full is not one it can continue. + if ( + typeof data.deviceCode !== "string" || + typeof data.userCode !== "string" || + typeof data.verificationUrl !== "string" || + typeof data.interval !== "number" || + typeof data.expiresAt !== "number" + ) { + return undefined; + } + return { + deviceCode: data.deviceCode, + userCode: data.userCode, + verificationUrl: data.verificationUrl, + interval: data.interval, + expiresAt: data.expiresAt, + }; +} + +/** Save a pending login to `.taskless/.auth-pending.json`, readable only by its owner. */ +export async function savePendingLogin( + cwd: string, + pending: PendingLogin +): Promise { + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await addToGitignore(cwd, [PENDING_LOGIN_FILE]); + await writeFile( + join(cwd, ".taskless", PENDING_LOGIN_FILE), + JSON.stringify(pending, null, 2) + "\n", + { mode: 0o600 } + ); +} + +/** Remove a pending login, if one is saved. */ +export async function clearPendingLogin(cwd: string): Promise { + await rm(join(cwd, ".taskless", PENDING_LOGIN_FILE), { force: true }); +} diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 390bd6d4..974d4edb 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -4,6 +4,8 @@ import { defineCommand } from "citty"; import { whoami } from "../api/v2"; import { loginInteractive } from "../auth/login-interactive"; +import { loginStep } from "../auth/login-step"; +import { clearPendingLogin } from "../auth/pending-login"; import { describeAuthStatus } from "../auth/status"; import { getToken, isEnvironmentToken, removeToken } from "../auth/token"; import { getTelemetry } from "../telemetry"; @@ -30,7 +32,7 @@ const loginCommand = defineCommand({ json: { type: "boolean", description: - "On error, write the standardized { ok:false, code, message } envelope to stdout instead of human text on stderr", + "Run one bounded step for an agent: start a login or wait up to 45s on the pending one, then print { ok, status } (pending carries the URL and code). Repeat until status is not pending.", default: false, }, }, @@ -57,12 +59,59 @@ const loginCommand = defineCommand({ // cli_authenticated event in the finally. let authenticated = false; try { - // In --json mode the user is an agent / pipe; suppress the device-flow - // chatter and only emit a single structured line on error. - const noop = (): void => {}; - const result = await loginInteractive( - args.json ? { cwd, out: noop, err: noop } : { cwd } - ); + // An agent sees a command's output only once it exits, so under + // --json a login is a step it repeats rather than one blocking call + // that holds back the URL its user needs. + if (args.json) { + const step = await loginStep({ cwd }); + switch (step.status) { + case "already_logged_in": { + console.log( + JSON.stringify({ + ok: true, + status: step.status, + source: step.source, + }) + ); + return; + } + case "authenticated": { + authenticated = true; + console.log(JSON.stringify({ ok: true, status: step.status })); + return; + } + case "pending": { + console.log( + JSON.stringify({ + ok: true, + status: step.status, + verificationUrl: step.verificationUrl, + userCode: step.userCode, + expiresAt: new Date(step.expiresAt).toISOString(), + }) + ); + return; + } + case "failed": { + const code: CLIErrorCode = + step.reason === "denied" + ? "AUTH_REQUIRED" + : step.reason === "expired" + ? "AUTH_EXPIRED" + : "NETWORK_ERROR"; + fail( + code, + step.message ?? + (step.reason === "denied" + ? "Authorization denied." + : "The login code expired before it was approved. Run `auth login --json` again for a new one.") + ); + return; + } + } + } + + const result = await loginInteractive({ cwd }); switch (result.status) { case "ok": { @@ -70,20 +119,18 @@ const loginCommand = defineCommand({ return; } case "already_logged_in": { - if (!args.json) { - if (result.source === "environment") { - console.log( - "You are already logged in with the TASKLESS_TOKEN environment variable." - ); - console.log( - "A saved login would not be used while it is set. Unset TASKLESS_TOKEN first to log in here." - ); - } else { - console.log("You are already logged in."); - console.log( - `Run \`${getCliPrefix()} auth logout\` first to re-authenticate.` - ); - } + if (result.source === "environment") { + console.log( + "You are already logged in with the TASKLESS_TOKEN environment variable." + ); + console.log( + "A saved login would not be used while it is set. Unset TASKLESS_TOKEN first to log in here." + ); + } else { + console.log("You are already logged in."); + console.log( + `Run \`${getCliPrefix()} auth logout\` first to re-authenticate.` + ); } return; } @@ -140,6 +187,9 @@ const logoutCommand = defineCommand({ let removed = false; try { removed = await removeToken(cwd); + // A login still waiting on approval would otherwise sign this + // repository back in after the user asked to be logged out. + await clearPendingLogin(cwd); if (!args.json) { // logout only removes the saved token. Without saying so, a user // whose token comes from the environment reads "Not logged in." and diff --git a/packages/cli/src/types/errors.ts b/packages/cli/src/types/errors.ts index ee2681d8..15698756 100644 --- a/packages/cli/src/types/errors.ts +++ b/packages/cli/src/types/errors.ts @@ -8,6 +8,9 @@ */ export type CLIErrorCode = | "AUTH_REQUIRED" + // A device-code login expired before the user approved it. Not a network + // failure: running `auth login` again starts a new login with a new code. + | "AUTH_EXPIRED" // Remote rule generation needs a verifiable org, which comes from a GitHub // `origin`. The three codes below name WHICH of the three populations a // project is in, because the remedies differ: `git init`, add a remote, or diff --git a/packages/cli/test/auth-login-json.test.ts b/packages/cli/test/auth-login-json.test.ts new file mode 100644 index 00000000..a843a040 --- /dev/null +++ b/packages/cli/test/auth-login-json.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCommand } from "citty"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + type PendingLogin, + readPendingLogin, + savePendingLogin, +} from "../src/auth/pending-login"; + +const { mockedRequestDeviceCode, mockedPollForToken } = vi.hoisted(() => ({ + mockedRequestDeviceCode: vi.fn(), + mockedPollForToken: vi.fn(), +})); + +vi.mock("../src/api/v2", () => ({ whoami: vi.fn() })); +vi.mock("../src/auth/device-flow", () => ({ + deviceFlowProvider: { + requestDeviceCode: mockedRequestDeviceCode, + pollForToken: mockedPollForToken, + }, +})); +vi.mock("../src/telemetry", () => ({ + getTelemetry: vi.fn(() => + Promise.resolve({ capture: vi.fn(), shutdown: () => Promise.resolve() }) + ), +})); + +const { authCommand } = await import("../src/commands/auth"); + +let cwd: string; +let previousToken: string | undefined; +let logSpy: ReturnType; + +async function run(...args: string[]): Promise { + await runCommand(authCommand, { rawArgs: [...args, "-d", cwd] }); + const [line] = logSpy.mock.calls.map((call) => String(call[0])); + return line === undefined ? undefined : JSON.parse(line); +} + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-auth-login-json-")); + previousToken = process.env.TASKLESS_TOKEN; + delete process.env.TASKLESS_TOKEN; + process.exitCode = undefined; + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(async () => { + logSpy.mockRestore(); + vi.resetAllMocks(); + process.exitCode = undefined; + if (previousToken === undefined) { + delete process.env.TASKLESS_TOKEN; + } else { + process.env.TASKLESS_TOKEN = previousToken; + } + await rm(cwd, { recursive: true, force: true }); +}); + +/** What an agent reads from each `auth login --json` step. */ +describe("auth login --json", () => { + it("prints the URL and code of a new login, with its expiry", async () => { + mockedRequestDeviceCode.mockResolvedValue({ + device_code: "device-1", + user_code: "ABCD-EFGH", + verification_uri: "https://taskless.io/device", + verification_uri_complete: "https://taskless.io/device?code=ABCD-EFGH", + expires_in: 600, + interval: 5, + }); + const envelope = (await run("login", "--json")) as Record; + expect(envelope).toMatchObject({ + ok: true, + status: "pending", + verificationUrl: "https://taskless.io/device?code=ABCD-EFGH", + userCode: "ABCD-EFGH", + }); + expect(Date.parse(String(envelope.expiresAt))).toBeGreaterThan(Date.now()); + expect(process.exitCode).toBeUndefined(); + }); + + it("prints authenticated when a pending login is approved", async () => { + await savePendingLogin(cwd, { + deviceCode: "device-1", + userCode: "ABCD-EFGH", + verificationUrl: "https://taskless.io/device", + interval: 0, + expiresAt: Date.now() + 600_000, + }); + mockedPollForToken.mockResolvedValue({ + status: "success", + token: { access_token: "fresh-token", token_type: "Bearer" }, + }); + expect(await run("login", "--json")).toEqual({ + ok: true, + status: "authenticated", + }); + }); + + it("prints already_logged_in for TASKLESS_TOKEN", async () => { + process.env.TASKLESS_TOKEN = "env-token"; + expect(await run("login", "--json")).toEqual({ + ok: true, + status: "already_logged_in", + source: "environment", + }); + }); + + it("fails with AUTH_EXPIRED when the code expires", async () => { + await savePendingLogin(cwd, { + deviceCode: "device-1", + userCode: "ABCD-EFGH", + verificationUrl: "https://taskless.io/device", + interval: 0, + expiresAt: Date.now() + 600_000, + }); + mockedPollForToken.mockResolvedValue({ status: "expired" }); + expect(await run("login", "--json")).toMatchObject({ + ok: false, + code: "AUTH_EXPIRED", + }); + expect(process.exitCode).toBe(1); + }); +}); + +describe("auth logout", () => { + it("discards a pending login", async () => { + const pending: PendingLogin = { + deviceCode: "device-1", + userCode: "ABCD-EFGH", + verificationUrl: "https://taskless.io/device", + interval: 5, + expiresAt: Date.now() + 600_000, + }; + await savePendingLogin(cwd, pending); + await runCommand(authCommand, { rawArgs: ["logout", "-d", cwd] }); + expect(await readPendingLogin(cwd)).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/login-interactive.test.ts b/packages/cli/test/login-interactive.test.ts index 40f33931..09cb03a7 100644 --- a/packages/cli/test/login-interactive.test.ts +++ b/packages/cli/test/login-interactive.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { whoami } from "../src/api/v2"; import { loginInteractive } from "../src/auth/login-interactive"; +import { readPendingLogin, savePendingLogin } from "../src/auth/pending-login"; import { rejectedTokenRemedy } from "../src/auth/token"; const { mockedRequestDeviceCode, mockedPollForToken } = vi.hoisted(() => ({ @@ -139,6 +140,31 @@ describe("loginInteractive", () => { expect(await readSavedToken()).toBe("fresh-token"); expect(sinks.logs).toContain("Logged in successfully."); }); + it("finishes a login started under --json, so it cannot complete twice", async () => { + await savePendingLogin(cwd, { + deviceCode: "stale-device", + userCode: "OLDC-0DE0", + verificationUrl: "https://example.test/device", + interval: 5, + expiresAt: Date.now() + 600_000, + }); + mockedRequestDeviceCode.mockResolvedValue({ + device_code: "device", + user_code: "ABCD-EFGH", + verification_uri: "https://example.test/device", + expires_in: 600, + interval: 0, + }); + mockedPollForToken.mockResolvedValue({ + status: "success", + token: { access_token: "fresh-token", token_type: "Bearer" }, + }); + + expect(await loginInteractive({ cwd, ...quiet() })).toEqual({ + status: "ok", + }); + expect(await readPendingLogin(cwd)).toBeUndefined(); + }); }); describe("rejectedTokenRemedy", () => { diff --git a/packages/cli/test/login-step.test.ts b/packages/cli/test/login-step.test.ts new file mode 100644 index 00000000..1cd57090 --- /dev/null +++ b/packages/cli/test/login-step.test.ts @@ -0,0 +1,244 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { whoami } from "../src/api/v2"; +import { loginStep } from "../src/auth/login-step"; +import { + type PendingLogin, + readPendingLogin, + savePendingLogin, +} from "../src/auth/pending-login"; + +const { mockedRequestDeviceCode, mockedPollForToken } = vi.hoisted(() => ({ + mockedRequestDeviceCode: vi.fn(), + mockedPollForToken: vi.fn(), +})); + +vi.mock("../src/api/v2", () => ({ whoami: vi.fn() })); +vi.mock("../src/auth/device-flow", () => ({ + deviceFlowProvider: { + requestDeviceCode: mockedRequestDeviceCode, + pollForToken: mockedPollForToken, + }, +})); + +const mockedWhoami = vi.mocked(whoami); + +const START = 1_800_000_000_000; + +let cwd: string; +let previousToken: string | undefined; +let clock: number; + +/** A clock that only moves when the step sleeps, so waits cost nothing. */ +const time = { + now: () => clock, + sleep: (ms: number) => { + clock += ms; + return Promise.resolve(); + }, +}; + +const PENDING: PendingLogin = { + deviceCode: "device-123", + userCode: "ABCD-EFGH", + verificationUrl: "https://taskless.io/device?code=ABCD-EFGH", + interval: 5, + expiresAt: START + 600_000, +}; + +function issueCode(code = "WXYZ-1234"): void { + mockedRequestDeviceCode.mockResolvedValue({ + device_code: `device-${code}`, + user_code: code, + verification_uri: "https://taskless.io/device", + verification_uri_complete: `https://taskless.io/device?code=${code}`, + expires_in: 600, + interval: 5, + }); +} + +async function savedToken(): Promise { + const raw = await readFile(join(cwd, ".taskless", ".env.local.json"), "utf8"); + return (JSON.parse(raw) as { access_token?: string }).access_token; +} + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-login-step-")); + previousToken = process.env.TASKLESS_TOKEN; + delete process.env.TASKLESS_TOKEN; + clock = START; +}); + +afterEach(async () => { + vi.resetAllMocks(); + if (previousToken === undefined) { + delete process.env.TASKLESS_TOKEN; + } else { + process.env.TASKLESS_TOKEN = previousToken; + } + await rm(cwd, { recursive: true, force: true }); +}); + +/** + * `auth login --json` as a step an agent repeats: the first returns the URL + * at once, each later one waits a bounded time and reports where it got to. + */ +describe("a login step", () => { + it("starts a login and returns its URL without polling", async () => { + issueCode("WXYZ-1234"); + const result = await loginStep({ cwd, ...time }); + expect(result).toEqual({ + status: "pending", + verificationUrl: "https://taskless.io/device?code=WXYZ-1234", + userCode: "WXYZ-1234", + expiresAt: START + 600_000, + }); + expect(mockedPollForToken).not.toHaveBeenCalled(); + expect(clock).toBe(START); + expect(await readPendingLogin(cwd)).toMatchObject({ + deviceCode: "device-WXYZ-1234", + }); + }); + + it("finishes a pending login the user approved", async () => { + await savePendingLogin(cwd, PENDING); + mockedPollForToken + .mockResolvedValueOnce({ status: "pending" }) + .mockResolvedValueOnce({ + status: "success", + token: { access_token: "fresh-token", token_type: "Bearer" }, + }); + const result = await loginStep({ cwd, ...time }); + expect(result).toEqual({ status: "authenticated" }); + expect(await savedToken()).toBe("fresh-token"); + expect(await readPendingLogin(cwd)).toBeUndefined(); + expect(mockedRequestDeviceCode).not.toHaveBeenCalled(); + }); + + it("returns the same URL and code when its wait runs out", async () => { + await savePendingLogin(cwd, PENDING); + mockedPollForToken.mockResolvedValue({ status: "pending" }); + const result = await loginStep({ cwd, ...time }); + expect(result).toEqual({ + status: "pending", + verificationUrl: PENDING.verificationUrl, + userCode: PENDING.userCode, + expiresAt: PENDING.expiresAt, + }); + // 45 seconds of 5-second polls, and not one past the wait. + expect(mockedPollForToken).toHaveBeenCalledTimes(9); + expect(clock - START).toBeLessThanOrEqual(45_000); + expect(await readPendingLogin(cwd)).toEqual(PENDING); + }); + + it("reports a denial and forgets the login", async () => { + await savePendingLogin(cwd, PENDING); + mockedPollForToken.mockResolvedValue({ status: "denied" }); + expect(await loginStep({ cwd, ...time })).toEqual({ + status: "failed", + reason: "denied", + }); + expect(await readPendingLogin(cwd)).toBeUndefined(); + }); + + it("reports a code the service says expired", async () => { + await savePendingLogin(cwd, PENDING); + mockedPollForToken.mockResolvedValue({ status: "expired" }); + expect(await loginStep({ cwd, ...time })).toEqual({ + status: "failed", + reason: "expired", + }); + expect(await readPendingLogin(cwd)).toBeUndefined(); + }); + + it("reports a code that runs out during its wait", async () => { + await savePendingLogin(cwd, { ...PENDING, expiresAt: START + 12_000 }); + mockedPollForToken.mockResolvedValue({ status: "pending" }); + expect(await loginStep({ cwd, ...time })).toEqual({ + status: "failed", + reason: "expired", + }); + // Reported at the expiry itself, never before it, and inside the wait. + expect(clock).toBe(START + 12_000); + expect(await readPendingLogin(cwd)).toBeUndefined(); + }); + + it("leaves a code that outlives its wait pending, for the next step to report", async () => { + // Valid for 48s against a 45s wait: the wait, not the code, ends this step. + await savePendingLogin(cwd, { ...PENDING, expiresAt: START + 48_000 }); + mockedPollForToken.mockResolvedValue({ status: "pending" }); + expect(await loginStep({ cwd, ...time })).toMatchObject({ + status: "pending", + userCode: PENDING.userCode, + }); + expect(clock).toBe(START + 45_000); + expect(await readPendingLogin(cwd)).toBeDefined(); + + // The next step reaches the expiry and reports it. + expect(await loginStep({ cwd, ...time })).toEqual({ + status: "failed", + reason: "expired", + }); + expect(clock).toBe(START + 48_000); + expect(await readPendingLogin(cwd)).toBeUndefined(); + }); + + it("replaces a login that expired before it began, without reporting it", async () => { + await savePendingLogin(cwd, { ...PENDING, expiresAt: START - 1 }); + issueCode("NEWC-0DE0"); + const result = await loginStep({ cwd, ...time }); + expect(result).toMatchObject({ status: "pending", userCode: "NEWC-0DE0" }); + expect(mockedPollForToken).not.toHaveBeenCalled(); + }); + + it("backs off on slow_down and keeps the slower pace for the next step", async () => { + await savePendingLogin(cwd, PENDING); + mockedPollForToken + .mockResolvedValueOnce({ status: "slow_down" }) + .mockResolvedValue({ status: "pending" }); + await loginStep({ cwd, ...time }); + // One poll at 5s, then every 10s: 5, 15, 25, 35, 45. + expect(mockedPollForToken).toHaveBeenCalledTimes(5); + expect(await readPendingLogin(cwd)).toMatchObject({ interval: 10 }); + }); + + it("keeps the pending login when a request fails", async () => { + await savePendingLogin(cwd, PENDING); + mockedPollForToken.mockRejectedValue(new Error("socket hang up")); + expect(await loginStep({ cwd, ...time })).toEqual({ + status: "failed", + reason: "error", + message: "socket hang up", + }); + expect(await readPendingLogin(cwd)).toEqual(PENDING); + }); + + it("keeps a saved token the service accepts, before touching any pending login", async () => { + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", ".env.local.json"), + JSON.stringify({ access_token: "good-token" }) + ); + mockedWhoami.mockResolvedValue({ + status: "ok", + data: { user: "someone", orgs: [] } as never, + }); + expect(await loginStep({ cwd, ...time })).toEqual({ + status: "already_logged_in", + source: "saved", + }); + expect(mockedRequestDeviceCode).not.toHaveBeenCalled(); + }); + + it("names TASKLESS_TOKEN without asking the service", async () => { + process.env.TASKLESS_TOKEN = "env-token"; + expect(await loginStep({ cwd, ...time })).toEqual({ + status: "already_logged_in", + source: "environment", + }); + expect(mockedWhoami).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/pending-login.test.ts b/packages/cli/test/pending-login.test.ts new file mode 100644 index 00000000..d0493848 --- /dev/null +++ b/packages/cli/test/pending-login.test.ts @@ -0,0 +1,78 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + clearPendingLogin, + type PendingLogin, + readPendingLogin, + savePendingLogin, +} from "../src/auth/pending-login"; + +const PENDING: PendingLogin = { + deviceCode: "device-123", + userCode: "ABCD-EFGH", + verificationUrl: "https://taskless.io/device?code=ABCD-EFGH", + interval: 5, + expiresAt: 1_900_000_000_000, +}; + +let cwd: string; + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-pending-login-")); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); +}); + +/** + * A started device-code login, saved so a later `auth login --json` can + * continue it. The device code is a credential until it expires. + */ +describe("the pending login file", () => { + it("reads back what was saved", async () => { + await savePendingLogin(cwd, PENDING); + expect(await readPendingLogin(cwd)).toEqual(PENDING); + }); + + it("is readable only by its owner and ignored by git", async () => { + await savePendingLogin(cwd, PENDING); + const info = await stat(join(cwd, ".taskless", ".auth-pending.json")); + expect(info.mode & 0o777).toBe(0o600); + const ignored = await readFile( + join(cwd, ".taskless", ".gitignore"), + "utf8" + ); + expect(ignored.split("\n")).toContain(".auth-pending.json"); + }); + + it("is gone after it is cleared", async () => { + await savePendingLogin(cwd, PENDING); + await clearPendingLogin(cwd); + expect(await readPendingLogin(cwd)).toBeUndefined(); + }); + + it("clears quietly when nothing is saved", async () => { + await expect(clearPendingLogin(cwd)).resolves.toBeUndefined(); + }); + + it("reads a missing, malformed, or partial file as no pending login", async () => { + expect(await readPendingLogin(cwd)).toBeUndefined(); + await mkdir(join(cwd, ".taskless"), { recursive: true }); + const path = join(cwd, ".taskless", ".auth-pending.json"); + await writeFile(path, "{ not json"); + expect(await readPendingLogin(cwd)).toBeUndefined(); + await writeFile(path, JSON.stringify({ deviceCode: "device-123" })); + expect(await readPendingLogin(cwd)).toBeUndefined(); + }); +});