Support Anthropic subscription OAuth
Build Claw Telegram / build (push) Successful in 4m54s
Build Claw Telegram / cleanup (push) Successful in 1s

This commit is contained in:
Wylabb
2026-04-05 02:23:55 +02:00
parent 6431bcac50
commit 9f4bf2c3ee
7 changed files with 162 additions and 36 deletions
+7 -5
View File
@@ -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,
+4 -4
View File
@@ -20,7 +20,7 @@
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Requires>Docker socket access and an Anthropic API key</Requires>
<Requires>Docker socket access and either an Anthropic OAuth token or Anthropic API key</Requires>
<Config Name="Docker Socket" Target="/var/run/docker.sock" Default="/var/run/docker.sock" Mode="rw" Description="Docker socket for worker orchestration; this grants root-equivalent control of the host" Type="Path" Display="always" Required="true" Mask="false">/var/run/docker.sock</Config>
<Config Name="Unraid Templates" Target="/unraid/templates-user" Default="/boot/config/plugins/dockerMan/templates-user" Mode="rw" Description="Unraid template directory where the gateway writes managed worker templates" Type="Path" Display="always" Required="true" Mask="false">/boot/config/plugins/dockerMan/templates-user</Config>
<Config Name="Gateway AppData" Target="/appdata" Default="/mnt/user/appdata/claw-telegram-gateway" Mode="rw" Description="Gateway state and manifest directory" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/claw-telegram-gateway</Config>
@@ -33,9 +33,9 @@
<Config Name="Template Dir" Target="CLAW_GATEWAY_TEMPLATE_DIR" Default="/unraid/templates-user" Mode="" Description="Unraid template directory path inside the gateway container" Type="Variable" Display="advanced" Required="true" Mask="false">/unraid/templates-user</Config>
<Config Name="Template Archive Dir" Target="CLAW_GATEWAY_TEMPLATE_ARCHIVE_DIR" Default="/appdata/template-archive" Mode="" Description="Archive location for removed worker templates" Type="Variable" Display="advanced" Required="true" Mask="false">/appdata/template-archive</Config>
<Config Name="Worker Network" Target="CLAW_GATEWAY_WORKER_NETWORK" Default="claw_gateway" Mode="" Description="Docker network name shared by the gateway and workers" Type="Variable" Display="advanced" Required="true" Mask="false">claw_gateway</Config>
<Config Name="Inherited Env" Target="CLAW_GATEWAY_INHERITED_ENV" Default="ANTHROPIC_API_KEY" Mode="" Description="Comma-separated env vars copied from gateway into worker containers" Type="Variable" Display="advanced" Required="false" Mask="false">ANTHROPIC_API_KEY</Config>
<Config Name="Inherited Env" Target="CLAW_GATEWAY_INHERITED_ENV" Default="ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY" Mode="" Description="Comma-separated env vars copied from gateway into worker containers" Type="Variable" Display="advanced" Required="false" Mask="false">ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY</Config>
<Config Name="Worker Ready Timeout (s)" Target="CLAW_GATEWAY_WORKER_READY_TIMEOUT_SECS" Default="15" Mode="" Description="Seconds to wait for a worker container to pass its health check" Type="Variable" Display="advanced" Required="false" Mask="false">15</Config>
<Config Name="Anthropic API Key" Target="ANTHROPIC_API_KEY" Default="" Mode="" Description="Anthropic API key inherited by worker containers for Messages API access" Type="Variable" Display="always" Required="true" Mask="true"></Config>
<Config Name="Anthropic OAuth Token" Target="ANTHROPIC_AUTH_TOKEN" Default="" Mode="" Description="Not used by the current gateway deployment because the Anthropic API still requires x-api-key auth" Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="Anthropic OAuth Token" Target="ANTHROPIC_AUTH_TOKEN" Default="" Mode="" Description="Claude subscription OAuth token (sk-ant-oat...) inherited by worker containers" Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="Anthropic API Key" Target="ANTHROPIC_API_KEY" Default="" Mode="" Description="Anthropic API key inherited by worker containers for direct API access" Type="Variable" Display="always" Required="false" Mask="true"></Config>
<TailscaleStateDir/>
</Container>
+45 -3
View File
@@ -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)
);
}
}
@@ -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<String>;
@@ -175,7 +177,8 @@ impl RuntimeHost {
event_tx: UnboundedSender<RuntimeEvent>,
) -> 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<serde_json::Error> for HostError {
}
}
fn build_system_prompt(cwd: &Path) -> Result<Vec<String>, 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<Vec<String>, 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<String>, auth: &AuthSource) -> Vec<String> {
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<BuiltRuntime, HostError> {
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<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
) -> Result<Self, HostError> {
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<AuthSource, HostError> {
resolve_startup_auth_source(|| {
let config = ConfigLoader::default_for(cwd).load().map_err(|error| {
@@ -33,9 +33,9 @@
<Config Name="CLAW_GATEWAY_TEMPLATE_DIR" Target="CLAW_GATEWAY_TEMPLATE_DIR" Default="/unraid/templates-user" Mode="" Description="Unraid template directory path inside the gateway container" Type="Variable" Display="advanced" Required="true" Mask="false">/unraid/templates-user</Config>
<Config Name="CLAW_GATEWAY_TEMPLATE_ARCHIVE_DIR" Target="CLAW_GATEWAY_TEMPLATE_ARCHIVE_DIR" Default="/appdata/template-archive" Mode="" Description="Archive location for removed worker templates" Type="Variable" Display="advanced" Required="true" Mask="false">/appdata/template-archive</Config>
<Config Name="CLAW_GATEWAY_WORKER_NETWORK" Target="CLAW_GATEWAY_WORKER_NETWORK" Default="claw_gateway" Mode="" Description="Docker network name shared by the gateway and workers" Type="Variable" Display="advanced" Required="true" Mask="false">claw_gateway</Config>
<Config Name="CLAW_GATEWAY_INHERITED_ENV" Target="CLAW_GATEWAY_INHERITED_ENV" Default="ANTHROPIC_API_KEY" Mode="" Description="Comma-separated env vars copied from gateway into worker containers" Type="Variable" Display="advanced" Required="false" Mask="false">ANTHROPIC_API_KEY</Config>
<Config Name="CLAW_GATEWAY_INHERITED_ENV" Target="CLAW_GATEWAY_INHERITED_ENV" Default="ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY" Mode="" Description="Comma-separated env vars copied from gateway into worker containers" Type="Variable" Display="advanced" Required="false" Mask="false">ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY</Config>
<Config Name="CLAW_GATEWAY_WORKER_READY_TIMEOUT_SECS" Target="CLAW_GATEWAY_WORKER_READY_TIMEOUT_SECS" Default="15" Mode="" Description="Seconds to wait for a worker container to pass its health check" Type="Variable" Display="advanced" Required="false" Mask="false">15</Config>
<Config Name="ANTHROPIC_API_KEY" Target="ANTHROPIC_API_KEY" Default="" Mode="" Description="Anthropic API key inherited by worker containers for Messages API access" Type="Variable" Display="always" Required="true" Mask="true"></Config>
<Config Name="ANTHROPIC_AUTH_TOKEN" Target="ANTHROPIC_AUTH_TOKEN" Default="" Mode="" Description="Not used by the current gateway deployment because the Anthropic API still requires x-api-key auth" Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="ANTHROPIC_AUTH_TOKEN" Target="ANTHROPIC_AUTH_TOKEN" Default="" Mode="" Description="Claude subscription OAuth token (sk-ant-oat...) inherited by worker containers" Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="ANTHROPIC_API_KEY" Target="ANTHROPIC_API_KEY" Default="" Mode="" Description="Anthropic API key inherited by worker containers for direct API access" Type="Variable" Display="always" Required="false" Mask="true"></Config>
<TailscaleStateDir></TailscaleStateDir>
</Container>
+51 -10
View File
@@ -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<String>;
@@ -173,7 +175,8 @@ impl RuntimeHost {
event_tx: UnboundedSender<RuntimeEvent>,
) -> 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<serde_json::Error> for HostError {
}
}
fn build_system_prompt(cwd: &Path) -> Result<Vec<String>, 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<Vec<String>, 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<String>, auth: &AuthSource) -> Vec<String> {
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<BuiltRuntime, HostError> {
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<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
) -> Result<Self, HostError> {
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<AuthSource, HostError> {
resolve_startup_auth_source(|| {
let config = ConfigLoader::default_for(cwd).load().map_err(|error| {
File diff suppressed because one or more lines are too long