diff --git a/docs/telegram-gateway-operator-guide.md b/docs/telegram-gateway-operator-guide.md index e7861d8..cc1f4e4 100644 --- a/docs/telegram-gateway-operator-guide.md +++ b/docs/telegram-gateway-operator-guide.md @@ -110,12 +110,14 @@ The gateway container needs: - Docker network - `claw_gateway` +- `ANTHROPIC_AUTH_TOKEN` + - Claude subscription OAuth token such as `sk-ant-oat...` + - the gateway now sends the extra Claude Code headers required for this path + - `ANTHROPIC_API_KEY` + - standard Anthropic API key -Bearer-only auth is not currently supported by the Anthropic Messages API used by this runtime. -If you pass only `ANTHROPIC_AUTH_TOKEN`, workers will fail with: - -- `401 Unauthorized (authentication_error): OAuth authentication is currently not supported.` +If you use `ANTHROPIC_AUTH_TOKEN`, the gateway workers add the Claude Code OAuth headers and agent identity prefix that `nanobot` also uses for subscription auth. The gateway copies inherited auth env vars into worker containers according to `CLAW_GATEWAY_INHERITED_ENV`. @@ -156,7 +158,7 @@ Example: "template_dir": "/unraid/templates-user", "template_file_prefix": "claw-worker-", "template_archive_dir": "/appdata/template-archive", - "inherited_env": ["ANTHROPIC_API_KEY"] + "inherited_env": ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] }, "worker_defaults": { "bind_port": 8080, diff --git a/my-claw-telegram.xml b/my-claw-telegram.xml index 46e7383..a98678d 100644 --- a/my-claw-telegram.xml +++ b/my-claw-telegram.xml @@ -20,7 +20,7 @@ - Docker socket access and an Anthropic API key + Docker socket access and either an Anthropic OAuth token or Anthropic API key /var/run/docker.sock /boot/config/plugins/dockerMan/templates-user /mnt/user/appdata/claw-telegram-gateway @@ -33,9 +33,9 @@ /unraid/templates-user /appdata/template-archive claw_gateway - ANTHROPIC_API_KEY + ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY 15 - - + + diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index f8b41ac..790e308 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -24,6 +24,9 @@ const ALT_REQUEST_ID_HEADER: &str = "x-request-id"; const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200); const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(2); const DEFAULT_MAX_RETRIES: u32 = 2; +const CLAUDE_CODE_OAUTH_BETA_HEADER: &str = + "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27"; +const CLAUDE_CODE_OAUTH_USER_AGENT: &str = "claude-cli/2.1.2 (external, cli)"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthSource { @@ -83,6 +86,12 @@ impl AuthSource { } } + #[must_use] + pub fn uses_claude_code_oauth(&self) -> bool { + self.bearer_token() + .is_some_and(is_claude_code_oauth_token) + } + pub fn apply(&self, mut request_builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { if let Some(api_key) = self.api_key() { request_builder = request_builder.header("x-api-key", api_key); @@ -90,10 +99,22 @@ impl AuthSource { if let Some(token) = self.bearer_token() { request_builder = request_builder.bearer_auth(token); } + if self.uses_claude_code_oauth() { + request_builder = request_builder + .header("anthropic-beta", CLAUDE_CODE_OAUTH_BETA_HEADER) + .header("anthropic-dangerous-direct-browser-access", "true") + .header("user-agent", CLAUDE_CODE_OAUTH_USER_AGENT) + .header("x-app", "cli"); + } request_builder } } +#[must_use] +pub fn is_claude_code_oauth_token(token: &str) -> bool { + token.contains("sk-ant-oat") +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct OAuthTokenSet { pub access_token: String, @@ -840,7 +861,10 @@ struct AnthropicErrorBody { #[cfg(test)] mod tests { - use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER}; + use super::{ + ALT_REQUEST_ID_HEADER, CLAUDE_CODE_OAUTH_BETA_HEADER, CLAUDE_CODE_OAUTH_USER_AGENT, + REQUEST_ID_HEADER, + }; use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::{Mutex, OnceLock}; @@ -1226,7 +1250,7 @@ mod tests { fn auth_source_applies_headers() { let auth = AuthSource::ApiKeyAndBearer { api_key: "test-key".to_string(), - bearer_token: "proxy-token".to_string(), + bearer_token: "sk-ant-oat01-proxy-token".to_string(), }; let request = auth .apply(reqwest::Client::new().post("https://example.test")) @@ -1239,7 +1263,25 @@ mod tests { ); assert_eq!( headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer proxy-token") + Some("Bearer sk-ant-oat01-proxy-token") + ); + assert_eq!( + headers.get("anthropic-beta").and_then(|v| v.to_str().ok()), + Some(CLAUDE_CODE_OAUTH_BETA_HEADER) + ); + assert_eq!( + headers + .get("anthropic-dangerous-direct-browser-access") + .and_then(|v| v.to_str().ok()), + Some("true") + ); + assert_eq!( + headers.get("x-app").and_then(|v| v.to_str().ok()), + Some("cli") + ); + assert_eq!( + headers.get("user-agent").and_then(|v| v.to_str().ok()), + Some(CLAUDE_CODE_OAUTH_USER_AGENT) ); } } diff --git a/rust/crates/channel-gateway-core/src/runtime_host.rs b/rust/crates/channel-gateway-core/src/runtime_host.rs index ac201d3..14a0a04 100644 --- a/rust/crates/channel-gateway-core/src/runtime_host.rs +++ b/rust/crates/channel-gateway-core/src/runtime_host.rs @@ -29,6 +29,8 @@ use tools::{GlobalToolRegistry, RuntimeToolDefinition}; const DEFAULT_PROMPT_DATE: &str = "2026-03-31"; const APPROVAL_POLL_INTERVAL: Duration = Duration::from_millis(250); +const CLAUDE_CODE_OAUTH_SYSTEM_PREFIX: &str = + "You are a Claude agent, built on Anthropic's Claude Agent SDK."; pub type AllowedToolSet = BTreeSet; @@ -175,7 +177,8 @@ impl RuntimeHost { event_tx: UnboundedSender, ) -> Result<(), HostError> { let session = self.load_or_create_session(session_path)?; - let system_prompt = build_system_prompt(&self.config.cwd)?; + let auth = resolve_cli_auth_source(&self.config.cwd)?; + let system_prompt = build_system_prompt(&self.config.cwd, &auth)?; let mut runtime = build_runtime( &self.config, session, @@ -183,6 +186,7 @@ impl RuntimeHost { .file_stem() .and_then(|value| value.to_str()) .unwrap_or("telegram-session"), + auth, )?; let mut observer = BridgeObserver::new(event_tx.clone()); let mut prompter = BridgePermissionPrompter::new( @@ -263,9 +267,21 @@ impl From for HostError { } } -fn build_system_prompt(cwd: &Path) -> Result, HostError> { - load_system_prompt(cwd, DEFAULT_PROMPT_DATE, std::env::consts::OS, "unknown") - .map_err(|error| HostError::Other(error.to_string())) +fn build_system_prompt(cwd: &Path, auth: &AuthSource) -> Result, HostError> { + let system_prompt = load_system_prompt(cwd, DEFAULT_PROMPT_DATE, std::env::consts::OS, "unknown") + .map_err(|error| HostError::Other(error.to_string()))?; + Ok(with_claude_code_oauth_prefix(system_prompt, auth)) +} + +fn with_claude_code_oauth_prefix(mut system_prompt: Vec, auth: &AuthSource) -> Vec { + if auth.uses_claude_code_oauth() + && !system_prompt + .iter() + .any(|segment| segment.contains(CLAUDE_CODE_OAUTH_SYSTEM_PREFIX)) + { + system_prompt.insert(0, CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()); + } + system_prompt } #[derive(Debug, Clone)] @@ -577,6 +593,7 @@ fn build_runtime( config: &RuntimeHostConfig, session: Session, session_id: &str, + auth: AuthSource, ) -> Result { let runtime_plugin_state = build_runtime_plugin_state(&config.cwd)?; let RuntimePluginState { @@ -593,10 +610,11 @@ fn build_runtime( let runtime = ConversationRuntime::new_with_features( session, AnthropicBridgeClient::new( - &config.cwd, session_id, + auth.clone(), config.model.clone(), config.allowed_tools.clone(), + tool_registry.clone(), )?, BridgeToolExecutor::new( config.allowed_tools.clone(), @@ -604,7 +622,7 @@ fn build_runtime( mcp_state.clone(), ), policy, - build_system_prompt(&config.cwd)?, + build_system_prompt(&config.cwd, &auth)?, &feature_config, ); Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) @@ -833,16 +851,15 @@ struct AnthropicBridgeClient { impl AnthropicBridgeClient { fn new( - cwd: &Path, session_id: &str, + auth: AuthSource, model: String, allowed_tools: Option, + tool_registry: GlobalToolRegistry, ) -> Result { - let runtime_plugin_state = build_runtime_plugin_state(cwd)?; - let tool_registry = runtime_plugin_state.tool_registry.clone(); Ok(Self { runtime: tokio::runtime::Runtime::new().map_err(HostError::Io)?, - client: AnthropicClient::from_auth(resolve_cli_auth_source(cwd)?) + client: AnthropicClient::from_auth(auth) .with_base_url(api::read_base_url()) .with_prompt_cache(PromptCache::new(session_id)), model, @@ -852,6 +869,30 @@ impl AnthropicBridgeClient { } } +#[cfg(test)] +mod oauth_tests { + use super::{with_claude_code_oauth_prefix, AuthSource, CLAUDE_CODE_OAUTH_SYSTEM_PREFIX}; + + #[test] + fn oauth_system_prefix_is_prepended_for_claude_code_tokens() { + let system_prompt = with_claude_code_oauth_prefix( + vec!["existing prompt".to_string()], + &AuthSource::BearerToken("sk-ant-oat01-test".to_string()), + ); + assert_eq!(system_prompt[0], CLAUDE_CODE_OAUTH_SYSTEM_PREFIX); + assert_eq!(system_prompt[1], "existing prompt"); + } + + #[test] + fn oauth_system_prefix_is_not_duplicated() { + let system_prompt = with_claude_code_oauth_prefix( + vec![CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()], + &AuthSource::BearerToken("sk-ant-oat01-test".to_string()), + ); + assert_eq!(system_prompt, vec![CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()]); + } +} + fn resolve_cli_auth_source(cwd: &Path) -> Result { resolve_startup_auth_source(|| { let config = ConfigLoader::default_for(cwd).load().map_err(|error| { diff --git a/rust/crates/claw-telegram/assets/unraid/claw-telegram-gateway.xml b/rust/crates/claw-telegram/assets/unraid/claw-telegram-gateway.xml index e3d0311..75993d3 100644 --- a/rust/crates/claw-telegram/assets/unraid/claw-telegram-gateway.xml +++ b/rust/crates/claw-telegram/assets/unraid/claw-telegram-gateway.xml @@ -33,9 +33,9 @@ /unraid/templates-user /appdata/template-archive claw_gateway - ANTHROPIC_API_KEY + ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY 15 - - + + diff --git a/rust/crates/claw-telegram/src/runtime_host.rs b/rust/crates/claw-telegram/src/runtime_host.rs index c215627..b60f836 100644 --- a/rust/crates/claw-telegram/src/runtime_host.rs +++ b/rust/crates/claw-telegram/src/runtime_host.rs @@ -29,6 +29,8 @@ use tools::{GlobalToolRegistry, RuntimeToolDefinition}; const DEFAULT_PROMPT_DATE: &str = "2026-03-31"; const APPROVAL_POLL_INTERVAL: Duration = Duration::from_millis(250); +const CLAUDE_CODE_OAUTH_SYSTEM_PREFIX: &str = + "You are a Claude agent, built on Anthropic's Claude Agent SDK."; pub type AllowedToolSet = BTreeSet; @@ -173,7 +175,8 @@ impl RuntimeHost { event_tx: UnboundedSender, ) -> Result<(), HostError> { let session = self.load_or_create_session(session_path)?; - let system_prompt = build_system_prompt(&self.config.cwd)?; + let auth = resolve_cli_auth_source(&self.config.cwd)?; + let system_prompt = build_system_prompt(&self.config.cwd, &auth)?; let mut runtime = build_runtime( &self.config, session, @@ -181,6 +184,7 @@ impl RuntimeHost { .file_stem() .and_then(|value| value.to_str()) .unwrap_or("telegram-session"), + auth, )?; let mut observer = BridgeObserver::new(event_tx.clone()); let mut prompter = BridgePermissionPrompter::new( @@ -261,9 +265,21 @@ impl From for HostError { } } -fn build_system_prompt(cwd: &Path) -> Result, HostError> { - load_system_prompt(cwd, DEFAULT_PROMPT_DATE, std::env::consts::OS, "unknown") - .map_err(|error| HostError::Other(error.to_string())) +fn build_system_prompt(cwd: &Path, auth: &AuthSource) -> Result, HostError> { + let system_prompt = load_system_prompt(cwd, DEFAULT_PROMPT_DATE, std::env::consts::OS, "unknown") + .map_err(|error| HostError::Other(error.to_string()))?; + Ok(with_claude_code_oauth_prefix(system_prompt, auth)) +} + +fn with_claude_code_oauth_prefix(mut system_prompt: Vec, auth: &AuthSource) -> Vec { + if auth.uses_claude_code_oauth() + && !system_prompt + .iter() + .any(|segment| segment.contains(CLAUDE_CODE_OAUTH_SYSTEM_PREFIX)) + { + system_prompt.insert(0, CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()); + } + system_prompt } #[derive(Debug, Clone)] @@ -575,6 +591,7 @@ fn build_runtime( config: &RuntimeHostConfig, session: Session, session_id: &str, + auth: AuthSource, ) -> Result { let runtime_plugin_state = build_runtime_plugin_state(&config.cwd)?; let RuntimePluginState { @@ -591,10 +608,11 @@ fn build_runtime( let runtime = ConversationRuntime::new_with_features( session, AnthropicBridgeClient::new( - &config.cwd, session_id, + auth.clone(), config.model.clone(), config.allowed_tools.clone(), + tool_registry.clone(), )?, BridgeToolExecutor::new( config.allowed_tools.clone(), @@ -602,7 +620,7 @@ fn build_runtime( mcp_state.clone(), ), policy, - build_system_prompt(&config.cwd)?, + build_system_prompt(&config.cwd, &auth)?, &feature_config, ); Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) @@ -831,16 +849,15 @@ struct AnthropicBridgeClient { impl AnthropicBridgeClient { fn new( - cwd: &Path, session_id: &str, + auth: AuthSource, model: String, allowed_tools: Option, + tool_registry: GlobalToolRegistry, ) -> Result { - let runtime_plugin_state = build_runtime_plugin_state(cwd)?; - let tool_registry = runtime_plugin_state.tool_registry.clone(); Ok(Self { runtime: tokio::runtime::Runtime::new().map_err(HostError::Io)?, - client: AnthropicClient::from_auth(resolve_cli_auth_source(cwd)?) + client: AnthropicClient::from_auth(auth) .with_base_url(api::read_base_url()) .with_prompt_cache(PromptCache::new(session_id)), model, @@ -850,6 +867,30 @@ impl AnthropicBridgeClient { } } +#[cfg(test)] +mod oauth_tests { + use super::{with_claude_code_oauth_prefix, AuthSource, CLAUDE_CODE_OAUTH_SYSTEM_PREFIX}; + + #[test] + fn oauth_system_prefix_is_prepended_for_claude_code_tokens() { + let system_prompt = with_claude_code_oauth_prefix( + vec!["existing prompt".to_string()], + &AuthSource::BearerToken("sk-ant-oat01-test".to_string()), + ); + assert_eq!(system_prompt[0], CLAUDE_CODE_OAUTH_SYSTEM_PREFIX); + assert_eq!(system_prompt[1], "existing prompt"); + } + + #[test] + fn oauth_system_prefix_is_not_duplicated() { + let system_prompt = with_claude_code_oauth_prefix( + vec![CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()], + &AuthSource::BearerToken("sk-ant-oat01-test".to_string()), + ); + assert_eq!(system_prompt, vec![CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()]); + } +} + fn resolve_cli_auth_source(cwd: &Path) -> Result { resolve_startup_auth_source(|| { let config = ConfigLoader::default_for(cwd).load().map_err(|error| { diff --git a/rust/crates/claw-telegram/src/unraid_template_manager.rs b/rust/crates/claw-telegram/src/unraid_template_manager.rs index 98dc1eb..3e106aa 100644 --- a/rust/crates/claw-telegram/src/unraid_template_manager.rs +++ b/rust/crates/claw-telegram/src/unraid_template_manager.rs @@ -115,7 +115,7 @@ impl UnraidTemplateManager { repository: &str, ) -> Result<(), TemplateError> { let xml = format!( - "\n\n claw-telegram-gateway\n {repository}\n \n claw_gateway\n \n sh\n false\n https://git.wylab.me/wylab/claw-code-parity\n claw-code-parity\n Telegram gateway with Docker-socket worker management. Warning: mounting the Docker socket grants the container root-equivalent control over the Unraid host.\n AI:Tools\n \n \n \n --entrypoint claw-telegram\n gateway serve\n \n {}\n \n \n \n /var/run/docker.sock\n /boot/config/plugins/dockerMan/templates-user\n /mnt/user/appdata/claw-telegram-gateway\n \n \n {repository}\n /appdata/state\n /appdata/profiles.json\n /var/run/docker.sock\n /unraid/templates-user\n /appdata/template-archive\n claw_gateway\n ANTHROPIC_API_KEY\n 15\n \n \n \n\n", + "\n\n claw-telegram-gateway\n {repository}\n \n claw_gateway\n \n sh\n false\n https://git.wylab.me/wylab/claw-code-parity\n claw-code-parity\n Telegram gateway with Docker-socket worker management. Warning: mounting the Docker socket grants the container root-equivalent control over the Unraid host.\n AI:Tools\n \n \n \n --entrypoint claw-telegram\n gateway serve\n \n {}\n \n \n \n /var/run/docker.sock\n /boot/config/plugins/dockerMan/templates-user\n /mnt/user/appdata/claw-telegram-gateway\n \n \n {repository}\n /appdata/state\n /appdata/profiles.json\n /var/run/docker.sock\n /unraid/templates-user\n /appdata/template-archive\n claw_gateway\n ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY\n 15\n \n \n \n\n", current_timestamp_secs() ); let destination = destination.as_ref();