Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/cli/src/agent_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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.");
Expand Down
43 changes: 39 additions & 4 deletions apps/cli/src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,31 @@ fn env_var(name: &str) -> Option<String> {
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<String> {
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")
Expand Down Expand Up @@ -360,7 +385,7 @@ pub fn store_agent(

pub fn resolve_agent() -> Result<AgentCredentials, String> {
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)?,
Expand Down Expand Up @@ -442,8 +467,9 @@ pub fn resolve() -> Result<Credentials, String> {
}

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(),
)
}
Expand Down Expand Up @@ -603,7 +629,7 @@ async fn verify_agent_status(credentials: &AgentCredentials) -> AgentVerificatio

fn resolve_status() -> Result<StatusCredentials, String> {
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();
Expand Down Expand Up @@ -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(""));
}
}
6 changes: 3 additions & 3 deletions apps/cli/src/guide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions apps/cli/src/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand All @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions apps/web/__tests__/unit/agent-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions apps/web/app/(org)/dashboard/settings/account/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,8 @@ export const Settings = () => {
<div className="space-y-1">
<CardTitle>Sign out of all devices</CardTitle>
<CardDescription>
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.
</CardDescription>
</div>
<Button
Expand All @@ -315,7 +315,7 @@ export const Settings = () => {
<DialogContent>
<DialogHeader
icon={<LogOut className="size-4" />}
description="This will immediately invalidate existing Cap web sessions, desktop session tokens, and desktop API keys for your account."
description="This will immediately invalidate existing Cap web sessions, desktop session tokens, desktop API keys, and CLI API keys for your account."
>
<DialogTitle>Sign out of all devices?</DialogTitle>
</DialogHeader>
Expand Down
Loading
Loading