Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
af44308
feat(native-agent): add Anthropic OAuth (Claude Pro/Max) login + prov…
canyugs Jun 24, 2026
f5fcb82
fix(native-agent): default Anthropic model to claude-opus-4-8
canyugs Jun 24, 2026
de14bd7
fix(native-agent): address PR review (CI workspace, PKCE state, error…
canyugs Jun 24, 2026
8934798
fix(native-agent): flush stdout drain on ACP server shutdown
canyugs Jun 24, 2026
532ec1d
fix: address review #6/#7/#11 (auth mode, 401 refresh, error UX)
canyugs Jun 24, 2026
d8fbe78
fix: address review #8 — always verify CSRF state on bare-code paste
canyugs Jun 24, 2026
b748c8f
fix: don't pin a hardcoded Anthropic default model (review F4 follow-up)
canyugs Jun 24, 2026
3e9ac45
docs: fix stale PKCE state comment (review F5)
canyugs Jun 24, 2026
90cc60e
feat(native-agent): accept canonical provider/model in OPENAB_AGENT_M…
canyugs Jun 25, 2026
f9475b0
fix: satisfy clippy::manual_is_multiple_of (Rust 1.96 stable)
canyugs Jun 25, 2026
f9381d7
fix: limit OAuth 401 force-refresh to one retry
canyugs Jun 26, 2026
9962ad0
Merge remote-tracking branch 'upstream/main' into feat/native-agent-a…
Jun 27, 2026
d4d2e0b
feat(native-agent): introduce OAuthVendor descriptor abstraction (ADR…
Jun 27, 2026
d6c29c9
feat(native-agent): credential precedence + CLAUDE_CODE_OAUTH_TOKEN (…
Jun 27, 2026
3f3d0a8
fix(native-agent): ModelRef::parse only splits known provider prefixe…
Jun 27, 2026
a20389a
fix(native-agent): fail loud on present-but-misconfigured Anthropic c…
Jun 27, 2026
1d1c862
feat(native-agent): centralized config.json for default model/params …
Jun 27, 2026
8dcabb0
docs(native-agent): document OAuthVendor, credential precedence, conf…
Jun 27, 2026
63cef85
style: cargo fmt (OAuthVendor + config.json)
Jun 27, 2026
f32de33
test(native-agent): prove §5.4 lock single-flight for the anthropic-o…
Jun 27, 2026
41becbb
Merge branch 'main' into feat/native-agent-anthropic-oauth
canyugs Jun 27, 2026
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[workspace]
members = ["crates/openab-core", "crates/openab-gateway"]
exclude = ["openab-agent"]

[package]
name = "openab"
Expand Down
2 changes: 1 addition & 1 deletion crates/openab-core/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ mod tests {
for (i, chunk) in chunks.iter().enumerate() {
let fence_count = chunk.lines().filter(|l| l.starts_with("```")).count();
assert!(
fence_count % 2 == 0,
fence_count.is_multiple_of(2),
"chunk {i} has unbalanced fences ({fence_count}):\n{chunk}"
);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/openab-gateway/src/adapters/wecom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ fn decrypt_message(
.decode(encrypted)
.map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?;

if cipher_bytes.is_empty() || cipher_bytes.len() % 16 != 0 {
if cipher_bytes.is_empty() || !cipher_bytes.len().is_multiple_of(16) {
anyhow::bail!("ciphertext length {} not a multiple of 16", cipher_bytes.len());
}

Expand Down
60 changes: 52 additions & 8 deletions docs/native-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,41 @@ openab-agent
env = { OPENAB_AGENT_OPENAI_MODEL = "gpt-5.4-mini" }
```

### Configuration file (config.json)

A small JSON file next to `auth.json` (default `<auth dir>/config.json`,
overridable with `OPENAB_CONFIG_PATH`) declares the default model and params, so
a deployment can set them in a file instead of only via env vars. **Secrets never
go here** — credentials stay in the locked `auth.json` store.

```jsonc
{
"model": "anthropic/claude-sonnet-4-6", // single provider/model string
"max_tokens": 8192 // optional
}
```

Resolution is **env-over-config**: `OPENAB_AGENT_MODEL` / `OPENAB_AGENT_MAX_TOKENS`
override the file, so a pod's injected env stays authoritative over a baked
config. A missing file is fine (empty config); a malformed file is logged and
ignored (the agent then falls back to env / built-in defaults). Unknown keys are
tolerated for forward-compatibility.

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `OPENAB_AGENT_MODEL` | — (required for Anthropic) | Anthropic model id, optionally `provider/`-qualified (e.g. `claude-opus-4-8`, `anthropic/claude-opus-4-8`). No hardcoded default — dateless 4.6+ IDs are fixed canonical IDs that retire each generation, so the agent fails loud if unset rather than pin a model that will eventually 404. Overrides `model` in [config.json](#configuration-file-configjson). |
| `OPENAB_AGENT_OPENAI_MODEL` | `gpt-5.4-mini` | Model to use (must be supported by your ChatGPT plan — see [Supported Models](#supported-models-chatgpt-subscription)) |
| `OPENAB_AGENT_OPENAI_BASE_URL` | `https://chatgpt.com/backend-api` | API base URL |
| `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`) |
| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens |
| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom OAuth client ID |
| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens. Overrides `max_tokens` in config.json. |
| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom Codex OAuth client ID |
| `OPENAB_AGENT_ANTHROPIC_CLIENT_ID` | Claude Code's client | Custom Anthropic OAuth client ID |
| `OPENAB_AGENT_MAX_TOOL_LOOPS` | `50` | Max tool-call iterations per prompt before the agent gives up |
| `ANTHROPIC_API_KEY` | — | Anthropic API key (alternative to OAuth) |
| `ANTHROPIC_API_KEY` | — | Anthropic API key. Highest-precedence Anthropic credential (see [Anthropic credentials](#anthropic-credentials)). |
| `CLAUDE_CODE_OAUTH_TOKEN` | — | Pre-provisioned long-lived Claude Pro/Max subscription token (from `claude setup-token`). Fleet route — no interactive login, no `auth.json` write. |
| `OPENAB_CONFIG_PATH` | `<auth dir>/config.json` | Override the config-file path. |

## Authentication

Expand Down Expand Up @@ -69,13 +93,33 @@ openab-agent auth codex-device

Note: Device flow currently has limited scopes and may not work with all models.

### API Key (Anthropic)
### Anthropic credentials

```bash
export ANTHROPIC_API_KEY=sk-ant-...
```
Three ways to authenticate Anthropic, resolved in this **precedence** (ADR §5.3):

1. **API key** — `export ANTHROPIC_API_KEY=sk-ant-...`. No login; auto-detected.
2. **Pre-provisioned subscription token (fleet route)** — `export CLAUDE_CODE_OAUTH_TOKEN=...`
(mint once with `claude setup-token`; ~1-year Claude Pro/Max token). Sent as a
`Bearer` subscription token with the Claude Code identity headers — no
interactive login, no `auth.json` write, no refresh. Recommended for pods (inject
as a k8s secret).
3. **Interactive Claude Pro/Max OAuth** — browser PKCE login, refreshed from the
stored `anthropic-oauth` tenant in `auth.json`:

```bash
openab-agent auth anthropic-oauth # browser
openab-agent auth anthropic-oauth --no-browser # paste code#state
```

A higher-precedence source's own errors (e.g. a key set but no model) surface
rather than silently falling through to a lower one.

### Adding an OAuth vendor

No login needed — set the env var and the agent auto-detects it.
Subscription-OAuth providers are declared as a single `OAuthVendor` descriptor
(`auth.rs`, ADR §5.1) — namespace, client id, authorize/token URLs, redirect,
scope, token-body encoding. The shared PKCE/device/refresh driver reads the
descriptor, so a new vendor is a new descriptor, not a new hand-rolled flow.

## Custom System Prompt

Expand Down
128 changes: 84 additions & 44 deletions openab-agent/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ impl AcpServer {
// through `out_tx` into this one drain task, preserving the
// one-writer invariant the HostBridge relies on.
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
tokio::spawn(async move {
let drain = tokio::spawn(async move {
let mut stdout = io::stdout();
while let Some(line) = out_rx.recv().await {
let _ = writeln!(stdout, "{}", line);
Expand Down Expand Up @@ -268,6 +268,16 @@ impl AcpServer {
let _ = out_tx.send(line);
}
}

// Shutdown: stdin hit EOF and the dispatch loop ended. Drop our senders
// so the drain task can flush any queued output and finish before this
// returns — otherwise `#[tokio::main]` aborts the detached drain on
// return and the last response can be lost (the ACP `initialize` smoke
// test depends on this). Bounded await so a lingering sender (e.g. an
// MCP background task holding an `out_tx` clone) can't wedge shutdown.
drop(bridge);
drop(out_tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), drain).await;
}

fn handle_initialize(&self, id: u64) -> String {
Expand Down Expand Up @@ -297,15 +307,18 @@ impl AcpServer {
let provider_choice = self
.active_provider
.clone()
.or_else(|| std::env::var("OPENAB_AGENT_PROVIDER").ok())
.unwrap_or_default();
.unwrap_or_else(crate::llm::resolve_provider_choice);
let model_override = self.active_model.as_deref();
let (provider, active_provider): (Box<dyn crate::llm::LlmProvider>, &str) =
match provider_choice.as_str() {
"anthropic" => {
let res = match model_override {
Some(m) => AnthropicProvider::from_env_with_model(m),
None => AnthropicProvider::from_env(),
// `auto*` covers both ANTHROPIC_API_KEY and a stored Claude
// subscription OAuth token; `anthropic-oauth` forces the latter.
"anthropic" | "anthropic-oauth" | "claude" => {
let res = match (provider_choice.as_str(), model_override) {
("anthropic", Some(m)) => AnthropicProvider::auto_with_model(m),
("anthropic", None) => AnthropicProvider::auto(),
(_, Some(m)) => AnthropicProvider::from_oauth_store_with_model(m),
(_, None) => AnthropicProvider::from_oauth_store(),
};
match res {
Ok(p) => (Box::new(p), "anthropic"),
Expand All @@ -323,10 +336,10 @@ impl AcpServer {
}
}
_ => {
// Auto-detect: try API key first, then OAuth token
// Auto-detect: Anthropic (API key or OAuth) first, then codex.
let anthropic_res = match model_override {
Some(m) => AnthropicProvider::from_env_with_model(m),
None => AnthropicProvider::from_env(),
Some(m) => AnthropicProvider::auto_with_model(m),
None => AnthropicProvider::auto(),
};
match anthropic_res {
Ok(p) => (Box::new(p), "anthropic"),
Expand All @@ -343,7 +356,7 @@ impl AcpServer {
return self.error_response(
id,
-32000,
&format!("No credentials: set ANTHROPIC_API_KEY or run `openab-agent auth codex-oauth`. {e}"),
&format!("No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `openab-agent auth codex-oauth`. {e}"),
)
Comment on lines 356 to 360
}
}
Expand All @@ -352,27 +365,13 @@ impl AcpServer {
}
};

// The provider already resolved its model (explicit override →
// OPENAB_AGENT_MODEL, validated at construction). Use it as the
// authoritative reported model instead of a separate hardcoded default.
let model_name = provider.model().to_string();
let agent = Agent::new_boxed(provider, self.working_dir.clone(), self.mcp_manager.clone());
self.sessions.insert(session_id.clone(), agent);

let model_name = self
.active_model
.clone()
.or_else(|| {
if active_provider == "openai" {
std::env::var("OPENAB_AGENT_OPENAI_MODEL").ok()
} else {
None
}
})
.or_else(|| std::env::var("OPENAB_AGENT_MODEL").ok())
.unwrap_or_else(|| {
if active_provider == "anthropic" {
"claude-sonnet-4-20250514".to_string()
} else {
"gpt-5.4-mini".to_string()
}
});
self.active_model = Some(model_name.clone());
self.active_provider = Some(active_provider.to_string());
self.model_options = Self::available_models().await;
Expand Down Expand Up @@ -425,13 +424,11 @@ impl AcpServer {
self.model_options = Self::available_models().await;
}

let model_name = self.active_model.clone().unwrap_or_else(|| {
if self.active_provider.as_deref() == Some("openai") {
"gpt-5.4-mini".to_string()
} else {
"claude-sonnet-4-20250514".to_string()
}
});
// Report the loaded session's actual model (no hardcoded default).
let model_name = self
.active_model
.clone()
.unwrap_or_else(|| self.sessions[session_id].provider_model());

self.ok_response(
id,
Expand All @@ -457,7 +454,9 @@ impl AcpServer {

fn static_available_models() -> Vec<ModelOption> {
let mut models = Vec::new();
if std::env::var("ANTHROPIC_API_KEY").is_ok() {
if std::env::var("ANTHROPIC_API_KEY").is_ok()
|| crate::auth::load_tokens_for(crate::auth::ANTHROPIC_NAMESPACE).is_ok()
{
models.extend(Self::static_anthropic_models());
}
if crate::auth::load_tokens().is_ok() {
Expand Down Expand Up @@ -595,11 +594,15 @@ impl AcpServer {

// Rebuild the current session's provider so the switch takes effect immediately
if !session_id.is_empty() && self.sessions.contains_key(session_id) {
// Preserve the session's auth mode: an OAuth-forced session must not
// silently fall back to ANTHROPIC_API_KEY (which `auto_*` prefers).
let session_is_oauth = self.sessions[session_id].provider_is_oauth();
let new_provider: Result<Box<dyn crate::llm::LlmProvider>, String> = match provider_name
{
"anthropic" => {
AnthropicProvider::from_env_with_model(value).map(|p| Box::new(p) as _)
"anthropic" if session_is_oauth => {
AnthropicProvider::from_oauth_store_with_model(value).map(|p| Box::new(p) as _)
}
"anthropic" => AnthropicProvider::auto_with_model(value).map(|p| Box::new(p) as _),
_ => crate::llm::OpenAiProvider::from_auth_store_with_model(value)
.map(|p| Box::new(p) as _),
};
Expand Down Expand Up @@ -679,10 +682,14 @@ mod tests {
#[tokio::test]
async fn test_session_new() {
let _guard = ENV_LOCK.lock().unwrap();
// Set a fake key so from_env() succeeds in CI
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") };
// Set a fake key + model so provider construction succeeds in CI
unsafe {
std::env::set_var("ANTHROPIC_API_KEY", "test-key");
std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6");
}
let mut server = AcpServer::new();
let resp_str = server.handle_session_new(2).await;
unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") };
let resp: Value = serde_json::from_str(&resp_str).unwrap();
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], 2);
Expand All @@ -692,6 +699,7 @@ mod tests {
assert!(!config_options.is_empty());
assert_eq!(config_options[0]["id"], "model");
assert_eq!(config_options[0]["category"], "model");
assert_eq!(config_options[0]["currentValue"], "claude-sonnet-4-6");
assert!(!config_options[0]["options"].as_array().unwrap().is_empty());
}

Expand Down Expand Up @@ -789,6 +797,30 @@ mod tests {
.contains("ANTHROPIC_API_KEY"));
}

#[tokio::test]
async fn test_session_new_requires_model() {
// No hardcoded default: a forced anthropic provider without
// OPENAB_AGENT_MODEL must fail loud.
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("OPENAB_AGENT_PROVIDER", "anthropic");
std::env::set_var("ANTHROPIC_API_KEY", "test-key");
std::env::remove_var("OPENAB_AGENT_MODEL");
}
let mut server = AcpServer::new();
let resp_str = server.handle_session_new(7).await;
unsafe {
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("OPENAB_AGENT_PROVIDER");
}
let resp: Value = serde_json::from_str(&resp_str).unwrap();
assert!(resp["error"].is_object());
assert!(resp["error"]["message"]
.as_str()
.unwrap()
.contains("no model configured"));
}

#[test]
fn test_set_config_option_accepts_cached_dynamic_model() {
let mut server = AcpServer::new();
Expand Down Expand Up @@ -847,11 +879,15 @@ mod tests {
#[tokio::test]
async fn test_model_switch_preserves_session_history() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") };
unsafe {
std::env::set_var("ANTHROPIC_API_KEY", "test-key");
std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6");
}
let mut server = AcpServer::new();

// Create a session
let resp_str = server.handle_session_new(10).await;
unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") };
let resp: Value = serde_json::from_str(&resp_str).unwrap();
let session_id = resp["result"]["sessionId"].as_str().unwrap().to_string();

Expand Down Expand Up @@ -918,7 +954,7 @@ mod tests {

// Insert a dummy session using anthropic key
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") };
let provider = AnthropicProvider::from_env_with_model("claude-sonnet-4-20250514").unwrap();
let provider = AnthropicProvider::auto_with_model("claude-sonnet-4-20250514").unwrap();
let agent = Agent::new_boxed(Box::new(provider), "/tmp".to_string(), None);
server.sessions.insert("test-session".to_string(), agent);

Expand Down Expand Up @@ -954,11 +990,15 @@ mod tests {
#[tokio::test]
async fn test_session_load_returns_config_options() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") };
unsafe {
std::env::set_var("ANTHROPIC_API_KEY", "test-key");
std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6");
}
let mut server = AcpServer::new();

// Create a session first
let new_resp_str = server.handle_session_new(10).await;
unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") };
let new_resp: Value = serde_json::from_str(&new_resp_str).unwrap();
let session_id = new_resp["result"]["sessionId"].as_str().unwrap();

Expand Down
12 changes: 12 additions & 0 deletions openab-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ impl Agent {
self.provider = provider;
}

/// True if the current provider authenticates via OAuth. Used on model
/// switch to rebuild with the same auth mode.
pub fn provider_is_oauth(&self) -> bool {
self.provider.is_oauth()
}

/// The model id the current provider will use. Authoritative source for the
/// session's reported model (avoids a separate hardcoded default).
pub fn provider_model(&self) -> String {
self.provider.model().to_string()
}

/// Update working directory and rebuild system prompt.
pub fn set_working_dir(&mut self, cwd: String) {
self.system_prompt = Self::build_system_prompt(&cwd, self.mcp_manager.as_ref());
Expand Down
Loading
Loading