diff --git a/apps/cli/src/agent_auth.rs b/apps/cli/src/agent_auth.rs index fbc6e8752d5..a0f032c0c34 100644 --- a/apps/cli/src/agent_auth.rs +++ b/apps/cli/src/agent_auth.rs @@ -366,11 +366,11 @@ impl LogoutArgs { let credentials = credentials::resolve_agent()?; if !is_agent_credential_source(credentials.source) { return Err( - "No Cap CLI agent credential is stored. CAP_API_KEY and Cap Desktop login are not changed by `cap auth logout`." + "No Cap CLI agent credential is stored. A legacy CAP_API_KEY and Cap Desktop login are not changed by `cap auth logout`." .to_string(), ); } - let revocable = credentials.access_token.starts_with("cap_cli_"); + let revocable = credentials::is_agent_api_key(&credentials.access_token); let revoked = if revocable { let response = auth_client()? .post(format!("{}/api/v1/auth/revoke", credentials.server)) @@ -407,7 +407,9 @@ impl LogoutArgs { if credentials.source == AgentCredentialSource::Env { println!("Cap CLI environment credential revoked."); if std::io::stdin().is_terminal() { - println!("Unset CAP_AGENT_TOKEN to remove it from this shell."); + let variable = + credentials::agent_env_var_name().unwrap_or("CAP_AGENT_TOKEN"); + println!("Unset {variable} to remove it from this shell."); } } else { println!("Cap CLI credential removed."); diff --git a/apps/cli/src/credentials.rs b/apps/cli/src/credentials.rs index 634fe3e8a8a..ad9b4225d03 100644 --- a/apps/cli/src/credentials.rs +++ b/apps/cli/src/credentials.rs @@ -103,6 +103,31 @@ fn env_var(name: &str) -> Option { std::env::var(name).ok().filter(|v| !v.is_empty()) } +/// Tokens minted for the CLI (from `cap auth login` or the dashboard's CLI API keys section) carry +/// this prefix. A `cap_cli_` value in `CAP_API_KEY` is an agent token the user pasted from the +/// dashboard, not a legacy desktop key, so it must take the agent path. +const AGENT_TOKEN_PREFIX: &str = "cap_cli_"; + +pub fn is_agent_api_key(value: &str) -> bool { + value.starts_with(AGENT_TOKEN_PREFIX) +} + +fn agent_env_token() -> Option { + env_var("CAP_AGENT_TOKEN").or_else(|| env_var("CAP_API_KEY").filter(|v| is_agent_api_key(v))) +} + +/// The environment variable currently supplying the agent token, for messages that tell the user +/// what to unset. +pub fn agent_env_var_name() -> Option<&'static str> { + if env_var("CAP_AGENT_TOKEN").is_some() { + Some("CAP_AGENT_TOKEN") + } else if env_var("CAP_API_KEY").is_some_and(|v| is_agent_api_key(&v)) { + Some("CAP_API_KEY") + } else { + None + } +} + pub fn server_url() -> String { normalize_server( env_var("CAP_SERVER_URL") @@ -360,7 +385,7 @@ pub fn store_agent( pub fn resolve_agent() -> Result { let server = server_url(); - if let Some(access_token) = env_var("CAP_AGENT_TOKEN") { + if let Some(access_token) = agent_env_token() { return Ok(AgentCredentials { access_token, server: validate_agent_server(server)?, @@ -442,8 +467,9 @@ pub fn resolve() -> Result { } Err( - "Not signed in. Sign in to Cap Desktop (the CLI reuses its login), or set CAP_API_KEY to a \ - Cap auth key from Settings." + "Not signed in. Run `cap auth login` (needs a browser), sign in to Cap Desktop (the CLI \ + reuses its login), or create a CLI API key in the Cap dashboard under Settings -> Account \ + and set it as CAP_API_KEY." .to_string(), ) } @@ -603,7 +629,7 @@ async fn verify_agent_status(credentials: &AgentCredentials) -> AgentVerificatio fn resolve_status() -> Result { let agent = resolve_agent(); - if env_var("CAP_AGENT_TOKEN").is_some() { + if agent_env_token().is_some() { return agent.map(StatusCredentials::Agent); } let legacy = resolve(); @@ -751,4 +777,13 @@ mod tests { fn file_fallback_is_only_available_where_permissions_are_enforced() { assert_eq!(file_fallback_supported(), cfg!(unix)); } + + #[test] + fn dashboard_minted_keys_are_recognized_as_agent_tokens() { + assert!(is_agent_api_key( + "cap_cli_0123456789abcdefghijklmnopqrstuvwxyzABCDEF-" + )); + assert!(!is_agent_api_key("00000000-0000-0000-0000-000000000000")); + assert!(!is_agent_api_key("")); + } } diff --git a/apps/cli/src/guide.rs b/apps/cli/src/guide.rs index 2d018d7d929..5a99d462234 100644 --- a/apps/cli/src/guide.rs +++ b/apps/cli/src/guide.rs @@ -111,8 +111,8 @@ fn build() -> Guide { EnvVar { name: "CAP_API_KEY", required: false, - used_by: "upload", - description: "Overrides auth for upload (Cap auth key from Settings). Optional when signed into Cap Desktop, which the CLI reuses automatically.", + used_by: "upload, auth, caps, mcp", + description: "Overrides auth (create a CLI API key in the Cap dashboard under Settings -> Account, or use a legacy desktop key). Optional when signed into Cap Desktop, which the CLI reuses automatically.", }, EnvVar { name: "CAP_SERVER_URL", @@ -124,7 +124,7 @@ fn build() -> Guide { name: "CAP_AGENT_TOKEN", required: false, used_by: "caps, mcp", - description: "Overrides the OS-stored Cap agent credential for headless use.", + description: "Overrides the OS-stored Cap agent credential for headless use. Mint one in the Cap dashboard under Settings -> Account.", }, EnvVar { name: "CAP_NO_MODIFY_PATH", diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index cd77ed9722a..f1aa889853f 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -126,11 +126,11 @@ OUTPUT AUTH `cap upload` authenticates automatically by reusing the login Cap Desktop already stored — no key to copy when you are signed in there. Check with `cap auth status --json`. For headless/CI, - set CAP_API_KEY to a Cap auth key (Settings) to override. + create a CLI API key in the Cap dashboard under Settings -> Account and set it as CAP_API_KEY. ENVIRONMENT - CAP_API_KEY Overrides auth for `cap upload` (Cap auth key from Settings); optional when - signed into Cap Desktop. + CAP_API_KEY Overrides auth (CLI API key from the Cap dashboard, Settings -> Account); + optional when signed into Cap Desktop. CAP_SERVER_URL Cap server base URL; defaults to Cap Desktop's server, else https://cap.so. CAP_NO_MODIFY_PATH Set to skip editing shell profiles during `cap desktop install-cli`. CAP_DESKTOP_FORCE_INSTALL diff --git a/apps/cli/src/upload.rs b/apps/cli/src/upload.rs index fc9d49a6a9e..0844b483247 100644 --- a/apps/cli/src/upload.rs +++ b/apps/cli/src/upload.rs @@ -116,7 +116,17 @@ impl UploadArgs { async fn run_inner(self, format: OutputFormat) -> Result<(), String> { let file_path = self.resolve_upload_file().await?; let meta = probe_video_meta(&file_path)?; - let (video_id, link) = if prefer_agent_upload() { + let use_agent = prefer_agent_upload(); + // The agent upload API always creates a new Cap, so honoring --video-id there would + // silently upload to the wrong place. Fail loudly instead of scattering new videos. + if use_agent && self.video_id.is_some() { + return Err( + "--video-id is not supported with a CLI API key (cap_cli_) or agent login; it \ + requires Cap Desktop login or a legacy desktop CAP_API_KEY." + .to_string(), + ); + } + let (video_id, link) = if use_agent { upload_file_with_agent(&file_path, self.name.as_deref(), &meta) .await .map_err(|error| { @@ -141,7 +151,7 @@ impl UploadArgs { Err(legacy_error) => { if self.video_id.is_some() { return Err(format!( - "{legacy_error} --video-id currently requires Cap Desktop or CAP_API_KEY authentication" + "{legacy_error} --video-id currently requires Cap Desktop login or a legacy desktop CAP_API_KEY" )); } upload_file_with_agent(&file_path, self.name.as_deref(), &meta) diff --git a/apps/web/__tests__/unit/agent-auth.test.ts b/apps/web/__tests__/unit/agent-auth.test.ts index 42b5f984d90..f3c1f440dd5 100644 --- a/apps/web/__tests__/unit/agent-auth.test.ts +++ b/apps/web/__tests__/unit/agent-auth.test.ts @@ -5,11 +5,13 @@ import { } from "@cap/web-backend"; import { describe, expect, it } from "vitest"; import { + agentScopeProfiles, buildAgentCallbackUrl, createAgentAccessToken, hashAgentSecret, isAgentCodeVerifier, isAgentLoopbackRedirectUri, + isAgentScopeProfile, parseAgentAuthorizationRequest, parseAgentScopes, verifyAgentCodeChallenge, @@ -108,6 +110,32 @@ describe("agent browser authorization", () => { expect(hashAgentSecret(token)).not.toContain(token); }); }); +describe("agent scope profiles", () => { + it("mirrors the CLI login profiles incrementally", () => { + const { creator, admin, full } = agentScopeProfiles; + expect(creator).toContain("caps:upload"); + expect(creator).not.toContain("organizations:manage"); + expect(admin).toContain("organizations:manage"); + expect(admin).not.toContain("developer:secrets"); + expect(full).toContain("developer:secrets"); + expect(admin).toEqual(expect.arrayContaining(creator)); + expect(full).toEqual(expect.arrayContaining(admin)); + }); + + it("every profile passes scope validation as sent by the CLI", () => { + for (const scopes of Object.values(agentScopeProfiles)) { + expect(parseAgentScopes(scopes.join(" "))).toEqual(scopes); + } + }); + + it("rejects unknown profile names", () => { + expect(isAgentScopeProfile("creator")).toBe(true); + expect(isAgentScopeProfile("full")).toBe(true); + expect(isAgentScopeProfile("root")).toBe(false); + expect(isAgentScopeProfile("")).toBe(false); + }); +}); + describe("legacy agent credentials", () => { it("accepts desktop-era keys without extending mobile or extension keys", () => { expect(isLegacyAgentKeySource("desktop")).toBe(true); diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 07217511b3c..05cdc10148d 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -294,8 +294,8 @@ export const Settings = () => {
Sign out of all devices - Invalidate every Cap web session and desktop app authentication - token connected to your account. + Invalidate every Cap web session, desktop app authentication token, + and CLI API key connected to your account.
+ + {loadFailed && ( +
+ Your API keys could not be loaded. Refresh the page to try again. + Existing keys are still active and can be revoked once the list loads. +
+ )} + {keys.length > 0 && ( +
+ {keys.map((key) => { + const expired = new Date(key.expiresAt).getTime() <= Date.now(); + return ( +
+
+

+ {key.name} +

+

+ {key.scopes.length} scopes · created{" "} + {formatDate(key.createdAt)} ·{" "} + {key.lastUsedAt + ? `last used ${formatDate(key.lastUsedAt)}` + : "never used"}{" "} + ·{" "} + {expired + ? "expired" + : `expires ${formatDate(key.expiresAt)}`} +

+
+ +
+ ); + })} +
+ )} + + + + } + description="The key gets the same permissions as the matching cap auth login profile." + > + Create a CLI API key + +
{ + event.preventDefault(); + createMutation.mutate(); + }} + > +
+
+ + setName(event.target.value)} + placeholder="e.g. Build sandbox" + type="text" + value={name} + /> +
+
+

Access profile

+ +
+
+ + + + +
+
+
+ + { + if (!open) setMintedToken(null); + }} + > + + } + description="Cap stores only a hash of this key, so it cannot be shown again." + > + Copy your API key + +
+ {mintedToken && ( + + )} +

+ Set it as CAP_API_KEY (or{" "} + CAP_AGENT_TOKEN) in the environment where the Cap CLI + runs, then verify with cap auth status --json. +

+
+ + + +
+
+ + { + if (!open) setRevokeTarget(null); + }} + > + + } + description="Anything authenticating with this key loses access immediately." + > + Revoke "{revokeTarget?.name}"? + + + + + + + + + ); +}; diff --git a/apps/web/app/(org)/dashboard/settings/account/page.tsx b/apps/web/app/(org)/dashboard/settings/account/page.tsx index 1408e3b9d79..e674a60496a 100644 --- a/apps/web/app/(org)/dashboard/settings/account/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/page.tsx @@ -1,10 +1,23 @@ import type { Metadata } from "next"; +import { CliApiKeys } from "./components/CliApiKeys"; import { Settings } from "./Settings"; +import { listCliApiKeys } from "./server"; export const metadata: Metadata = { title: "Settings — Cap", }; export default async function SettingsPage() { - return ; + // A failed key query must not 500 the whole settings page, but it also must not render as + // an empty list, which would hide still-active keys the user may need to revoke. + const cliApiKeys = await listCliApiKeys().catch(() => null); + return ( + <> + + + + ); } diff --git a/apps/web/app/(org)/dashboard/settings/account/server.ts b/apps/web/app/(org)/dashboard/settings/account/server.ts index e2e0c1dec55..cddc2dea354 100644 --- a/apps/web/app/(org)/dashboard/settings/account/server.ts +++ b/apps/web/app/(org)/dashboard/settings/account/server.ts @@ -2,7 +2,9 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; +import { nanoId } from "@cap/database/helpers"; import { + agentApiKeys, authApiKeys, organizationMembers, organizations, @@ -10,8 +12,26 @@ import { users, } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; -import { and, eq, or, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + agentScopeProfiles, + createAgentAccessToken, + hashAgentSecret, + isAgentScopeProfile, +} from "@/lib/agent-auth"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; + +export type CliApiKeySummary = { + id: string; + name: string; + scopes: string[]; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string; +}; + +const cliApiKeyExpiryDays = new Set([30, 90, 365]); export async function patchAccountSettings( firstName?: string, @@ -69,6 +89,116 @@ export async function patchAccountSettings( revalidatePath("/dashboard/settings/account"); } +export async function listCliApiKeys(): Promise { + const currentUser = await getCurrentUser(); + if (!currentUser) throw new Error("Unauthorized"); + + const keys = await db() + .select({ + id: agentApiKeys.id, + name: agentApiKeys.name, + scopes: agentApiKeys.scopes, + createdAt: agentApiKeys.createdAt, + lastUsedAt: agentApiKeys.lastUsedAt, + expiresAt: agentApiKeys.expiresAt, + }) + .from(agentApiKeys) + .where( + and( + eq(agentApiKeys.userId, currentUser.id), + isNull(agentApiKeys.revokedAt), + ), + ) + .orderBy(desc(agentApiKeys.createdAt)); + + return keys.map((key) => ({ + id: key.id, + name: key.name, + scopes: [...key.scopes], + createdAt: key.createdAt.toISOString(), + lastUsedAt: key.lastUsedAt?.toISOString() ?? null, + expiresAt: key.expiresAt.toISOString(), + })); +} + +export async function createCliApiKey(input: { + name: string; + profile: string; + expiresInDays: number; +}): Promise<{ token: string; key: CliApiKeySummary }> { + const currentUser = await getCurrentUser(); + if (!currentUser) throw new Error("Unauthorized"); + + const name = input.name.trim(); + if (name.length === 0 || name.length > 100) { + throw new Error("The key name must be between 1 and 100 characters"); + } + if (!isAgentScopeProfile(input.profile)) { + throw new Error("Unknown access profile"); + } + if (!cliApiKeyExpiryDays.has(input.expiresInDays)) { + throw new Error("Unknown expiry"); + } + + // Same bucket as the CLI OAuth authorize page: both mint agent credentials. + if ( + await isRateLimited(RATE_LIMIT_IDS.AGENT_AUTHORIZATION, { + key: `agent-authorization:${currentUser.id}`, + }) + ) { + throw new Error("Too many API key requests. Try again later."); + } + + const token = createAgentAccessToken(); + const now = new Date(); + const expiresAt = new Date( + now.getTime() + input.expiresInDays * 24 * 60 * 60 * 1000, + ); + const scopes = agentScopeProfiles[input.profile]; + const id = nanoId(); + await db() + .insert(agentApiKeys) + .values({ + id, + userId: currentUser.id, + tokenHash: hashAgentSecret(token), + name, + scopes, + expiresAt, + }); + + revalidatePath("/dashboard/settings/account"); + return { + token, + key: { + id, + name, + scopes: [...scopes], + createdAt: now.toISOString(), + lastUsedAt: null, + expiresAt: expiresAt.toISOString(), + }, + }; +} + +export async function revokeCliApiKey(keyId: string): Promise { + const currentUser = await getCurrentUser(); + if (!currentUser) throw new Error("Unauthorized"); + + await db() + .update(agentApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(agentApiKeys.id, keyId), + eq(agentApiKeys.userId, currentUser.id), + isNull(agentApiKeys.revokedAt), + ), + ); + + revalidatePath("/dashboard/settings/account"); +} + export async function signOutAllDevices() { const currentUser = await getCurrentUser(); if (!currentUser) throw new Error("Unauthorized"); @@ -80,5 +210,14 @@ export async function signOutAllDevices() { .where(eq(users.id, currentUser.id)); await tx.delete(sessions).where(eq(sessions.userId, currentUser.id)); await tx.delete(authApiKeys).where(eq(authApiKeys.userId, currentUser.id)); + await tx + .update(agentApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(agentApiKeys.userId, currentUser.id), + isNull(agentApiKeys.revokedAt), + ), + ); }); } diff --git a/apps/web/content/docs/agents/setup.mdx b/apps/web/content/docs/agents/setup.mdx index 886878da4d5..884c9b76488 100644 --- a/apps/web/content/docs/agents/setup.mdx +++ b/apps/web/content/docs/agents/setup.mdx @@ -64,7 +64,25 @@ cap auth login --profile admin --json cap auth login --profile full --json ``` -Cap CLI credentials are stored by the operating system when available. `CAP_AGENT_TOKEN` is supported for headless environments, but do not paste tokens into an agent conversation or commit them to a project. +Cap CLI credentials are stored by the operating system when available. For machines without a browser, see [Headless environments](#headless-environments-ci-containers-and-remote-sandboxes) below. + +## Headless environments (CI, containers, and remote sandboxes) + +`cap auth login` opens a browser on the same machine, so it does not work on headless runners. Mint an API key from the Cap dashboard instead: + +1. Open [Settings, then Account](https://cap.so/dashboard/settings/account) in the Cap dashboard and find **Cap CLI access**. +2. Choose **Create API key**, then pick a name, an access profile (`creator`, `admin`, or `full`, the same profiles as `cap auth login`), and an expiry. +3. Copy the key when it is shown. Cap stores only a hash, so it cannot be displayed again. +4. Inject the key into the headless environment as `CAP_API_KEY` or `CAP_AGENT_TOKEN` (both are honored), then verify: + +```sh +export CAP_API_KEY="cap_cli_..." +cap auth status --json +``` + +The key authenticates every `cap` command and MCP tool with the scopes of the chosen profile. Revoke it at any time from the same settings page; keys minted by `cap auth login` are listed and revocable there too. + +Follow least privilege here as well: prefer the `creator` profile and the shortest expiry that fits the job, and use your platform's secret storage (for example CI secrets) to inject the key. Do not paste keys into an agent conversation or commit them to a project. ## Codex diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index b68d6238e66..176024ea006 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -11,6 +11,8 @@ There are two APIs: - **REST API** (`/api/developer/v1`) -- Server-side management of videos and usage, authenticated with secret keys (`csk_`). - **SDK API** (`/api/developer/sdk/v1`) -- Client-side video creation and uploads, authenticated with public keys (`cpk_`). +These keys do not authenticate the Cap CLI. For CLI keys (`cap_cli_`), used with `CAP_API_KEY` in headless environments, see [Set Up Your Agent](/docs/agents/setup#headless-environments-ci-containers-and-remote-sandboxes). + ## Authentication ### Getting Your API Keys diff --git a/apps/web/lib/agent-auth.ts b/apps/web/lib/agent-auth.ts index 54c844fe781..9ddf9366899 100644 --- a/apps/web/lib/agent-auth.ts +++ b/apps/web/lib/agent-auth.ts @@ -27,6 +27,57 @@ export const agentScopes = [ "developer:secrets", ] as const satisfies readonly Agent.AgentScope[]; +// Mirrors the CLI's `cap auth login --profile` scope sets (apps/cli/src/agent_auth.rs) so a key +// minted from the dashboard grants exactly what the equivalent browser login would. +const creatorProfileScopes: Agent.AgentScope[] = [ + "caps:read", + "caps:comment", + "caps:write", + "profile:read", + "profile:write", + "caps:upload", + "caps:process", + "caps:delete", + "library:read", + "library:write", + "analytics:read", + "notifications:read", + "notifications:write", +]; + +const adminProfileScopes: Agent.AgentScope[] = [ + ...creatorProfileScopes, + "organizations:read", + "organizations:manage", + "organizations:members", + "integrations:read", + "integrations:write", + "billing:read", + "billing:write", +]; + +const fullProfileScopes: Agent.AgentScope[] = [ + ...adminProfileScopes, + "developer:read", + "developer:write", + "developer:secrets", +]; + +const canonicalScopeOrder = (scopes: Agent.AgentScope[]) => + agentScopes.filter((scope) => scopes.includes(scope)); + +export const agentScopeProfiles = { + creator: canonicalScopeOrder(creatorProfileScopes), + admin: canonicalScopeOrder(adminProfileScopes), + full: canonicalScopeOrder(fullProfileScopes), +} as const; + +export type AgentScopeProfile = keyof typeof agentScopeProfiles; + +export const isAgentScopeProfile = ( + value: string, +): value is AgentScopeProfile => Object.hasOwn(agentScopeProfiles, value); + export type AgentAuthorizationRequest = { clientId: "cap-cli"; redirectUri: string;