diff --git a/packages/cli/src/lib/oauth.test.ts b/packages/cli/src/lib/oauth.test.ts new file mode 100644 index 00000000..229aeebe --- /dev/null +++ b/packages/cli/src/lib/oauth.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { buildAuthorizeUrl } from "./oauth"; + +function getRequestedScopes(): string[] { + const url = new URL( + buildAuthorizeUrl({ + redirectUri: "http://127.0.0.1:1234/callback", + state: "state", + codeChallenge: "challenge", + }), + ); + return url.searchParams.get("scope")?.split(" ") ?? []; +} + +describe("buildAuthorizeUrl", () => { + // Every `/accounts/{accountSlug}/...` endpoint requires it: without it, + // `argos account *` answers 403 with no way to grant it from the CLI. + it("requests the scope the `account` commands need", () => { + expect(getRequestedScopes()).toContain("account:admin"); + }); +}); diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index 299932c1..a03e6131 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -15,7 +15,8 @@ export const OAUTH_CLIENT_ID = "argos-cli"; /** * Scopes requested by `argos login`, covering the CLI's user actions (identity, - * projects, reviews, comments, media). Build upload keeps using a project token. + * projects, reviews, comments, media, team administration). Build upload keeps + * using a project token. * * Every scope a CLI command needs has to be listed here: the consent screen * only offers what the client asks for, so a missing entry is not something the @@ -30,8 +31,26 @@ export const OAUTH_SCOPES = [ "comments:write", "media:read", "media:write", + "account:admin", ] as const; +export type OAuthScope = (typeof OAUTH_SCOPES)[number]; + +/** + * Requested scopes that the consent screen grants only with "Admin" access + * (`level: "admin"` in the Argos backend's `apps/backend/src/oauth/scopes.ts`). + * The default, "Read and write", leaves them out, so a session can lack them + * even when it was granted after the CLI started asking for them. + */ +export const OAUTH_ADMIN_SCOPES: ReadonlySet = new Set([ + "projects:write", + "account:admin", +]); + +export function isRequestedOAuthScope(value: string): value is OAuthScope { + return (OAUTH_SCOPES as readonly string[]).includes(value); +} + /** Refresh a bit early so a token never expires mid-request. */ const EXPIRY_SKEW_MS = 60 * 1000; diff --git a/packages/cli/src/lib/run.test.ts b/packages/cli/src/lib/run.test.ts index fe719a1a..e355848f 100644 --- a/packages/cli/src/lib/run.test.ts +++ b/packages/cli/src/lib/run.test.ts @@ -1,7 +1,7 @@ import { APIError } from "@argos-ci/api-client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { handleCliError } from "./run"; +import { getScopeHint, handleCliError } from "./run"; /** * `handleCliError` ends the process, so both effects have to be intercepted: @@ -38,6 +38,19 @@ describe("handleCliError", () => { expect(message).not.toContain("personal access token"); }); + it("names the missing scope and the access level that grants it", () => { + const message = runAndCapture( + new APIError( + "Insufficient scope. This endpoint requires: account:admin.", + { status: 403 }, + ), + "user", + ); + expect(message).toContain("does not include `account:admin`"); + expect(message).toContain('choose "Admin" access'); + expect(message).not.toContain("personal access token"); + }); + it("keeps the token-type hint for other permission failures", () => { const message = runAndCapture( new APIError("Forbidden.", { status: 403 }), @@ -52,3 +65,57 @@ describe("handleCliError", () => { expect(message).toBe("Error: Boom."); }); }); + +describe("getScopeHint", () => { + it("asks for a new login when a write scope is missing", () => { + const hint = getScopeHint( + "Insufficient scope. This endpoint requires: media:write.", + ); + expect(hint).toBe( + "Your `argos login` session does not include `media:write`. Run `argos login` again to grant it.", + ); + }); + + it.each(["projects:write", "account:admin"])( + "points at the Admin access level for %s", + (scope) => { + const hint = getScopeHint( + `Insufficient scope. This endpoint requires: ${scope}.`, + ); + expect(hint).toContain(`does not include \`${scope}\``); + expect(hint).toContain('choose "Admin" access on the consent screen'); + }, + ); + + it("points at the Admin access level when any required scope is an admin one", () => { + const hint = getScopeHint( + "Insufficient scope. This endpoint requires: projects:read, account:admin.", + ); + expect(hint).toContain("`projects:read`, `account:admin`"); + expect(hint).toContain('choose "Admin" access'); + }); + + it("suggests a personal access token for a scope the CLI never requests", () => { + const hint = getScopeHint( + "Insufficient scope. This endpoint requires: builds:write.", + ); + expect(hint).toBe( + "`argos login` cannot grant `builds:write`. Use a personal access token instead (`--token` or `ARGOS_TOKEN`).", + ); + }); + + it("falls back to a generic hint when the scope is not named", () => { + expect(getScopeHint("Insufficient scope.")).toBe( + "Run `argos login` again to grant the permission this command needs.", + ); + }); + + it("never echoes text that is not shaped like a scope", () => { + const hint = getScopeHint( + "Insufficient scope. This endpoint requires: \u001b[2Jevil, media:write.", + ); + expect(hint).not.toContain("\u001b"); + expect(hint).not.toContain("evil"); + expect(hint).toContain("`media:write`"); + }); +}); diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts index 8bab8e02..3293ba32 100644 --- a/packages/cli/src/lib/run.ts +++ b/packages/cli/src/lib/run.ts @@ -1,6 +1,7 @@ import { APIError } from "@argos-ci/api-client"; import { CliError } from "./cli-error"; +import { isRequestedOAuthScope, OAUTH_ADMIN_SCOPES } from "./oauth"; import { resolveBuildTarget, type AuthMode, @@ -14,10 +15,6 @@ const USER_AUTH_HINT = "Ensure your token is a personal access token with access to this project. " + "Project tokens (ARGOS_TOKEN in CI) can read build data but cannot perform review or comment actions."; -const SCOPE_HINT = - "Your `argos login` session was granted before the CLI asked for this permission. " + - "Run `argos login` again to grant it."; - /** * A scope rejection, as opposed to any other 403: the token is valid and the * user is allowed, it was simply issued without the permission this endpoint @@ -28,6 +25,60 @@ function isInsufficientScope(error: APIError): boolean { return error.status === 403 && /insufficient scope/i.test(error.message); } +/** + * The scopes named by a scope rejection ("Insufficient scope. This endpoint + * requires: account:admin."). Anything not shaped like a scope is dropped, so + * the hint never echoes arbitrary server text. + */ +function parseRequiredScopes(message: string): string[] { + const match = /requires:\s*([^.]*)/i.exec(message); + if (!match?.[1]) { + return []; + } + return match[1] + .split(",") + .map((scope) => scope.trim()) + .filter((scope) => /^[a-z]+(?::[a-z]+)?$/.test(scope)); +} + +function formatScopes(scopes: readonly string[]): string { + return scopes.map((scope) => `\`${scope}\``).join(", "); +} + +/** + * How to get the scopes an `argos login` session is missing. A session lacks a + * scope for one of three reasons, each with its own fix: the CLI never asks for + * it (only a personal access token helps), it is an admin scope left out by the + * consent screen's default access level, or the session was granted before the + * CLI asked for it. + */ +export function getScopeHint(message: string): string { + const required = parseRequiredScopes(message); + if (required.length === 0) { + return "Run `argos login` again to grant the permission this command needs."; + } + const ungrantable = required.filter((scope) => !isRequestedOAuthScope(scope)); + if (ungrantable.length > 0) { + return ( + `\`argos login\` cannot grant ${formatScopes(ungrantable)}. ` + + "Use a personal access token instead (`--token` or `ARGOS_TOKEN`)." + ); + } + const needsAdmin = required.some( + (scope) => isRequestedOAuthScope(scope) && OAUTH_ADMIN_SCOPES.has(scope), + ); + if (needsAdmin) { + return ( + `Your \`argos login\` session does not include ${formatScopes(required)}. ` + + 'Run `argos login` again and choose "Admin" access on the consent screen to grant it.' + ); + } + return ( + `Your \`argos login\` session does not include ${formatScopes(required)}. ` + + "Run `argos login` again to grant it." + ); +} + /** * Print an error and exit. {@link CliError} and {@link APIError} messages are * shown as-is; permission failures get an extra hint — how to grant a missing @@ -40,7 +91,7 @@ export function handleCliError(error: unknown, auth?: AuthMode): never { if (error instanceof APIError && isInsufficientScope(error)) { // A missing scope is never fixed by switching token type, so this hint // replaces the one about tokens rather than piling on next to it. - message += `\n${SCOPE_HINT}`; + message += `\n${getScopeHint(error.message)}`; } else if ( auth === "user" && error instanceof APIError && diff --git a/skills/argos-cli/SKILL.md b/skills/argos-cli/SKILL.md index f83e3bb9..59750a20 100644 --- a/skills/argos-cli/SKILL.md +++ b/skills/argos-cli/SKILL.md @@ -58,6 +58,14 @@ Two token types — pick by command: `baseline download` takes only a project token: the baseline lookup it relies on rejects a PAT, including the one from `argos login`. +`argos login` asks the user which access to grant. Its default, "Read and +write", covers reviews, comments and media but not administration: `account *`, +`create-project`, `project update`, `project transfer`, `project contributor +set|remove`, `project domain set` and `automation create|update|deactivate` +need "Admin" access. An `Insufficient scope` error names the missing +permission: ask the user to run `argos login` again and choose "Admin", or use +a PAT. + Project tokens read build data but **cannot** review, comment, ignore changes, or administer a project or team — those need a PAT. If no suitable token is available, ask the user. For these actions, if no PAT exists, report the