From ae6b0b5466693678a0ed53e6b65d9c10f05468b5 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 21:49:08 -0700 Subject: [PATCH 1/3] fix(auth): let auth login replace a token the service rejects, and name the fix in every 401 message --- .changeset/rejected-token-recovery.md | 5 + packages/cli/src/agent/auth.md | 24 +++- packages/cli/src/auth/login-interactive.ts | 18 ++- packages/cli/src/auth/token.ts | 20 +++ packages/cli/src/commands/auth.ts | 76 ++++++++--- packages/cli/src/commands/rules.ts | 3 +- packages/cli/src/rules/generate.ts | 7 +- packages/cli/src/rules/plan-check.ts | 6 +- packages/cli/src/rules/recover.ts | 3 +- packages/cli/test/auth-token-source.test.ts | 50 +++++++ packages/cli/test/login-interactive.test.ts | 141 ++++++++++++++++++-- packages/cli/test/runtime-check.test.ts | 10 +- 12 files changed, 312 insertions(+), 51 deletions(-) create mode 100644 .changeset/rejected-token-recovery.md create mode 100644 packages/cli/test/auth-token-source.test.ts diff --git a/.changeset/rejected-token-recovery.md b/.changeset/rejected-token-recovery.md new file mode 100644 index 00000000..fb5fd98f --- /dev/null +++ b/.changeset/rejected-token-recovery.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +A token the Taskless service rejects no longer leaves you at a dead end. `taskless auth login` now checks a saved token with the service and replaces it when it has been revoked or has expired, instead of refusing because a token is present, so the `auth login` that every authentication error recommends actually works. Every "authentication was rejected" message now names the command to run, including the `rule create` and `rule improve` messages that only said "Log in again." When the token comes from the `TASKLESS_TOKEN` environment variable, those messages, `taskless auth`, `auth login` and `auth logout` say so, since only changing the variable fixes it. diff --git a/packages/cli/src/agent/auth.md b/packages/cli/src/agent/auth.md index 001aacf2..7833d429 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 v1) +# Topic: auth (CLI v%(CLI_VERSION)s / topic v2) ## Goal Manage Taskless authentication. Three branches: @@ -29,6 +29,15 @@ Pick the branch matching the user's intent. printed. 4. Report success. Suggest `%(TASKLESS_CLI)s info` to verify identity. +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. + +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`. + The `--anonymous` flag is rejected on `auth login`, it errors with "auth commands cannot be anonymous". Don't pass it. @@ -39,7 +48,8 @@ The `--anonymous` flag is rejected on `auth login`, it errors with %(TASKLESS_CLI)s auth logout ``` 2. The CLI removes the saved token (or reports "Not logged in" if - none was present). + none was present). When `TASKLESS_TOKEN` is set it says the + variable is still used instead: logout cannot remove it. 3. Report the outcome. ### Status (no subcommand) @@ -50,9 +60,13 @@ The `--anonymous` flag is rejected on `auth login`, it errors with ``` 2. Output is one of: - "Not logged in." (with hint to run `auth login`) - - "Logged in as ()." - - "Logged in, but unable to verify identity." (token expired or - revoked, suggest re-login) + - "Logged in as ()." (with "via TASKLESS_TOKEN" when + the token comes from the environment) + - "Logged in, but the token was rejected." (revoked or expired; + the next line names the fix: `auth login` for a saved token, + replacing or unsetting `TASKLESS_TOKEN` otherwise) + - "Logged in, but unable to verify identity." (the service could + not be reached; retry later) 3. Report to the user. ## Errors diff --git a/packages/cli/src/auth/login-interactive.ts b/packages/cli/src/auth/login-interactive.ts index e2d609cc..213bc2ac 100644 --- a/packages/cli/src/auth/login-interactive.ts +++ b/packages/cli/src/auth/login-interactive.ts @@ -1,12 +1,13 @@ import process from "node:process"; import { deviceFlowProvider } from "./device-flow"; -import { getToken, saveToken } from "./token"; +import { getToken, isEnvironmentToken, saveToken } from "./token"; +import { whoami } from "../api/v2"; import { resolveRepositoryUrl } from "../util/git-remote"; export type LoginResult = | { status: "ok" } - | { status: "already_logged_in" } + | { status: "already_logged_in"; source: "environment" | "saved" } | { status: "cancelled"; reason: "expired" | "denied" | "error"; @@ -42,7 +43,18 @@ export async function loginInteractive( // not to the login flow that is about to replace it. const existing = await getToken(cwd, { silent: true }); if (existing) { - return { status: "already_logged_in" }; + // 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" }; + } } try { diff --git a/packages/cli/src/auth/token.ts b/packages/cli/src/auth/token.ts index 7223987a..d985ab20 100644 --- a/packages/cli/src/auth/token.ts +++ b/packages/cli/src/auth/token.ts @@ -42,6 +42,26 @@ export async function getToken( return undefined; } +/** Whether `getToken` answers from the `TASKLESS_TOKEN` environment variable. */ +export function isEnvironmentToken(): boolean { + return Boolean(process.env.TASKLESS_TOKEN); +} + +/** + * What to do about a token the service rejected (a `401`), as a sentence that + * follows "Authentication was rejected." or stands as a remedy on its own. + * + * A token from `TASKLESS_TOKEN` is out of reach of `auth login` and + * `auth logout`, so the variable itself is the fix. A saved token is replaced + * by `auth login`, which re-authenticates when the service rejects the token + * it finds rather than refusing because one is present. + */ +export function rejectedTokenRemedy(): string { + return isEnvironmentToken() + ? "The token comes from the TASKLESS_TOKEN environment variable, so replace or unset it; `auth login` and `auth logout` do not change it." + : `Run \`${getCliPrefix()} auth login\` to replace the saved token.`; +} + /** Save token data to per-repo .taskless/.env.local.json */ export async function saveToken( data: { diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index f1c9759f..dcbeb5b4 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -2,9 +2,14 @@ import { resolve } from "node:path"; import process from "node:process"; import { defineCommand } from "citty"; +import { whoami } from "../api/v2"; import { loginInteractive } from "../auth/login-interactive"; -import { getToken, removeToken } from "../auth/token"; -import { fetchWhoami } from "../auth/whoami"; +import { + getToken, + isEnvironmentToken, + rejectedTokenRemedy, + removeToken, +} from "../auth/token"; import { getTelemetry } from "../telemetry"; import { type CLIErrorCode, writeJsonError } from "../types/errors"; import { splitRawArguments } from "../util/argv"; @@ -70,10 +75,19 @@ const loginCommand = defineCommand({ } case "already_logged_in": { if (!args.json) { - 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; } @@ -131,7 +145,22 @@ const logoutCommand = defineCommand({ try { removed = await removeToken(cwd); if (!args.json) { - console.log(removed ? "Logged out." : "Not logged in."); + // logout only removes the saved token. Without saying so, a user + // whose token comes from the environment reads "Not logged in." and + // is still authenticated as before. + const environment = isEnvironmentToken(); + console.log( + removed + ? "Logged out." + : environment + ? "No saved login to remove." + : "Not logged in." + ); + if (environment) { + console.log( + "TASKLESS_TOKEN is set in the environment and is still used. Unset it to log out." + ); + } } } finally { // Concrete state event: a saved token was actually removed. @@ -181,17 +210,28 @@ export const authCommand = defineCommand({ return; } - const whoami = await fetchWhoami(token); - if (!whoami) { - console.log("Logged in, but unable to verify identity."); - console.log( - `Your token may be invalid or expired. Run \`${getCliPrefix()} auth login\` to re-authenticate.` - ); - return; + const source = isEnvironmentToken() ? " via TASKLESS_TOKEN" : ""; + const outcome = await whoami(token); + switch (outcome.status) { + case "ok": { + const orgs = outcome.data.orgs.map((o) => o.name); + const orgSuffix = orgs.length > 0 ? ` (${orgs.join(", ")})` : ""; + console.log(`Logged in as ${outcome.data.user}${orgSuffix}${source}.`); + return; + } + case "unauthorized": { + console.log(`Logged in${source}, but the token was rejected.`); + console.log(`It is invalid or expired. ${rejectedTokenRemedy()}`); + return; + } + default: { + console.log(`Logged in${source}, but unable to verify identity.`); + if (outcome.status === "unavailable") { + console.log( + `The Taskless service was unreachable (${outcome.reason}).` + ); + } + } } - - const orgs = whoami.orgs.map((o) => o.name); - const orgSuffix = orgs.length > 0 ? ` (${orgs.join(", ")})` : ""; - console.log(`Logged in as ${whoami.user}${orgSuffix}.`); }, }); diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index 7cd8cdd7..99ee0b7a 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -17,6 +17,7 @@ import { submitRequest, type V2Outcome, } from "../api/v2"; +import { rejectedTokenRemedy } from "../auth/token"; import { readRuleMetaFile, deleteRuleFiles } from "../rules/files"; import { awaitRequest, @@ -59,7 +60,7 @@ function describeSubmitFailure( switch (outcome.status) { case "unauthorized": { return { - message: "Authentication was rejected. Log in again.", + message: `Authentication was rejected. ${rejectedTokenRemedy()}`, code: "AUTH_REQUIRED", }; } diff --git a/packages/cli/src/rules/generate.ts b/packages/cli/src/rules/generate.ts index e892b9b5..4a0ac9b0 100644 --- a/packages/cli/src/rules/generate.ts +++ b/packages/cli/src/rules/generate.ts @@ -7,6 +7,7 @@ import { } from "../api/v2"; import { notRunOnPlanSentence, parseEntitlementV2 } from "../api/entitlement"; import { describeRefusal, stripControlCharacters } from "../api/refusal"; +import { rejectedTokenRemedy } from "../auth/token"; import { CLIError } from "../util/cli-error"; import { getCliPrefix } from "../util/package-manager"; import { writeServedRule } from "./files"; @@ -62,7 +63,7 @@ export function orgNotFoundMessage(): string { export function orgNotFoundRemedy(): string[] { return [ "Confirm the Taskless app is installed on this repository's owner and includes this repository.", - `If access recently changed, re-authenticate with \`${getCliPrefix()} auth login\`.`, + `If access recently changed, re-authenticate with \`${getCliPrefix()} auth logout\` then \`${getCliPrefix()} auth login\`.`, ]; } @@ -94,7 +95,7 @@ export async function awaitRequest( } case "unauthorized": { throw new CLIError( - "Polling failed: authentication was rejected. Log in again.", + `Polling failed: authentication was rejected. ${rejectedTokenRemedy()}`, "AUTH_REQUIRED" ); } @@ -256,7 +257,7 @@ function servedOrThrow( } case "unauthorized": { throw new CLIError( - `Rule ${ruleId} could not be fetched: authentication was rejected. Log in again.`, + `Rule ${ruleId} could not be fetched: authentication was rejected. ${rejectedTokenRemedy()}`, "AUTH_REQUIRED" ); } diff --git a/packages/cli/src/rules/plan-check.ts b/packages/cli/src/rules/plan-check.ts index 5e89177b..c5a5f787 100644 --- a/packages/cli/src/rules/plan-check.ts +++ b/packages/cli/src/rules/plan-check.ts @@ -1,4 +1,4 @@ -import { getToken } from "../auth/token"; +import { getToken, rejectedTokenRemedy } from "../auth/token"; import { resolveActingOrg } from "../auth/org"; import { reconcileRules, retryAdvice } from "../api/v2"; import { CLIError } from "../util/cli-error"; @@ -401,9 +401,7 @@ function reconcileFailure( case "unauthorized": { return { cause: "authentication was rejected", - remedy: [ - `Re-authenticate with \`${getCliPrefix()} auth login\`, or replace an expired \`TASKLESS_TOKEN\`.`, - ], + remedy: [rejectedTokenRemedy()], }; } case "unavailable": { diff --git a/packages/cli/src/rules/recover.ts b/packages/cli/src/rules/recover.ts index b6d23445..d991ec22 100644 --- a/packages/cli/src/rules/recover.ts +++ b/packages/cli/src/rules/recover.ts @@ -11,6 +11,7 @@ import { import { notRunOnPlanSentence, parseEntitlementV2 } from "../api/entitlement"; import { describeRefusal } from "../api/refusal"; import type { Identity } from "../auth/identity"; +import { rejectedTokenRemedy } from "../auth/token"; import { CLIError } from "../util/cli-error"; import { isRecord } from "../util/is-record"; import { getCliPrefix } from "../util/package-manager"; @@ -82,7 +83,7 @@ function failure( } case "unauthorized": { return new CLIError( - `Authentication was rejected. Run \`${getCliPrefix()} auth login\` and try again.`, + `Authentication was rejected. ${rejectedTokenRemedy()}`, "AUTH_REQUIRED" ); } diff --git a/packages/cli/test/auth-token-source.test.ts b/packages/cli/test/auth-token-source.test.ts new file mode 100644 index 00000000..e0c293f0 --- /dev/null +++ b/packages/cli/test/auth-token-source.test.ts @@ -0,0 +1,50 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { builtCli } from "./support/built-cli"; + +const execFileAsync = promisify(execFile); +const binPath = builtCli(); + +// `auth login` and `auth logout` only manage the saved token. When the token +// comes from TASKLESS_TOKEN instead, neither can change it, and both used to +// answer as if no token existed at all, so nothing pointed at the variable. +describe("auth commands with TASKLESS_TOKEN set", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-auth-token-source-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + async function runAuth(args: string[]): Promise { + const { stdout } = await execFileAsync( + "node", + [binPath, "auth", ...args, "-d", cwd], + { env: { ...process.env, TASKLESS_TOKEN: "env-token" } } + ); + return stdout; + } + + it("logout says the variable is still used", async () => { + const stdout = await runAuth(["logout"]); + + expect(stdout).toContain("No saved login to remove."); + expect(stdout).toContain("TASKLESS_TOKEN is set in the environment"); + expect(stdout).not.toContain("Not logged in."); + }); + + it("login names the variable instead of sending the user to logout", async () => { + const stdout = await runAuth(["login"]); + + expect(stdout).toContain("TASKLESS_TOKEN"); + expect(stdout).not.toContain("auth logout"); + }); +}); diff --git a/packages/cli/test/login-interactive.test.ts b/packages/cli/test/login-interactive.test.ts index 52dbe101..40f33931 100644 --- a/packages/cli/test/login-interactive.test.ts +++ b/packages/cli/test/login-interactive.test.ts @@ -1,19 +1,68 @@ -import { mkdtemp, rm } from "node:fs/promises"; +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 } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { whoami } from "../src/api/v2"; import { loginInteractive } from "../src/auth/login-interactive"; +import { rejectedTokenRemedy } from "../src/auth/token"; + +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); let cwd: string; let previousToken: string | undefined; +/** Write a per-repo saved token, as `auth login` would. */ +async function saveTokenFile(accessToken: string): Promise { + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", ".env.local.json"), + JSON.stringify({ access_token: accessToken }) + ); +} + +async function readSavedToken(): Promise { + const raw = await readFile(join(cwd, ".taskless", ".env.local.json"), "utf8"); + return (JSON.parse(raw) as { access_token?: string }).access_token; +} + +function quiet(): { + out: (l: string) => void; + err: (l: string) => void; + logs: string[]; + errors: string[]; +} { + const logs: string[] = []; + const errors: string[] = []; + return { + out: (l) => logs.push(l), + err: (l) => errors.push(l), + logs, + errors, + }; +} + beforeEach(async () => { cwd = await mkdtemp(join(tmpdir(), "taskless-login-interactive-")); previousToken = process.env.TASKLESS_TOKEN; + delete process.env.TASKLESS_TOKEN; }); afterEach(async () => { + vi.resetAllMocks(); if (previousToken === undefined) { delete process.env.TASKLESS_TOKEN; } else { @@ -23,19 +72,87 @@ afterEach(async () => { }); describe("loginInteractive", () => { - it("short-circuits with already_logged_in when a token is available", async () => { + it("short-circuits with already_logged_in when TASKLESS_TOKEN is set, without asking the service", async () => { process.env.TASKLESS_TOKEN = "env-token"; - const logs: string[] = []; - const errors: string[] = []; - const result = await loginInteractive({ - cwd, - out: (l) => logs.push(l), - err: (l) => errors.push(l), + const sinks = quiet(); + const result = await loginInteractive({ cwd, ...sinks }); + + expect(result).toEqual({ + status: "already_logged_in", + source: "environment", + }); + expect(mockedWhoami).not.toHaveBeenCalled(); + expect(sinks.logs).toEqual([]); + expect(sinks.errors).toEqual([]); + }); + + it("keeps a saved token the service accepts", async () => { + await saveTokenFile("good-token"); + mockedWhoami.mockResolvedValue({ + status: "ok", + data: { user: "someone", orgs: [] } as never, + }); + + const result = await loginInteractive({ cwd, ...quiet() }); + + expect(result).toEqual({ status: "already_logged_in", source: "saved" }); + expect(mockedRequestDeviceCode).not.toHaveBeenCalled(); + expect(await readSavedToken()).toBe("good-token"); + }); + + it("keeps a saved token when the service is unreachable, since that says nothing about the token", async () => { + await saveTokenFile("good-token"); + mockedWhoami.mockResolvedValue({ + status: "unavailable", + reason: "network error: offline", + retryable: true, }); - expect(result.status).toBe("already_logged_in"); - expect(logs).toEqual([]); - expect(errors).toEqual([]); + const result = await loginInteractive({ cwd, ...quiet() }); + + expect(result).toEqual({ status: "already_logged_in", source: "saved" }); + expect(mockedRequestDeviceCode).not.toHaveBeenCalled(); + }); + + it("replaces a saved token the service rejects", async () => { + await saveTokenFile("revoked-token"); + mockedWhoami.mockResolvedValue({ status: "unauthorized" }); + mockedRequestDeviceCode.mockResolvedValue({ + device_code: "device", + user_code: "ABCD-EFGH", + verification_uri: "https://example.test/device", + expires_in: 600, + // Polls immediately, so the flow completes without waiting on a timer. + interval: 0, + }); + mockedPollForToken.mockResolvedValue({ + status: "success", + token: { access_token: "fresh-token", token_type: "Bearer" }, + }); + + const sinks = quiet(); + const result = await loginInteractive({ cwd, ...sinks }); + + expect(mockedWhoami).toHaveBeenCalledWith("revoked-token"); + expect(result).toEqual({ status: "ok" }); + expect(await readSavedToken()).toBe("fresh-token"); + expect(sinks.logs).toContain("Logged in successfully."); + }); +}); + +describe("rejectedTokenRemedy", () => { + it("sends a saved token to auth login", () => { + expect(rejectedTokenRemedy()).toMatch(/auth login` to replace/); + expect(rejectedTokenRemedy()).not.toMatch(/TASKLESS_TOKEN/); + }); + + it("names TASKLESS_TOKEN when the token comes from the environment", () => { + process.env.TASKLESS_TOKEN = "env-token"; + + const remedy = rejectedTokenRemedy(); + + expect(remedy).toMatch(/TASKLESS_TOKEN/); + expect(remedy).toMatch(/replace or unset it/); }); }); diff --git a/packages/cli/test/runtime-check.test.ts b/packages/cli/test/runtime-check.test.ts index 7f92580b..4ea2289e 100644 --- a/packages/cli/test/runtime-check.test.ts +++ b/packages/cli/test/runtime-check.test.ts @@ -794,7 +794,7 @@ describe("check: static vs runtime dispatch", () => { "Confirm the Taskless app is installed on this repository's owner" ); expect(stderr).toMatch( - /If access recently changed, re-authenticate with `.+ auth login`/ + /If access recently changed, re-authenticate with `.+ auth logout` then `.+ auth login`/ ); }); @@ -811,7 +811,9 @@ describe("check: static vs runtime dispatch", () => { expect(notices).not.toMatch(/unavailable|try again/); }); - it("token rejected: the notice names auth login and replacing TASKLESS_TOKEN", async () => { + // authedCheck supplies the token through TASKLESS_TOKEN, which `auth login` + // cannot replace, so the remedy names the variable rather than the command. + it("token rejected: the notice names replacing TASKLESS_TOKEN", async () => { const { stderr, exitCode } = await authedCheck( () => ({ statusCode: 401 }), [] @@ -821,8 +823,8 @@ describe("check: static vs runtime dispatch", () => { expect(stderr).toContain( "Rules were not verified: authentication was rejected." ); - expect(stderr).toMatch( - /Re-authenticate with `.+ auth login`, or replace an expired `TASKLESS_TOKEN`\./ + expect(stderr).toContain( + "The token comes from the TASKLESS_TOKEN environment variable, so replace or unset it; `auth login` and `auth logout` do not change it." ); expect(stderr).toContain( "runtime rule demo was not run — authentication was rejected, so it was not verified." From ca3667d6f1a43ce706702dc003ad050f00cd821e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 21:49:17 -0700 Subject: [PATCH 2/3] docs(openspec): archive rejected-token-recovery --- .../proposal.md | 76 +++++++ .../specs/cli-auth/spec.md | 206 ++++++++++++++++++ .../tasks.md | 33 +++ openspec/specs/cli-auth/spec.md | 192 ++++++++++------ 4 files changed, 439 insertions(+), 68 deletions(-) create mode 100644 openspec/changes/archive/2026-10-05-rejected-token-recovery/proposal.md create mode 100644 openspec/changes/archive/2026-10-05-rejected-token-recovery/specs/cli-auth/spec.md create mode 100644 openspec/changes/archive/2026-10-05-rejected-token-recovery/tasks.md diff --git a/openspec/changes/archive/2026-10-05-rejected-token-recovery/proposal.md b/openspec/changes/archive/2026-10-05-rejected-token-recovery/proposal.md new file mode 100644 index 00000000..d99949f5 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-rejected-token-recovery/proposal.md @@ -0,0 +1,76 @@ +## Why + +Every message that asks a user to re-authenticate after a `401` points at +`auth login`, and `auth login` refused whenever any token was present (#450). +A token the service has revoked or expired is still present locally, since +`getToken` only knows about a locally recorded `expires_at`, so the advice +could not be followed as written. Three messages (`rule create` and +`rule improve`) named no command at all. + +With `TASKLESS_TOKEN` set it was worse: `auth logout` removes only the saved +file and printed `Not logged in.`, `auth login` kept refusing, and no message +mentioned the variable, so nothing the CLI printed got the user out. + +The standing `cli-auth` spec also still describes the global +`~/.config/taskless/auth.json` store, which the CLI stopped writing and reading +when tokens moved per repository. It now only warns that a legacy file exists. +This change corrects those requirements while it is touching the capability. + +## What Changes + +- `auth login` asks the service about a saved token before refusing. A `401` + runs the device flow and replaces the token. An accepted token, or a service + that could not answer, keeps the token and the existing "already logged in" + answer. +- With `TASKLESS_TOKEN` set, `auth login` does not ask the service and names + the variable instead of sending the user to `auth logout`. +- Every "authentication was rejected" message shares one remedy: `auth login` + for a saved token, replacing or unsetting `TASKLESS_TOKEN` when the token + comes from the environment. This covers `check`, `rule create`, `improve`, + `restore`, `rollback`, `revisions`, and the poll and fetch inside generation. +- `auth logout` says when `TASKLESS_TOKEN` is set and still used. +- `taskless auth` says "via TASKLESS_TOKEN" when that is the source, and + separates a rejected token, with its remedy, from an unreachable service. +- The organization-not-found hint says `auth logout` then `auth login`, since + its token is valid and `auth login` alone keeps it. +- `auth` agent recipe topic v2. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-auth`: + - MODIFIED, restated in full under their existing titles: "Auth login + initiates Device Flow" (two scenarios kept, four added), "Auth logout + removes saved token" (two kept, one added), "Token file format", and + "Token resolution is a shared utility". The last two are corrections to + current behavior: every scenario is kept, with the file path and return + value the CLI actually has. + - REMOVED and re-ADDED under new titles, because a MODIFIED block can neither + rename a requirement nor drop a scenario: + - "Token resolution prefers per-repo over global" becomes "Token + resolution prefers the environment over the saved token". Its + "Global file used as fallback" scenario becomes "A legacy global token + is not used". + - "Per-repository token storage in .env.local.json" becomes "Login stores + the token only in the repository". Its "Login also writes to global + auth.json" scenario becomes "Login does not write a global token". + - REMOVED: "Token is stored in XDG config directory". The CLI writes no + global token. + - ADDED: "A rejected token's message names a fix that works". + +## Impact + +Messages and one behavior change in `auth login`, which now replaces a token +the service rejects. `auth login --json` output is unchanged: it still prints +nothing and exits 0 when it keeps a token. `patch`: the package is pre-1.0 and +no consumer contract changes shape. + +## Delivery shape + +**Single PR.** The behavior, messages, recipe, spec and tests are one +reviewable diff. It is the tip, so the change is archived here. diff --git a/openspec/changes/archive/2026-10-05-rejected-token-recovery/specs/cli-auth/spec.md b/openspec/changes/archive/2026-10-05-rejected-token-recovery/specs/cli-auth/spec.md new file mode 100644 index 00000000..6c3f34ce --- /dev/null +++ b/openspec/changes/archive/2026-10-05-rejected-token-recovery/specs/cli-auth/spec.md @@ -0,0 +1,206 @@ +## 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 nothing. + +#### 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` + +### 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). + +Logout SHALL NOT read or change `TASKLESS_TOKEN`. When the variable is set, logout SHALL say that it is still used and that unsetting it is how to log out, and SHALL NOT report "Not logged in." + +#### Scenario: Standard logout still works + +- **WHEN** a user runs `taskless auth logout` +- **THEN** the CLI SHALL remove the saved token per the existing behavior + +#### Scenario: Logout accepts --anonymous as no-op + +- **WHEN** a user runs `taskless auth logout --anonymous` +- **THEN** the CLI SHALL behave identically to `taskless auth logout` + +#### Scenario: Logout names TASKLESS_TOKEN + +- **WHEN** `TASKLESS_TOKEN` is set and a user runs `taskless auth logout` +- **THEN** the CLI SHALL say that `TASKLESS_TOKEN` is still used +- **AND** SHALL NOT print "Not logged in." + +### Requirement: Token file format + +The saved token file (`.taskless/.env.local.json`) SHALL be a JSON object containing at minimum an `access_token` field. It MAY contain additional fields returned by the OAuth token response (`token_type`, `expires_in`, `refresh_token`). When the response carries a positive `expires_in`, the file SHALL also carry `expires_at`, the absolute expiry in milliseconds since the epoch, and a token past its `expires_at` SHALL be treated as absent. + +#### Scenario: Minimal token file + +- **WHEN** the OAuth response contains only an access token +- **THEN** `.taskless/.env.local.json` SHALL contain `{ "access_token": "" }` + +#### Scenario: Full token file + +- **WHEN** the OAuth response contains access token, token type, and refresh token +- **THEN** `.taskless/.env.local.json` SHALL contain all provided fields + +### Requirement: Token resolution is a shared utility + +A `getToken()` function SHALL exist that encapsulates the token resolution logic (env var check, then file read). All commands that need the current token SHALL use this function rather than reading the file directly. + +#### Scenario: getToken returns env var value + +- **WHEN** `TASKLESS_TOKEN` is set to `"test-token"` +- **THEN** `getToken()` SHALL return `"test-token"` + +#### Scenario: getToken returns file token + +- **WHEN** `TASKLESS_TOKEN` is not set and `.taskless/.env.local.json` contains `{ "access_token": "file-token" }` +- **THEN** `getToken()` SHALL return `"file-token"` + +#### Scenario: getToken returns null + +- **WHEN** `TASKLESS_TOKEN` is not set and no token file exists +- **THEN** `getToken()` SHALL return `undefined` + +## REMOVED Requirements + +### Requirement: Token is stored in XDG config directory + +**Reason**: The CLI no longer writes or reads a global token. Tokens are stored per repository in `.taskless/.env.local.json`, and a leftover `auth.json` under the XDG config directory only produces a notice. The requirement described behavior the CLI has not had since that move. + +**Migration**: None for users: the notice already tells anyone with a legacy file to run `auth login` in the repository. "Per-repository token storage in .env.local.json" now states that no global token is written. + +### Requirement: Token resolution prefers per-repo over global + +**Reason**: The global store is no longer a resolution source, and the requirement's title names it. A title is matched byte-for-byte when the change is archived, so it cannot be corrected by a MODIFIED block. It is removed and re-added as "Token resolution prefers the environment over the saved token". + +**Migration**: None. The replacement keeps the environment-first order and the no-token result; the global fallback becomes a scenario stating the legacy file is not used. + +### Requirement: Per-repository token storage in .env.local.json + +**Reason**: Its scenario "Login also writes to global auth.json" is no longer true: login writes only the per-repo file. A MODIFIED block cannot drop a scenario, and the correction inverts it rather than editing it, so the requirement is removed and re-added as "Login stores the token only in the repository" with that scenario replaced by "Login does not write a global token". + +**Migration**: None. The other scenarios carry over unchanged. + +## ADDED Requirements + +### Requirement: Token resolution prefers the environment over the saved token + +When resolving the current authentication token, the CLI SHALL check in this order: (1) the `TASKLESS_TOKEN` environment variable, (2) `.taskless/.env.local.json` in the working directory. The first available token SHALL be used. A global `auth.json` under the XDG config directory SHALL NOT be used; if one exists, the CLI SHALL print a notice to stderr directing the user to `auth login` for the repository. + +#### Scenario: Env var takes precedence over the saved token + +- **WHEN** `TASKLESS_TOKEN` is set and `.taskless/.env.local.json` exists +- **THEN** the CLI SHALL use the `TASKLESS_TOKEN` value + +#### Scenario: Saved token is used without the env var + +- **WHEN** `TASKLESS_TOKEN` is not set and `.taskless/.env.local.json` holds an unexpired token +- **THEN** the CLI SHALL use the token from `.taskless/.env.local.json` + +#### Scenario: A legacy global token is not used + +- **WHEN** `TASKLESS_TOKEN` is not set, `.taskless/.env.local.json` does not exist, and a global `auth.json` exists +- **THEN** token resolution SHALL return undefined +- **AND** the CLI SHALL print a notice naming the legacy file + +#### Scenario: No token available + +- **WHEN** `TASKLESS_TOKEN` is not set and no `.env.local.json` exists +- **THEN** token resolution SHALL return undefined + +### Requirement: Login stores the token only in the repository + +The `taskless auth login` command SHALL store the auth token in `.taskless/.env.local.json` in the current repository, and nowhere else. The per-repo file SHALL be a JSON object containing the `access_token` and any additional OAuth response fields. The file SHALL be created with permissions `0600`. + +#### Scenario: Login writes to per-repo .env.local.json + +- **WHEN** a user runs `taskless auth login` in a git repository +- **THEN** the CLI SHALL write the token to `.taskless/.env.local.json` in the repository root +- **AND** the CLI SHALL ensure `.taskless/.gitignore` exists with `.env.local.json` listed + +#### Scenario: Login does not write a global token + +- **WHEN** a user runs `taskless auth login` +- **THEN** the CLI SHALL NOT write `auth.json` under the XDG config directory (`$XDG_CONFIG_HOME/taskless/` or `~/.config/taskless/`) + +#### Scenario: .taskless/ directory is created if missing + +- **WHEN** `.taskless/` does not exist in the repository root +- **THEN** the CLI SHALL create it before writing `.env.local.json` + +### Requirement: A rejected token's message names a fix that works + +When the service rejects the token (`401`), every message the CLI prints for it SHALL name a step that resolves it, chosen by where the token comes from: + +- a saved token: run `auth login`, which replaces a token the service rejects; +- a token from `TASKLESS_TOKEN`: replace or unset the variable, stating that `auth login` and `auth logout` do not change it. + +This SHALL hold for every command that reaches the service with a token, including `check`, `rule create`, `rule improve`, `rule restore`, `rule rollback`, `rule revisions`, and `taskless auth` status. The error code SHALL remain `AUTH_REQUIRED` wherever it is today. + +`taskless auth` status SHALL distinguish a rejected token, with its remedy, from a service that could not be reached, and SHALL say "via TASKLESS_TOKEN" when that is the token's source. + +#### Scenario: A saved token's rejection names auth login + +- **WHEN** `TASKLESS_TOKEN` is not set and a command's request is answered `401` +- **THEN** the message SHALL name `auth login` +- **AND** the message SHALL NOT be only "Log in again." + +#### Scenario: An environment token's rejection names the variable + +- **WHEN** `TASKLESS_TOKEN` is set and a command's request is answered `401` +- **THEN** the message SHALL name `TASKLESS_TOKEN` and say to replace or unset it + +#### Scenario: Status reports a rejected token with its remedy + +- **WHEN** a user runs `taskless auth` and the service answers `401` +- **THEN** the output SHALL say the token was rejected and give the remedy + +#### Scenario: Status does not call an unverified token invalid + +- **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 diff --git a/openspec/changes/archive/2026-10-05-rejected-token-recovery/tasks.md b/openspec/changes/archive/2026-10-05-rejected-token-recovery/tasks.md new file mode 100644 index 00000000..e23aeb2a --- /dev/null +++ b/openspec/changes/archive/2026-10-05-rejected-token-recovery/tasks.md @@ -0,0 +1,33 @@ +## 1. Spec + +- [x] 1.1 MODIFIED `cli-auth` requirements restated in full under their + existing titles; REMOVED the two global-store requirements; ADDED the + environment-first resolution and rejected-token requirements. +- [x] 1.2 Dry-run `openspec archive` and confirm every prior scenario survives + or is accounted for in the proposal. + +## 2. Behavior + +- [x] 2.1 `isEnvironmentToken` and `rejectedTokenRemedy` in `auth/token.ts`. +- [x] 2.2 `auth login` replaces a saved token the service rejects; reports the + token's source when it keeps one. +- [x] 2.3 `auth logout` and `taskless auth` name `TASKLESS_TOKEN`; status + separates a rejected token from an unreachable service. + +## 3. Messages + +- [x] 3.1 Every `unauthorized` outcome uses the shared remedy: recover, + plan-check, generation poll and fetch, submit. +- [x] 3.2 Organization-not-found hint names `auth logout` then `auth login`. + +## 4. Recipes and changeset + +- [x] 4.1 `auth` recipe topic v2. +- [x] 4.2 `rejected-token-recovery` changeset. + +## 5. Tests + +- [x] 5.1 `loginInteractive`: environment token skips the service; accepted + and unreachable keep the token; rejected is replaced. +- [x] 5.2 `rejectedTokenRemedy` with and without `TASKLESS_TOKEN`. +- [x] 5.3 Built CLI: `auth logout` and `auth login` with `TASKLESS_TOKEN` set. diff --git a/openspec/specs/cli-auth/spec.md b/openspec/specs/cli-auth/spec.md index c6f3fc55..c84ba3e2 100644 --- a/openspec/specs/cli-auth/spec.md +++ b/openspec/specs/cli-auth/spec.md @@ -19,6 +19,13 @@ The CLI SHALL register an `auth` subcommand group with `login` and `logout` as n `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 nothing. + #### Scenario: Standard login still works - **WHEN** a user runs `taskless auth login` @@ -30,10 +37,40 @@ The CLI SHALL register an `auth` subcommand group with `login` and `logout` as n - **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` + ### 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). +Logout SHALL NOT read or change `TASKLESS_TOKEN`. When the variable is set, logout SHALL say that it is still used and that unsetting it is how to log out, and SHALL NOT report "Not logged in." + #### Scenario: Standard logout still works - **WHEN** a user runs `taskless auth logout` @@ -44,63 +81,25 @@ The CLI SHALL register an `auth` subcommand group with `login` and `logout` as n - **WHEN** a user runs `taskless auth logout --anonymous` - **THEN** the CLI SHALL behave identically to `taskless auth logout` -### Requirement: Token is stored in XDG config directory +#### Scenario: Logout names TASKLESS_TOKEN -The CLI SHALL store the authentication token at `$XDG_CONFIG_HOME/taskless/auth.json`. If `$XDG_CONFIG_HOME` is not set, it SHALL default to `~/.config/taskless/auth.json`. The file SHALL be created with permissions `0600` (owner read/write only). - -#### Scenario: Token file is created in default location - -- **WHEN** `$XDG_CONFIG_HOME` is not set and a user logs in -- **THEN** the token SHALL be written to `~/.config/taskless/auth.json` -- **AND** the file permissions SHALL be `0600` - -#### Scenario: Token file respects XDG_CONFIG_HOME - -- **WHEN** `$XDG_CONFIG_HOME` is set to `/custom/config` -- **THEN** the token SHALL be written to `/custom/config/taskless/auth.json` - -#### Scenario: Config directory is created if missing - -- **WHEN** the `taskless/` directory does not exist under the config home -- **THEN** the CLI SHALL create it before writing the token file +- **WHEN** `TASKLESS_TOKEN` is set and a user runs `taskless auth logout` +- **THEN** the CLI SHALL say that `TASKLESS_TOKEN` is still used +- **AND** SHALL NOT print "Not logged in." ### Requirement: Token file format -The token file (`auth.json`) SHALL be a JSON object containing at minimum an `access_token` field. It MAY contain additional fields returned by the OAuth token response (`token_type`, `expires_in`, `refresh_token`). +The saved token file (`.taskless/.env.local.json`) SHALL be a JSON object containing at minimum an `access_token` field. It MAY contain additional fields returned by the OAuth token response (`token_type`, `expires_in`, `refresh_token`). When the response carries a positive `expires_in`, the file SHALL also carry `expires_at`, the absolute expiry in milliseconds since the epoch, and a token past its `expires_at` SHALL be treated as absent. #### Scenario: Minimal token file - **WHEN** the OAuth response contains only an access token -- **THEN** `auth.json` SHALL contain `{ "access_token": "" }` +- **THEN** `.taskless/.env.local.json` SHALL contain `{ "access_token": "" }` #### Scenario: Full token file - **WHEN** the OAuth response contains access token, token type, and refresh token -- **THEN** `auth.json` SHALL contain all provided fields - -### Requirement: Token resolution prefers per-repo over global - -When resolving the current authentication token, the CLI SHALL check in this order: (1) `TASKLESS_TOKEN` environment variable, (2) `.taskless/.env.local.json` in the working directory, (3) global `~/.config/taskless/auth.json`. The first available token SHALL be used. - -#### Scenario: Env var takes precedence over all files - -- **WHEN** `TASKLESS_TOKEN` is set and both `.env.local.json` and global auth file exist -- **THEN** the CLI SHALL use the `TASKLESS_TOKEN` value - -#### Scenario: Per-repo file takes precedence over global - -- **WHEN** `TASKLESS_TOKEN` is not set, `.taskless/.env.local.json` exists, and global auth file exists -- **THEN** the CLI SHALL use the token from `.taskless/.env.local.json` - -#### Scenario: Global file used as fallback - -- **WHEN** `TASKLESS_TOKEN` is not set and `.taskless/.env.local.json` does not exist -- **THEN** the CLI SHALL use the token from the global auth file - -#### Scenario: No token available - -- **WHEN** `TASKLESS_TOKEN` is not set, no `.env.local.json` exists, and no global auth file exists -- **THEN** token resolution SHALL return undefined +- **THEN** `.taskless/.env.local.json` SHALL contain all provided fields ### Requirement: Token resolution is a shared utility @@ -113,13 +112,13 @@ A `getToken()` function SHALL exist that encapsulates the token resolution logic #### Scenario: getToken returns file token -- **WHEN** `TASKLESS_TOKEN` is not set and `auth.json` contains `{ "access_token": "file-token" }` +- **WHEN** `TASKLESS_TOKEN` is not set and `.taskless/.env.local.json` contains `{ "access_token": "file-token" }` - **THEN** `getToken()` SHALL return `"file-token"` #### Scenario: getToken returns null - **WHEN** `TASKLESS_TOKEN` is not set and no token file exists -- **THEN** `getToken()` SHALL return `null` +- **THEN** `getToken()` SHALL return `undefined` ### Requirement: Network layer is behind an interface @@ -167,27 +166,6 @@ The Device Flow HTTP calls (device authorization and token polling) SHALL be def - **WHEN** the device endpoint returns a non-200 status code - **THEN** the provider SHALL throw an error indicating the request failed -### Requirement: Per-repository token storage in .env.local.json - -The `taskless auth login` command SHALL store the auth token in `.taskless/.env.local.json` in the current repository, in addition to the existing global XDG config location. The per-repo file SHALL be a JSON object containing the `access_token` and any additional OAuth response fields. The file SHALL be created with permissions `0600`. - -#### Scenario: Login writes to per-repo .env.local.json - -- **WHEN** a user runs `taskless auth login` in a git repository -- **THEN** the CLI SHALL write the token to `.taskless/.env.local.json` in the repository root -- **AND** the CLI SHALL ensure `.taskless/.gitignore` exists with `.env.local.json` listed - -#### Scenario: Login also writes to global auth.json - -- **WHEN** a user runs `taskless auth login` -- **THEN** the CLI SHALL continue to write to the global XDG config location (`~/.config/taskless/auth.json`) -- **AND** the CLI SHALL write to `.taskless/.env.local.json` if in a git repository - -#### Scenario: .taskless/ directory is created if missing - -- **WHEN** `.taskless/` does not exist in the repository root -- **THEN** the CLI SHALL create it before writing `.env.local.json` - ### Requirement: CLI warns if .env.local.json is tracked by git On any command that reads from `.taskless/.env.local.json`, the CLI SHALL check whether the file is tracked by git. If tracked, the CLI SHALL print a warning to stderr. @@ -232,3 +210,81 @@ Repository URL resolution SHALL distinguish three failure populations: the direc - **WHEN** a user runs `taskless auth` - **THEN** the output SHALL be the existing plain-text status - **AND** no structured payload SHALL be added to this command + +### Requirement: Token resolution prefers the environment over the saved token + +When resolving the current authentication token, the CLI SHALL check in this order: (1) the `TASKLESS_TOKEN` environment variable, (2) `.taskless/.env.local.json` in the working directory. The first available token SHALL be used. A global `auth.json` under the XDG config directory SHALL NOT be used; if one exists, the CLI SHALL print a notice to stderr directing the user to `auth login` for the repository. + +#### Scenario: Env var takes precedence over the saved token + +- **WHEN** `TASKLESS_TOKEN` is set and `.taskless/.env.local.json` exists +- **THEN** the CLI SHALL use the `TASKLESS_TOKEN` value + +#### Scenario: Saved token is used without the env var + +- **WHEN** `TASKLESS_TOKEN` is not set and `.taskless/.env.local.json` holds an unexpired token +- **THEN** the CLI SHALL use the token from `.taskless/.env.local.json` + +#### Scenario: A legacy global token is not used + +- **WHEN** `TASKLESS_TOKEN` is not set, `.taskless/.env.local.json` does not exist, and a global `auth.json` exists +- **THEN** token resolution SHALL return undefined +- **AND** the CLI SHALL print a notice naming the legacy file + +#### Scenario: No token available + +- **WHEN** `TASKLESS_TOKEN` is not set and no `.env.local.json` exists +- **THEN** token resolution SHALL return undefined + +### Requirement: Login stores the token only in the repository + +The `taskless auth login` command SHALL store the auth token in `.taskless/.env.local.json` in the current repository, and nowhere else. The per-repo file SHALL be a JSON object containing the `access_token` and any additional OAuth response fields. The file SHALL be created with permissions `0600`. + +#### Scenario: Login writes to per-repo .env.local.json + +- **WHEN** a user runs `taskless auth login` in a git repository +- **THEN** the CLI SHALL write the token to `.taskless/.env.local.json` in the repository root +- **AND** the CLI SHALL ensure `.taskless/.gitignore` exists with `.env.local.json` listed + +#### Scenario: Login does not write a global token + +- **WHEN** a user runs `taskless auth login` +- **THEN** the CLI SHALL NOT write `auth.json` under the XDG config directory (`$XDG_CONFIG_HOME/taskless/` or `~/.config/taskless/`) + +#### Scenario: .taskless/ directory is created if missing + +- **WHEN** `.taskless/` does not exist in the repository root +- **THEN** the CLI SHALL create it before writing `.env.local.json` + +### Requirement: A rejected token's message names a fix that works + +When the service rejects the token (`401`), every message the CLI prints for it SHALL name a step that resolves it, chosen by where the token comes from: + +- a saved token: run `auth login`, which replaces a token the service rejects; +- a token from `TASKLESS_TOKEN`: replace or unset the variable, stating that `auth login` and `auth logout` do not change it. + +This SHALL hold for every command that reaches the service with a token, including `check`, `rule create`, `rule improve`, `rule restore`, `rule rollback`, `rule revisions`, and `taskless auth` status. The error code SHALL remain `AUTH_REQUIRED` wherever it is today. + +`taskless auth` status SHALL distinguish a rejected token, with its remedy, from a service that could not be reached, and SHALL say "via TASKLESS_TOKEN" when that is the token's source. + +#### Scenario: A saved token's rejection names auth login + +- **WHEN** `TASKLESS_TOKEN` is not set and a command's request is answered `401` +- **THEN** the message SHALL name `auth login` +- **AND** the message SHALL NOT be only "Log in again." + +#### Scenario: An environment token's rejection names the variable + +- **WHEN** `TASKLESS_TOKEN` is set and a command's request is answered `401` +- **THEN** the message SHALL name `TASKLESS_TOKEN` and say to replace or unset it + +#### Scenario: Status reports a rejected token with its remedy + +- **WHEN** a user runs `taskless auth` and the service answers `401` +- **THEN** the output SHALL say the token was rejected and give the remedy + +#### Scenario: Status does not call an unverified token invalid + +- **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 From 3d628d45bf64e82236f9860aca1448d0d3f815ca Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 22:10:05 -0700 Subject: [PATCH 3/3] test(auth): cover every taskless auth status outcome --- packages/cli/src/auth/status.ts | 38 +++++++++++++ packages/cli/src/commands/auth.ts | 32 ++--------- packages/cli/test/auth-status.test.ts | 80 +++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 28 deletions(-) create mode 100644 packages/cli/src/auth/status.ts create mode 100644 packages/cli/test/auth-status.test.ts diff --git a/packages/cli/src/auth/status.ts b/packages/cli/src/auth/status.ts new file mode 100644 index 00000000..d2337cbf --- /dev/null +++ b/packages/cli/src/auth/status.ts @@ -0,0 +1,38 @@ +import type { V2Outcome, WhoamiResult } from "../api/v2"; +import { isEnvironmentToken, rejectedTokenRemedy } from "./token"; + +/** + * The lines `taskless auth` prints for a token, given the service's answer + * about it. + * + * A rejected token and an unreachable service read differently on purpose: the + * first names its fix, the second says nothing about the token, which may be + * fine. + */ +export function describeAuthStatus( + outcome: V2Outcome +): string[] { + const source = isEnvironmentToken() ? " via TASKLESS_TOKEN" : ""; + switch (outcome.status) { + case "ok": { + const orgs = outcome.data.orgs.map((o) => o.name); + const orgSuffix = orgs.length > 0 ? ` (${orgs.join(", ")})` : ""; + return [`Logged in as ${outcome.data.user}${orgSuffix}${source}.`]; + } + case "unauthorized": { + return [ + `Logged in${source}, but the token was rejected.`, + `It is invalid or expired. ${rejectedTokenRemedy()}`, + ]; + } + case "unavailable": { + return [ + `Logged in${source}, but unable to verify identity.`, + `The Taskless service was unreachable (${outcome.reason}).`, + ]; + } + default: { + return [`Logged in${source}, but unable to verify identity.`]; + } + } +} diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index dcbeb5b4..390bd6d4 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -4,12 +4,8 @@ import { defineCommand } from "citty"; import { whoami } from "../api/v2"; import { loginInteractive } from "../auth/login-interactive"; -import { - getToken, - isEnvironmentToken, - rejectedTokenRemedy, - removeToken, -} from "../auth/token"; +import { describeAuthStatus } from "../auth/status"; +import { getToken, isEnvironmentToken, removeToken } from "../auth/token"; import { getTelemetry } from "../telemetry"; import { type CLIErrorCode, writeJsonError } from "../types/errors"; import { splitRawArguments } from "../util/argv"; @@ -210,28 +206,8 @@ export const authCommand = defineCommand({ return; } - const source = isEnvironmentToken() ? " via TASKLESS_TOKEN" : ""; - const outcome = await whoami(token); - switch (outcome.status) { - case "ok": { - const orgs = outcome.data.orgs.map((o) => o.name); - const orgSuffix = orgs.length > 0 ? ` (${orgs.join(", ")})` : ""; - console.log(`Logged in as ${outcome.data.user}${orgSuffix}${source}.`); - return; - } - case "unauthorized": { - console.log(`Logged in${source}, but the token was rejected.`); - console.log(`It is invalid or expired. ${rejectedTokenRemedy()}`); - return; - } - default: { - console.log(`Logged in${source}, but unable to verify identity.`); - if (outcome.status === "unavailable") { - console.log( - `The Taskless service was unreachable (${outcome.reason}).` - ); - } - } + for (const line of describeAuthStatus(await whoami(token))) { + console.log(line); } }, }); diff --git a/packages/cli/test/auth-status.test.ts b/packages/cli/test/auth-status.test.ts new file mode 100644 index 00000000..cac160e9 --- /dev/null +++ b/packages/cli/test/auth-status.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { WhoamiResult } from "../src/api/v2"; +import { describeAuthStatus } from "../src/auth/status"; + +const identity: WhoamiResult = { + user: "someone", + orgs: [ + { + orgId: 1, + id: "00000000-0000-0000-0000-000000000001", + name: "acme", + source: "github", + url: "https://github.com/acme", + }, + ], +}; + +let previousToken: string | undefined; + +beforeEach(() => { + previousToken = process.env.TASKLESS_TOKEN; + delete process.env.TASKLESS_TOKEN; +}); + +afterEach(() => { + if (previousToken === undefined) { + delete process.env.TASKLESS_TOKEN; + } else { + process.env.TASKLESS_TOKEN = previousToken; + } +}); + +describe("describeAuthStatus", () => { + it("names the user and orgs for an accepted saved token", () => { + expect(describeAuthStatus({ status: "ok", data: identity })).toEqual([ + "Logged in as someone (acme).", + ]); + }); + + it("says when the token comes from TASKLESS_TOKEN", () => { + process.env.TASKLESS_TOKEN = "env-token"; + + expect(describeAuthStatus({ status: "ok", data: identity })).toEqual([ + "Logged in as someone (acme) via TASKLESS_TOKEN.", + ]); + }); + + it("reports a rejected saved token with auth login as the fix", () => { + const lines = describeAuthStatus({ status: "unauthorized" }); + + expect(lines[0]).toBe("Logged in, but the token was rejected."); + expect(lines[1]).toMatch(/auth login` to replace the saved token\.$/); + }); + + it("reports a rejected TASKLESS_TOKEN with the variable as the fix", () => { + process.env.TASKLESS_TOKEN = "env-token"; + + const lines = describeAuthStatus({ status: "unauthorized" }); + + expect(lines[0]).toBe( + "Logged in via TASKLESS_TOKEN, but the token was rejected." + ); + expect(lines[1]).toMatch(/replace or unset it/); + }); + + it("does not call a token invalid when the service is unreachable", () => { + const lines = describeAuthStatus({ + status: "unavailable", + reason: "network error: offline", + retryable: true, + }); + + expect(lines).toEqual([ + "Logged in, but unable to verify identity.", + "The Taskless service was unreachable (network error: offline).", + ]); + expect(lines.join("\n")).not.toMatch(/rejected|invalid|expired/); + }); +});