Skip to content

feat(auth): let an agent log a user in with repeated auth login --json steps - #565

Merged
theCodeDrift merged 6 commits into
mainfrom
auth-agent-login
Oct 21, 2026
Merged

theCodeDrift merged 6 commits into
mainfrom
auth-agent-login

Conversation

@theCodeDrift

@theCodeDrift theCodeDrift commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Stack (root → tip):

An agent couldn't log a user in. auth login prints a URL and code, then blocks while it polls. Agent harnesses return a command's output only when it exits, so the URL the user needs was held behind a wait for that same user, and a tool timeout usually ended the call first. Under --json the URL was never printed at all.

Stacked on #562, merging forward. #562 lands first.

What changes

  • auth login --json is now a step an agent repeats:
    • The first call requests a device code, saves it, and returns at once: { ok: true, status: "pending", verificationUrl, userCode, expiresAt }.
    • Each later call polls that code for at most 45 seconds. It returns pending again (same URL and code), authenticated, or an error.
    • A denial fails with AUTH_REQUIRED, as before. A code that expires during a call fails with the new AUTH_EXPIRED, where it used to report NETWORK_ERROR and send agents off to check connectivity.
    • A code found already expired when a call starts (an abandoned login) is replaced quietly with a new login.
  • The pending code is stored like the token. It goes in .taskless/.auth-pending.json, with mode 0600 and a .taskless/.gitignore entry. A device code is a credential until it expires. It is deleted on approval, denial, expiry, auth logout, and a successful interactive login. A failed network request keeps it.
  • Output change: auth login --json now prints success envelopes, including already_logged_in with source, where it printed nothing. Exit codes are unchanged. We're pre-1.0, so this is in the stack's existing patch changeset.
  • auth login without --json is unchanged: one blocking call for a person at a terminal.
  • The auth recipe (topic v5) loops on auth login --json. It shows the URL before the next call, shows the new code if it changes, and lists AUTH_EXPIRED in its error table.

Verification

  • Unit tests: every branch of the step runs against a fake provider and a clock that advances only when the step sleeps. Covered: start, approve, wait timing out, denial, expiry reported by the service, expiry during the wait, an abandoned login replaced, slow_down carried to the next step, and a network failure keeping the login. Command tests cover each envelope, and that logout and an interactive login clear a pending login.
  • A real login against the service with a build:next build: the first step returned the URL in 0.9 seconds and wrote .auth-pending.json with mode 0600. After approval, the next step returned authenticated in 5.4 seconds and removed the pending file. auth then reported the account.
  • Repo checks: pnpm typecheck, pnpm lint, and the full suite (2633 tests) pass.

OpenSpec: openspec/changes/archive/2026-10-21-auth-agent-login/. The delta to cli-auth modifies the login requirement (all 6 existing scenarios kept, 1 added) and adds one requirement with 8 scenarios. Archived in this PR.

Built on top of #562

Engine upgrades no longer carry backward-compatibility weight: rule migrations rewrite whatever an ast-grep or Vale release breaks. What still went wrong was running a CLI against rules reconciled for other engines. Until now the only version gate was the scaffold layout number, and rule migrations deliberately leave that alone. So nothing refused either of these cases:

  • A new CLI ran its new engines against rules nobody had migrated. With the Haskell type_synomym rename, check reported no ast-grep findings at all.
  • An older pinned CLI ran rules that npx @taskless/cli@latest update --rules had already migrated past it.

What changes

  • Version-pin gate: a CLI that is not the version .taskless/taskless.json records for its package refuses every command except init, info, auth and feedback, with CLI_VERSION_MISMATCH. This applies however the CLI was started: npx, dlx, node_modules or the launcher. "Recorded" means what that package's launcher runs (recordedLauncherVersion), so the gate and the launcher can't disagree. init is the one command that moves the record, which makes it the way to upgrade. update is gated, because it moves the rules' record, not the CLI's.
  • Engine gate: check and verify refuse with RULES_ENGINE_MISMATCH when rules.engines differs from the bundled ast-grep or Vale. This only applies to an engine the project has rules for, and only when engines were recorded at all. When the rules are behind, the message names update then update --rules. When they are ahead, it names the recorded CLI. validate is not gated, because an agent runs it during the walk.
  • --allow-version-mismatches skips both gates, and TASKLESS_ALLOW_VERSION_MISMATCHES=1 is its environment form. The root pnpm cli script sets it, since this repo's manifest pins 0.11.2 with engines recorded at 0.45.2/3.18.0, and pnpm lint would otherwise refuse its own fresh build.
  • update recipe (topic v19): the walk passes the override to any mid-walk check, and the pinned-CLI bump in step 4 is now required whether or not a migration ran. It also adds the new codes to the error table and a ledger note under 0.13.3.
  • npx is only for installing and upgrading (the bare wizard, or init). Everything else goes through ./.taskless/bin/cli.mjs:
    • The docs-npx-cli Vale rule used to rewrite docs toward npx @taskless/cli <command>. It now refuses an npx/dlx/bunx run of any subcommand but init and auth (a login is a per-repo token, and auth runs on any version) in READMEs and dotagents.md, and still bans pnpm cli in READMEs. It lists subcommands explicitly so prose like "run npx @taskless/cli to install" passes.
    • The root, package and example READMEs move to the launcher. The .taskless/README.md template runs check through the launcher too.
    • init's upgrade trailer says ./.taskless/bin/cli.mjs update, not the npx invocation that ran it. The bare non-interactive notice names the launcher for recipes when the project has one.
    • The auth recipe (topic v4) says auth runs on any version, and that logging in does not clear a CLI_VERSION_MISMATCH from another command.

Reviewer notes

  • CLIs already published don't carry the gate, so the engine gate's "ahead" branch only protects CLIs released from here on.

  • After an upgrade with init, CI stays red on check until someone runs update --rules. That's intended, and the refusal names both commands.

  • Two test fixtures leaked a recorded version. reconcile-marker copied this repo's scaffold, and onboard hard-coded 0.7.0. Both now record no version or the built CLI's version.

  • Error remedies (auth login, rule restore, …) still repeat however the CLI was started. Under the gate, an npx run that gets far enough to print one is already the recorded version, so the remedy works, and changing ~20 call sites would change nothing anyone sees.

  • This repo's own .taskless/ (pinned 0.11.2) moves in a separate PR. Its README is outside the Vale rule's scope.

OpenSpec: openspec/changes/archive/2026-10-20-cli-version-pin-gate/ and openspec/changes/archive/2026-10-21-npx-install-only/ (ADDED-only deltas to cli, both archived in this PR).

@theCodeDrift

Copy link
Copy Markdown
Member Author

@claude /review

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @theCodeDrift's task in 3m 11s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no review threads, review summaries, or inline comments (only the @claude /review trigger comment itself), so this is the first review of this PR — the whole diff was assessed fresh.

  • Read prior review state (.prior-review.json)
  • Fetch PR diff and changed files
  • Review auth login-step / pending-login logic
  • Review command/auth.ts and login-interactive.ts changes
  • Review tests for coverage
  • Review openspec deltas
  • Post findings

Findings

[New] packages/cli/src/auth/login-step.ts:124 — the post-wait check can misclassify a timed-out step as an expired code.

if (pending.expiresAt < now() + interval) {
  await clearPendingLogin(cwd);
  return { status: "failed", reason: "expired" };
}
return pendingResult(pending);

The polling loop (line 90) exits when either the 45s deadline or pending.expiresAt is reached, whichever comes first (Math.min(deadline, pending.expiresAt)). This post-loop check doesn't tell the two apart — it just checks whether expiresAt is within one more interval of now(). When the loop is cut short by deadline (not a real expiry) but expiresAt happens to sit just beyond that deadline (within one interval), the step reports AUTH_EXPIRED and deletes the pending login, even though the code was still valid and the server never reported it as expired.

Concrete repro: waitMs = 45_000, interval = 5_000, expires_in = 48 (expiresAt = start + 48_000). The loop is deadline-bound, exits after 9 polls at now = start + 45_000; then 48_000 < 45_000 + 5_000 is true, so it wrongly reports expiry with 3 real seconds of validity left. The window widens further after any slow_down, since the grown interval is reused as the buffer here. No existing test exercises expiresAt landing just outside the deadline's reach — the "runs out during its wait" test uses expiresAt = start + 12_000, squarely in the expiresAt-bound branch, not this one.

This requires a short expires_in relative to the 45s step to trigger, but the design doc explicitly favors a short-lived device code as a security mitigation, so this isn't a purely theoretical server configuration. Suggested fix: only report expired when pending.expiresAt <= deadline (i.e., expiresAt was actually the limiting bound), rather than re-deriving it from now() + interval.

I posted this as an inline comment on the line as well. No other correctness, security, or test-coverage issues stood out — the pending-login storage (mode 0600, gitignore entry, discard-on-terminal-state) and command wiring (auth login --json envelopes, auth logout clearing a pending login) all match the openspec deltas, and the unit tests cover the other branches (start, approve, denial, expiry-during-wait, expiry-before-start, slow_down carryover, network-failure-keeps-login) well.

Comment thread packages/cli/src/auth/login-step.ts Outdated
Base automatically changed from cli-version-pin-gate to main October 21, 2026 08:46
@theCodeDrift

Copy link
Copy Markdown
Member Author

Re: @github-actions — "Claude finished @theCodeDrift's task in 3m 11s — Review complete…"
#565 (comment)

Fixed the one finding in fcb1cf3: AUTH_EXPIRED is reported only when the code expires within the step's own wait, at the expiry itself rather than early, and a code that outlives the wait stays pending for the next step.

— AI Coding Agent

…inue it

Adds AUTH_EXPIRED for a device code that expires before approval.
…inishes

The first step returns the URL at once; each later one waits at most 45s.
Logout and an interactive login discard a pending login.
…step

A code that outlived the 45s wait was reported expired and discarded
while still valid. Expiry within the window now waits for the expiry
itself, so the report is never early.
@theCodeDrift
theCodeDrift merged commit d06b489 into main Oct 21, 2026
4 checks passed
@theCodeDrift
theCodeDrift deleted the auth-agent-login branch October 21, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant