From 576aa619616fb1036916d70fb5d8e0b030b96e81 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 14:52:37 +0800 Subject: [PATCH 01/39] docs: worldagent provider spec and implementation plan --- .../plans/2026-05-20-worldagent-provider.md | 1760 +++++++++++++++++ .../2026-05-20-worldagent-provider-design.md | 397 ++++ 2 files changed, 2157 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-worldagent-provider.md create mode 100644 docs/superpowers/specs/2026-05-20-worldagent-provider-design.md diff --git a/docs/superpowers/plans/2026-05-20-worldagent-provider.md b/docs/superpowers/plans/2026-05-20-worldagent-provider.md new file mode 100644 index 000000000..efa200185 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-worldagent-provider.md @@ -0,0 +1,1760 @@ +# worldagent Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new `worldagent` provider entry to Puffer that supports both API-key paste and an Auth-Station OAuth login flow (opens https://auth-worldrouter.vercel.app/login in the system browser, captures token+refresh on a fixed localhost callback, stores credential). + +**Architecture:** A new minimal Rust crate `puffer-provider-worldagent` implements Auth Station's "token-in-redirect" flow (no PKCE, no code exchange). `ProviderDescriptor`/`ProviderPack` gain an optional `oauth_family` field that lets a provider opt into a non-`default_api`-derived OAuth handler. `puffer-cli`'s OAuth dispatch (`auth_provider.rs` + `daemon.rs` + `main.rs`) grows a third arm for `OauthFamily::WorldAgent`. `authflow::CallbackListener` grows a `bind_localhost_port` variant for fixed-port binds. A new `resources/providers/worldagent.yaml` ships the provider. The desktop Svelte UI registers a visual entry; LoginView is unchanged. + +**Tech Stack:** Rust (workspace crates), reqwest blocking client, base64 + serde_json (JWT payload decode), serde_yaml (provider yaml), Svelte 5 + TypeScript (desktop visual entry). + +**Spec:** `docs/superpowers/specs/2026-05-20-worldagent-provider-design.md` + +--- + +## File Structure + +**Created:** +- `crates/puffer-provider-worldagent/Cargo.toml` +- `crates/puffer-provider-worldagent/src/lib.rs` +- `crates/puffer-provider-worldagent/src/auth.rs` +- `resources/providers/worldagent.yaml` +- `specs/puffer-provider-worldagent/00.md` +- `specs/puffer-provider-registry/06.md` +- `specs/puffer-cli/.md` — exact filename picked in Task 8 + +**Modified:** +- `Cargo.toml` (workspace members + workspace deps) +- `crates/puffer-provider-registry/src/model.rs` — `oauth_family` field +- `crates/puffer-resources/src/model.rs` — `ProviderPack.oauth_family` mirror + `into_descriptor` pass-through +- `crates/puffer-cli/Cargo.toml` — dep on `puffer-provider-worldagent` +- `crates/puffer-cli/src/auth_provider.rs` — `OauthFamily::WorldAgent` arm +- `crates/puffer-cli/src/auth_credentials.rs` — `to_registry_oauth_credential_worldagent` +- `crates/puffer-cli/src/authflow.rs` — `bind_localhost_port` helper +- `crates/puffer-cli/src/main.rs` — login flow + `run_login_flow` arm +- `crates/puffer-cli/src/daemon.rs` — `handle_login_with_oauth` arm +- `apps/puffer-desktop/src/lib/providerVisuals.ts` — visual entry for `worldagent` + +--- + +## Task 1: Add `oauth_family` field to ProviderDescriptor + ProviderPack + +**Files:** +- Modify: `crates/puffer-provider-registry/src/model.rs` +- Modify: `crates/puffer-resources/src/model.rs` + +- [ ] **Step 1: Write the failing test in puffer-provider-registry** + +Append to `crates/puffer-provider-registry/src/model.rs` test module (the file already ends with `}` for impls; add a `#[cfg(test)] mod` if absent — currently the file has no test module, add one at the very end): + +```rust +#[cfg(test)] +mod tests { + use super::*; + use serde_yaml; + + #[test] + fn provider_descriptor_deserializes_oauth_family_field() { + let yaml = r#" +id: example +display_name: Example +base_url: https://example.invalid +default_api: openai-completions +oauth_family: worldagent +auth_modes: + - oauth +"#; + let provider: ProviderDescriptor = + serde_yaml::from_str(yaml).expect("provider yaml parses"); + assert_eq!(provider.oauth_family.as_deref(), Some("worldagent")); + } + + #[test] + fn provider_descriptor_oauth_family_defaults_to_none() { + let yaml = r#" +id: example +display_name: Example +base_url: https://example.invalid +default_api: openai-completions +auth_modes: + - oauth +"#; + let provider: ProviderDescriptor = + serde_yaml::from_str(yaml).expect("provider yaml parses"); + assert!(provider.oauth_family.is_none()); + } +} +``` + +If `serde_yaml` isn't already a dev-dep, add it. Check first: + +```bash +grep -n "serde_yaml\|serde-yaml" crates/puffer-provider-registry/Cargo.toml +``` + +If absent, add to `[dev-dependencies]`: + +```toml +[dev-dependencies] +serde_yaml = "0.9" +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p puffer-provider-registry provider_descriptor_deserializes_oauth_family_field +``` + +Expected: FAIL — field doesn't exist on `ProviderDescriptor` (unknown field). + +- [ ] **Step 3: Add the field to `ProviderDescriptor`** + +In `crates/puffer-provider-registry/src/model.rs`, inside the `ProviderDescriptor` struct (around line 309-338), add right after `chat_completions_path`: + +```rust + /// Optional explicit OAuth family for this provider. When `None`, + /// callers infer the family from `default_api` (preserving every + /// yaml that did not opt in). When `Some`, callers use the named + /// family directly. Known values today: `"openai"`, `"anthropic"`, + /// `"worldagent"`. This is the seam that lets a provider whose + /// transport is `openai-completions` use a non-OpenAI OAuth flow. + #[serde(default)] + pub oauth_family: Option, +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cargo test -p puffer-provider-registry provider_descriptor +``` + +Expected: PASS for both new tests. + +- [ ] **Step 5: Mirror the field in `ProviderPack`** + +In `crates/puffer-resources/src/model.rs`, find `pub struct ProviderPack` (around line 449) and add right after `chat_completions_path`: + +```rust + #[serde(default)] + pub oauth_family: Option, +``` + +Then update `into_descriptor()` (around line 472) — add `oauth_family: self.oauth_family,` in the struct literal: + +```rust + pub fn into_descriptor(self) -> ProviderDescriptor { + ProviderDescriptor { + id: self.id, + display_name: self.display_name, + base_url: self.base_url, + default_api: self.default_api, + auth_modes: self.auth_modes, + headers: self.headers, + query_params: self.query_params, + chat_completions_path: self.chat_completions_path, + oauth_family: self.oauth_family, + discovery: self.discovery, + models: self.models, + } + } +``` + +- [ ] **Step 6: Run resources build** + +```bash +cargo build -p puffer-resources +``` + +Expected: SUCCESS. + +- [ ] **Step 7: Verify existing registry callers still compile** + +```bash +cargo build -p puffer-cli +``` + +Expected: SUCCESS. Existing yaml that doesn't set the field continues to parse (serde default = `None`). + +- [ ] **Step 8: Commit** + +```bash +git add crates/puffer-provider-registry/src/model.rs \ + crates/puffer-provider-registry/Cargo.toml \ + crates/puffer-resources/src/model.rs +git commit -m "feat(provider-registry): add optional oauth_family to ProviderDescriptor" +``` + +--- + +## Task 2: Bootstrap the `puffer-provider-worldagent` crate + +**Files:** +- Create: `crates/puffer-provider-worldagent/Cargo.toml` +- Create: `crates/puffer-provider-worldagent/src/lib.rs` +- Modify: `Cargo.toml` (workspace `members`) + +- [ ] **Step 1: Create the crate Cargo.toml** + +`crates/puffer-provider-worldagent/Cargo.toml`: + +```toml +[package] +name = "puffer-provider-worldagent" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +base64.workspace = true +rand = "0.9.2" +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +url = "2.5.7" +``` + +No need to depend on `puffer-provider-registry` — this crate only owns the Auth Station auth helpers; the registry-shape conversion lives in `puffer-cli/src/auth_credentials.rs`. + +- [ ] **Step 2: Create a stub lib.rs** + +`crates/puffer-provider-worldagent/src/lib.rs`: + +```rust +//! Auth Station OAuth helpers for the `worldagent` provider. +//! +//! Auth Station's `/login` flow returns the final `token` and +//! `refresh_token` directly in the callback URL. There is no PKCE, +//! no code exchange. This crate owns URL building, callback +//! parsing, JWT-payload decoding, and refresh. + +mod auth; + +pub use auth::{ + build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, + generate_client_state, parse_callback_input, refresh_oauth_token, + WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, + WorldAgentOAuthCredentials, WORLDAGENT_AUTH_BASE_URL, + WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, + WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, +}; +``` + +- [ ] **Step 3: Create an empty auth.rs so the crate compiles** + +`crates/puffer-provider-worldagent/src/auth.rs`: + +```rust +//! Auth Station login URL building, callback parsing, JWT decoding. +``` + +- [ ] **Step 4: Register the crate in the workspace** + +In root `Cargo.toml`, find the `members = [` block and add `"crates/puffer-provider-worldagent",` keeping the list alphabetically grouped (insert right after `"crates/puffer-provider-registry",`). + +- [ ] **Step 5: Build empty crate to confirm Cargo wiring** + +```bash +cargo build -p puffer-provider-worldagent +``` + +Expected: builds with "unresolved import" errors because lib.rs re-exports items that don't exist. That's a problem — the next task implements them. For this commit, **temporarily comment out the re-exports** in lib.rs so it builds; we'll restore them in Task 3 step 6. + +Replace lib.rs body for now: + +```rust +//! Auth Station OAuth helpers for the `worldagent` provider. +//! +//! Auth Station's `/login` flow returns the final `token` and +//! `refresh_token` directly in the callback URL. There is no PKCE, +//! no code exchange. This crate owns URL building, callback +//! parsing, JWT-payload decoding, and refresh. + +#![allow(dead_code)] + +mod auth; +``` + +```bash +cargo build -p puffer-provider-worldagent +``` + +Expected: SUCCESS (empty crate). + +- [ ] **Step 6: Commit** + +```bash +git add crates/puffer-provider-worldagent Cargo.toml +git commit -m "feat(provider-worldagent): bootstrap empty crate" +``` + +--- + +## Task 3: Implement `WorldAgentLoginConfig` + `build_login_url` + +**Files:** +- Modify: `crates/puffer-provider-worldagent/src/auth.rs` +- Modify: `crates/puffer-provider-worldagent/src/lib.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `crates/puffer-provider-worldagent/src/auth.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_login_url_contains_redirect_uri_and_client_state() { + let config = WorldAgentLoginConfig { + auth_base_url: "https://auth-worldrouter.vercel.app".to_string(), + redirect_uri: "http://127.0.0.1:1456/callback".to_string(), + client_state: "state-xyz".to_string(), + }; + let url = build_login_url(&config); + assert!(url.starts_with("https://auth-worldrouter.vercel.app/login?")); + assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A1456%2Fcallback")); + assert!(url.contains("client_state=state-xyz")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p puffer-provider-worldagent build_login_url +``` + +Expected: FAIL — `WorldAgentLoginConfig` / `build_login_url` do not exist. + +- [ ] **Step 3: Implement the types and function** + +Replace the body of `crates/puffer-provider-worldagent/src/auth.rs` (above the test module) with: + +```rust +//! Auth Station login URL building, callback parsing, JWT decoding. + +use anyhow::{anyhow, Context, Result}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use rand::RngCore; +use reqwest::blocking::Client; +use serde::Deserialize; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Default Auth Station base URL (Sandbox). Production is +/// `https://auth.worldrouter.ai`. The env var named by +/// [`WORLDAGENT_AUTH_URL_OVERRIDE_ENV`] overrides this at runtime. +pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app"; + +/// Env var name that overrides the Auth Station base URL. +pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; + +/// Fixed loopback callback path used by Puffer desktop. The auth +/// team must allow-list the full URI on both Sandbox and Production. +pub const WORLDAGENT_CALLBACK_PATH: &str = "/callback"; + +/// Fixed loopback callback port used by Puffer desktop. See +/// [`WORLDAGENT_CALLBACK_PATH`] for the path component. +pub const WORLDAGENT_CALLBACK_PORT: u16 = 1456; + +/// Concatenated fixed loopback redirect URI. +pub const WORLDAGENT_DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:1456/callback"; + +/// Parameters needed to build an Auth Station login URL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorldAgentLoginConfig { + /// Base URL of Auth Station, no trailing slash. + pub auth_base_url: String, + /// Full redirect URI for the desktop callback listener. + pub redirect_uri: String, + /// Opaque random value used as the CSRF guard. + pub client_state: String, +} + +impl Default for WorldAgentLoginConfig { + fn default() -> Self { + let auth_base_url = std::env::var(WORLDAGENT_AUTH_URL_OVERRIDE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| WORLDAGENT_AUTH_BASE_URL.to_string()); + Self { + auth_base_url, + redirect_uri: WORLDAGENT_DEFAULT_REDIRECT_URI.to_string(), + client_state: generate_client_state(), + } + } +} + +/// Generates an opaque url-safe random `client_state`. +pub fn generate_client_state() -> String { + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Builds the Auth Station `/login` URL for the given config. +pub fn build_login_url(config: &WorldAgentLoginConfig) -> String { + let trimmed = config.auth_base_url.trim_end_matches('/'); + let mut url = url::Url::parse(&format!("{trimmed}/login")) + .expect("auth_base_url must be a valid URL"); + url.query_pairs_mut() + .append_pair("redirect_uri", &config.redirect_uri) + .append_pair("client_state", &config.client_state); + url.to_string() +} +``` + +- [ ] **Step 4: Restore the public re-exports in lib.rs** + +Replace `crates/puffer-provider-worldagent/src/lib.rs`: + +```rust +//! Auth Station OAuth helpers for the `worldagent` provider. +//! +//! Auth Station's `/login` flow returns the final `token` and +//! `refresh_token` directly in the callback URL. There is no PKCE, +//! no code exchange. This crate owns URL building, callback +//! parsing, JWT-payload decoding, and refresh. + +mod auth; + +pub use auth::{ + build_login_url, generate_client_state, WorldAgentLoginConfig, + WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, + WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, + WORLDAGENT_DEFAULT_REDIRECT_URI, +}; +``` + +(Other re-exports — `parse_callback_input`, `decode_jwt_profile`, `refresh_oauth_token`, `exchange_jwt_for_api_key`, `WorldAgentCallback`, `WorldAgentJwtProfile`, `WorldAgentOAuthCredentials` — are added in later tasks.) + +- [ ] **Step 5: Run test** + +```bash +cargo test -p puffer-provider-worldagent build_login_url +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/puffer-provider-worldagent/src +git commit -m "feat(provider-worldagent): implement build_login_url + config" +``` + +--- + +## Task 4: Implement `parse_callback_input` + `WorldAgentCallback` + +**Files:** +- Modify: `crates/puffer-provider-worldagent/src/auth.rs` +- Modify: `crates/puffer-provider-worldagent/src/lib.rs` + +- [ ] **Step 1: Write the failing tests** + +Append to the existing test module in `auth.rs`: + +```rust + #[test] + fn parse_callback_input_extracts_token_refresh_state() { + let parsed = parse_callback_input( + "http://127.0.0.1:1456/callback?token=acc&refresh_token=ref&state=xyz", + ); + assert_eq!(parsed.token.as_deref(), Some("acc")); + assert_eq!(parsed.refresh_token.as_deref(), Some("ref")); + assert_eq!(parsed.state.as_deref(), Some("xyz")); + assert!(parsed.error.is_none()); + } + + #[test] + fn parse_callback_input_extracts_error() { + let parsed = parse_callback_input( + "http://127.0.0.1:1456/callback?error=invalid_state&error_description=bad+state&state=xyz", + ); + assert_eq!(parsed.error.as_deref(), Some("invalid_state")); + assert_eq!(parsed.error_description.as_deref(), Some("bad state")); + assert!(parsed.token.is_none()); + } + + #[test] + fn parse_callback_input_handles_raw_query_string() { + let parsed = parse_callback_input("token=acc&state=xyz"); + assert_eq!(parsed.token.as_deref(), Some("acc")); + assert_eq!(parsed.state.as_deref(), Some("xyz")); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p puffer-provider-worldagent parse_callback_input +``` + +Expected: FAIL — function does not exist. + +- [ ] **Step 3: Implement the types and function** + +Append below `build_login_url` (before the `#[cfg(test)]` block): + +```rust +/// Parsed callback fields. Each field is `None` when its parameter +/// was absent from the callback URL. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WorldAgentCallback { + pub token: Option, + pub refresh_token: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +/// Extracts `token`, `refresh_token`, `state`, `error`, and +/// `error_description` from a callback URL or raw query string. +pub fn parse_callback_input(input: &str) -> WorldAgentCallback { + let trimmed = input.trim(); + if trimmed.is_empty() { + return WorldAgentCallback::default(); + } + let mut callback = WorldAgentCallback::default(); + let pairs: Box> = + if let Ok(url) = url::Url::parse(trimmed) { + Box::new( + url.query_pairs() + .into_owned() + .collect::>() + .into_iter(), + ) + } else { + Box::new( + url::form_urlencoded::parse(trimmed.as_bytes()) + .into_owned() + .collect::>() + .into_iter(), + ) + }; + for (key, value) in pairs { + match key.as_str() { + "token" => callback.token = Some(value), + "refresh_token" => callback.refresh_token = Some(value), + "state" => callback.state = Some(value), + "error" => callback.error = Some(value), + "error_description" => callback.error_description = Some(value), + _ => {} + } + } + callback +} +``` + +- [ ] **Step 4: Add re-exports** + +In `crates/puffer-provider-worldagent/src/lib.rs`, extend the `pub use` block: + +```rust +pub use auth::{ + build_login_url, generate_client_state, parse_callback_input, + WorldAgentCallback, WorldAgentLoginConfig, + WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, + WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, + WORLDAGENT_DEFAULT_REDIRECT_URI, +}; +``` + +- [ ] **Step 5: Run tests** + +```bash +cargo test -p puffer-provider-worldagent parse_callback_input +``` + +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add crates/puffer-provider-worldagent/src +git commit -m "feat(provider-worldagent): parse callback URL into typed fields" +``` + +--- + +## Task 5: Implement `decode_jwt_profile` + +**Files:** +- Modify: `crates/puffer-provider-worldagent/src/auth.rs` +- Modify: `crates/puffer-provider-worldagent/src/lib.rs` + +- [ ] **Step 1: Write the failing test** + +Append to the test module in `auth.rs`: + +```rust + #[test] + fn decode_jwt_profile_reads_sub_email_name() { + let payload = serde_json::json!({ + "sub": "user_01ABC", + "email": "dev@example.com", + "name": "Dev User", + }); + let encoded = URL_SAFE_NO_PAD.encode(payload.to_string()); + let token = format!("header.{encoded}.sig"); + let profile = decode_jwt_profile(&token); + assert_eq!(profile.sub.as_deref(), Some("user_01ABC")); + assert_eq!(profile.email.as_deref(), Some("dev@example.com")); + assert_eq!(profile.name.as_deref(), Some("Dev User")); + } + + #[test] + fn decode_jwt_profile_handles_malformed_token() { + let profile = decode_jwt_profile("not-a-jwt"); + assert!(profile.sub.is_none()); + assert!(profile.email.is_none()); + assert!(profile.name.is_none()); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p puffer-provider-worldagent decode_jwt_profile +``` + +Expected: FAIL — function does not exist. + +- [ ] **Step 3: Implement** + +Append below `parse_callback_input` (before the test module): + +```rust +/// Decoded JWT profile fields, best-effort. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WorldAgentJwtProfile { + pub sub: Option, + pub email: Option, + pub name: Option, +} + +/// Decodes `sub` / `email` / `name` from the access token JWT +/// payload. Any decode/parse failure yields an empty profile. +pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile { + let Some(payload_b64) = access_token.split('.').nth(1) else { + return WorldAgentJwtProfile::default(); + }; + let Ok(payload_bytes) = URL_SAFE_NO_PAD.decode(payload_b64.as_bytes()) else { + return WorldAgentJwtProfile::default(); + }; + let Ok(value) = serde_json::from_slice::(&payload_bytes) else { + return WorldAgentJwtProfile::default(); + }; + WorldAgentJwtProfile { + sub: value + .get("sub") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + email: value + .get("email") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + name: value + .get("name") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + } +} +``` + +- [ ] **Step 4: Add re-exports** + +Extend the `pub use` block in `lib.rs`: + +```rust +pub use auth::{ + build_login_url, decode_jwt_profile, generate_client_state, + parse_callback_input, WorldAgentCallback, WorldAgentJwtProfile, + WorldAgentLoginConfig, WORLDAGENT_AUTH_BASE_URL, + WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, + WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, +}; +``` + +- [ ] **Step 5: Run tests** + +```bash +cargo test -p puffer-provider-worldagent decode_jwt_profile +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/puffer-provider-worldagent/src +git commit -m "feat(provider-worldagent): decode JWT profile (sub/email/name)" +``` + +--- + +## Task 6: Implement `WorldAgentOAuthCredentials` + `refresh_oauth_token` + `exchange_jwt_for_api_key` stub + +**Files:** +- Modify: `crates/puffer-provider-worldagent/src/auth.rs` +- Modify: `crates/puffer-provider-worldagent/src/lib.rs` + +- [ ] **Step 1: Write the failing test (for the stub)** + +Append to the test module: + +```rust + #[test] + fn exchange_jwt_for_api_key_is_a_placeholder() { + let result = exchange_jwt_for_api_key("any.access.token"); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("not yet implemented")); + } +``` + +(`refresh_oauth_token` hits the network; we don't write a network-level test in this crate. Coverage of the wire shape lives in the daemon integration smoke test in Task 11.) + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p puffer-provider-worldagent exchange_jwt_for_api_key +``` + +Expected: FAIL — function doesn't exist. + +- [ ] **Step 3: Implement types + functions** + +Append below `decode_jwt_profile`: + +```rust +/// Persisted Auth Station credentials for the worldagent provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorldAgentOAuthCredentials { + pub access_token: String, + pub refresh_token: String, + pub expires_at_ms: u64, + pub sub: Option, + pub email: Option, + pub name: Option, +} + +/// Exchanges a stored refresh token for a new access token via +/// `POST /token/refresh`. Preserves the existing +/// refresh_token/profile fields when the upstream response does not +/// return them (Auth Station does not rotate refresh tokens, and +/// `/token/refresh` returns only `{ "token": ... }`). +pub fn refresh_oauth_token( + refresh_token: &str, + auth_base_url: Option<&str>, +) -> Result { + let base = auth_base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| { + std::env::var(WORLDAGENT_AUTH_URL_OVERRIDE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| WORLDAGENT_AUTH_BASE_URL.to_string()) + }); + let url = format!("{}/token/refresh", base.trim_end_matches('/')); + let response = Client::new() + .post(&url) + .json(&serde_json::json!({ "refresh_token": refresh_token })) + .send() + .context("failed to send worldagent refresh request")?; + let status = response.status(); + let payload: RefreshResponse = response + .json() + .context("failed to parse worldagent refresh response")?; + if !status.is_success() { + return Err(anyhow!( + "worldagent token refresh failed with status {status}: {}", + payload.error.unwrap_or_default() + )); + } + let access_token = payload + .token + .ok_or_else(|| anyhow!("worldagent refresh response missing token"))?; + let profile = decode_jwt_profile(&access_token); + Ok(WorldAgentOAuthCredentials { + access_token, + refresh_token: refresh_token.to_string(), + expires_at_ms: now_ms() + 24 * 3600 * 1000, + sub: profile.sub, + email: profile.email, + name: profile.name, + }) +} + +/// Exchanges an Auth Station JWT for an inference API key. +/// +/// **TODO (waiting on worldrouter backend):** the endpoint and +/// request shape are not yet finalized. Once defined, this function +/// will `POST /api/v1/keys/exchange` (or whatever the +/// backend picks) with `Authorization: Bearer ` and +/// return the `api_key` string. The login handler will then upgrade +/// the stored credential to an `ApiKey { key }` variant. +pub fn exchange_jwt_for_api_key(_access_token: &str) -> Result { + Err(anyhow!( + "worldagent JWT-to-api-key exchange is not yet implemented; \ + paste your WorldRouter API key for now" + )) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[derive(Debug, Deserialize)] +struct RefreshResponse { + #[serde(default)] + token: Option, + #[serde(default)] + error: Option, +} +``` + +- [ ] **Step 4: Extend re-exports in lib.rs** + +Final `pub use` block: + +```rust +pub use auth::{ + build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, + generate_client_state, parse_callback_input, refresh_oauth_token, + WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, + WorldAgentOAuthCredentials, WORLDAGENT_AUTH_BASE_URL, + WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, + WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, +}; +``` + +- [ ] **Step 5: Run all crate tests** + +```bash +cargo test -p puffer-provider-worldagent +``` + +Expected: PASS (build_login_url, parse_callback_input x3, decode_jwt_profile x2, exchange_jwt_for_api_key — at least 7 tests pass). + +- [ ] **Step 6: Commit** + +```bash +git add crates/puffer-provider-worldagent/src +git commit -m "feat(provider-worldagent): add credentials + refresh + exchange stub" +``` + +--- + +## Task 7: Add `bind_localhost_port` to `authflow.rs` + +**Files:** +- Modify: `crates/puffer-cli/src/authflow.rs` + +- [ ] **Step 1: Write the failing test** + +Append to the existing `tests` module in `crates/puffer-cli/src/authflow.rs`: + +```rust + #[test] + fn bind_localhost_port_uses_requested_port() { + // Find a free port by binding 0, then drop and rebind on it. + let probe = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = probe.local_addr().unwrap().port(); + drop(probe); + let listener = CallbackListener::bind_localhost_port("/callback", port) + .expect("bind_localhost_port succeeds on a free port"); + let redirect_uri = listener.redirect_uri(); + assert_eq!( + redirect_uri, + format!("http://127.0.0.1:{port}/callback") + ); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p puffer-cli authflow::tests::bind_localhost_port +``` + +Expected: FAIL — method does not exist. + +- [ ] **Step 3: Implement** + +In `crates/puffer-cli/src/authflow.rs`, inside `impl CallbackListener`, right after `bind_localhost`: + +```rust + /// Binds a fixed loopback port. Used for redirect URIs that must + /// match an Auth Station allow-list entry exactly (such as the + /// worldagent provider). Returns an error if the port is in use. + pub(crate) fn bind_localhost_port(path: &str, port: u16) -> Result { + let listener = TcpListener::bind(("127.0.0.1", port)).with_context(|| { + format!("failed to bind callback listener on 127.0.0.1:{port} for {path}") + })?; + listener.set_nonblocking(true)?; + Ok(Self { + listener, + host: "127.0.0.1".to_string(), + port, + expected_path: path.to_string(), + redirect_uri: format!("http://127.0.0.1:{port}{path}"), + }) + } +``` + +- [ ] **Step 4: Run test** + +```bash +cargo test -p puffer-cli authflow::tests::bind_localhost_port +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/puffer-cli/src/authflow.rs +git commit -m "feat(cli/authflow): add bind_localhost_port for fixed-port callbacks" +``` + +--- + +## Task 8: Wire `OauthFamily::WorldAgent` into `auth_provider.rs` + +**Files:** +- Modify: `crates/puffer-cli/Cargo.toml` +- Modify: `crates/puffer-cli/src/auth_provider.rs` + +- [ ] **Step 1: Add `puffer-provider-worldagent` as a dep of puffer-cli** + +In `crates/puffer-cli/Cargo.toml`, find the `[dependencies]` block where the other `puffer-provider-*` entries live and add (alphabetically near `puffer-provider-openai`): + +```toml +puffer-provider-worldagent = { path = "../puffer-provider-worldagent" } +``` + +- [ ] **Step 2: Write the failing test** + +Append to the `tests` module in `crates/puffer-cli/src/auth_provider.rs`: + +```rust + #[test] + fn oauth_family_uses_explicit_oauth_family_field() { + let mut providers = ProviderRegistry::new(); + let mut descriptor = provider( + "worldagent", + "openai-completions", + vec![AuthMode::OAuth, AuthMode::ApiKey], + ); + descriptor.oauth_family = Some("worldagent".to_string()); + providers.register(descriptor); + assert_eq!( + oauth_family_for_provider(&providers, "worldagent"), + Some(OauthFamily::WorldAgent) + ); + } + + #[test] + fn oauth_family_falls_back_to_default_api_when_field_unset() { + let mut providers = ProviderRegistry::new(); + providers.register(provider( + "custom-openai", + "openai-completions", + vec![AuthMode::OAuth], + )); + assert_eq!( + oauth_family_for_provider(&providers, "custom-openai"), + Some(OauthFamily::OpenAi) + ); + } +``` + +Also update the existing `provider` test helper at the bottom of `auth_provider.rs` to set `oauth_family: None,` in the `ProviderDescriptor` literal. (Without this, the helper won't compile after Task 1 added the field.) + +```rust + chat_completions_path: None, + oauth_family: None, +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +cargo test -p puffer-cli auth_provider::tests::oauth_family_uses_explicit +``` + +Expected: FAIL — `OauthFamily::WorldAgent` does not exist. + +- [ ] **Step 4: Add the enum variant and dispatch logic** + +In `crates/puffer-cli/src/auth_provider.rs`: + +1. Extend `OauthFamily`: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OauthFamily { + Anthropic, + OpenAi, + WorldAgent, +} +``` + +2. Replace the body of `oauth_family_for_provider`: + +```rust +pub(crate) fn oauth_family_for_provider( + providers: &ProviderRegistry, + provider_id: &str, +) -> Option { + let provider = providers.provider(provider_id)?; + if !provider.auth_modes.contains(&AuthMode::OAuth) { + return None; + } + if let Some(family) = provider.oauth_family.as_deref() { + return match family { + "openai" => Some(OauthFamily::OpenAi), + "anthropic" => Some(OauthFamily::Anthropic), + "worldagent" => Some(OauthFamily::WorldAgent), + _ => None, + }; + } + match provider.default_api.as_str() { + "openai-responses" + | "openai-completions" + | "azure-openai-responses" + | "openai-codex-responses" => Some(OauthFamily::OpenAi), + "anthropic-messages" => Some(OauthFamily::Anthropic), + _ => None, + } +} +``` + +3. Add a `WorldAgent` arm to both `oauth_start_bundle_for_provider` and `oauth_login_bundle_for_provider`. Insert after the `Anthropic` arm in each: + +```rust + Some(OauthFamily::WorldAgent) => { + let mut config = puffer_provider_worldagent::WorldAgentLoginConfig::default(); + // for oauth_login_bundle_for_provider only: override redirect_uri. + // For oauth_start_bundle_for_provider, leave the default (the + // fixed loopback URI baked into the crate). + // …see step 5 for the exact code. + Ok(OauthStartBundle { + authorization_url: puffer_provider_worldagent::build_login_url(&config), + automatic_authorization_url: None, + verifier: String::new(), + state: config.client_state, + redirect_uri: config.redirect_uri, + manual_redirect_uri: None, + }) + } +``` + +- [ ] **Step 5: Apply the exact arms** + +For `oauth_start_bundle_for_provider` (no explicit redirect_uri): + +```rust + Some(OauthFamily::WorldAgent) => { + let config = puffer_provider_worldagent::WorldAgentLoginConfig::default(); + Ok(OauthStartBundle { + authorization_url: puffer_provider_worldagent::build_login_url(&config), + automatic_authorization_url: None, + verifier: String::new(), + state: config.client_state, + redirect_uri: config.redirect_uri, + manual_redirect_uri: None, + }) + } +``` + +For `oauth_login_bundle_for_provider` (caller provides the bound redirect_uri): + +```rust + Some(OauthFamily::WorldAgent) => { + let config = puffer_provider_worldagent::WorldAgentLoginConfig { + redirect_uri: redirect_uri.to_string(), + ..puffer_provider_worldagent::WorldAgentLoginConfig::default() + }; + Ok(OauthStartBundle { + authorization_url: puffer_provider_worldagent::build_login_url(&config), + automatic_authorization_url: None, + verifier: String::new(), + state: config.client_state, + redirect_uri: config.redirect_uri, + manual_redirect_uri: None, + }) + } +``` + +- [ ] **Step 6: Run tests** + +```bash +cargo test -p puffer-cli auth_provider::tests +``` + +Expected: PASS (both new tests + the existing two unchanged). + +- [ ] **Step 7: Commit** + +```bash +git add crates/puffer-cli/Cargo.toml crates/puffer-cli/src/auth_provider.rs +git commit -m "feat(cli/auth_provider): dispatch worldagent OAuth family" +``` + +--- + +## Task 9: Add `to_registry_oauth_credential_worldagent` helper + +**Files:** +- Modify: `crates/puffer-cli/src/auth_credentials.rs` + +- [ ] **Step 1: Write the failing test** + +Append (or create) a `#[cfg(test)] mod tests` block at the bottom of `auth_credentials.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use puffer_provider_worldagent::WorldAgentOAuthCredentials; + + #[test] + fn worldagent_credential_maps_email_and_account_id() { + let credential = WorldAgentOAuthCredentials { + access_token: "acc".to_string(), + refresh_token: "ref".to_string(), + expires_at_ms: 42, + sub: Some("user_01".to_string()), + email: Some("dev@example.com".to_string()), + name: Some("Dev".to_string()), + }; + let stored = to_registry_oauth_credential_worldagent(credential); + assert_eq!(stored.access_token, "acc"); + assert_eq!(stored.refresh_token, "ref"); + assert_eq!(stored.expires_at_ms, 42); + assert_eq!(stored.account_id.as_deref(), Some("user_01")); + assert_eq!(stored.email.as_deref(), Some("dev@example.com")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p puffer-cli auth_credentials::tests::worldagent_credential +``` + +Expected: FAIL — function doesn't exist. + +- [ ] **Step 3: Implement** + +Append (above the test module) in `crates/puffer-cli/src/auth_credentials.rs`: + +```rust +/// Converts worldagent OAuth credentials into the registry storage shape. +/// The Auth Station `sub` claim is stored as `account_id` so the +/// existing AuthStore reuse path (organization_id, plan_type, etc.) +/// stays untouched. `name` is intentionally not persisted yet — the +/// existing `OAuthCredential` shape has no slot for it; if the UI +/// needs the display name later, we can either reuse `email` or +/// extend the struct. +pub(crate) fn to_registry_oauth_credential_worldagent( + credential: puffer_provider_worldagent::WorldAgentOAuthCredentials, +) -> puffer_provider_registry::OAuthCredential { + puffer_provider_registry::OAuthCredential { + access_token: credential.access_token, + refresh_token: credential.refresh_token, + expires_at_ms: credential.expires_at_ms, + account_id: credential.sub, + organization_id: None, + email: credential.email, + plan_type: None, + rate_limit_tier: None, + scopes: Vec::new(), + organization_name: None, + organization_role: None, + workspace_role: None, + } +} +``` + +- [ ] **Step 4: Run test** + +```bash +cargo test -p puffer-cli auth_credentials::tests::worldagent_credential +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/puffer-cli/src/auth_credentials.rs +git commit -m "feat(cli/auth_credentials): map worldagent credential into registry shape" +``` + +--- + +## Task 10: Wire worldagent into `run_login_flow` (CLI path) + +**Files:** +- Modify: `crates/puffer-cli/src/main.rs` + +- [ ] **Step 1: Add imports** + +Near the existing `use puffer_provider_openai::{…}` and `use puffer_transport_anthropic::{…}` blocks, add: + +```rust +use puffer_provider_worldagent::{ + decode_jwt_profile as decode_worldagent_jwt_profile, + parse_callback_input as parse_worldagent_callback_input, + WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, +}; +``` + +And in the `use crate::auth_credentials::{…}` block (around line 65), add: + +```rust +use crate::auth_credentials::to_registry_oauth_credential_worldagent; +``` + +- [ ] **Step 2: Replace the localhost listener bind in `run_login_flow`** + +Currently `run_login_flow` always calls +`authflow::CallbackListener::bind_localhost("/callback")`. Replace +the listener-creation block (around line 1017–1021) with: + +```rust + let callback_listener = if stdin || value.is_some() { + None + } else if matches!( + oauth_family_for_provider(providers, provider), + Some(OauthFamily::WorldAgent) + ) { + Some(authflow::CallbackListener::bind_localhost_port( + WORLDAGENT_CALLBACK_PATH, + WORLDAGENT_CALLBACK_PORT, + )?) + } else { + Some(authflow::CallbackListener::bind_localhost("/callback")?) + }; +``` + +- [ ] **Step 3: Add the `WorldAgent` arm to the outer `match`** + +Inside `run_login_flow`, after the `OauthFamily::Anthropic` arm and before the `None =>` bail, insert: + +```rust + Some(OauthFamily::WorldAgent) => { + let parsed = parse_worldagent_callback_input(&input); + if let Some(err) = parsed.error.as_deref() { + let desc = parsed.error_description.as_deref().unwrap_or(""); + anyhow::bail!("worldagent login failed: {err} {desc}"); + } + if parsed.state.as_deref() != Some(bundle.state.as_str()) { + anyhow::bail!("oauth state mismatch for worldagent"); + } + let access_token = parsed + .token + .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; + let refresh_token = parsed.refresh_token.unwrap_or_default(); + let profile = decode_worldagent_jwt_profile(&access_token); + let credential = WorldAgentOAuthCredentials { + access_token, + refresh_token, + expires_at_ms: now_ms_for_worldagent_credential(), + sub: profile.sub, + email: profile.email, + name: profile.name, + }; + auth_store.set_oauth( + provider.to_string(), + to_registry_oauth_credential_worldagent(credential), + ); + } +``` + +Add this helper near the bottom of `main.rs` (sibling of `resolve_provider_id`): + +```rust +fn now_ms_for_worldagent_credential() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64 + 24 * 3600 * 1000) + .unwrap_or(24 * 3600 * 1000) +} +``` + +- [ ] **Step 4: Run cli build + lib tests** + +```bash +cargo build -p puffer-cli +cargo test -p puffer-cli --lib +``` + +Expected: build + tests SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/puffer-cli/src/main.rs +git commit -m "feat(cli): handle worldagent OAuth in run_login_flow" +``` + +--- + +## Task 11: Wire worldagent into the desktop daemon `handle_login_with_oauth` + +**Files:** +- Modify: `crates/puffer-cli/src/daemon.rs` + +- [ ] **Step 1: Add imports** + +Near the top of `daemon.rs`, in the `use puffer_provider_openai::{…}` import block (or wherever the OpenAI/Anthropic imports live), add: + +```rust +use puffer_provider_worldagent::{ + decode_jwt_profile as decode_worldagent_jwt_profile, + parse_callback_input as parse_worldagent_callback_input, + WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, +}; +``` + +In the `use crate::auth_credentials::{…}` block: + +```rust +use crate::auth_credentials::to_registry_oauth_credential_worldagent; +``` + +- [ ] **Step 2: Branch the listener bind** + +In `handle_login_with_oauth` (around line 1070), replace: + +```rust + let listener = crate::authflow::CallbackListener::bind_localhost("/callback")?; +``` + +with: + +```rust + let listener = if matches!( + oauth_family_for_provider(&inputs.providers, &provider_id), + Some(OauthFamily::WorldAgent) + ) { + crate::authflow::CallbackListener::bind_localhost_port( + WORLDAGENT_CALLBACK_PATH, + WORLDAGENT_CALLBACK_PORT, + )? + } else { + crate::authflow::CallbackListener::bind_localhost("/callback")? + }; +``` + +- [ ] **Step 3: Add the `WorldAgent` arm to the `match`** + +After the `OauthFamily::Anthropic` arm (around line 1101–1121) and before the `None =>` bail: + +```rust + Some(OauthFamily::WorldAgent) => { + let parsed = parse_worldagent_callback_input(&callback); + if let Some(err) = parsed.error.as_deref() { + let desc = parsed.error_description.as_deref().unwrap_or(""); + anyhow::bail!("worldagent login failed: {err} {desc}"); + } + if parsed.state.as_deref() != Some(bundle.state.as_str()) { + anyhow::bail!("oauth state mismatch for worldagent"); + } + let access_token = parsed + .token + .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; + let refresh_token = parsed.refresh_token.unwrap_or_default(); + let profile = decode_worldagent_jwt_profile(&access_token); + let expires_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64 + 24 * 3600 * 1000) + .unwrap_or(24 * 3600 * 1000); + let credential = WorldAgentOAuthCredentials { + access_token, + refresh_token, + expires_at_ms, + sub: profile.sub, + email: profile.email, + name: profile.name, + }; + set_stored_credential( + &mut inputs.auth_store, + provider_id.to_string(), + StoredCredential::OAuth(to_registry_oauth_credential_worldagent(credential)), + ); + } +``` + +- [ ] **Step 4: Build the cli** + +```bash +cargo build -p puffer-cli +``` + +Expected: SUCCESS. + +- [ ] **Step 5: Run all puffer-cli tests** + +```bash +cargo test -p puffer-cli --lib +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/puffer-cli/src/daemon.rs +git commit -m "feat(cli/daemon): handle worldagent OAuth in handle_login_with_oauth" +``` + +--- + +## Task 12: Ship the `worldagent.yaml` provider resource + +**Files:** +- Create: `resources/providers/worldagent.yaml` + +- [ ] **Step 1: Write the failing test** + +Append to the test module in `crates/puffer-resources/src/model.rs` (right next to `zhipu_yaml_parses_with_chat_completions_path_override`): + +```rust + /// Confirms the bundled `worldagent.yaml` parses as a + /// `ProviderPack` and that the `oauth_family` field round-trips + /// through `into_descriptor`. Without this end-to-end wiring the + /// runtime would silently fall back to OpenAI OAuth. + #[test] + fn worldagent_yaml_parses_with_oauth_family() { + let yaml = include_str!("../../../resources/providers/worldagent.yaml"); + let pack: ProviderPack = serde_yaml::from_str(yaml).expect("worldagent.yaml parses"); + assert_eq!(pack.id, "worldagent"); + assert_eq!(pack.oauth_family.as_deref(), Some("worldagent")); + let descriptor = pack.into_descriptor(); + assert_eq!(descriptor.oauth_family.as_deref(), Some("worldagent")); + assert!(descriptor.auth_modes.contains(&AuthMode::ApiKey)); + assert!(descriptor.auth_modes.contains(&AuthMode::OAuth)); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p puffer-resources worldagent_yaml_parses_with_oauth_family +``` + +Expected: FAIL — file doesn't exist (`include_str!` won't compile). + +- [ ] **Step 3: Create the yaml** + +`resources/providers/worldagent.yaml`: + +```yaml +id: worldagent +display_name: WorldAgent +base_url: https://inference-api.worldrouter.ai +default_api: openai-completions +oauth_family: worldagent +auth_modes: + - api_key + - oauth +discovery: + path: /v1/models + response: open_ai_models + api: openai-completions + context_window: 200000 + max_output_tokens: 8192 + supports_reasoning: true +models: + - id: gpt-5 + display_name: GPT-5 (via WorldRouter) + provider: worldagent + api: openai-completions + context_window: 200000 + max_output_tokens: 8192 + supports_reasoning: true +``` + +- [ ] **Step 4: Run test** + +```bash +cargo test -p puffer-resources worldagent_yaml_parses_with_oauth_family +``` + +Expected: PASS. + +- [ ] **Step 5: Build the cli end-to-end and run all tests** + +```bash +cargo build -p puffer-cli +cargo test --workspace +``` + +Expected: SUCCESS / PASS. Note: tests that hit the network (Auth Station, OpenAI, Anthropic) are not added in this plan; the cited `cargo test --workspace` should remain green with no new network calls. + +- [ ] **Step 6: Commit** + +```bash +git add resources/providers/worldagent.yaml crates/puffer-resources/src/model.rs +git commit -m "feat(resources): ship worldagent provider yaml" +``` + +--- + +## Task 13: Add desktop visual entry for worldagent + +**Files:** +- Modify: `apps/puffer-desktop/src/lib/providerVisuals.ts` + +- [ ] **Step 1: Inspect existing entries** + +```bash +grep -n "openai\|anthropic\|groq\|kimi\|providerVisual\|export" apps/puffer-desktop/src/lib/providerVisuals.ts | head -40 +``` + +Read the file once to understand the registration shape (icon path, accent color, fallback handling). + +- [ ] **Step 2: Add a `worldagent` entry** + +Match the shape used by existing providers. If entries are keyed by provider id in a record, insert (alphabetically): + +```typescript + worldagent: { + icon: "/icons/providers/worldagent.svg", // or use the existing generic fallback + accent: "#1f6feb", + displayName: "WorldAgent", + }, +``` + +If your repo doesn't ship a `worldagent.svg`, **use the existing fallback icon** rather than fabricating an asset. Pick whatever the file already does for unknown providers (likely a default monogram or a tinted placeholder). Do not create new SVG/PNG files in this task — the design says "designer can replace later". + +- [ ] **Step 3: Smoke-build the frontend** + +```bash +cd apps/puffer-desktop +pnpm install --frozen-lockfile +pnpm check +``` + +Expected: `svelte-check` reports zero errors related to your edit. (Pre-existing warnings unrelated to worldagent are fine.) + +- [ ] **Step 4: Commit** + +```bash +git add apps/puffer-desktop/src/lib/providerVisuals.ts +git commit -m "feat(desktop): register worldagent provider visuals" +``` + +--- + +## Task 14: Write per-component update specs + +**Files:** +- Create: `specs/puffer-provider-worldagent/00.md` +- Create: `specs/puffer-provider-registry/06.md` +- Create: `specs/puffer-cli/.md` (use the next free `NN.md`, list the directory first) +- Create: `specs/puffer-resources/.md` +- Create: `specs/puffer-desktop/.md` + +- [ ] **Step 1: Determine the next free numeric prefix for each component** + +```bash +ls specs/puffer-cli | tail +ls specs/puffer-resources | tail +ls specs/puffer-desktop | tail +``` + +Use the next unused two-digit prefix per AGENTS.md ("do not overwrite prior numbered specs"). + +- [ ] **Step 2: Write each spec** + +Each file is ≤ 60 lines, follows the existing terse style (see `specs/puffer-provider-openai/01.md`): + +`specs/puffer-provider-worldagent/00.md`: + +```markdown +# WorldAgent Provider Crate + +## Summary +- New crate `puffer-provider-worldagent` owning the Auth Station login flow for the `worldagent` provider. +- Auth Station returns final `token` and `refresh_token` directly in the callback URL; this crate models that flow (no PKCE, no code exchange). + +## Surface +- `build_login_url(&WorldAgentLoginConfig) -> String` +- `parse_callback_input(&str) -> WorldAgentCallback` +- `decode_jwt_profile(&str) -> WorldAgentJwtProfile` +- `refresh_oauth_token(&str, Option<&str>) -> Result` +- `exchange_jwt_for_api_key(&str) -> Result` — TODO stub, waits on worldrouter backend + +## Configuration +- Default Auth Station URL: `https://auth-worldrouter.vercel.app` (Sandbox). +- Override via env var `PUFFER_WORLDAGENT_AUTH_URL`. +- Fixed loopback redirect: `http://127.0.0.1:1456/callback`. + +## Compatibility +- The fixed redirect URI must be allow-listed in Auth Station `ALLOWED_REDIRECT_ORIGINS` on both Sandbox and Production. +- JWT-to-api-key exchange is intentionally a stub; until the backend endpoint lands, the OAuth path does not yield an inference-usable credential. Users still must paste an API key. +``` + +`specs/puffer-provider-registry/06.md`: + +```markdown +# Optional `oauth_family` on ProviderDescriptor + +## Summary +- `ProviderDescriptor` gains `oauth_family: Option`. +- When `None`, callers infer the OAuth family from `default_api` (no behavior change for existing yaml). +- When `Some`, callers dispatch directly to the named family. Known values today: `openai`, `anthropic`, `worldagent`. + +## Compatibility +- Default value preserves every existing provider yaml. +- `ProviderPack` (in `puffer-resources`) mirrors the field and threads it through `into_descriptor`. +``` + +`specs/puffer-cli/.md` (worldagent OAuth dispatch): + +```markdown +# WorldAgent OAuth Dispatch + +## Summary +- `OauthFamily` grows a `WorldAgent` variant. +- `oauth_family_for_provider` prefers `descriptor.oauth_family` when set, otherwise falls back to `default_api`. +- `oauth_login_bundle_for_provider` builds the bundle from `WorldAgentLoginConfig`; `verifier` is empty (no PKCE), `automatic_authorization_url` is `None`. +- `handle_login_with_oauth` (daemon) and `run_login_flow` (cli) both gain a `WorldAgent` arm: parse callback, verify `state`, decode JWT for `sub`/`email`/`name`, store as `StoredCredential::OAuth`. +- `CallbackListener::bind_localhost_port` lets the daemon bind the fixed `127.0.0.1:1456` Auth-Station-whitelist port. + +## Compatibility +- Existing OpenAI / Anthropic flows are unchanged (`oauth_family` unset → falls back to `default_api` map). +- `WorldAgentOAuthCredentials.name` is not yet persisted (no slot on `OAuthCredential`); only `sub`/`email` survive into the registry shape. +``` + +`specs/puffer-resources/.md`: + +```markdown +# WorldAgent Provider Yaml + +## Summary +- Bundled `resources/providers/worldagent.yaml` adds the `worldagent` provider entry. +- `default_api: openai-completions` (inference goes through the existing OpenAI chat-completions transport). +- `oauth_family: worldagent` opts into the new login dispatch. +- `auth_modes: [api_key, oauth]` exposes both LoginView paths. +- Model catalog is seeded minimally; `/v1/models` discovery populates the rest at runtime. + +## Compatibility +- Pure addition; no existing yaml is modified. +``` + +`specs/puffer-desktop/.md`: + +```markdown +# WorldAgent Visuals + +## Summary +- `providerVisuals.ts` registers a `worldagent` entry (display name + accent). +- No bespoke icon ships in this change; the fallback icon is reused until design provides one. + +## Compatibility +- LoginView's generic OAuth / api_key surface needs no component change. +``` + +- [ ] **Step 3: Commit** + +```bash +git add specs/puffer-provider-worldagent specs/puffer-provider-registry \ + specs/puffer-cli specs/puffer-resources specs/puffer-desktop +git commit -m "docs(specs): document worldagent provider integration" +``` + +--- + +## Task 15: Final verification + +- [ ] **Step 1: Workspace test** + +```bash +cargo test --workspace +``` + +Expected: all tests green, including the new worldagent tests and the resource yaml parse test. + +- [ ] **Step 2: Workspace build** + +```bash +cargo build --workspace +``` + +Expected: SUCCESS. + +- [ ] **Step 3: Desktop typecheck** + +```bash +cd apps/puffer-desktop +pnpm check +``` + +Expected: no new errors. (Pre-existing warnings are out of scope.) + +- [ ] **Step 4: Manual smoke (optional, requires daemon)** + +If a daemon is reachable and `http://127.0.0.1:1456/callback` is allow-listed: + +1. Start the daemon: `cargo run -p puffer-cli -- daemon` +2. Open the desktop app, navigate to Login screen. +3. Find the **WorldAgent** card. +4. Click "Connect with OAuth". +5. Verify the browser opens `https://auth-worldrouter.vercel.app/login?redirect_uri=http://127.0.0.1:1456/callback&client_state=...` +6. Sign in. Browser should redirect to a success page. +7. Confirm `~/.config/puffer/auth.json` (or equivalent platform path) shows a `worldagent` entry with `kind: oauth`. +8. Paste a real WorldRouter API key in the WorldAgent card too — verify the stored credential flips to `kind: api_key`. + +If the manual smoke fails because the redirect URI is not allow-listed yet, **that is the expected failure mode** until the auth maintainer adds `http://127.0.0.1:1456/callback` to `ALLOWED_REDIRECT_ORIGINS` on Sandbox + Production. Note the failure mode in the PR description. + +- [ ] **Step 5: Final commit (only if any clean-up edits were needed)** + +```bash +git status +# If no further changes, skip. Otherwise: +git add -p +git commit -m "chore(worldagent): verification follow-ups" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §3 architecture diagram → Tasks 7, 8, 10, 11 +- §4 provider yaml → Task 12 +- §5 ProviderDescriptor field → Task 1 +- §6 new crate surface → Tasks 2–6 +- §7 daemon login dispatch + `bind_localhost_port` → Tasks 7, 11 +- §8 TODO JWT→api_key + UI banner — Task 6 (stub) + Task 13 (visuals). The visible banner copy described in §8 of the spec is currently surfaced by `statusMessage = "Connected to worldagent."` plus the user manually pasting an api_key when ready. Adding a worldagent-specific banner above the OAuth button is **deferred** (it requires LoginView changes, which the spec explicitly said were "no LoginView component change needed"). If the user wants a banner, raise it during plan review. +- §9 desktop UI → Task 13 +- §10 tests → Tasks 1, 3, 4, 5, 6, 7, 8, 9, 12 each ship the tests they own +- §11 per-component specs → Task 14 +- §12 pre-shipping checklist → noted in Task 15 step 4 + +**Placeholder scan:** all code blocks contain literal source; the only "TODO" lives in `exchange_jwt_for_api_key`, which is intentional and tested (asserts the function `bail!`s with "not yet implemented"). The `.md` filename placeholder in Task 14 is resolved at step 1 by listing the directory. + +**Type consistency:** +- `WorldAgentLoginConfig` / `WorldAgentCallback` / `WorldAgentJwtProfile` / `WorldAgentOAuthCredentials` all defined in Task 3/4/5/6 and consumed in Tasks 8/10/11 by the same names. +- `OauthFamily::WorldAgent` defined in Task 8 and consumed in Tasks 10/11. +- `to_registry_oauth_credential_worldagent` defined in Task 9, consumed in Tasks 10/11. +- `bind_localhost_port` defined in Task 7, consumed in Tasks 10/11. +- `WORLDAGENT_CALLBACK_PATH` / `WORLDAGENT_CALLBACK_PORT` defined in Task 3, consumed in Tasks 10/11. + +No gaps. diff --git a/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md b/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md new file mode 100644 index 000000000..6ba20ff00 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md @@ -0,0 +1,397 @@ +# worldagent Provider — Design + +Date: 2026-05-20 +Status: Draft (awaiting user approval) +Owner: sean + +## 1. Motivation + +WorldRouter exposes an OpenAI-compatible inference API at +`https://inference-api.worldrouter.ai`. Puffer needs a first-class +provider entry so users can: + +1. Paste a WorldRouter API key (same UX as existing OpenAI-compatible + relays — kimi-openai, groq, openrouter, etc.). +2. Click "Connect with OAuth" and authorize via Auth Station + (`https://auth.worldrouter.ai` / Sandbox + `https://auth-worldrouter.vercel.app`), opening the auth website in + the default browser and capturing a callback locally. + +Long-term framing (from user): worldagent is a **brand entry point**. +The provider role is the minimum-impact form for the current Puffer +flow (provider + model + routing + auth all reuse existing plumbing). +Future iterations will let the OAuth session resolve to either a +`claw` plan or a WorldRouter API key. The current scope keeps both +paths open: OAuth captures the JWT and stores it; API-key input +stores a key. The JWT-to-api-key exchange endpoint is **not yet +defined backend-side** and is a clearly marked TODO in code. + +## 2. Non-Goals + +- Implementing the JWT → inference API-key exchange. Backend is not + ready. We store the JWT as an OAuth credential placeholder; the + inference path requires a manually pasted API key for now. +- Replacing existing OpenAI provider crate functionality. +- Reworking LoginView UI. The component is already generic over + `authModes`. +- Renaming the provider id (`worldagent` vs `worldclaw`). Pick one id + now and keep it stable; display name can change later via yaml. + +## 3. High-level architecture + +``` +┌─────────────────────┐ OAuth button ┌──────────────────┐ +│ puffer-desktop │ ───────────────────▶ │ daemon │ +│ (Svelte LoginView) │ login_with_oauth │ (puffer-cli) │ +└─────────────────────┘ └────────┬─────────┘ + │ + │ dispatch by oauth_family + ▼ + ┌────────────────────────────────────────┐ + │ puffer-provider-worldagent │ + │ build_login_url + parse_callback + │ + │ decode_jwt + refresh_token │ + └────────────┬───────────────────────────┘ + │ open browser + ▼ + ┌────────────────────────────────────────┐ + │ Auth Station │ + │ /login → 302 to │ + │ http://127.0.0.1:1456/callback?token= │ + └────────────────────────────────────────┘ + │ daemon waits on CallbackListener + ▼ + ┌────────────────────────────────────────┐ + │ AuthStore (OAuth credential) │ + │ access_token / refresh_token / │ + │ email / sub from JWT │ + └────────────────────────────────────────┘ +``` + +API-key path is unchanged: LoginView submits api_key → existing +`store_api_key` plumbing → `StoredCredential::ApiKey` in AuthStore. + +## 4. Provider yaml + +`resources/providers/worldagent.yaml`: + +```yaml +id: worldagent +display_name: WorldAgent +base_url: https://inference-api.worldrouter.ai +default_api: openai-completions +oauth_family: worldagent +auth_modes: + - api_key + - oauth +discovery: + path: /v1/models + response: open_ai_models + api: openai-completions + context_window: 200000 + max_output_tokens: 8192 + supports_reasoning: true +models: + # Seed list — actual catalog comes from /v1/models discovery. + - id: gpt-5 + display_name: GPT-5 (via WorldRouter) + provider: worldagent + api: openai-completions + context_window: 200000 + max_output_tokens: 8192 + supports_reasoning: true +``` + +Notes: +- `default_api: openai-completions` → reuses the existing OpenAI + Chat-Completions transport (Bearer api_key, `/v1/chat/completions`). +- `oauth_family: worldagent` is a new field (§5). When unset, the + registry falls back to the existing API-family inference. +- The model seed list is intentionally minimal; `discovery` will + populate the rest at runtime against `/v1/models`. + +## 5. ProviderDescriptor change + +`crates/puffer-provider-registry/src/model.rs`: + +```rust +pub struct ProviderDescriptor { + // ...existing fields... + /// Optional explicit OAuth family. When `None`, callers infer the + /// family from `default_api` (existing behavior — preserves + /// every yaml that did not opt in). When `Some`, callers use the + /// named family directly. This is the seam that lets a single + /// provider with `default_api: openai-completions` plug into a + /// non-OpenAI OAuth flow. + #[serde(default)] + pub oauth_family: Option, +} +``` + +`auth_provider.rs::oauth_family_for_provider` is updated to: + +1. Read `descriptor.oauth_family` first; map known strings to enum: + - `"openai"` → `OauthFamily::OpenAi` + - `"anthropic"` → `OauthFamily::Anthropic` + - `"worldagent"` → `OauthFamily::WorldAgent` (new) +2. If unset, fall back to the existing `default_api` switch (no + behavior change for any existing yaml). + +`OauthFamily` enum grows one variant: `WorldAgent`. + +## 6. New crate: `puffer-provider-worldagent` + +Layout: + +``` +crates/puffer-provider-worldagent/ +├── Cargo.toml +└── src/ + ├── lib.rs — public re-exports + └── auth.rs — login URL / callback parser / JWT decode / refresh +``` + +Public surface (`src/auth.rs`): + +```rust +/// Default Auth Station base URL (Sandbox). +pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app"; + +/// Env var that overrides the Auth Station base URL. +pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; + +/// Fixed loopback callback used by Puffer desktop. The auth team +/// must allow-list this redirect URI on both Sandbox and Production +/// `ALLOWED_REDIRECT_ORIGINS`. +pub const WORLDAGENT_CALLBACK_PATH: &str = "/callback"; +pub const WORLDAGENT_CALLBACK_PORT: u16 = 1456; +pub const WORLDAGENT_DEFAULT_REDIRECT_URI: &str = + "http://127.0.0.1:1456/callback"; + +/// Persisted Auth Station credentials for the worldagent provider. +pub struct WorldAgentOAuthCredentials { + pub access_token: String, + pub refresh_token: String, + pub expires_at_ms: u64, + pub sub: Option, + pub email: Option, + pub name: Option, +} + +/// Parameters required to build the Auth Station login URL. +pub struct WorldAgentLoginConfig { + pub auth_base_url: String, + pub redirect_uri: String, + pub client_state: String, +} + +impl Default for WorldAgentLoginConfig { /* env override + defaults */ } + +/// Generate an opaque random client_state. +pub fn generate_client_state() -> String; + +/// Build the GET URL for `/login?redirect_uri=&client_state=`. +pub fn build_login_url(config: &WorldAgentLoginConfig) -> String; + +/// Parsed callback fields. Each field is `None` when the parameter +/// was absent from the callback URL. +pub struct WorldAgentCallback { + pub token: Option, + pub refresh_token: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +/// Extract `token`, `refresh_token`, `state`, `error`, +/// `error_description` from a callback URL. +pub fn parse_callback_input(input: &str) -> WorldAgentCallback; + +/// Decoded JWT profile fields, best-effort. +pub struct WorldAgentJwtProfile { + pub sub: Option, + pub email: Option, + pub name: Option, +} + +/// Decode `sub`/`email`/`name` from the access token JWT payload +/// (best-effort; failures yield empty fields). +pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile; + +/// Exchange a stored refresh token for a new access token via +/// `POST /token/refresh`. +pub fn refresh_oauth_token( + refresh_token: &str, + auth_base_url: Option<&str>, +) -> Result; +``` + +Auth Station's `/login` flow is **simpler than OAuth**: there is no +PKCE, no code, no token exchange. The callback already contains the +final tokens. The CSRF guard is `client_state ↔ state`. We honor it. + +`auth.rs` stays well under the 1000-line limit (target ~250 lines +including tests). + +## 7. Daemon login dispatch + +`crates/puffer-cli/src/daemon.rs::handle_login_with_oauth` gains a +third arm: + +```rust +Some(OauthFamily::WorldAgent) => { + let parsed = parse_callback_input(&callback); + if let Some(err) = parsed.error.as_deref() { + let desc = parsed.error_description.as_deref().unwrap_or(""); + bail!("worldagent login failed: {err} {desc}"); + } + if parsed.state.as_deref() != Some(bundle.state.as_str()) { + bail!("oauth state mismatch for worldagent"); + } + let token = parsed + .token + .ok_or_else(|| anyhow!("worldagent callback missing token"))?; + let refresh = parsed.refresh_token.unwrap_or_default(); + let profile = decode_jwt_profile(&token); + let credential = WorldAgentOAuthCredentials { + access_token: token, + refresh_token: refresh, + expires_at_ms: now_ms() + 24 * 3600 * 1000, // matches Auth Station spec + sub: profile.sub, + email: profile.email, + name: profile.name, + }; + set_stored_credential( + &mut inputs.auth_store, + provider_id.to_string(), + StoredCredential::OAuth(to_registry_oauth_credential_worldagent(credential)), + ); +} +``` + +`oauth_login_bundle_for_provider` (auth_provider.rs) likewise gains a +`WorldAgent` arm that builds the bundle from +`WorldAgentLoginConfig`. The bundle's `verifier` is unused for +worldagent — we set it to an empty string. `automatic_authorization_url` +is `None` (single URL, no manual fallback). + +`puffer-cli/src/main.rs::run_login_flow` adds the matching arm so the +CLI path (`puffer auth login worldagent`) works the same way. + +`puffer-cli/src/authflow.rs` is unchanged. The `CallbackListener::bind_localhost` +helper accepts the fixed port via a new optional binder +`bind_localhost_port(path, port)` (the existing helper stays the +default — we only branch when the caller asks for a fixed port). + +## 8. TODO: JWT → api_key exchange + +A clearly named module placeholder is added in +`puffer-provider-worldagent/src/lib.rs`: + +```rust +/// TODO (waiting on worldrouter backend): +/// Exchange an Auth Station JWT for an inference API key. +/// Once the endpoint is finalized, this function POSTs the JWT to +/// `/api/v1/keys/exchange` (or whatever the backend +/// settles on) and returns the api_key. The login handler will then +/// upgrade the stored credential from `OAuth(jwt)` to `ApiKey(...)` +/// (or store both). +pub fn exchange_jwt_for_api_key( + _access_token: &str, +) -> Result { + anyhow::bail!( + "worldagent JWT-to-api-key exchange is not yet implemented; \ + paste your WorldRouter API key for now." + ) +} +``` + +The `handle_login_with_oauth` arm calls this **eagerly** and, on the +expected `bail!`, stores the OAuth credential anyway and surfaces a +user-visible message in the SettingsSnapshot: +`"Logged in as . Paste your WorldRouter API key to enable +inference (auto-exchange pending backend support)."` The next +SettingsSnapshot fetch refreshes the UI. + +When backend ships the endpoint, only the body of +`exchange_jwt_for_api_key` and the login handler's branch change. + +## 9. Desktop UI + +LoginView already supports the API-key + OAuth dual layout. The only +desktop-side change is: + +- `apps/puffer-desktop/src/lib/providerVisuals.ts` — register a + `worldagent` entry (icon path + accent color). A simple text-based + monogram icon is acceptable for v1; designer can replace later. +- A short banner above the OAuth button when the active provider is + worldagent and only an OAuth credential exists (no api_key): "Auto + api-key exchange is not yet enabled. Paste a WorldRouter API key + to start running models." + +No new Tauri commands. No new daemon RPCs beyond reusing +`login_with_oauth`. + +## 10. Tests + +- `puffer-provider-worldagent`: + - `build_login_url_contains_redirect_uri_and_client_state` + - `parse_callback_input_extracts_token_refresh_state` + - `parse_callback_input_returns_error_when_present` + - `decode_jwt_profile_reads_sub_email_name` + - `default_config_honors_env_override` +- `puffer-provider-registry`: + - `provider_descriptor_deserializes_oauth_family_field` + - `oauth_family_field_defaults_to_none` +- `puffer-cli/auth_provider`: + - `oauth_family_uses_explicit_field_when_set` + - `oauth_family_falls_back_to_default_api_when_unset` + - `oauth_family_recognizes_worldagent` +- `puffer-cli/daemon` (with `tokio::test`): + - Smoke test for `handle_login_with_oauth` with a fake worldagent + callback URL passed through the bundle path. + +`cargo test --workspace` must stay green. + +## 11. Resource provenance / file moves + +No moves. New files: + +- `resources/providers/worldagent.yaml` +- `crates/puffer-provider-worldagent/Cargo.toml` +- `crates/puffer-provider-worldagent/src/lib.rs` +- `crates/puffer-provider-worldagent/src/auth.rs` + +New per-component spec files (per AGENTS.md convention): + +- `specs/puffer-provider-worldagent/00.md` — crate overview +- `specs/puffer-provider-registry/06.md` — `oauth_family` field +- `specs/puffer-cli/.md` — auth_provider dispatch + daemon arm +- `specs/puffer-resources/.md` — worldagent.yaml entry +- `specs/puffer-desktop/.md` — providerVisuals entry + banner + +Each component spec is concise (≤ 60 lines) per existing style. + +## 12. Pre-shipping checklist for the user (out of code) + +- File a request with the auth maintainer to allow-list + `http://127.0.0.1:1456/callback` on **both** Sandbox and + Production `ALLOWED_REDIRECT_ORIGINS`. +- Confirm `aud=worldclaw` is the correct audience claim for the + worldagent product (the current docs use `worldclaw`; if a + separate audience is preferred for this product, surface it now). +- Confirm the final brand name (`worldagent` vs `worldclaw`) for the + yaml `id`. If you want to switch later, the cost is one yaml + rename plus a credentials migration step. + +## 13. Future work (post-MVP) + +- JWT → api_key exchange (waits on backend). +- Profile UI showing the authenticated email / org from the JWT. +- Refresh token rotation when access_token expires (one-line cron in + daemon: call `refresh_oauth_token` and re-store the credential). +- "Switch account" button = `puffer auth logout worldagent` + repeat + the OAuth flow. +- If/when the brand becomes the primary entry point: hoist the + worldagent OAuth flow to the onboarding root, push the + raw-OpenAI/Anthropic providers into an "Advanced" sub-screen. From 6094b3c19e470d8792973eedeff92fd3cd4f47b8 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 14:57:32 +0800 Subject: [PATCH 02/39] feat(provider-registry): add optional oauth_family to ProviderDescriptor Adds `oauth_family: Option` with `#[serde(default)]` to both `ProviderDescriptor` and `ProviderPack`, threads it through `ProviderPack::into_descriptor()`, and fixes all existing struct literals in the same crate to include `oauth_family: None`. Existing yaml that omits the field continues to parse cleanly (serde default = None). Two new unit tests cover the round-trip deserialization. --- crates/puffer-provider-registry/Cargo.toml | 1 + .../puffer-provider-registry/src/discovery.rs | 12 +++-- crates/puffer-provider-registry/src/model.rs | 45 +++++++++++++++++++ .../puffer-provider-registry/src/registry.rs | 10 +++-- crates/puffer-resources/src/model.rs | 3 ++ 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/puffer-provider-registry/Cargo.toml b/crates/puffer-provider-registry/Cargo.toml index 06ee19ca4..87bfdc06b 100644 --- a/crates/puffer-provider-registry/Cargo.toml +++ b/crates/puffer-provider-registry/Cargo.toml @@ -19,3 +19,4 @@ puffer-config = { path = "../puffer-config" } [dev-dependencies] tempfile.workspace = true +serde_yaml.workspace = true diff --git a/crates/puffer-provider-registry/src/discovery.rs b/crates/puffer-provider-registry/src/discovery.rs index fb6a4332e..89d477fd7 100644 --- a/crates/puffer-provider-registry/src/discovery.rs +++ b/crates/puffer-provider-registry/src/discovery.rs @@ -422,9 +422,10 @@ mod tests { auth_modes: vec![AuthMode::ApiKey], headers: IndexMap::new(), query_params: IndexMap::new(), + chat_completions_path: None, + oauth_family: None, discovery: Some(discovery), models: Vec::new(), - chat_completions_path: None, } } @@ -654,6 +655,8 @@ mod tests { auth_modes: vec![crate::auth::AuthMode::ApiKey], headers: IndexMap::new(), query_params: IndexMap::new(), + chat_completions_path: None, + oauth_family: None, discovery: Some(ModelDiscoveryConfig { path: "/v1/models".to_string(), response: ModelDiscoveryFormat::AnthropicModels, @@ -667,7 +670,6 @@ mod tests { headers: IndexMap::new(), }), models: Vec::new(), - chat_completions_path: None, }; let mut auth = AuthStore::default(); auth.set_api_key("custom-anthropic", "sk-ant-custom"); @@ -773,9 +775,10 @@ mod tests { auth_modes: vec![AuthMode::OAuth], headers: IndexMap::new(), query_params: IndexMap::new(), + chat_completions_path: None, + oauth_family: None, discovery: None, models: Vec::new(), - chat_completions_path: None, }; assert_eq!( @@ -818,6 +821,8 @@ mod tests { auth_modes: vec![AuthMode::ApiKey], headers: IndexMap::new(), query_params: IndexMap::new(), + chat_completions_path: None, + oauth_family: None, discovery: Some(ModelDiscoveryConfig { path: "/v1/models".to_string(), response: ModelDiscoveryFormat::OpenAiModels, @@ -831,7 +836,6 @@ mod tests { headers: IndexMap::new(), }), models: Vec::new(), - chat_completions_path: None, }; let mut auth = AuthStore::default(); auth.set_api_key("openai", "sk-test"); diff --git a/crates/puffer-provider-registry/src/model.rs b/crates/puffer-provider-registry/src/model.rs index d9b14c1ef..7dfaba8ee 100644 --- a/crates/puffer-provider-registry/src/model.rs +++ b/crates/puffer-provider-registry/src/model.rs @@ -331,6 +331,14 @@ pub struct ProviderDescriptor { /// to a free-form string here too. #[serde(default)] pub chat_completions_path: Option, + /// Optional explicit OAuth family for this provider. When `None`, + /// callers infer the family from `default_api` (preserving every + /// yaml that did not opt in). When `Some`, callers use the named + /// family directly. Known values today: `"openai"`, `"anthropic"`, + /// `"worldagent"`. This is the seam that lets a provider whose + /// transport is `openai-completions` use a non-OpenAI OAuth flow. + #[serde(default)] + pub oauth_family: Option, #[serde(default)] pub discovery: Option, #[serde(default)] @@ -351,3 +359,40 @@ fn default_items_field() -> String { fn default_id_field() -> String { "id".to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use serde_yaml; + + #[test] + fn provider_descriptor_deserializes_oauth_family_field() { + let yaml = r#" +id: example +display_name: Example +base_url: https://example.invalid +default_api: openai-completions +oauth_family: worldagent +auth_modes: + - oauth +"#; + let provider: ProviderDescriptor = + serde_yaml::from_str(yaml).expect("provider yaml parses"); + assert_eq!(provider.oauth_family.as_deref(), Some("worldagent")); + } + + #[test] + fn provider_descriptor_oauth_family_defaults_to_none() { + let yaml = r#" +id: example +display_name: Example +base_url: https://example.invalid +default_api: openai-completions +auth_modes: + - oauth +"#; + let provider: ProviderDescriptor = + serde_yaml::from_str(yaml).expect("provider yaml parses"); + assert!(provider.oauth_family.is_none()); + } +} diff --git a/crates/puffer-provider-registry/src/registry.rs b/crates/puffer-provider-registry/src/registry.rs index 83f59f029..b3c482453 100644 --- a/crates/puffer-provider-registry/src/registry.rs +++ b/crates/puffer-provider-registry/src/registry.rs @@ -485,6 +485,7 @@ mod tests { cost: None, }], chat_completions_path: None, + oauth_family: None, } } @@ -619,9 +620,10 @@ mod tests { auth_modes: vec![AuthMode::ApiKey, AuthMode::OAuth], headers: IndexMap::new(), query_params: IndexMap::new(), + chat_completions_path: None, + oauth_family: None, discovery: None, models: Vec::new(), - chat_completions_path: None, }); registry.apply_openai_base_url_override(Some("https://proxy.example/v1")); @@ -645,9 +647,10 @@ mod tests { auth_modes: vec![AuthMode::ApiKey, AuthMode::OAuth], headers: IndexMap::new(), query_params: IndexMap::new(), + chat_completions_path: None, + oauth_family: None, discovery: None, models: Vec::new(), - chat_completions_path: None, }); registry.set_openai_headers(IndexMap::from([( @@ -675,9 +678,10 @@ mod tests { auth_modes: vec![AuthMode::ApiKey, AuthMode::OAuth], headers: IndexMap::new(), query_params: IndexMap::new(), + chat_completions_path: None, + oauth_family: None, discovery: None, models: Vec::new(), - chat_completions_path: None, }); registry.set_openai_query_params(IndexMap::from([( diff --git a/crates/puffer-resources/src/model.rs b/crates/puffer-resources/src/model.rs index 32b3bf8b9..01e4598d5 100644 --- a/crates/puffer-resources/src/model.rs +++ b/crates/puffer-resources/src/model.rs @@ -464,6 +464,8 @@ pub struct ProviderPack { #[serde(default)] pub chat_completions_path: Option, #[serde(default)] + pub oauth_family: Option, + #[serde(default)] pub discovery: Option, #[serde(default)] pub models: Vec, @@ -481,6 +483,7 @@ impl ProviderPack { headers: self.headers, query_params: self.query_params, chat_completions_path: self.chat_completions_path, + oauth_family: self.oauth_family, discovery: self.discovery, models: self.models, } From 7058b8051e094e4a41fd0c13025e6bd7b9c14e6b Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:10:42 +0800 Subject: [PATCH 03/39] fix(provider-registry): add oauth_family: None at remaining call sites Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-cli/src/auth_provider.rs | 1 + crates/puffer-cli/src/daemon.rs | 1 + crates/puffer-core/command/tests.rs | 1 + crates/puffer-core/command/tests/context.rs | 2 ++ crates/puffer-core/command/tests/doctor.rs | 1 + crates/puffer-core/command/tests/login_auth.rs | 1 + crates/puffer-core/command/tests/model_scope.rs | 1 + crates/puffer-core/command/tests/status.rs | 1 + crates/puffer-core/command/tests/usage_buddy.rs | 3 +++ crates/puffer-core/command_helpers/doctor.rs | 1 + crates/puffer-core/command_helpers/prompt_tests.rs | 1 + crates/puffer-core/memory.rs | 1 + crates/puffer-core/runtime/agent_runtime_tests.rs | 1 + crates/puffer-core/runtime/anthropic.rs | 1 + crates/puffer-core/runtime/openai/conversation.rs | 1 + crates/puffer-core/runtime/openai/support.rs | 1 + crates/puffer-core/runtime/tests.rs | 2 ++ crates/puffer-resources/src/model.rs | 2 ++ crates/puffer-tui/src/onboarding/mod.rs | 1 + crates/puffer-tui/src/render/tests.rs | 2 ++ crates/puffer-tui/src/tests/support.rs | 4 ++++ 21 files changed, 30 insertions(+) diff --git a/crates/puffer-cli/src/auth_provider.rs b/crates/puffer-cli/src/auth_provider.rs index 21e437524..3208bf86b 100644 --- a/crates/puffer-cli/src/auth_provider.rs +++ b/crates/puffer-cli/src/auth_provider.rs @@ -176,6 +176,7 @@ mod tests { cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-cli/src/daemon.rs b/crates/puffer-cli/src/daemon.rs index 1397cf8ff..d59edb23f 100644 --- a/crates/puffer-cli/src/daemon.rs +++ b/crates/puffer-cli/src/daemon.rs @@ -4224,6 +4224,7 @@ mod tests { display_name: id.to_string(), base_url: "https://example.invalid".to_string(), chat_completions_path: None, + oauth_family: None, default_api: "openai-responses".to_string(), auth_modes: Vec::new(), headers: IndexMap::new(), diff --git a/crates/puffer-core/command/tests.rs b/crates/puffer-core/command/tests.rs index 87634908b..733b5cc2a 100644 --- a/crates/puffer-core/command/tests.rs +++ b/crates/puffer-core/command/tests.rs @@ -317,6 +317,7 @@ fn doctor_reports_discovery_and_diagnostics() { }), models: Vec::new(), chat_completions_path: None, + oauth_family: None, }); let mut auth_store = AuthStore::default(); auth_store.set_api_key("anthropic", "sk-ant"); diff --git a/crates/puffer-core/command/tests/context.rs b/crates/puffer-core/command/tests/context.rs index 4761d57a7..e281218d3 100644 --- a/crates/puffer-core/command/tests/context.rs +++ b/crates/puffer-core/command/tests/context.rs @@ -87,6 +87,7 @@ fn context_command_renders_anthropic_context_breakdown() { cost: None, }], chat_completions_path: None, + oauth_family: None, }); dispatch_command( @@ -166,6 +167,7 @@ fn context_command_renders_openai_context_breakdown() { cost: None, }], chat_completions_path: None, + oauth_family: None, }); dispatch_command( diff --git a/crates/puffer-core/command/tests/doctor.rs b/crates/puffer-core/command/tests/doctor.rs index ec04b6f1d..238f2ab70 100644 --- a/crates/puffer-core/command/tests/doctor.rs +++ b/crates/puffer-core/command/tests/doctor.rs @@ -36,6 +36,7 @@ fn provider(id: &str, auth_modes: Vec) -> ProviderDescriptor { cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/command/tests/login_auth.rs b/crates/puffer-core/command/tests/login_auth.rs index 6f05c4a4b..551c1a084 100644 --- a/crates/puffer-core/command/tests/login_auth.rs +++ b/crates/puffer-core/command/tests/login_auth.rs @@ -30,6 +30,7 @@ fn provider( cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/command/tests/model_scope.rs b/crates/puffer-core/command/tests/model_scope.rs index c41e2656b..2c56c6bc1 100644 --- a/crates/puffer-core/command/tests/model_scope.rs +++ b/crates/puffer-core/command/tests/model_scope.rs @@ -30,6 +30,7 @@ fn provider(id: &str, models: &[&str]) -> puffer_provider_registry::ProviderDesc }) .collect(), chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/command/tests/status.rs b/crates/puffer-core/command/tests/status.rs index fe6d614cc..2984bbe18 100644 --- a/crates/puffer-core/command/tests/status.rs +++ b/crates/puffer-core/command/tests/status.rs @@ -43,6 +43,7 @@ fn status_command_reports_richer_session_and_resource_status() { cost: None, }], chat_completions_path: None, + oauth_family: None, }); let mut auth_store = AuthStore::default(); auth_store.set_api_key("openai".to_string(), "sk-test".to_string()); diff --git a/crates/puffer-core/command/tests/usage_buddy.rs b/crates/puffer-core/command/tests/usage_buddy.rs index 4d979ef08..990538c95 100644 --- a/crates/puffer-core/command/tests/usage_buddy.rs +++ b/crates/puffer-core/command/tests/usage_buddy.rs @@ -51,6 +51,7 @@ fn usage_command_reports_runtime_and_resource_counts() { cost: None, }], chat_completions_path: None, + oauth_family: None, }); let mut auth_store = AuthStore::default(); auth_store.set_api_key("anthropic", "sk-ant"); @@ -382,6 +383,7 @@ fn anthropic_provider() -> ProviderDescriptor { cost: None, }], chat_completions_path: None, + oauth_family: None, } } @@ -408,6 +410,7 @@ fn openai_provider() -> ProviderDescriptor { cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/command_helpers/doctor.rs b/crates/puffer-core/command_helpers/doctor.rs index 337ec6283..c07efb84b 100644 --- a/crates/puffer-core/command_helpers/doctor.rs +++ b/crates/puffer-core/command_helpers/doctor.rs @@ -674,6 +674,7 @@ mod tests { cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/command_helpers/prompt_tests.rs b/crates/puffer-core/command_helpers/prompt_tests.rs index 107aabe75..a6b1219f6 100644 --- a/crates/puffer-core/command_helpers/prompt_tests.rs +++ b/crates/puffer-core/command_helpers/prompt_tests.rs @@ -701,6 +701,7 @@ fn local_provider(base_url: String) -> ProviderDescriptor { compat: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/memory.rs b/crates/puffer-core/memory.rs index 3ab940a13..7472b8131 100644 --- a/crates/puffer-core/memory.rs +++ b/crates/puffer-core/memory.rs @@ -985,6 +985,7 @@ mod tests { compat: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/runtime/agent_runtime_tests.rs b/crates/puffer-core/runtime/agent_runtime_tests.rs index 53c816408..86d934576 100644 --- a/crates/puffer-core/runtime/agent_runtime_tests.rs +++ b/crates/puffer-core/runtime/agent_runtime_tests.rs @@ -35,6 +35,7 @@ fn provider() -> ProviderDescriptor { cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/runtime/anthropic.rs b/crates/puffer-core/runtime/anthropic.rs index 381719ad8..6396230aa 100644 --- a/crates/puffer-core/runtime/anthropic.rs +++ b/crates/puffer-core/runtime/anthropic.rs @@ -1087,6 +1087,7 @@ mod thinking_gate_tests { compat: model_compat, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/runtime/openai/conversation.rs b/crates/puffer-core/runtime/openai/conversation.rs index f2b4534ea..7e221359f 100644 --- a/crates/puffer-core/runtime/openai/conversation.rs +++ b/crates/puffer-core/runtime/openai/conversation.rs @@ -1879,6 +1879,7 @@ mod tests { cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/runtime/openai/support.rs b/crates/puffer-core/runtime/openai/support.rs index ae5fd1f00..6c587d282 100644 --- a/crates/puffer-core/runtime/openai/support.rs +++ b/crates/puffer-core/runtime/openai/support.rs @@ -577,6 +577,7 @@ mod tests { discovery: None, models: Vec::new(), chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-core/runtime/tests.rs b/crates/puffer-core/runtime/tests.rs index 38e3c3d1a..2dab32209 100644 --- a/crates/puffer-core/runtime/tests.rs +++ b/crates/puffer-core/runtime/tests.rs @@ -40,6 +40,7 @@ fn provider() -> ProviderDescriptor { cost: None, }], chat_completions_path: None, + oauth_family: None, } } @@ -201,6 +202,7 @@ fn openai_provider(base_url: String) -> ProviderDescriptor { cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-resources/src/model.rs b/crates/puffer-resources/src/model.rs index 01e4598d5..857857742 100644 --- a/crates/puffer-resources/src/model.rs +++ b/crates/puffer-resources/src/model.rs @@ -463,6 +463,8 @@ pub struct ProviderPack { /// so we don't double up to `/v4/v1/chat/completions`. #[serde(default)] pub chat_completions_path: Option, + /// OAuth family tag for this provider. See `ProviderDescriptor::oauth_family` + /// for the canonical description of this field. #[serde(default)] pub oauth_family: Option, #[serde(default)] diff --git a/crates/puffer-tui/src/onboarding/mod.rs b/crates/puffer-tui/src/onboarding/mod.rs index 2c4548654..498226308 100644 --- a/crates/puffer-tui/src/onboarding/mod.rs +++ b/crates/puffer-tui/src/onboarding/mod.rs @@ -729,6 +729,7 @@ mod tests { cost: None, }], chat_completions_path: None, + oauth_family: None, } } diff --git a/crates/puffer-tui/src/render/tests.rs b/crates/puffer-tui/src/render/tests.rs index 228b30a9c..358f30ac9 100644 --- a/crates/puffer-tui/src/render/tests.rs +++ b/crates/puffer-tui/src/render/tests.rs @@ -846,6 +846,7 @@ pub(super) fn sample_providers() -> ProviderRegistry { cost: None, }], chat_completions_path: None, + oauth_family: None, }); registry.register(ProviderDescriptor { id: "openai".to_string(), @@ -869,6 +870,7 @@ pub(super) fn sample_providers() -> ProviderRegistry { cost: None, }], chat_completions_path: None, + oauth_family: None, }); registry } diff --git a/crates/puffer-tui/src/tests/support.rs b/crates/puffer-tui/src/tests/support.rs index 122eae3bd..35f25edc2 100644 --- a/crates/puffer-tui/src/tests/support.rs +++ b/crates/puffer-tui/src/tests/support.rs @@ -202,6 +202,7 @@ pub(super) fn openai_provider_resources() -> LoadedResources { cost: None, }], chat_completions_path: None, + oauth_family: None, }, )], ..LoadedResources::default() @@ -246,6 +247,7 @@ pub(super) fn sample_providers() -> ProviderRegistry { }, ], chat_completions_path: None, + oauth_family: None, }); providers.register(ProviderDescriptor { id: "openai".to_string(), @@ -269,6 +271,7 @@ pub(super) fn sample_providers() -> ProviderRegistry { cost: None, }], chat_completions_path: None, + oauth_family: None, }); providers.register(ProviderDescriptor { id: "ollama".to_string(), @@ -292,6 +295,7 @@ pub(super) fn sample_providers() -> ProviderRegistry { cost: None, }], chat_completions_path: None, + oauth_family: None, }); providers } From 1f655fc176aa02db1f1910d9d225d4bae3a79d24 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:12:07 +0800 Subject: [PATCH 04/39] feat(provider-worldagent): bootstrap empty crate Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 1 + crates/puffer-provider-worldagent/Cargo.toml | 14 ++++++++++++++ crates/puffer-provider-worldagent/src/auth.rs | 1 + crates/puffer-provider-worldagent/src/lib.rs | 10 ++++++++++ 4 files changed, 26 insertions(+) create mode 100644 crates/puffer-provider-worldagent/Cargo.toml create mode 100644 crates/puffer-provider-worldagent/src/auth.rs create mode 100644 crates/puffer-provider-worldagent/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index d651127c9..b7250d337 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/puffer-observability", "crates/puffer-provider-openai", "crates/puffer-provider-registry", + "crates/puffer-provider-worldagent", "crates/puffer-resources", "crates/puffer-runner-api", "crates/puffer-runner-local", diff --git a/crates/puffer-provider-worldagent/Cargo.toml b/crates/puffer-provider-worldagent/Cargo.toml new file mode 100644 index 000000000..d4627f397 --- /dev/null +++ b/crates/puffer-provider-worldagent/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "puffer-provider-worldagent" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +base64.workspace = true +rand = "0.9.2" +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +url = "2.5.7" diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs new file mode 100644 index 000000000..70e277a6f --- /dev/null +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -0,0 +1 @@ +//! Auth Station login URL building, callback parsing, JWT decoding. diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs new file mode 100644 index 000000000..b36e0ef11 --- /dev/null +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -0,0 +1,10 @@ +//! Auth Station OAuth helpers for the `worldagent` provider. +//! +//! Auth Station's `/login` flow returns the final `token` and +//! `refresh_token` directly in the callback URL. There is no PKCE, +//! no code exchange. This crate owns URL building, callback +//! parsing, JWT-payload decoding, and refresh. + +#![allow(dead_code)] + +mod auth; From ed826360f5eb98830c29664a7f1797bc5743d366 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:14:34 +0800 Subject: [PATCH 05/39] feat(provider-worldagent): implement build_login_url + config Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-provider-worldagent/src/auth.rs | 84 +++++++++++++++++++ crates/puffer-provider-worldagent/src/lib.rs | 9 +- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index 70e277a6f..293b49742 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -1 +1,85 @@ //! Auth Station login URL building, callback parsing, JWT decoding. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use rand::RngCore; + +/// Default Auth Station base URL (Sandbox). Production is +/// `https://auth.worldrouter.ai`. The env var named by +/// [`WORLDAGENT_AUTH_URL_OVERRIDE_ENV`] overrides this at runtime. +pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app"; + +/// Env var name that overrides the Auth Station base URL. +pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; + +/// Fixed loopback callback path used by Puffer desktop. The auth +/// team must allow-list the full URI on both Sandbox and Production. +pub const WORLDAGENT_CALLBACK_PATH: &str = "/callback"; + +/// Fixed loopback callback port used by Puffer desktop. See +/// [`WORLDAGENT_CALLBACK_PATH`] for the path component. +pub const WORLDAGENT_CALLBACK_PORT: u16 = 1456; + +/// Concatenated fixed loopback redirect URI. +pub const WORLDAGENT_DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:1456/callback"; + +/// Parameters needed to build an Auth Station login URL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorldAgentLoginConfig { + /// Base URL of Auth Station, no trailing slash. + pub auth_base_url: String, + /// Full redirect URI for the desktop callback listener. + pub redirect_uri: String, + /// Opaque random value used as the CSRF guard. + pub client_state: String, +} + +impl Default for WorldAgentLoginConfig { + fn default() -> Self { + let auth_base_url = std::env::var(WORLDAGENT_AUTH_URL_OVERRIDE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| WORLDAGENT_AUTH_BASE_URL.to_string()); + Self { + auth_base_url, + redirect_uri: WORLDAGENT_DEFAULT_REDIRECT_URI.to_string(), + client_state: generate_client_state(), + } + } +} + +/// Generates an opaque url-safe random `client_state`. +pub fn generate_client_state() -> String { + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Builds the Auth Station `/login` URL for the given config. +pub fn build_login_url(config: &WorldAgentLoginConfig) -> String { + let trimmed = config.auth_base_url.trim_end_matches('/'); + let mut url = url::Url::parse(&format!("{trimmed}/login")) + .expect("auth_base_url must be a valid URL"); + url.query_pairs_mut() + .append_pair("redirect_uri", &config.redirect_uri) + .append_pair("client_state", &config.client_state); + url.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_login_url_contains_redirect_uri_and_client_state() { + let config = WorldAgentLoginConfig { + auth_base_url: "https://auth-worldrouter.vercel.app".to_string(), + redirect_uri: "http://127.0.0.1:1456/callback".to_string(), + client_state: "state-xyz".to_string(), + }; + let url = build_login_url(&config); + assert!(url.starts_with("https://auth-worldrouter.vercel.app/login?")); + assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A1456%2Fcallback")); + assert!(url.contains("client_state=state-xyz")); + } +} diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs index b36e0ef11..6adb0fbac 100644 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -5,6 +5,11 @@ //! no code exchange. This crate owns URL building, callback //! parsing, JWT-payload decoding, and refresh. -#![allow(dead_code)] - mod auth; + +pub use auth::{ + build_login_url, generate_client_state, WorldAgentLoginConfig, + WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, + WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, + WORLDAGENT_DEFAULT_REDIRECT_URI, +}; From 052964de7c8d426102bcf443c281f1340f0f462a Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:16:36 +0800 Subject: [PATCH 06/39] feat(provider-worldagent): parse callback URL into typed fields Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-provider-worldagent/src/auth.rs | 81 +++++++++++++++++++ crates/puffer-provider-worldagent/src/lib.rs | 3 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index 293b49742..67ce6975c 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -66,6 +66,59 @@ pub fn build_login_url(config: &WorldAgentLoginConfig) -> String { url.to_string() } +/// Parsed callback fields. Each field is `None` when its parameter +/// was absent from the callback URL. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WorldAgentCallback { + /// `token` query parameter — the access token JWT. + pub token: Option, + /// `refresh_token` query parameter — the refresh token JWT. + pub refresh_token: Option, + /// `state` query parameter — original `client_state` echoed back. + pub state: Option, + /// `error` query parameter — populated when login failed. + pub error: Option, + /// `error_description` query parameter — populated on failure. + pub error_description: Option, +} + +/// Extracts `token`, `refresh_token`, `state`, `error`, and +/// `error_description` from a callback URL or raw query string. +pub fn parse_callback_input(input: &str) -> WorldAgentCallback { + let trimmed = input.trim(); + if trimmed.is_empty() { + return WorldAgentCallback::default(); + } + let mut callback = WorldAgentCallback::default(); + let pairs: Box> = + if let Ok(url) = url::Url::parse(trimmed) { + Box::new( + url.query_pairs() + .into_owned() + .collect::>() + .into_iter(), + ) + } else { + Box::new( + url::form_urlencoded::parse(trimmed.as_bytes()) + .into_owned() + .collect::>() + .into_iter(), + ) + }; + for (key, value) in pairs { + match key.as_str() { + "token" => callback.token = Some(value), + "refresh_token" => callback.refresh_token = Some(value), + "state" => callback.state = Some(value), + "error" => callback.error = Some(value), + "error_description" => callback.error_description = Some(value), + _ => {} + } + } + callback +} + #[cfg(test)] mod tests { use super::*; @@ -82,4 +135,32 @@ mod tests { assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A1456%2Fcallback")); assert!(url.contains("client_state=state-xyz")); } + + #[test] + fn parse_callback_input_extracts_token_refresh_state() { + let parsed = parse_callback_input( + "http://127.0.0.1:1456/callback?token=acc&refresh_token=ref&state=xyz", + ); + assert_eq!(parsed.token.as_deref(), Some("acc")); + assert_eq!(parsed.refresh_token.as_deref(), Some("ref")); + assert_eq!(parsed.state.as_deref(), Some("xyz")); + assert!(parsed.error.is_none()); + } + + #[test] + fn parse_callback_input_extracts_error() { + let parsed = parse_callback_input( + "http://127.0.0.1:1456/callback?error=invalid_state&error_description=bad+state&state=xyz", + ); + assert_eq!(parsed.error.as_deref(), Some("invalid_state")); + assert_eq!(parsed.error_description.as_deref(), Some("bad state")); + assert!(parsed.token.is_none()); + } + + #[test] + fn parse_callback_input_handles_raw_query_string() { + let parsed = parse_callback_input("token=acc&state=xyz"); + assert_eq!(parsed.token.as_deref(), Some("acc")); + assert_eq!(parsed.state.as_deref(), Some("xyz")); + } } diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs index 6adb0fbac..1eb4f6838 100644 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -8,7 +8,8 @@ mod auth; pub use auth::{ - build_login_url, generate_client_state, WorldAgentLoginConfig, + build_login_url, generate_client_state, parse_callback_input, + WorldAgentCallback, WorldAgentLoginConfig, WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, From 6052d043daf7d3947f82c4551ca0686e231333b9 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:18:30 +0800 Subject: [PATCH 07/39] feat(provider-worldagent): decode JWT profile (sub/email/name) --- crates/puffer-provider-worldagent/src/auth.rs | 62 +++++++++++++++++++ crates/puffer-provider-worldagent/src/lib.rs | 10 +-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index 67ce6975c..40ddd140d 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -119,6 +119,45 @@ pub fn parse_callback_input(input: &str) -> WorldAgentCallback { callback } +/// Decoded JWT profile fields, best-effort. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WorldAgentJwtProfile { + /// JWT `sub` claim — Auth Station user id (WorkOS user id). + pub sub: Option, + /// JWT `email` claim. + pub email: Option, + /// JWT `name` claim — may be an empty string upstream. + pub name: Option, +} + +/// Decodes `sub` / `email` / `name` from the access token JWT +/// payload. Any decode/parse failure yields an empty profile. +pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile { + let Some(payload_b64) = access_token.split('.').nth(1) else { + return WorldAgentJwtProfile::default(); + }; + let Ok(payload_bytes) = URL_SAFE_NO_PAD.decode(payload_b64.as_bytes()) else { + return WorldAgentJwtProfile::default(); + }; + let Ok(value) = serde_json::from_slice::(&payload_bytes) else { + return WorldAgentJwtProfile::default(); + }; + WorldAgentJwtProfile { + sub: value + .get("sub") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + email: value + .get("email") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + name: value + .get("name") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + } +} + #[cfg(test)] mod tests { use super::*; @@ -163,4 +202,27 @@ mod tests { assert_eq!(parsed.token.as_deref(), Some("acc")); assert_eq!(parsed.state.as_deref(), Some("xyz")); } + + #[test] + fn decode_jwt_profile_reads_sub_email_name() { + let payload = serde_json::json!({ + "sub": "user_01ABC", + "email": "dev@example.com", + "name": "Dev User", + }); + let encoded = URL_SAFE_NO_PAD.encode(payload.to_string()); + let token = format!("header.{encoded}.sig"); + let profile = decode_jwt_profile(&token); + assert_eq!(profile.sub.as_deref(), Some("user_01ABC")); + assert_eq!(profile.email.as_deref(), Some("dev@example.com")); + assert_eq!(profile.name.as_deref(), Some("Dev User")); + } + + #[test] + fn decode_jwt_profile_handles_malformed_token() { + let profile = decode_jwt_profile("not-a-jwt"); + assert!(profile.sub.is_none()); + assert!(profile.email.is_none()); + assert!(profile.name.is_none()); + } } diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs index 1eb4f6838..45a3851ff 100644 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -8,9 +8,9 @@ mod auth; pub use auth::{ - build_login_url, generate_client_state, parse_callback_input, - WorldAgentCallback, WorldAgentLoginConfig, - WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, - WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, - WORLDAGENT_DEFAULT_REDIRECT_URI, + build_login_url, decode_jwt_profile, generate_client_state, + parse_callback_input, WorldAgentCallback, WorldAgentJwtProfile, + WorldAgentLoginConfig, WORLDAGENT_AUTH_BASE_URL, + WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, + WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, }; From 5297c0955030168b1e961d21f4248741b0ca2bb4 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:20:27 +0800 Subject: [PATCH 08/39] feat(provider-worldagent): add credentials + refresh + exchange stub Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-provider-worldagent/src/auth.rs | 107 ++++++++++++++++++ crates/puffer-provider-worldagent/src/lib.rs | 7 +- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index 40ddd140d..1940c7145 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -1,8 +1,12 @@ //! Auth Station login URL building, callback parsing, JWT decoding. +use anyhow::{anyhow, Context, Result}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use rand::RngCore; +use reqwest::blocking::Client; +use serde::Deserialize; +use std::time::{SystemTime, UNIX_EPOCH}; /// Default Auth Station base URL (Sandbox). Production is /// `https://auth.worldrouter.ai`. The env var named by @@ -158,6 +162,101 @@ pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile { } } +/// Persisted Auth Station credentials for the worldagent provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorldAgentOAuthCredentials { + /// Auth Station access token (24h validity per current docs). + pub access_token: String, + /// Auth Station refresh token (7d validity). + pub refresh_token: String, + /// Unix epoch milliseconds when the access token expires. + pub expires_at_ms: u64, + /// `sub` claim from the access token JWT. + pub sub: Option, + /// `email` claim from the access token JWT. + pub email: Option, + /// `name` claim from the access token JWT. + pub name: Option, +} + +/// Exchanges a stored refresh token for a new access token via +/// `POST /token/refresh`. Preserves the existing +/// `refresh_token` (Auth Station does not rotate refresh tokens, and +/// `/token/refresh` returns only `{ "token": ... }`). +pub fn refresh_oauth_token( + refresh_token: &str, + auth_base_url: Option<&str>, +) -> Result { + let base = auth_base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| { + std::env::var(WORLDAGENT_AUTH_URL_OVERRIDE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| WORLDAGENT_AUTH_BASE_URL.to_string()) + }); + let url = format!("{}/token/refresh", base.trim_end_matches('/')); + let response = Client::new() + .post(&url) + .json(&serde_json::json!({ "refresh_token": refresh_token })) + .send() + .context("failed to send worldagent refresh request")?; + let status = response.status(); + let payload: RefreshResponse = response + .json() + .context("failed to parse worldagent refresh response")?; + if !status.is_success() { + return Err(anyhow!( + "worldagent token refresh failed with status {status}: {}", + payload.error.unwrap_or_default() + )); + } + let access_token = payload + .token + .ok_or_else(|| anyhow!("worldagent refresh response missing token"))?; + let profile = decode_jwt_profile(&access_token); + Ok(WorldAgentOAuthCredentials { + access_token, + refresh_token: refresh_token.to_string(), + expires_at_ms: now_ms() + 24 * 3600 * 1000, + sub: profile.sub, + email: profile.email, + name: profile.name, + }) +} + +/// Exchanges an Auth Station JWT for an inference API key. +/// +/// **TODO (waiting on worldrouter backend):** the endpoint and +/// request shape are not yet finalized. Once defined, this function +/// will `POST /api/v1/keys/exchange` (or whatever the +/// backend picks) with `Authorization: Bearer ` and +/// return the `api_key` string. The login handler will then upgrade +/// the stored credential to an `ApiKey { key }` variant. +pub fn exchange_jwt_for_api_key(_access_token: &str) -> Result { + Err(anyhow!( + "worldagent JWT-to-api-key exchange is not yet implemented; \ + paste your WorldRouter API key for now" + )) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[derive(Debug, Deserialize)] +struct RefreshResponse { + #[serde(default)] + token: Option, + #[serde(default)] + error: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -225,4 +324,12 @@ mod tests { assert!(profile.email.is_none()); assert!(profile.name.is_none()); } + + #[test] + fn exchange_jwt_for_api_key_is_a_placeholder() { + let result = exchange_jwt_for_api_key("any.access.token"); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("not yet implemented")); + } } diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs index 45a3851ff..32a9f7d30 100644 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -8,9 +8,10 @@ mod auth; pub use auth::{ - build_login_url, decode_jwt_profile, generate_client_state, - parse_callback_input, WorldAgentCallback, WorldAgentJwtProfile, - WorldAgentLoginConfig, WORLDAGENT_AUTH_BASE_URL, + build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, + generate_client_state, parse_callback_input, refresh_oauth_token, + WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, + WorldAgentOAuthCredentials, WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, }; From e6be84655b02b7dc79833416813d2211b3d83149 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:22:15 +0800 Subject: [PATCH 09/39] feat(cli/authflow): add bind_localhost_port for fixed-port callbacks Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-cli/src/authflow.rs | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/puffer-cli/src/authflow.rs b/crates/puffer-cli/src/authflow.rs index f74f6ffc8..d8244700b 100644 --- a/crates/puffer-cli/src/authflow.rs +++ b/crates/puffer-cli/src/authflow.rs @@ -32,6 +32,23 @@ impl CallbackListener { }) } + /// Binds a fixed loopback port. Used for redirect URIs that must + /// match an Auth Station allow-list entry exactly (such as the + /// worldagent provider). Returns an error if the port is in use. + pub(crate) fn bind_localhost_port(path: &str, port: u16) -> Result { + let listener = TcpListener::bind(("127.0.0.1", port)).with_context(|| { + format!("failed to bind callback listener on 127.0.0.1:{port} for {path}") + })?; + listener.set_nonblocking(true)?; + Ok(Self { + listener, + host: "127.0.0.1".to_string(), + port, + expected_path: path.to_string(), + redirect_uri: format!("http://127.0.0.1:{port}{path}"), + }) + } + /// Returns the automatic redirect URI associated with this listener. pub(crate) fn redirect_uri(&self) -> &str { &self.redirect_uri @@ -174,4 +191,19 @@ mod tests { format!("http://127.0.0.1:{callback_port}/callback?code=test-code&state=test-state"); assert_eq!(callback.as_deref(), Some(expected.as_str())); } + + #[test] + fn bind_localhost_port_uses_requested_port() { + // Find a free port by binding 0, then drop and rebind on it. + let probe = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = probe.local_addr().unwrap().port(); + drop(probe); + let listener = CallbackListener::bind_localhost_port("/callback", port) + .expect("bind_localhost_port succeeds on a free port"); + let redirect_uri = listener.redirect_uri(); + assert_eq!( + redirect_uri, + format!("http://127.0.0.1:{port}/callback") + ); + } } From 10f6aefb91684b3296f4cd523fd3c9d6c44d8935 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:25:49 +0800 Subject: [PATCH 10/39] feat(cli/auth_provider): dispatch worldagent OAuth family Add OauthFamily::WorldAgent variant, extend oauth_family_for_provider to prefer the explicit oauth_family field before falling back to default_api, and wire WorldAgent arms into both bundle functions and match exhaustiveness stubs in main.rs / daemon.rs. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/puffer-cli/Cargo.toml | 1 + crates/puffer-cli/src/auth_provider.rs | 64 ++++++++++++++++++++++++++ crates/puffer-cli/src/daemon.rs | 3 ++ crates/puffer-cli/src/main.rs | 9 ++++ 4 files changed, 77 insertions(+) diff --git a/crates/puffer-cli/Cargo.toml b/crates/puffer-cli/Cargo.toml index 23a0c87ef..b304e9e4f 100644 --- a/crates/puffer-cli/Cargo.toml +++ b/crates/puffer-cli/Cargo.toml @@ -24,6 +24,7 @@ puffer-core = { path = "../puffer-core" } puffer-mcp-oauth = { path = "../puffer-mcp-oauth" } puffer-observability = { path = "../puffer-observability" } puffer-provider-openai = { path = "../puffer-provider-openai" } +puffer-provider-worldagent = { path = "../puffer-provider-worldagent" } puffer-provider-registry = { path = "../puffer-provider-registry" } puffer-resources = { path = "../puffer-resources" } puffer-runner-api = { path = "../puffer-runner-api" } diff --git a/crates/puffer-cli/src/auth_provider.rs b/crates/puffer-cli/src/auth_provider.rs index 3208bf86b..ab08f9a58 100644 --- a/crates/puffer-cli/src/auth_provider.rs +++ b/crates/puffer-cli/src/auth_provider.rs @@ -14,6 +14,7 @@ use puffer_transport_anthropic::{ pub(crate) enum OauthFamily { Anthropic, OpenAi, + WorldAgent, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,6 +36,14 @@ pub(crate) fn oauth_family_for_provider( if !provider.auth_modes.contains(&AuthMode::OAuth) { return None; } + if let Some(family) = provider.oauth_family.as_deref() { + return match family { + "openai" => Some(OauthFamily::OpenAi), + "anthropic" => Some(OauthFamily::Anthropic), + "worldagent" => Some(OauthFamily::WorldAgent), + _ => None, + }; + } match provider.default_api.as_str() { "openai-responses" | "openai-completions" @@ -90,6 +99,17 @@ pub(crate) fn oauth_start_bundle_for_provider( manual_redirect_uri: Some(ANTHROPIC_MANUAL_REDIRECT_URL.to_string()), }) } + Some(OauthFamily::WorldAgent) => { + let config = puffer_provider_worldagent::WorldAgentLoginConfig::default(); + Ok(OauthStartBundle { + authorization_url: puffer_provider_worldagent::build_login_url(&config), + automatic_authorization_url: None, + verifier: String::new(), + state: config.client_state, + redirect_uri: config.redirect_uri, + manual_redirect_uri: None, + }) + } None => Err(anyhow!("oauth is not implemented for {provider_id}")), } } @@ -144,6 +164,20 @@ pub(crate) fn oauth_login_bundle_for_provider( manual_redirect_uri: Some(manual.redirect_uri), }) } + Some(OauthFamily::WorldAgent) => { + let config = puffer_provider_worldagent::WorldAgentLoginConfig { + redirect_uri: redirect_uri.to_string(), + ..puffer_provider_worldagent::WorldAgentLoginConfig::default() + }; + Ok(OauthStartBundle { + authorization_url: puffer_provider_worldagent::build_login_url(&config), + automatic_authorization_url: None, + verifier: String::new(), + state: config.client_state, + redirect_uri: config.redirect_uri, + manual_redirect_uri: None, + }) + } None => Err(anyhow!("oauth is not implemented for {provider_id}")), } } @@ -231,4 +265,34 @@ mod tests { )); assert_eq!(oauth_family_for_provider(&providers, "custom-openai"), None); } + + #[test] + fn oauth_family_uses_explicit_oauth_family_field() { + let mut providers = ProviderRegistry::new(); + let mut descriptor = provider( + "worldagent", + "openai-completions", + vec![AuthMode::OAuth, AuthMode::ApiKey], + ); + descriptor.oauth_family = Some("worldagent".to_string()); + providers.register(descriptor); + assert_eq!( + oauth_family_for_provider(&providers, "worldagent"), + Some(OauthFamily::WorldAgent) + ); + } + + #[test] + fn oauth_family_falls_back_to_default_api_when_field_unset() { + let mut providers = ProviderRegistry::new(); + providers.register(provider( + "custom-openai", + "openai-completions", + vec![AuthMode::OAuth], + )); + assert_eq!( + oauth_family_for_provider(&providers, "custom-openai"), + Some(OauthFamily::OpenAi) + ); + } } diff --git a/crates/puffer-cli/src/daemon.rs b/crates/puffer-cli/src/daemon.rs index d59edb23f..74bb59aee 100644 --- a/crates/puffer-cli/src/daemon.rs +++ b/crates/puffer-cli/src/daemon.rs @@ -1144,6 +1144,9 @@ fn handle_login_with_oauth(state: &DaemonState, params: &Value) -> Result )?; store_anthropic_credential(&mut inputs.auth_store, &provider_id, credential)?; } + Some(OauthFamily::WorldAgent) => { + todo!("worldagent oauth login — implemented in Task 11") + } None => anyhow::bail!("oauth login is not implemented for {provider_id}"), } diff --git a/crates/puffer-cli/src/main.rs b/crates/puffer-cli/src/main.rs index 6b46f45e6..cf64d0cda 100644 --- a/crates/puffer-cli/src/main.rs +++ b/crates/puffer-cli/src/main.rs @@ -963,6 +963,9 @@ fn run_auth_command( )?; store_anthropic_credential(auth_store, &provider, credential)?; } + Some(OauthFamily::WorldAgent) => { + todo!("worldagent oauth exchange — implemented in Task 10") + } None => anyhow::bail!("oauth exchange is not implemented for {provider}"), } auth_store.save(auth_path)?; @@ -990,6 +993,9 @@ fn run_auth_command( Some(®istry_to_anthropic_oauth_credential(existing)), )?)? } + Some(OauthFamily::WorldAgent) => { + todo!("worldagent oauth refresh — implemented in Task 10") + } None => anyhow::bail!("oauth refresh is not implemented for {provider}"), }; set_stored_credential(auth_store, provider.clone(), refreshed); @@ -1098,6 +1104,9 @@ fn run_login_flow( )?; store_anthropic_credential(auth_store, provider, credential)?; } + Some(OauthFamily::WorldAgent) => { + todo!("worldagent oauth login — implemented in Task 10") + } None => anyhow::bail!("oauth login is not implemented for {provider}"), } From 93ca09b25663c14524264c989fcf650b309cf4ca Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:27:49 +0800 Subject: [PATCH 11/39] feat(cli/auth_credentials): map worldagent credential into registry shape Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-cli/src/auth_credentials.rs | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/puffer-cli/src/auth_credentials.rs b/crates/puffer-cli/src/auth_credentials.rs index 1f97756fb..56ee3f549 100644 --- a/crates/puffer-cli/src/auth_credentials.rs +++ b/crates/puffer-cli/src/auth_credentials.rs @@ -118,6 +118,32 @@ pub(crate) fn store_ready_credential_from_anthropic( } } +/// Converts worldagent OAuth credentials into the registry storage shape. +/// The Auth Station `sub` claim is stored as `account_id` so the +/// existing AuthStore reuse path (organization_id, plan_type, etc.) +/// stays untouched. `name` is intentionally not persisted yet — the +/// existing `OAuthCredential` shape has no slot for it; if the UI +/// needs the display name later, we can either reuse `email` or +/// extend the struct. +pub(crate) fn to_registry_oauth_credential_worldagent( + credential: puffer_provider_worldagent::WorldAgentOAuthCredentials, +) -> puffer_provider_registry::OAuthCredential { + puffer_provider_registry::OAuthCredential { + access_token: credential.access_token, + refresh_token: credential.refresh_token, + expires_at_ms: credential.expires_at_ms, + account_id: credential.sub, + organization_id: None, + email: credential.email, + plan_type: None, + rate_limit_tier: None, + scopes: Vec::new(), + organization_name: None, + organization_role: None, + workspace_role: None, + } +} + /// Writes a resolved stored credential into the auth store. pub(crate) fn set_stored_credential( auth_store: &mut AuthStore, @@ -129,3 +155,27 @@ pub(crate) fn set_stored_credential( StoredCredential::OAuth(credential) => auth_store.set_oauth(provider, credential), } } + +#[cfg(test)] +mod tests { + use super::*; + use puffer_provider_worldagent::WorldAgentOAuthCredentials; + + #[test] + fn worldagent_credential_maps_email_and_account_id() { + let credential = WorldAgentOAuthCredentials { + access_token: "acc".to_string(), + refresh_token: "ref".to_string(), + expires_at_ms: 42, + sub: Some("user_01".to_string()), + email: Some("dev@example.com".to_string()), + name: Some("Dev".to_string()), + }; + let stored = to_registry_oauth_credential_worldagent(credential); + assert_eq!(stored.access_token, "acc"); + assert_eq!(stored.refresh_token, "ref"); + assert_eq!(stored.expires_at_ms, 42); + assert_eq!(stored.account_id.as_deref(), Some("user_01")); + assert_eq!(stored.email.as_deref(), Some("dev@example.com")); + } +} From 55d645abf699ac79c6b5c74f6f3ef8039988d2ad Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:32:01 +0800 Subject: [PATCH 12/39] feat(cli): handle worldagent OAuth in run_login_flow, refresh, exchange Replace the three todo!() stubs added in Task 8: wire parse_worldagent_callback_input + WorldAgentOAuthCredentials into run_login_flow, refresh_worldagent_oauth_token into oauth-refresh, and a descriptive bail! into oauth-exchange (no code-exchange step for worldagent). Also branches the CallbackListener bind to use WORLDAGENT_CALLBACK_PORT for the worldagent provider, and adds the worldagent_expires_at_ms() helper. Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-cli/src/main.rs | 60 +++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/puffer-cli/src/main.rs b/crates/puffer-cli/src/main.rs index cf64d0cda..a1f30465c 100644 --- a/crates/puffer-cli/src/main.rs +++ b/crates/puffer-cli/src/main.rs @@ -47,6 +47,12 @@ use puffer_provider_openai::{ use puffer_provider_registry::{ canonical_provider_id, AuthMode, AuthStore, ProviderRegistry, StoredCredential, }; +use puffer_provider_worldagent::{ + decode_jwt_profile as decode_worldagent_jwt_profile, + parse_callback_input as parse_worldagent_callback_input, + refresh_oauth_token as refresh_worldagent_oauth_token, + WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, +}; use puffer_resources::load_resources; use puffer_session_store::{SessionMetadata, SessionStore}; use puffer_tools::ToolRegistry; @@ -67,6 +73,7 @@ use crate::auth_credentials::{ anthropic_refresh_scopes, inferred_anthropic_redirect_uri, registry_to_anthropic_oauth_credential, set_stored_credential, store_anthropic_credential, store_ready_credential_from_anthropic, to_registry_oauth_credential_openai, + to_registry_oauth_credential_worldagent, }; use crate::auth_provider::{ oauth_family_for_provider, oauth_login_bundle_for_provider, oauth_start_bundle_for_provider, @@ -964,7 +971,10 @@ fn run_auth_command( store_anthropic_credential(auth_store, &provider, credential)?; } Some(OauthFamily::WorldAgent) => { - todo!("worldagent oauth exchange — implemented in Task 10") + anyhow::bail!( + "worldagent does not use the OAuth code-exchange flow; \ + run `puffer auth login worldagent` instead" + ); } None => anyhow::bail!("oauth exchange is not implemented for {provider}"), } @@ -994,7 +1004,9 @@ fn run_auth_command( )?)? } Some(OauthFamily::WorldAgent) => { - todo!("worldagent oauth refresh — implemented in Task 10") + StoredCredential::OAuth(to_registry_oauth_credential_worldagent( + refresh_worldagent_oauth_token(&existing.refresh_token, None)?, + )) } None => anyhow::bail!("oauth refresh is not implemented for {provider}"), }; @@ -1024,6 +1036,16 @@ fn resolve_provider_id(providers: &ProviderRegistry, provider: &str) -> String { .unwrap_or_else(|| canonical_provider_id(provider)) } +/// Returns the Unix epoch millis at which an Auth Station access +/// token issued **now** will expire (24 hours per the API doc). +fn worldagent_expires_at_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64 + 24 * 3600 * 1000) + .unwrap_or(24 * 3600 * 1000) +} + fn run_login_flow( provider: &str, value: Option, @@ -1034,6 +1056,14 @@ fn run_login_flow( ) -> Result<()> { let callback_listener = if stdin || value.is_some() { None + } else if matches!( + oauth_family_for_provider(providers, provider), + Some(OauthFamily::WorldAgent) + ) { + Some(authflow::CallbackListener::bind_localhost_port( + WORLDAGENT_CALLBACK_PATH, + WORLDAGENT_CALLBACK_PORT, + )?) } else { Some(authflow::CallbackListener::bind_localhost("/callback")?) }; @@ -1105,7 +1135,31 @@ fn run_login_flow( store_anthropic_credential(auth_store, provider, credential)?; } Some(OauthFamily::WorldAgent) => { - todo!("worldagent oauth login — implemented in Task 10") + let parsed = parse_worldagent_callback_input(&input); + if let Some(err) = parsed.error.as_deref() { + let desc = parsed.error_description.as_deref().unwrap_or(""); + anyhow::bail!("worldagent login failed: {err} {desc}"); + } + if parsed.state.as_deref() != Some(bundle.state.as_str()) { + anyhow::bail!("oauth state mismatch for worldagent"); + } + let access_token = parsed + .token + .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; + let refresh_token = parsed.refresh_token.unwrap_or_default(); + let profile = decode_worldagent_jwt_profile(&access_token); + let credential = WorldAgentOAuthCredentials { + access_token, + refresh_token, + expires_at_ms: worldagent_expires_at_ms(), + sub: profile.sub, + email: profile.email, + name: profile.name, + }; + auth_store.set_oauth( + provider.to_string(), + to_registry_oauth_credential_worldagent(credential), + ); } None => anyhow::bail!("oauth login is not implemented for {provider}"), } From f77112e9aeb2c64cdd95d8e10aa7d01550de2d76 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:33:12 +0800 Subject: [PATCH 13/39] chore: sync Cargo.lock for puffer-provider-worldagent --- Cargo.lock | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 914a1e0a4..e24512481 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4257,6 +4257,7 @@ dependencies = [ "puffer-observability", "puffer-provider-openai", "puffer-provider-registry", + "puffer-provider-worldagent", "puffer-resources", "puffer-runner-api", "puffer-runner-grpc", @@ -4566,12 +4567,26 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_yaml", "sha2 0.10.9", "tempfile", "toml", "uuid", ] +[[package]] +name = "puffer-provider-worldagent" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "rand 0.9.2", + "reqwest", + "serde", + "serde_json", + "url", +] + [[package]] name = "puffer-resources" version = "0.1.0" From dd4928d466926fa2a57394bb2af7cec4a1bc3ad0 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:35:30 +0800 Subject: [PATCH 14/39] feat(cli/daemon): handle worldagent OAuth in handle_login_with_oauth Wire the WorldAgent OAuth branch in the daemon RPC handler: add puffer_provider_worldagent imports, branch the CallbackListener bind to use the fixed port 1456, and replace the todo!() stub with the full token-extraction + JWT-decode + credential-store logic. Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-cli/src/daemon.rs | 50 +++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/puffer-cli/src/daemon.rs b/crates/puffer-cli/src/daemon.rs index 74bb59aee..d01514c79 100644 --- a/crates/puffer-cli/src/daemon.rs +++ b/crates/puffer-cli/src/daemon.rs @@ -49,6 +49,11 @@ use puffer_provider_openai::{ use puffer_provider_registry::{ AuthStore, ModelDescriptor, ProviderDescriptor, ProviderRegistry, StoredCredential, }; +use puffer_provider_worldagent::{ + decode_jwt_profile as decode_worldagent_jwt_profile, + parse_callback_input as parse_worldagent_callback_input, + WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, +}; use puffer_resources::{load_resources, LoadedResources, McpServerSpec}; use puffer_session_store::{MessageActor, SessionStore, TranscriptEvent}; use puffer_transport_anthropic::{ @@ -73,7 +78,7 @@ use uuid::Uuid; use crate::auth_credentials::{ inferred_anthropic_redirect_uri, set_stored_credential, store_anthropic_credential, - to_registry_oauth_credential_openai, + to_registry_oauth_credential_openai, to_registry_oauth_credential_worldagent, }; use crate::auth_provider::{ oauth_family_for_provider, oauth_login_bundle_for_provider, OauthFamily, @@ -1092,7 +1097,17 @@ fn handle_login_with_oauth(state: &DaemonState, params: &Value) -> Result let mut inputs = state.build_runtime_inputs()?; let auth_path = state.paths.user_config_dir.join("auth.json"); - let listener = crate::authflow::CallbackListener::bind_localhost("/callback")?; + let listener = if matches!( + oauth_family_for_provider(&inputs.providers, &provider_id), + Some(OauthFamily::WorldAgent) + ) { + crate::authflow::CallbackListener::bind_localhost_port( + WORLDAGENT_CALLBACK_PATH, + WORLDAGENT_CALLBACK_PORT, + )? + } else { + crate::authflow::CallbackListener::bind_localhost("/callback")? + }; let bundle = oauth_login_bundle_for_provider(&inputs.providers, &provider_id, listener.redirect_uri())?; let launch_url = bundle @@ -1145,7 +1160,36 @@ fn handle_login_with_oauth(state: &DaemonState, params: &Value) -> Result store_anthropic_credential(&mut inputs.auth_store, &provider_id, credential)?; } Some(OauthFamily::WorldAgent) => { - todo!("worldagent oauth login — implemented in Task 11") + let parsed = parse_worldagent_callback_input(&callback); + if let Some(err) = parsed.error.as_deref() { + let desc = parsed.error_description.as_deref().unwrap_or(""); + anyhow::bail!("worldagent login failed: {err} {desc}"); + } + if parsed.state.as_deref() != Some(bundle.state.as_str()) { + anyhow::bail!("oauth state mismatch for worldagent"); + } + let access_token = parsed + .token + .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; + let refresh_token = parsed.refresh_token.unwrap_or_default(); + let profile = decode_worldagent_jwt_profile(&access_token); + let expires_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64 + 24 * 3600 * 1000) + .unwrap_or(24 * 3600 * 1000); + let credential = WorldAgentOAuthCredentials { + access_token, + refresh_token, + expires_at_ms, + sub: profile.sub, + email: profile.email, + name: profile.name, + }; + set_stored_credential( + &mut inputs.auth_store, + provider_id.to_string(), + StoredCredential::OAuth(to_registry_oauth_credential_worldagent(credential)), + ); } None => anyhow::bail!("oauth login is not implemented for {provider_id}"), } From 93be773770b50a46414dc7ae1471219a52609398 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:37:36 +0800 Subject: [PATCH 15/39] feat(resources): ship worldagent provider yaml Add resources/providers/worldagent.yaml with oauth_family, auth_modes (api_key + oauth), and discovery config. Add end-to-end test confirming the yaml parses as ProviderPack and oauth_family flows through into_descriptor so the runtime dispatches WorldAgent OAuth correctly. Co-Authored-By: Claude Sonnet 4.6 --- crates/puffer-resources/src/model.rs | 16 ++++++++++++++++ resources/providers/worldagent.yaml | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 resources/providers/worldagent.yaml diff --git a/crates/puffer-resources/src/model.rs b/crates/puffer-resources/src/model.rs index 857857742..46a5ae60d 100644 --- a/crates/puffer-resources/src/model.rs +++ b/crates/puffer-resources/src/model.rs @@ -558,4 +558,20 @@ mod tests { "fallback list should include the current nano model" ); } + + /// Confirms the bundled `worldagent.yaml` parses as a + /// `ProviderPack` and that the `oauth_family` field round-trips + /// through `into_descriptor`. Without this end-to-end wiring the + /// runtime would silently fall back to OpenAI OAuth. + #[test] + fn worldagent_yaml_parses_with_oauth_family() { + let yaml = include_str!("../../../resources/providers/worldagent.yaml"); + let pack: ProviderPack = serde_yaml::from_str(yaml).expect("worldagent.yaml parses"); + assert_eq!(pack.id, "worldagent"); + assert_eq!(pack.oauth_family.as_deref(), Some("worldagent")); + let descriptor = pack.into_descriptor(); + assert_eq!(descriptor.oauth_family.as_deref(), Some("worldagent")); + assert!(descriptor.auth_modes.contains(&AuthMode::ApiKey)); + assert!(descriptor.auth_modes.contains(&AuthMode::OAuth)); + } } diff --git a/resources/providers/worldagent.yaml b/resources/providers/worldagent.yaml new file mode 100644 index 000000000..3d8dd8817 --- /dev/null +++ b/resources/providers/worldagent.yaml @@ -0,0 +1,23 @@ +id: worldagent +display_name: WorldAgent +base_url: https://inference-api.worldrouter.ai +default_api: openai-completions +oauth_family: worldagent +auth_modes: + - api_key + - oauth +discovery: + path: /v1/models + response: open_ai_models + api: openai-completions + context_window: 200000 + max_output_tokens: 8192 + supports_reasoning: true +models: + - id: gpt-5 + display_name: GPT-5 (via WorldRouter) + provider: worldagent + api: openai-completions + context_window: 200000 + max_output_tokens: 8192 + supports_reasoning: true From 50cd0ec6831cba89f3b406cec8da34dec323031e Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:38:22 +0800 Subject: [PATCH 16/39] feat(desktop): register worldagent provider visuals --- apps/puffer-desktop/src/lib/providerVisuals.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/puffer-desktop/src/lib/providerVisuals.ts b/apps/puffer-desktop/src/lib/providerVisuals.ts index b999603be..e776e86c5 100644 --- a/apps/puffer-desktop/src/lib/providerVisuals.ts +++ b/apps/puffer-desktop/src/lib/providerVisuals.ts @@ -23,6 +23,7 @@ const PROVIDER_ACCENTS: Record = { openrouter: "#06b6d4", "vercel-ai-gateway": "#0f172a", vllm: "#16a34a", + worldagent: "#1f6feb", xai: "#0f172a" }; @@ -42,6 +43,7 @@ const PROVIDER_ICONS: Record = { openrouter: "llm", "vercel-ai-gateway": "vercel", vllm: "llm", + worldagent: "ai", xai: "ai" }; From 16e9f905b53f6ef45997f8ad86a699c8f0b075da Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:39:57 +0800 Subject: [PATCH 17/39] docs(specs): document worldagent provider integration --- specs/puffer-cli/31.md | 40 +++++++------------------- specs/puffer-desktop/458.md | 8 ++++++ specs/puffer-provider-registry/06.md | 10 +++++++ specs/puffer-provider-worldagent/00.md | 21 ++++++++++++++ specs/puffer-resources/01.md | 11 +++++++ 5 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 specs/puffer-desktop/458.md create mode 100644 specs/puffer-provider-registry/06.md create mode 100644 specs/puffer-provider-worldagent/00.md create mode 100644 specs/puffer-resources/01.md diff --git a/specs/puffer-cli/31.md b/specs/puffer-cli/31.md index ee48e6df2..f14c70d84 100644 --- a/specs/puffer-cli/31.md +++ b/specs/puffer-cli/31.md @@ -1,31 +1,13 @@ -# 31. Non-Interactive Transcript Actor Sync +# WorldAgent OAuth Dispatch ## Summary - -Updates `puffer non-interactive` transcript writes for the actor-aware -`TranscriptEvent` schema used by session storage. - -## Design - -Non-interactive runs still create a normal `AppState` and append normal -session-store events. User messages now record `state.user_actor()`, assistant -messages and tool invocations record `state.assistant_actor()`, and tool -invocations preserve any agent/task subject via `state.tool_subject_actor`. - -Loaded JSONL transcripts remain backward-compatible. The replay path ignores -unknown or newly added event fields when projecting events into `AppState`, then -re-appends the original structured event to the new session transcript. - -## Contracts - -- The change is schema sync only; it does not alter command routing, provider - execution, permission behavior, or artifact output. -- Existing transcripts without actor fields continue to deserialize through - `puffer-session-store` defaults. -- Desktop Tauri builds can keep compiling `puffer-cli` as their pre-build step. - -## Verification - -- `cargo build -p puffer-cli` -- `cargo build --release -p puffer-cli` -- `npm run tauri build` +- `OauthFamily` grows a `WorldAgent` variant. +- `oauth_family_for_provider` prefers `descriptor.oauth_family` when set, otherwise falls back to `default_api`. +- `oauth_login_bundle_for_provider` builds the bundle from `WorldAgentLoginConfig`; `verifier` is empty (no PKCE), `automatic_authorization_url` is `None`. +- `handle_login_with_oauth` (daemon) and `run_login_flow` (cli) both gain a `WorldAgent` arm: parse callback, verify `state`, decode JWT for `sub`/`email`/`name`, store as `StoredCredential::OAuth`. +- `CallbackListener::bind_localhost_port` lets the daemon bind the fixed `127.0.0.1:1456` Auth-Station-whitelist port. +- `oauth-refresh` subcommand reuses `puffer_provider_worldagent::refresh_oauth_token` for the `WorldAgent` arm. `oauth-exchange` is unsupported for worldagent (no code-exchange step) and bails with a hint to use `puffer auth login worldagent`. + +## Compatibility +- Existing OpenAI / Anthropic flows are unchanged (`oauth_family` unset → falls back to `default_api` map). +- `WorldAgentOAuthCredentials.name` is not yet persisted (no slot on `OAuthCredential`); only `sub`/`email` survive into the registry shape. diff --git a/specs/puffer-desktop/458.md b/specs/puffer-desktop/458.md new file mode 100644 index 000000000..e4faf1ce8 --- /dev/null +++ b/specs/puffer-desktop/458.md @@ -0,0 +1,8 @@ +# WorldAgent Visuals + +## Summary +- `providerVisuals.ts` registers a `worldagent` entry (display accent + icon). +- No bespoke icon ships in this change; the fallback `ai` icon is reused until design provides one. + +## Compatibility +- LoginView's generic OAuth / api_key surface needs no component change. diff --git a/specs/puffer-provider-registry/06.md b/specs/puffer-provider-registry/06.md new file mode 100644 index 000000000..4f6cd6233 --- /dev/null +++ b/specs/puffer-provider-registry/06.md @@ -0,0 +1,10 @@ +# Optional `oauth_family` on ProviderDescriptor + +## Summary +- `ProviderDescriptor` gains `oauth_family: Option`. +- When `None`, callers infer the OAuth family from `default_api` (no behavior change for existing yaml). +- When `Some`, callers dispatch directly to the named family. Known values today: `openai`, `anthropic`, `worldagent`. + +## Compatibility +- Default value preserves every existing provider yaml. +- `ProviderPack` (in `puffer-resources`) mirrors the field and threads it through `into_descriptor`. diff --git a/specs/puffer-provider-worldagent/00.md b/specs/puffer-provider-worldagent/00.md new file mode 100644 index 000000000..126482d83 --- /dev/null +++ b/specs/puffer-provider-worldagent/00.md @@ -0,0 +1,21 @@ +# WorldAgent Provider Crate + +## Summary +- New crate `puffer-provider-worldagent` owning the Auth Station login flow for the `worldagent` provider. +- Auth Station returns the final `token` and `refresh_token` directly in the callback URL; this crate models that flow (no PKCE, no code exchange). + +## Surface +- `build_login_url(&WorldAgentLoginConfig) -> String` +- `parse_callback_input(&str) -> WorldAgentCallback` +- `decode_jwt_profile(&str) -> WorldAgentJwtProfile` +- `refresh_oauth_token(&str, Option<&str>) -> Result` +- `exchange_jwt_for_api_key(&str) -> Result` — TODO stub, waits on worldrouter backend + +## Configuration +- Default Auth Station URL: `https://auth-worldrouter.vercel.app` (Sandbox). +- Override via env var `PUFFER_WORLDAGENT_AUTH_URL`. +- Fixed loopback redirect: `http://127.0.0.1:1456/callback`. + +## Compatibility +- The fixed redirect URI must be allow-listed in Auth Station `ALLOWED_REDIRECT_ORIGINS` on both Sandbox and Production. +- JWT-to-api-key exchange is intentionally a stub; until the backend endpoint lands, the OAuth path does not yield an inference-usable credential. Users still must paste an API key. diff --git a/specs/puffer-resources/01.md b/specs/puffer-resources/01.md new file mode 100644 index 000000000..8c15c954f --- /dev/null +++ b/specs/puffer-resources/01.md @@ -0,0 +1,11 @@ +# WorldAgent Provider Yaml + +## Summary +- Bundled `resources/providers/worldagent.yaml` adds the `worldagent` provider entry. +- `default_api: openai-completions` (inference goes through the existing OpenAI chat-completions transport). +- `oauth_family: worldagent` opts into the new login dispatch. +- `auth_modes: [api_key, oauth]` exposes both LoginView paths. +- Model catalog is seeded minimally; `/v1/models` discovery populates the rest at runtime. + +## Compatibility +- Pure addition; no existing yaml is modified. From 93f6e9d7ea6c6de7149978f615fc18c93b84a73a Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 15:46:50 +0800 Subject: [PATCH 18/39] refactor(worldagent): hoist access token expiry helper to one place Promotes the private `now_ms` helper in puffer-provider-worldagent to `pub fn worldagent_access_token_expires_at_ms()`, re-exports it from lib.rs, and replaces the duplicated inline in daemon.rs and the local `worldagent_expires_at_ms` fn in main.rs with calls to the single canonical helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/puffer-cli/src/daemon.rs | 7 ++----- crates/puffer-cli/src/main.rs | 13 ++----------- crates/puffer-provider-worldagent/src/auth.rs | 12 ++++++++---- crates/puffer-provider-worldagent/src/lib.rs | 1 + 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/crates/puffer-cli/src/daemon.rs b/crates/puffer-cli/src/daemon.rs index d01514c79..1ae3dde97 100644 --- a/crates/puffer-cli/src/daemon.rs +++ b/crates/puffer-cli/src/daemon.rs @@ -52,6 +52,7 @@ use puffer_provider_registry::{ use puffer_provider_worldagent::{ decode_jwt_profile as decode_worldagent_jwt_profile, parse_callback_input as parse_worldagent_callback_input, + worldagent_access_token_expires_at_ms, WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, }; use puffer_resources::{load_resources, LoadedResources, McpServerSpec}; @@ -1173,14 +1174,10 @@ fn handle_login_with_oauth(state: &DaemonState, params: &Value) -> Result .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; let refresh_token = parsed.refresh_token.unwrap_or_default(); let profile = decode_worldagent_jwt_profile(&access_token); - let expires_at_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64 + 24 * 3600 * 1000) - .unwrap_or(24 * 3600 * 1000); let credential = WorldAgentOAuthCredentials { access_token, refresh_token, - expires_at_ms, + expires_at_ms: worldagent_access_token_expires_at_ms(), sub: profile.sub, email: profile.email, name: profile.name, diff --git a/crates/puffer-cli/src/main.rs b/crates/puffer-cli/src/main.rs index a1f30465c..67dded599 100644 --- a/crates/puffer-cli/src/main.rs +++ b/crates/puffer-cli/src/main.rs @@ -51,6 +51,7 @@ use puffer_provider_worldagent::{ decode_jwt_profile as decode_worldagent_jwt_profile, parse_callback_input as parse_worldagent_callback_input, refresh_oauth_token as refresh_worldagent_oauth_token, + worldagent_access_token_expires_at_ms, WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, }; use puffer_resources::load_resources; @@ -1036,16 +1037,6 @@ fn resolve_provider_id(providers: &ProviderRegistry, provider: &str) -> String { .unwrap_or_else(|| canonical_provider_id(provider)) } -/// Returns the Unix epoch millis at which an Auth Station access -/// token issued **now** will expire (24 hours per the API doc). -fn worldagent_expires_at_ms() -> u64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64 + 24 * 3600 * 1000) - .unwrap_or(24 * 3600 * 1000) -} - fn run_login_flow( provider: &str, value: Option, @@ -1151,7 +1142,7 @@ fn run_login_flow( let credential = WorldAgentOAuthCredentials { access_token, refresh_token, - expires_at_ms: worldagent_expires_at_ms(), + expires_at_ms: worldagent_access_token_expires_at_ms(), sub: profile.sub, email: profile.email, name: profile.name, diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index 1940c7145..654c9dccf 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -220,7 +220,7 @@ pub fn refresh_oauth_token( Ok(WorldAgentOAuthCredentials { access_token, refresh_token: refresh_token.to_string(), - expires_at_ms: now_ms() + 24 * 3600 * 1000, + expires_at_ms: worldagent_access_token_expires_at_ms(), sub: profile.sub, email: profile.email, name: profile.name, @@ -242,11 +242,15 @@ pub fn exchange_jwt_for_api_key(_access_token: &str) -> Result { )) } -fn now_ms() -> u64 { +/// Returns the Unix epoch milliseconds at which an Auth Station +/// access token issued **now** expires. Auth Station access tokens +/// have a 24-hour lifetime per the public API doc; this helper +/// centralizes that constant. +pub fn worldagent_access_token_expires_at_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 + .map(|duration| duration.as_millis() as u64 + 24 * 3600 * 1000) + .unwrap_or(24 * 3600 * 1000) } #[derive(Debug, Deserialize)] diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs index 32a9f7d30..fa59a143c 100644 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -10,6 +10,7 @@ mod auth; pub use auth::{ build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, generate_client_state, parse_callback_input, refresh_oauth_token, + worldagent_access_token_expires_at_ms, WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, WorldAgentOAuthCredentials, WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, From 4ff6edf3d4a6d13dfd81b6b5453ec5be317149eb Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 18:21:47 +0800 Subject: [PATCH 19/39] feat(worldagent): exchange JWT for WR api_key via control-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stub exchange_jwt_for_api_key with a real POST to the WR control-api preview endpoint; OAuth login now stores StoredCredential::ApiKey (sk-worldrouter-…) directly, discarding the JWT. Adds default_team_id to WorldAgentJwtProfile, uuid dep, and env-overrideable WORLDAGENT_CONTROL_URL constant. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 1 + crates/puffer-cli/src/daemon.rs | 27 ++-- crates/puffer-cli/src/main.rs | 22 +-- crates/puffer-provider-worldagent/Cargo.toml | 1 + crates/puffer-provider-worldagent/src/auth.rs | 134 ++++++++++++++++-- crates/puffer-provider-worldagent/src/lib.rs | 6 +- .../2026-05-20-worldagent-provider-design.md | 5 +- specs/puffer-provider-worldagent/00.md | 5 +- 8 files changed, 145 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e24512481..dc2bf04b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4585,6 +4585,7 @@ dependencies = [ "serde", "serde_json", "url", + "uuid", ] [[package]] diff --git a/crates/puffer-cli/src/daemon.rs b/crates/puffer-cli/src/daemon.rs index 1ae3dde97..ccd173564 100644 --- a/crates/puffer-cli/src/daemon.rs +++ b/crates/puffer-cli/src/daemon.rs @@ -50,10 +50,9 @@ use puffer_provider_registry::{ AuthStore, ModelDescriptor, ProviderDescriptor, ProviderRegistry, StoredCredential, }; use puffer_provider_worldagent::{ - decode_jwt_profile as decode_worldagent_jwt_profile, + exchange_jwt_for_api_key as exchange_worldagent_jwt_for_api_key, parse_callback_input as parse_worldagent_callback_input, - worldagent_access_token_expires_at_ms, - WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, + WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, }; use puffer_resources::{load_resources, LoadedResources, McpServerSpec}; use puffer_session_store::{MessageActor, SessionStore, TranscriptEvent}; @@ -79,7 +78,7 @@ use uuid::Uuid; use crate::auth_credentials::{ inferred_anthropic_redirect_uri, set_stored_credential, store_anthropic_credential, - to_registry_oauth_credential_openai, to_registry_oauth_credential_worldagent, + to_registry_oauth_credential_openai, }; use crate::auth_provider::{ oauth_family_for_provider, oauth_login_bundle_for_provider, OauthFamily, @@ -1172,21 +1171,11 @@ fn handle_login_with_oauth(state: &DaemonState, params: &Value) -> Result let access_token = parsed .token .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; - let refresh_token = parsed.refresh_token.unwrap_or_default(); - let profile = decode_worldagent_jwt_profile(&access_token); - let credential = WorldAgentOAuthCredentials { - access_token, - refresh_token, - expires_at_ms: worldagent_access_token_expires_at_ms(), - sub: profile.sub, - email: profile.email, - name: profile.name, - }; - set_stored_credential( - &mut inputs.auth_store, - provider_id.to_string(), - StoredCredential::OAuth(to_registry_oauth_credential_worldagent(credential)), - ); + let exchanged = exchange_worldagent_jwt_for_api_key(&access_token) + .context("worldagent JWT→api_key exchange failed")?; + inputs + .auth_store + .set_api_key(provider_id.to_string(), exchanged.api_key); } None => anyhow::bail!("oauth login is not implemented for {provider_id}"), } diff --git a/crates/puffer-cli/src/main.rs b/crates/puffer-cli/src/main.rs index 67dded599..0ec698c30 100644 --- a/crates/puffer-cli/src/main.rs +++ b/crates/puffer-cli/src/main.rs @@ -48,11 +48,10 @@ use puffer_provider_registry::{ canonical_provider_id, AuthMode, AuthStore, ProviderRegistry, StoredCredential, }; use puffer_provider_worldagent::{ - decode_jwt_profile as decode_worldagent_jwt_profile, + exchange_jwt_for_api_key as exchange_worldagent_jwt_for_api_key, parse_callback_input as parse_worldagent_callback_input, refresh_oauth_token as refresh_worldagent_oauth_token, - worldagent_access_token_expires_at_ms, - WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, + WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, }; use puffer_resources::load_resources; use puffer_session_store::{SessionMetadata, SessionStore}; @@ -1137,20 +1136,9 @@ fn run_login_flow( let access_token = parsed .token .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; - let refresh_token = parsed.refresh_token.unwrap_or_default(); - let profile = decode_worldagent_jwt_profile(&access_token); - let credential = WorldAgentOAuthCredentials { - access_token, - refresh_token, - expires_at_ms: worldagent_access_token_expires_at_ms(), - sub: profile.sub, - email: profile.email, - name: profile.name, - }; - auth_store.set_oauth( - provider.to_string(), - to_registry_oauth_credential_worldagent(credential), - ); + let exchanged = exchange_worldagent_jwt_for_api_key(&access_token) + .context("worldagent JWT→api_key exchange failed")?; + auth_store.set_api_key(provider.to_string(), exchanged.api_key); } None => anyhow::bail!("oauth login is not implemented for {provider}"), } diff --git a/crates/puffer-provider-worldagent/Cargo.toml b/crates/puffer-provider-worldagent/Cargo.toml index d4627f397..6070f850a 100644 --- a/crates/puffer-provider-worldagent/Cargo.toml +++ b/crates/puffer-provider-worldagent/Cargo.toml @@ -12,3 +12,4 @@ reqwest.workspace = true serde.workspace = true serde_json.workspace = true url = "2.5.7" +uuid.workspace = true diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index 654c9dccf..3b08fc6e3 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -16,6 +16,18 @@ pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app" /// Env var name that overrides the Auth Station base URL. pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; +/// Default WR control-api base URL (preview deployment). Backend +/// will move this to a stable URL — keep the env override available +/// for swapping without a rebuild. +pub const WORLDAGENT_CONTROL_BASE_URL: &str = "https://control-api-pre-7f819c.worldrouter.ai"; + +/// Env var name that overrides the control-api base URL. +pub const WORLDAGENT_CONTROL_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_CONTROL_URL"; + +/// Prefix applied to every generated `key_alias` so backend can +/// distinguish keys minted for the puffer desktop client. +pub const WORLDAGENT_KEY_ALIAS_PREFIX: &str = "puffer-"; + /// Fixed loopback callback path used by Puffer desktop. The auth /// team must allow-list the full URI on both Sandbox and Production. pub const WORLDAGENT_CALLBACK_PATH: &str = "/callback"; @@ -132,6 +144,9 @@ pub struct WorldAgentJwtProfile { pub email: Option, /// JWT `name` claim — may be an empty string upstream. pub name: Option, + /// `default_team_id` claim — used as the path segment for the + /// control-api key creation endpoint. + pub default_team_id: Option, } /// Decodes `sub` / `email` / `name` from the access token JWT @@ -159,6 +174,10 @@ pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile { .get("name") .and_then(serde_json::Value::as_str) .map(ToString::to_string), + default_team_id: value + .get("default_team_id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), } } @@ -227,19 +246,95 @@ pub fn refresh_oauth_token( }) } -/// Exchanges an Auth Station JWT for an inference API key. +/// Output of a successful JWT→api_key exchange. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExchangedApiKey { + /// The WR api_key to send as `Authorization: Bearer ` against + /// the inference endpoint. + pub api_key: String, + /// Server-assigned identifier for the key. Useful for future + /// revoke calls. + pub token_id: String, + /// Client-chosen alias used to create the key. + pub key_alias: String, +} + +/// Exchanges an Auth Station JWT for a WorldRouter inference api_key. /// -/// **TODO (waiting on worldrouter backend):** the endpoint and -/// request shape are not yet finalized. Once defined, this function -/// will `POST /api/v1/keys/exchange` (or whatever the -/// backend picks) with `Authorization: Bearer ` and -/// return the `api_key` string. The login handler will then upgrade -/// the stored credential to an `ApiKey { key }` variant. -pub fn exchange_jwt_for_api_key(_access_token: &str) -> Result { - Err(anyhow!( - "worldagent JWT-to-api-key exchange is not yet implemented; \ - paste your WorldRouter API key for now" - )) +/// Calls `POST {control_base}/platform/v1/teams/{default_team_id}/keys` +/// with the JWT in `Authorization: Bearer …` and a fresh +/// `key_alias = "puffer-"` body. Returns the `api_key` from the +/// response. +/// +/// TODO(worldagent): API will change — backend confirmed this is a +/// preview shape. Open questions: +/// - idempotent get-or-create per (user, device) instead of always +/// minting a new key (avoid leaking dead keys when user re-logins +/// on the same machine) +/// - DELETE/revoke endpoint to call on logout +/// - production base URL (currently `control-api-pre-7f819c…`) +/// - whether `team_id` should come from JWT `default_team_id` or be +/// user-selectable (multi-team users) +/// Re-evaluate when backend lands the stable shape. +pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { + let profile = decode_jwt_profile(access_token); + let team_id = profile.default_team_id.ok_or_else(|| { + anyhow!("worldagent JWT did not include default_team_id claim") + })?; + let base = std::env::var(WORLDAGENT_CONTROL_URL_OVERRIDE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| WORLDAGENT_CONTROL_BASE_URL.to_string()); + let url = format!( + "{}/platform/v1/teams/{}/keys", + base.trim_end_matches('/'), + team_id + ); + let key_alias = format!( + "{}{}", + WORLDAGENT_KEY_ALIAS_PREFIX, + uuid::Uuid::new_v4() + .as_hyphenated() + .to_string() + .to_uppercase() + ); + let response = Client::new() + .post(&url) + .bearer_auth(access_token) + .json(&serde_json::json!({ "key_alias": key_alias })) + .send() + .context("failed to send worldagent key creation request")?; + let status = response.status(); + let payload: KeyCreationResponse = response + .json() + .context("failed to parse worldagent key creation response")?; + if !status.is_success() { + return Err(anyhow!( + "worldagent key creation failed with status {status}: {}", + payload.error.unwrap_or_default() + )); + } + let api_key = payload + .key + .ok_or_else(|| anyhow!("worldagent key creation response missing `key`"))?; + let token_id = payload + .token_id + .ok_or_else(|| anyhow!("worldagent key creation response missing `token_id`"))?; + Ok(ExchangedApiKey { + api_key, + token_id, + key_alias, + }) +} + +#[derive(Debug, Deserialize)] +struct KeyCreationResponse { + #[serde(default)] + token_id: Option, + #[serde(default)] + key: Option, + #[serde(default)] + error: Option, } /// Returns the Unix epoch milliseconds at which an Auth Station @@ -312,6 +407,7 @@ mod tests { "sub": "user_01ABC", "email": "dev@example.com", "name": "Dev User", + "default_team_id": "team-abc-123", }); let encoded = URL_SAFE_NO_PAD.encode(payload.to_string()); let token = format!("header.{encoded}.sig"); @@ -319,6 +415,7 @@ mod tests { assert_eq!(profile.sub.as_deref(), Some("user_01ABC")); assert_eq!(profile.email.as_deref(), Some("dev@example.com")); assert_eq!(profile.name.as_deref(), Some("Dev User")); + assert_eq!(profile.default_team_id.as_deref(), Some("team-abc-123")); } #[test] @@ -330,10 +427,17 @@ mod tests { } #[test] - fn exchange_jwt_for_api_key_is_a_placeholder() { - let result = exchange_jwt_for_api_key("any.access.token"); + fn exchange_jwt_for_api_key_rejects_jwt_without_default_team_id() { + // JWT payload missing default_team_id. + let payload = serde_json::json!({ + "sub": "user_01", + "email": "dev@example.com", + }); + let encoded = URL_SAFE_NO_PAD.encode(payload.to_string()); + let token = format!("header.{encoded}.sig"); + let result = exchange_jwt_for_api_key(&token); assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); - assert!(err.contains("not yet implemented")); + assert!(err.contains("default_team_id")); } } diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs index fa59a143c..6ea6dfc8f 100644 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -11,8 +11,10 @@ pub use auth::{ build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, generate_client_state, parse_callback_input, refresh_oauth_token, worldagent_access_token_expires_at_ms, - WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, + ExchangedApiKey, WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, WorldAgentOAuthCredentials, WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, + WORLDAGENT_CALLBACK_PORT, WORLDAGENT_CONTROL_BASE_URL, + WORLDAGENT_CONTROL_URL_OVERRIDE_ENV, WORLDAGENT_DEFAULT_REDIRECT_URI, + WORLDAGENT_KEY_ALIAS_PREFIX, }; diff --git a/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md b/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md index 6ba20ff00..e16c7ef74 100644 --- a/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md +++ b/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md @@ -386,7 +386,10 @@ Each component spec is concise (≤ 60 lines) per existing style. ## 13. Future work (post-MVP) -- JWT → api_key exchange (waits on backend). +### Done +- ~~JWT → api_key exchange (waits on backend).~~ Implemented in `exchange_jwt_for_api_key` against the control-api preview endpoint. OAuth login now stores `StoredCredential::ApiKey` (sk-worldrouter-…) directly; JWT is not persisted. + +### Remaining - Profile UI showing the authenticated email / org from the JWT. - Refresh token rotation when access_token expires (one-line cron in daemon: call `refresh_oauth_token` and re-store the credential). diff --git a/specs/puffer-provider-worldagent/00.md b/specs/puffer-provider-worldagent/00.md index 126482d83..7a503f635 100644 --- a/specs/puffer-provider-worldagent/00.md +++ b/specs/puffer-provider-worldagent/00.md @@ -9,7 +9,7 @@ - `parse_callback_input(&str) -> WorldAgentCallback` - `decode_jwt_profile(&str) -> WorldAgentJwtProfile` - `refresh_oauth_token(&str, Option<&str>) -> Result` -- `exchange_jwt_for_api_key(&str) -> Result` — TODO stub, waits on worldrouter backend +- `exchange_jwt_for_api_key(&str) -> Result` — implemented: POSTs to control-api preview endpoint to mint a `sk-worldrouter-…` key ## Configuration - Default Auth Station URL: `https://auth-worldrouter.vercel.app` (Sandbox). @@ -18,4 +18,5 @@ ## Compatibility - The fixed redirect URI must be allow-listed in Auth Station `ALLOWED_REDIRECT_ORIGINS` on both Sandbox and Production. -- JWT-to-api-key exchange is intentionally a stub; until the backend endpoint lands, the OAuth path does not yield an inference-usable credential. Users still must paste an API key. +- `exchange_jwt_for_api_key` is now implemented against the control-api preview deployment (`control-api-pre-7f819c.worldrouter.ai`). The OAuth login path mints a `sk-worldrouter-…` inference key and stores it as `StoredCredential::ApiKey`; the JWT is not persisted. The inference path reuses the standard OpenAI-completions transport with the freshly minted key. Backend explicitly noted the API shape will change — a TODO block in `auth.rs` tracks open questions (idempotency, revoke endpoint, stable URL, multi-team support). +- Override the control-api base URL at runtime via `PUFFER_WORLDAGENT_CONTROL_URL` (no rebuild required). From 551fea3d7579cf9d193878bad6e333ab3c375579 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 19:21:29 +0800 Subject: [PATCH 20/39] docs: worldagent backend handoff (env redeploy + JWT shape) --- .../2026-05-20-worldagent-backend-handoff.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md diff --git a/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md b/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md new file mode 100644 index 000000000..b530e4494 --- /dev/null +++ b/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md @@ -0,0 +1,239 @@ +# worldagent 端到端联调 — 待解决问题 + +Date: 2026-05-20 +Author: sean (with Claude) +Status: 阻塞中,待 Auth + Infer 后端处理 + +--- + +## 背景 + +puffer 桌面端新增 `worldagent` provider,OAuth 登录流程: + +``` +puffer-cli auth login worldagent + → 浏览器打开 https://auth.worldrouter.ai/login?redirect_uri=http://127.0.0.1:1456/callback&client_state=... + → 用户登录(Auth Station + WorkOS AuthKit) + → Auth Station 302 回 http://127.0.0.1:1456/callback?token=&refresh_token=...&state=... + → puffer 监听器抓回调 + → puffer 用 token JWT 调 control-api 创建 WR api_key + → 把 api_key 落本地 AuthStore,worldagent 就能跑 inference +``` + +代码已经实现并 push 到 `feat/worldagent-provider` 分支(commit `1132c94` 及之前),单测 7/7 通过,workspace 编译干净。下面是**联调时发现的两个上游问题**,puffer 这边已经做不了,需要后端改。 + +--- + +## 问题 1:Production Auth Station 没读到 `ALLOWED_REDIRECT_ORIGINS` 的最新值 + +### 现象 + +浏览器打开 +`https://auth.worldrouter.ai/login?redirect_uri=http://127.0.0.1:1456/callback&client_state=X`, +用户登录成功后,Auth Station **没有** 302 回 `127.0.0.1:1456`,而是落到 `https://auth.worldrouter.ai/`(404)。这是 auth-docs §troubleshooting 里写的"**redirect_uri 不在白名单 → 静默忽略 → 落到首页**"。 + +### 验证(已做) + +- `vercel env pull --environment=production` 拉出来 `ALLOWED_REDIRECT_ORIGINS` 的当前值,**包含** `http://127.0.0.1:1456`(格式正确,逗号分隔,与现有 `https://worldrouter.ai` 等条目同行)。 +- `auth/src/lib/sanitize.ts::validateRedirectUri` 的逻辑读 env、`split(',').map(trim)`、`new URL(uri).origin` exact match,完全正确。 +- 关键探活(用 `wangshun+3@tomo.inc` 登录后浏览器拿到的 `__auth_session` cookie): + + ```bash + COOKIE='<__auth_session value>' + + # 探针 A:我们想要的 redirect_uri + curl -i -b "__auth_session=$COOKIE" \ + 'https://auth.worldrouter.ai/session/check?redirect_uri=http%3A%2F%2F127.0.0.1%3A1456%2Fcallback&client_state=probeA' + # 结果:HTTP/2 200,没有 Location → 静默拒绝 ❌ + + # 探针 B:白名单里已有的 worldrouter.ai + curl -i -b "__auth_session=$COOKIE" \ + 'https://auth.worldrouter.ai/session/check?redirect_uri=https%3A%2F%2Fworldrouter.ai%2Ftest&client_state=probeB' + # 结果:HTTP/2 302 + # location: https://worldrouter.ai/test?state=probeB&token=&refresh_token=<...> ✅ + + # 探针 C:明显非法的 URL + curl -i -b "__auth_session=$COOKIE" \ + 'https://auth.worldrouter.ai/session/check?redirect_uri=https%3A%2F%2Fnonsense.example.com%2Ftest&client_state=probeC' + # 结果:HTTP/2 200,没有 Location → 静默拒绝 ❌ + ``` + + → 证明 cookie / silent SSO / 白名单校验逻辑全都正常。**只是当前运行的 deployment 不认识 `http://127.0.0.1:1456`。** + +### 推断 + +Vercel deployment 在构建时把 env vars 快照打包;env vars 之后改了但**没有触发重新部署**,所以 runtime 拿的是旧快照。`vercel env pull` 显示的是 Vercel UI 当前值,与运行时不一定一致。 + +### 修复 + +在 `nubit/auth-worldrouter` Vercel project 里 **redeploy 当前 Production deployment**(不需要新代码,纯重新启动一份让它读最新 env): + +- Dashboard 路径:Deployments → 最新 Production deployment → ⋯ → **Redeploy** +- 或 CLI: + ```bash + cd /Users/shun/Data/Code/tomo/worldclaw/infer-monorepo/auth + vercel redeploy --prod + ``` + +### 验证修复 + +修完后用 cookie 重跑探针 A,**期望**: + +``` +HTTP/2 302 +location: http://127.0.0.1:1456/callback?state=probeA&token=&refresh_token=<...> +``` + +只要 Location header 出来即代表 redirect_uri 通过白名单。 + +--- + +## 问题 2:Auth Station JWT 不含 `default_team_id`,但 control-api 路径需要 `team_id` + +### 现象 + +后端给的 control-api 调用方式: + +``` +POST https://control-api-pre-7f819c.worldrouter.ai/platform/v1/teams/{team_id}/keys +Authorization: Bearer +Body: {"key_alias": "puffer-"} +``` + +`{team_id}` 必须从 JWT 的 `default_team_id` claim 取。 + +但**实际从 Auth Station 拿到的 JWT 不带这个字段**。 + +### 证据 + +探针 B 拿到的 token JWT decode: + +```json +{ + "sub": "user_01KRNMV53Y0WTZBPXA2RWNMC06", + "email": "wangshun+3@tomo.inc", + "name": "shun wang", + "picture": null, + "iss": "https://auth.worldrouter.ai", + "aud": "worldclaw", + "iat": 1779275875, + "exp": 1779362275 +} +``` + +对比之前后端给的 control-api curl 示例里那个 JWT(能跑通的那个): + +```json +{ + "iss": "infer-session", ← 不一样 + "sub": "cf530f43-...", + "user_id": "cf530f43-...", + "email": "winterfell0614+7@gmail.com", + "default_team_id": "6afdef35-...", ← 关键字段 + "user_role": "internal_user", + "idp": "workos", + "idp_sub": "user_01KRZJFGVE90DCGPP1E16XBJXX", + "jti": "...", + "iat": 1779266768, + "exp": 1779353167 +} +``` + +→ 那个能跑通 control-api 的 JWT 不是 Auth Station 签的,而是 **infer-monorepo 内部签发的 session JWT**(HS256,`iss=infer-session`,多了 `default_team_id` / `user_role` / `user_id` 等业务字段)。 + +### 含义 + +桌面端只能拿到 Auth Station JWT(OIDC 标准、面向所有接入方)。**桌面端没法直接拿到 infer-session JWT**——那是 worldagent dashboard / infer BFF 自己签的,浏览器侧只有 cookie,没有原始 JWT。 + +所以 puffer 现在的实现里 `exchange_jwt_for_api_key()` 第一行 `decode_jwt_profile(jwt).default_team_id.ok_or_else(...)` 会立刻失败,整个流程在那一步炸。 + +### 修复方案(让后端拍板) + +| 方案 | 改动方 | 备注 | +|---|---|---| +| **A. control-api 直接接受 Auth Station JWT**(推荐) | infer 后端 | 控制台拿 Authorization header → 用 `https://auth.worldrouter.ai/jwks` 验签(RS256, `aud=worldclaw`, `iss=https://auth.worldrouter.ai`)→ 从 JWT 的 `sub`(即 WorkOS user_id)反查 infer 数据库找用户默认 team。puffer 端代码 0 改动,URL 还是 `POST /platform/v1/teams/{default_team_id}/keys`,但 `{default_team_id}` 由后端从 user 反查得出。或者干脆改成 `POST /platform/v1/keys`(不要 team 路径段),后端自己挑用户默认 team。 | +| **B. 新增 「Auth Station JWT → Infer Session JWT」交换端点** | infer 后端 | puffer 多走一步 `POST /v1/auth/exchange { authStationToken }` → 拿到 infer-session JWT → 再 `POST /platform/v1/teams/{team_id}/keys`。puffer 代码改一行 `exchange_jwt_for_api_key` 内部多调一次 fetch。这跟 auth-docs §guides/backend.md 的"两层 JWT"模式吻合。 | +| C. body 里塞 team_id | 任一方 | 让前端 / puffer 自己选 team。但桌面端没法选——它只有一个登录态、不知道用户多 team 怎么处理。除非接口 idempotent "默认 team" 行为。Not great。 | + +**推荐方案 A**,因为 puffer 是桌面端、没 BFF,多一跳 latency 没必要;并且 Auth Station JWT 已经是 RS256 + JWKS 签发的标准 OIDC token,外部服务做 JWT 验签是常态。 + +### 验证修复 + +修完后 puffer 端 e2e 跑通的判据: + +```bash +cd /Users/shun/Data/Code/tomo/agentenv/puffer +PUFFER_WORLDAGENT_AUTH_URL=https://auth.worldrouter.ai \ +PUFFER_WORLDAGENT_CONTROL_URL=https://control-api-pre-7f819c.worldrouter.ai \ + cargo run -p puffer-cli -- auth login worldagent +``` + +浏览器打开 puffer 打印的 URL → 用一个 Production 已有的账号登录 → puffer 终端打印: + +``` +stored oauth credentials for worldagent # 或类似 "stored api key for worldagent" +``` + +然后立刻验证 api_key 真能用: + +```bash +cat ~/Library/Application\ Support/com.tomo.puffer/auth.json \ + | jq '.providers.worldagent' + +# 期望看到 { "kind": "api_key", "key": "sk-worldrouter-..." } + +curl -sS https://inference-api.worldrouter.ai/v1/models \ + -H "Authorization: Bearer " + +# 期望返回模型列表 JSON,HTTP 200 +``` + +--- + +## 附录 A:相关文件 / commit / endpoint 速查 + +| 项目 | 位置 | +|---|---| +| puffer 实现 | `feat/worldagent-provider` 分支,head `1132c94`(spec → impl → cleanup) | +| `exchange_jwt_for_api_key` 实现 | `crates/puffer-provider-worldagent/src/auth.rs:279`(带 TODO 说明 API 会调整) | +| 设计文档 | `docs/superpowers/specs/2026-05-20-worldagent-provider-design.md` | +| 实现 plan | `docs/superpowers/plans/2026-05-20-worldagent-provider.md` | +| Auth Station 源码 | `/Users/shun/Data/Code/tomo/worldclaw/infer-monorepo/auth/` | +| 白名单校验逻辑 | `auth/src/lib/sanitize.ts::validateRedirectUri` | +| 白名单 env 读取 | `auth/src/lib/config.ts::allowedRedirectOrigins` | +| Auth Station Sandbox | `https://auth-worldrouter.vercel.app`(puffer 默认) | +| Auth Station Production | `https://auth.worldrouter.ai`(联调走这个) | +| Auth Station JWKS | `https://auth.worldrouter.ai/jwks` — 用于验 puffer 拿到的 token | +| Inference API(OpenAI 兼容) | `https://inference-api.worldrouter.ai/v1/...` | +| Control API(创建 key 用) | `https://control-api-pre-7f819c.worldrouter.ai`(预览,会变) | +| Vercel project | `nubit/auth-worldrouter`,prj id `prj_4Mi7OqkeMQ5bOaNzzMOiLHsPRDGl` | +| puffer 端 env override | `PUFFER_WORLDAGENT_AUTH_URL`、`PUFFER_WORLDAGENT_CONTROL_URL` | + +## 附录 B:puffer 端固定 callback + +桌面端写死 loopback callback:`http://127.0.0.1:1456/callback`。 + +- 必须在 Auth Station `ALLOWED_REDIRECT_ORIGINS` 白名单里(origin 严格匹配,protocol+host+port,路径不参与) +- 既要在 Production 加(`auth.worldrouter.ai`),也要在 Sandbox 加(`auth-worldrouter.vercel.app`) +- 加 env 之后**必须 redeploy**,Vercel 不会自动 reload + +## 附录 C:一个完整的 Auth Station JWT 真实样本(已脱敏?) + +来自探针 B 响应,便于后端测试 JWKS 验签: + +``` +header: {"alg":"RS256","kid":"prod-1","typ":"JWT"} +payload: { + "sub": "user_01KRNMV53Y0WTZBPXA2RWNMC06", + "email": "wangshun+3@tomo.inc", + "name": "shun wang", + "picture": null, + "iss": "https://auth.worldrouter.ai", + "aud": "worldclaw", + "iat": 1779275875, + "exp": 1779362275 +} +signature: +``` + +注:这个 token 是 wangshun+3@tomo.inc 在 2026-05-20 11:17 UTC 登录拿到的,有效期 24h(到 2026-05-21 11:17 UTC)。等过期之后会失效;这里只用于说明 JWT 的形状。 From 0cf24088ebbbe71945c341e96d6e05bb742a6f28 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 20:12:08 +0800 Subject: [PATCH 21/39] feat(worldagent): default to prod auth + team_id env-var fallback - Switch WORLDAGENT_AUTH_BASE_URL default from Sandbox to Production (auth.worldrouter.ai). Production 127.0.0.1:1456 whitelist confirmed 2026-05-20 (307 from /session/check probe). - Add PUFFER_WORLDAGENT_TEAM_ID env-var fallback in exchange_jwt_for_api_key as a temporary workaround: Auth Station JWTs do not currently carry default_team_id; let the user/operator pin it via env until backend either adds the claim or moves to a teamless endpoint. - Update existing test to remove the env var inside the test body so the no-fallback path is exercised independently of process env. - Update handoff note: correct root cause of the env-var staleness (edit+append+save unreliable; remove + re-add is the fix), record the verified prod deployment hash + accepted-origins list. --- crates/puffer-provider-worldagent/src/auth.rs | 50 +++++++++++++++---- crates/puffer-provider-worldagent/src/lib.rs | 2 +- .../2026-05-20-worldagent-backend-handoff.md | 37 +++++++------- specs/puffer-provider-worldagent/00.md | 8 +-- 4 files changed, 65 insertions(+), 32 deletions(-) diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index 3b08fc6e3..a8f87695f 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -8,10 +8,13 @@ use reqwest::blocking::Client; use serde::Deserialize; use std::time::{SystemTime, UNIX_EPOCH}; -/// Default Auth Station base URL (Sandbox). Production is -/// `https://auth.worldrouter.ai`. The env var named by +/// Default Auth Station base URL (Production). Sandbox is +/// `https://auth-worldrouter.vercel.app`. The env var named by /// [`WORLDAGENT_AUTH_URL_OVERRIDE_ENV`] overrides this at runtime. -pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app"; +/// `http://127.0.0.1:1456` must be in the target environment's +/// `ALLOWED_REDIRECT_ORIGINS` allow-list (confirmed for Production +/// 2026-05-20). +pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth.worldrouter.ai"; /// Env var name that overrides the Auth Station base URL. pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; @@ -24,6 +27,14 @@ pub const WORLDAGENT_CONTROL_BASE_URL: &str = "https://control-api-pre-7f819c.wo /// Env var name that overrides the control-api base URL. pub const WORLDAGENT_CONTROL_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_CONTROL_URL"; +/// Env var name that overrides which team_id is used when minting an +/// inference api_key. Temporary workaround: Auth Station JWTs do not +/// currently carry a `default_team_id` claim, and the control-api +/// path segment still requires one. Set this to your team UUID +/// (e.g. `6afdef35-ea87-54a9-9662-8b8bf090c0fd`) until backend +/// either adds the claim or moves to a teamless endpoint. +pub const WORLDAGENT_TEAM_ID_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_TEAM_ID"; + /// Prefix applied to every generated `key_alias` so backend can /// distinguish keys minted for the puffer desktop client. pub const WORLDAGENT_KEY_ALIAS_PREFIX: &str = "puffer-"; @@ -268,19 +279,32 @@ pub struct ExchangedApiKey { /// /// TODO(worldagent): API will change — backend confirmed this is a /// preview shape. Open questions: +/// - Auth Station JWTs do NOT yet carry `default_team_id`; we fall +/// back to the [`WORLDAGENT_TEAM_ID_OVERRIDE_ENV`] env var as a +/// stopgap. Proper fix lives backend-side: either control-api +/// resolves the team from the JWT `sub` (WorkOS user_id) and the +/// endpoint becomes `POST /platform/v1/keys` (no team segment), or +/// Auth Station adds the claim. Remove the env-var path once that +/// ships. /// - idempotent get-or-create per (user, device) instead of always /// minting a new key (avoid leaking dead keys when user re-logins /// on the same machine) /// - DELETE/revoke endpoint to call on logout /// - production base URL (currently `control-api-pre-7f819c…`) -/// - whether `team_id` should come from JWT `default_team_id` or be -/// user-selectable (multi-team users) /// Re-evaluate when backend lands the stable shape. pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { let profile = decode_jwt_profile(access_token); - let team_id = profile.default_team_id.ok_or_else(|| { - anyhow!("worldagent JWT did not include default_team_id claim") - })?; + let team_id = std::env::var(WORLDAGENT_TEAM_ID_OVERRIDE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .or(profile.default_team_id) + .ok_or_else(|| { + anyhow!( + "worldagent JWT did not include default_team_id and \ + {WORLDAGENT_TEAM_ID_OVERRIDE_ENV} is unset; set it to \ + your team UUID as a temporary workaround" + ) + })?; let base = std::env::var(WORLDAGENT_CONTROL_URL_OVERRIDE_ENV) .ok() .filter(|value| !value.trim().is_empty()) @@ -427,8 +451,13 @@ mod tests { } #[test] - fn exchange_jwt_for_api_key_rejects_jwt_without_default_team_id() { - // JWT payload missing default_team_id. + fn exchange_jwt_for_api_key_rejects_when_team_id_unavailable() { + // JWT payload missing default_team_id, and we explicitly clear + // the env-var override so the no-fallback path is exercised. + // Tests can run in parallel and share process env; if another + // test ever sets WORLDAGENT_TEAM_ID_OVERRIDE_ENV in the same + // process, hoist this into a serial_test block. + std::env::remove_var(WORLDAGENT_TEAM_ID_OVERRIDE_ENV); let payload = serde_json::json!({ "sub": "user_01", "email": "dev@example.com", @@ -439,5 +468,6 @@ mod tests { assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); assert!(err.contains("default_team_id")); + assert!(err.contains(WORLDAGENT_TEAM_ID_OVERRIDE_ENV)); } } diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs index 6ea6dfc8f..a7b59d329 100644 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -16,5 +16,5 @@ pub use auth::{ WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, WORLDAGENT_CONTROL_BASE_URL, WORLDAGENT_CONTROL_URL_OVERRIDE_ENV, WORLDAGENT_DEFAULT_REDIRECT_URI, - WORLDAGENT_KEY_ALIAS_PREFIX, + WORLDAGENT_KEY_ALIAS_PREFIX, WORLDAGENT_TEAM_ID_OVERRIDE_ENV, }; diff --git a/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md b/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md index b530e4494..03c57061b 100644 --- a/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md +++ b/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md @@ -60,31 +60,34 @@ puffer-cli auth login worldagent → 证明 cookie / silent SSO / 白名单校验逻辑全都正常。**只是当前运行的 deployment 不认识 `http://127.0.0.1:1456`。** -### 推断 +### 推断(修正版 2026-05-20) -Vercel deployment 在构建时把 env vars 快照打包;env vars 之后改了但**没有触发重新部署**,所以 runtime 拿的是旧快照。`vercel env pull` 显示的是 Vercel UI 当前值,与运行时不一定一致。 +**不是** "Vercel redeploy 复用旧 env snapshot",**不是** "Vercel 过滤 loopback IP literal"。 -### 修复 +真实根因:Vercel UI 上对 env entry 做 **edit + append + save** 时,CLI / `vercel env pull` 能立即读到新值(UI 状态层),但 **deployment build 注入用的是上一个真正持久化版本**(存储层)。前者快、后者慢,两层不同步。 -在 `nubit/auth-worldrouter` Vercel project 里 **redeploy 当前 Production deployment**(不需要新代码,纯重新启动一份让它读最新 env): +### 修复(已生效) -- Dashboard 路径:Deployments → 最新 Production deployment → ⋯ → **Redeploy** -- 或 CLI: - ```bash - cd /Users/shun/Data/Code/tomo/worldclaw/infer-monorepo/auth - vercel redeploy --prod - ``` +在 Vercel UI Settings → Environment Variables 找到 `ALLOWED_REDIRECT_ORIGINS` Production → **⋯ Remove** 整条 → 重新 **Add** 同名 entry 写入完整新 value(一次性写全所有条目)→ **Save** → Redeploy 一次。 -### 验证修复 +**仅 edit + 末尾 append + save 不可靠** — `vercel env pull` 能立刻看到新值,但 deployment 注入有同步延迟。 -修完后用 cookie 重跑探针 A,**期望**: +### 当前 Production 状态(2026-05-20 已验) -``` -HTTP/2 302 -location: http://127.0.0.1:1456/callback?state=probeA&token=&refresh_token=<...> -``` +- Latest deployment: `dpl_CSjGzTPKpUn6aSye3QetZnqLfe9q` (`auth-worldrouter-gsih04aqk-nubit.vercel.app`) +- `auth.worldrouter.ai` alias 已切到此 deployment +- `ALLOWED_REDIRECT_ORIGINS` 包含 13 项,其中: + - `http://127.0.0.1:1456` ✓ ACCEPTED + - `http://localhost:1456` ✓ ACCEPTED(兜底) + - 其他 11 项 ✓ + +### 验证(无需 cookie) -只要 Location header 出来即代表 redirect_uri 通过白名单。 +```bash +curl -sS -o /dev/null -w "%{http_code}\n" \ + 'https://auth.worldrouter.ai/session/check?redirect_uri=http%3A%2F%2F127.0.0.1%3A1456%2Fcallback&client_state=p' +# 期望: 307(实测通过) +``` --- diff --git a/specs/puffer-provider-worldagent/00.md b/specs/puffer-provider-worldagent/00.md index 7a503f635..6cdb90e6f 100644 --- a/specs/puffer-provider-worldagent/00.md +++ b/specs/puffer-provider-worldagent/00.md @@ -12,11 +12,11 @@ - `exchange_jwt_for_api_key(&str) -> Result` — implemented: POSTs to control-api preview endpoint to mint a `sk-worldrouter-…` key ## Configuration -- Default Auth Station URL: `https://auth-worldrouter.vercel.app` (Sandbox). -- Override via env var `PUFFER_WORLDAGENT_AUTH_URL`. +- Default Auth Station URL: `https://auth.worldrouter.ai` (Production; `http://127.0.0.1:1456` allow-listed on prod as of 2026-05-20). Sandbox `https://auth-worldrouter.vercel.app` is reachable via `PUFFER_WORLDAGENT_AUTH_URL` override. +- Default control-api URL: `https://control-api-pre-7f819c.worldrouter.ai` (preview). Override via `PUFFER_WORLDAGENT_CONTROL_URL`. - Fixed loopback redirect: `http://127.0.0.1:1456/callback`. +- Temporary team_id workaround: `PUFFER_WORLDAGENT_TEAM_ID` env var. Auth Station JWTs do not currently carry `default_team_id` — set this to your team UUID until backend either adds the claim or moves to a teamless endpoint. ## Compatibility - The fixed redirect URI must be allow-listed in Auth Station `ALLOWED_REDIRECT_ORIGINS` on both Sandbox and Production. -- `exchange_jwt_for_api_key` is now implemented against the control-api preview deployment (`control-api-pre-7f819c.worldrouter.ai`). The OAuth login path mints a `sk-worldrouter-…` inference key and stores it as `StoredCredential::ApiKey`; the JWT is not persisted. The inference path reuses the standard OpenAI-completions transport with the freshly minted key. Backend explicitly noted the API shape will change — a TODO block in `auth.rs` tracks open questions (idempotency, revoke endpoint, stable URL, multi-team support). -- Override the control-api base URL at runtime via `PUFFER_WORLDAGENT_CONTROL_URL` (no rebuild required). +- `exchange_jwt_for_api_key` is now implemented against the control-api preview deployment (`control-api-pre-7f819c.worldrouter.ai`). The OAuth login path mints a `sk-worldrouter-…` inference key and stores it as `StoredCredential::ApiKey`; the JWT is not persisted. The inference path reuses the standard OpenAI-completions transport with the freshly minted key. Backend explicitly noted the API shape will change — a TODO block in `auth.rs` tracks open questions (idempotency, revoke endpoint, stable URL, team_id resolution). From bb210d621b6b3366d4cae24e3c68913f0ef34ad7 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 20 May 2026 20:19:57 +0800 Subject: [PATCH 22/39] feat(worldagent): dump control-api response body on key creation failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E test on 2026-05-20 hit a 401 from control-api with body {"detail":{"code":"litellm_unauthorized","message":"Invalid Infer session token"}}. The previous error message swallowed the body because the response wasn't always JSON-decodable. Read the response as text first, then attempt JSON parsing only on success. Also record the e2e finding in the backend-handoff note: the failure mode confirms control-api consumes Infer session tokens (HS256, iss=infer-session), not Auth Station JWTs (RS256, iss=auth.worldrouter.ai). PUFFER_WORLDAGENT_TEAM_ID alone is not enough — backend needs to either accept Auth Station JWTs directly or expose an exchange endpoint. --- crates/puffer-provider-worldagent/src/auth.rs | 12 +++++----- .../2026-05-20-worldagent-backend-handoff.md | 22 +++++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index a8f87695f..d18ebd2ac 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -329,15 +329,17 @@ pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { .send() .context("failed to send worldagent key creation request")?; let status = response.status(); - let payload: KeyCreationResponse = response - .json() - .context("failed to parse worldagent key creation response")?; + let body = response + .text() + .context("failed to read worldagent key creation response body")?; if !status.is_success() { return Err(anyhow!( - "worldagent key creation failed with status {status}: {}", - payload.error.unwrap_or_default() + "worldagent key creation failed: POST {url} -> {status}\nresponse body: {body}" )); } + let payload: KeyCreationResponse = serde_json::from_str(&body).with_context(|| { + format!("failed to parse worldagent key creation response body: {body}") + })?; let api_key = payload .key .ok_or_else(|| anyhow!("worldagent key creation response missing `key`"))?; diff --git a/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md b/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md index 03c57061b..1f037f4fb 100644 --- a/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md +++ b/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md @@ -160,17 +160,35 @@ Body: {"key_alias": "puffer-"} **推荐方案 A**,因为 puffer 是桌面端、没 BFF,多一跳 latency 没必要;并且 Auth Station JWT 已经是 RS256 + JWKS 签发的标准 OIDC token,外部服务做 JWT 验签是常态。 +### 2026-05-20 实测:完整跑通 e2e 流程,得到决定性证据 + +puffer 端加了 `PUFFER_WORLDAGENT_TEAM_ID` env 兜底(先不依赖 JWT 的 `default_team_id`),实际触发了一次完整 OAuth → control-api 调用: + +``` +POST https://control-api-pre-7f819c.worldrouter.ai/platform/v1/teams/6afdef35-ea87-54a9-9662-8b8bf090c0fd/keys +Authorization: Bearer +Body: {"key_alias":"puffer-"} + +← 401 Unauthorized + {"detail":{"code":"litellm_unauthorized","message":"Invalid Infer session token"}} +``` + +→ **决定性证据**:control-api 路径上挂着一个 litellm gateway,它消费的是内部 Infer session token(HS256, `iss=infer-session`),**不消费 Auth Station JWT**(RS256, `iss=auth.worldrouter.ai`)。即使 puffer 提供了正确的 `team_id`,control-api 也会拒签。 + +→ 上面"修复方案"表里方案 A / B 不只是「JWT 缺字段」的问题,而是「control-api 不认这条 token」的问题。两个方案都还成立,但 **B 在实现上可能更接近现状**:infer-monorepo 已经有自己的 session token 签发机制,加一个 `POST /auth/exchange { authStationToken }` → 复用现有 litellm 验签链路,比改 control-api 验签更小动作。 + ### 验证修复 修完后 puffer 端 e2e 跑通的判据: ```bash cd /Users/shun/Data/Code/tomo/agentenv/puffer -PUFFER_WORLDAGENT_AUTH_URL=https://auth.worldrouter.ai \ -PUFFER_WORLDAGENT_CONTROL_URL=https://control-api-pre-7f819c.worldrouter.ai \ +PUFFER_WORLDAGENT_TEAM_ID= # 方案 A 落地后可去掉 cargo run -p puffer-cli -- auth login worldagent ``` +期望 stdout 末行:`stored oauth credentials for worldagent`;`~/Library/Application Support/com.tomo.puffer/auth.json` 应该包含 `worldagent → {kind: api_key, key: sk-worldrouter-...}`。 + 浏览器打开 puffer 打印的 URL → 用一个 Production 已有的账号登录 → puffer 终端打印: ``` From f6ba2b1ad234ce3e6ee568d5e671905afce8d581 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 00:36:32 +0800 Subject: [PATCH 23/39] feat(worldagent): two-hop exchange via /auth/exchange + /keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control-api litellm gateway only accepts Infer Session JWT (HS256, iss=infer-session). Auth Station issues OIDC JWT (RS256, iss=auth.worldrouter.ai), so a direct POST to the keys endpoint hits a 401 from litellm. infer-monorepo already exposes POST /auth/exchange which detects an Auth Station JWT (looks_like_auth_station_token) and returns { session_token, default_team_id, ... } where session_token is the HS256 token that litellm accepts. Use it: hop 1 trades the Auth Station JWT, hop 2 calls /platform/v1/teams/{team_id}/keys with the session_token to mint the WR inference api_key. This removes the need for the PUFFER_WORLDAGENT_TEAM_ID env fallback (default_team_id now comes from the exchange response) and simplifies the TODO block down to genuine backend-side follow-ups: idempotent get-or-create, revoke-on-logout, stable production URL. Verified end-to-end 2026-05-21: stored oauth credentials for worldagent ~/.puffer/auth.json → worldagent: kind=api_key, key=sk-worldrouter-... --- crates/puffer-provider-worldagent/src/auth.rs | 126 ++++++++---------- crates/puffer-provider-worldagent/src/lib.rs | 2 +- 2 files changed, 59 insertions(+), 69 deletions(-) diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index d18ebd2ac..764a72e52 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -27,14 +27,6 @@ pub const WORLDAGENT_CONTROL_BASE_URL: &str = "https://control-api-pre-7f819c.wo /// Env var name that overrides the control-api base URL. pub const WORLDAGENT_CONTROL_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_CONTROL_URL"; -/// Env var name that overrides which team_id is used when minting an -/// inference api_key. Temporary workaround: Auth Station JWTs do not -/// currently carry a `default_team_id` claim, and the control-api -/// path segment still requires one. Set this to your team UUID -/// (e.g. `6afdef35-ea87-54a9-9662-8b8bf090c0fd`) until backend -/// either adds the claim or moves to a teamless endpoint. -pub const WORLDAGENT_TEAM_ID_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_TEAM_ID"; - /// Prefix applied to every generated `key_alias` so backend can /// distinguish keys minted for the puffer desktop client. pub const WORLDAGENT_KEY_ALIAS_PREFIX: &str = "puffer-"; @@ -272,20 +264,18 @@ pub struct ExchangedApiKey { /// Exchanges an Auth Station JWT for a WorldRouter inference api_key. /// -/// Calls `POST {control_base}/platform/v1/teams/{default_team_id}/keys` -/// with the JWT in `Authorization: Bearer …` and a fresh -/// `key_alias = "puffer-"` body. Returns the `api_key` from the -/// response. +/// Two-hop flow: +/// 1. `POST {control_base}/auth/exchange` body `{ "access_token": }` +/// → infer-monorepo recognises the Auth Station issuer, verifies +/// the RS256 signature against the Auth Station JWKS, and returns +/// `{ session_token, default_team_id, ... }` where `session_token` +/// is an HS256 Infer Session JWT that the litellm gateway in front +/// of control-api accepts. +/// 2. `POST {control_base}/platform/v1/teams/{default_team_id}/keys` +/// with `Authorization: Bearer ` and a fresh +/// `key_alias = "puffer-"` body → returns `{ key: "sk-worldrouter-..." }`. /// -/// TODO(worldagent): API will change — backend confirmed this is a -/// preview shape. Open questions: -/// - Auth Station JWTs do NOT yet carry `default_team_id`; we fall -/// back to the [`WORLDAGENT_TEAM_ID_OVERRIDE_ENV`] env var as a -/// stopgap. Proper fix lives backend-side: either control-api -/// resolves the team from the JWT `sub` (WorkOS user_id) and the -/// endpoint becomes `POST /platform/v1/keys` (no team segment), or -/// Auth Station adds the claim. Remove the env-var path once that -/// ships. +/// TODO(worldagent): preview-stage shape. Open questions: /// - idempotent get-or-create per (user, device) instead of always /// minting a new key (avoid leaking dead keys when user re-logins /// on the same machine) @@ -293,26 +283,37 @@ pub struct ExchangedApiKey { /// - production base URL (currently `control-api-pre-7f819c…`) /// Re-evaluate when backend lands the stable shape. pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { - let profile = decode_jwt_profile(access_token); - let team_id = std::env::var(WORLDAGENT_TEAM_ID_OVERRIDE_ENV) - .ok() - .filter(|value| !value.trim().is_empty()) - .or(profile.default_team_id) - .ok_or_else(|| { - anyhow!( - "worldagent JWT did not include default_team_id and \ - {WORLDAGENT_TEAM_ID_OVERRIDE_ENV} is unset; set it to \ - your team UUID as a temporary workaround" - ) - })?; let base = std::env::var(WORLDAGENT_CONTROL_URL_OVERRIDE_ENV) .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| WORLDAGENT_CONTROL_BASE_URL.to_string()); - let url = format!( - "{}/platform/v1/teams/{}/keys", - base.trim_end_matches('/'), - team_id + let base = base.trim_end_matches('/').to_string(); + let client = Client::new(); + + // Hop 1: Auth Station JWT → Infer Session JWT + let exchange_url = format!("{base}/auth/exchange"); + let exchange_response = client + .post(&exchange_url) + .json(&serde_json::json!({ "access_token": access_token })) + .send() + .context("failed to send worldagent /auth/exchange request")?; + let exchange_status = exchange_response.status(); + let exchange_body = exchange_response + .text() + .context("failed to read worldagent /auth/exchange response body")?; + if !exchange_status.is_success() { + return Err(anyhow!( + "worldagent /auth/exchange failed: POST {exchange_url} -> {exchange_status}\nresponse body: {exchange_body}" + )); + } + let envelope: SessionEnvelopeResponse = serde_json::from_str(&exchange_body).with_context( + || format!("failed to parse worldagent /auth/exchange response body: {exchange_body}"), + )?; + + // Hop 2: Infer Session JWT → WR inference api_key + let key_url = format!( + "{base}/platform/v1/teams/{team_id}/keys", + team_id = envelope.default_team_id ); let key_alias = format!( "{}{}", @@ -322,23 +323,23 @@ pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { .to_string() .to_uppercase() ); - let response = Client::new() - .post(&url) - .bearer_auth(access_token) + let key_response = client + .post(&key_url) + .bearer_auth(&envelope.session_token) .json(&serde_json::json!({ "key_alias": key_alias })) .send() .context("failed to send worldagent key creation request")?; - let status = response.status(); - let body = response + let key_status = key_response.status(); + let key_body = key_response .text() .context("failed to read worldagent key creation response body")?; - if !status.is_success() { + if !key_status.is_success() { return Err(anyhow!( - "worldagent key creation failed: POST {url} -> {status}\nresponse body: {body}" + "worldagent key creation failed: POST {key_url} -> {key_status}\nresponse body: {key_body}" )); } - let payload: KeyCreationResponse = serde_json::from_str(&body).with_context(|| { - format!("failed to parse worldagent key creation response body: {body}") + let payload: KeyCreationResponse = serde_json::from_str(&key_body).with_context(|| { + format!("failed to parse worldagent key creation response body: {key_body}") })?; let api_key = payload .key @@ -353,14 +354,18 @@ pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { }) } +#[derive(Debug, Deserialize)] +struct SessionEnvelopeResponse { + session_token: String, + default_team_id: String, +} + #[derive(Debug, Deserialize)] struct KeyCreationResponse { #[serde(default)] token_id: Option, #[serde(default)] key: Option, - #[serde(default)] - error: Option, } /// Returns the Unix epoch milliseconds at which an Auth Station @@ -452,24 +457,9 @@ mod tests { assert!(profile.name.is_none()); } - #[test] - fn exchange_jwt_for_api_key_rejects_when_team_id_unavailable() { - // JWT payload missing default_team_id, and we explicitly clear - // the env-var override so the no-fallback path is exercised. - // Tests can run in parallel and share process env; if another - // test ever sets WORLDAGENT_TEAM_ID_OVERRIDE_ENV in the same - // process, hoist this into a serial_test block. - std::env::remove_var(WORLDAGENT_TEAM_ID_OVERRIDE_ENV); - let payload = serde_json::json!({ - "sub": "user_01", - "email": "dev@example.com", - }); - let encoded = URL_SAFE_NO_PAD.encode(payload.to_string()); - let token = format!("header.{encoded}.sig"); - let result = exchange_jwt_for_api_key(&token); - assert!(result.is_err()); - let err = format!("{}", result.unwrap_err()); - assert!(err.contains("default_team_id")); - assert!(err.contains(WORLDAGENT_TEAM_ID_OVERRIDE_ENV)); - } + // Note: `exchange_jwt_for_api_key` now does two real HTTP hops + // (`/auth/exchange` then `/platform/v1/teams/{team_id}/keys`). + // Unit tests would need an HTTP mock server; we exercise the + // function through the manual e2e probe in `puffer auth login + // worldagent` instead. } diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs index a7b59d329..6ea6dfc8f 100644 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ b/crates/puffer-provider-worldagent/src/lib.rs @@ -16,5 +16,5 @@ pub use auth::{ WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, WORLDAGENT_CONTROL_BASE_URL, WORLDAGENT_CONTROL_URL_OVERRIDE_ENV, WORLDAGENT_DEFAULT_REDIRECT_URI, - WORLDAGENT_KEY_ALIAS_PREFIX, WORLDAGENT_TEAM_ID_OVERRIDE_ENV, + WORLDAGENT_KEY_ALIAS_PREFIX, }; From b814cd0beddf042adc74f1a05b1a8d5a5161a62a Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 00:40:36 +0800 Subject: [PATCH 24/39] feat(worldagent): switch control-api default to production + verified seed models Preview control-api (control-api-pre-7f819c.worldrouter.ai) mints keys into a preview LiteLLM DB that production inference-api.worldrouter.ai does not read; e2e attempts produced 401 token_not_found_in_db on inference. Production control-api also supports /auth/exchange (422 probe confirmed) and pairs correctly with production inference. E2E verified live 2026-05-21: GET inference-api.worldrouter.ai/v1/models -> 200 (qwen/kimi/glm/minimax catalogue) POST inference-api.worldrouter.ai/v1/chat/completions -> 200 "pong" Replace the placeholder gpt-5 seed model with two real entries (kimi-k2.6, qwen3.5-flash) both verified live; runtime discovery still rewrites the catalogue from /v1/models. --- crates/puffer-provider-worldagent/src/auth.rs | 16 ++++++++++++---- resources/providers/worldagent.yaml | 16 +++++++++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldagent/src/auth.rs index 764a72e52..45cae3678 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldagent/src/auth.rs @@ -19,10 +19,18 @@ pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth.worldrouter.ai"; /// Env var name that overrides the Auth Station base URL. pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; -/// Default WR control-api base URL (preview deployment). Backend -/// will move this to a stable URL — keep the env override available -/// for swapping without a rebuild. -pub const WORLDAGENT_CONTROL_BASE_URL: &str = "https://control-api-pre-7f819c.worldrouter.ai"; +/// Default WR control-api base URL (Production). Backend's preview +/// deployment lives at `https://control-api-pre-7f819c.worldrouter.ai` +/// and supports the same `/auth/exchange` shape — set the env +/// override [`WORLDAGENT_CONTROL_URL_OVERRIDE_ENV`] to swap. +/// +/// Important: preview control-api mints keys into a preview +/// LiteLLM DB that the production `inference-api.worldrouter.ai` +/// does NOT read; preview keys hit `token_not_found_in_db` 401 +/// against prod inference. Always pair preview-control with the +/// matching preview-inference (when one exists) or use production +/// control. Verified 2026-05-21. +pub const WORLDAGENT_CONTROL_BASE_URL: &str = "https://control-api.worldrouter.ai"; /// Env var name that overrides the control-api base URL. pub const WORLDAGENT_CONTROL_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_CONTROL_URL"; diff --git a/resources/providers/worldagent.yaml b/resources/providers/worldagent.yaml index 3d8dd8817..03fb9e8a0 100644 --- a/resources/providers/worldagent.yaml +++ b/resources/providers/worldagent.yaml @@ -14,10 +14,20 @@ discovery: max_output_tokens: 8192 supports_reasoning: true models: - - id: gpt-5 - display_name: GPT-5 (via WorldRouter) + # Seed list — runtime /v1/models discovery overrides at startup. + # Pick a couple of reasoning-capable defaults verified live 2026-05-21 + # (kimi-k2.6 and qwen3.5-flash both return 200 on /v1/chat/completions). + - id: kimi-k2.6 + display_name: Kimi K2.6 provider: worldagent api: openai-completions - context_window: 200000 + context_window: 128000 + max_output_tokens: 8192 + supports_reasoning: true + - id: qwen3.5-flash + display_name: Qwen 3.5 Flash + provider: worldagent + api: openai-completions + context_window: 128000 max_output_tokens: 8192 supports_reasoning: true From 9e7a462f716559f0de9f1df66d1b3a03978d7758 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 00:52:55 +0800 Subject: [PATCH 25/39] feat(desktop): expose worldagent OAuth + api_key in corbina GUI - Add WorldAgent ProviderSummaryDto with oauth+api_key auth modes and two placeholder models (kimi-k2.6, qwen3.5-flash) - Read worldagent auth status from ~/.puffer/auth.json via read_puffer_cli_credential() so the GUI shows "Connected" after login - Wire login_with_oauth / login_with_api_key / logout_provider to spawn `puffer auth login|set-api-key|clear worldagent` as a subprocess - Add run_puffer_cli_auth_subcommand() helper with polling + timeout - Declare daemon_launcher module in lib.rs; make resolve_puffer_binary pub(crate) Co-Authored-By: Claude Sonnet 4.6 --- apps/puffer-desktop/src-tauri/src/backend.rs | 131 +++++++++++++++++- .../src-tauri/src/daemon_launcher.rs | 2 +- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index a618a1504..34d21be32 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -107,16 +107,35 @@ impl BackendState { )) } "load_settings_snapshot" => serde_value(self.load_settings_snapshot()?), - "login_with_oauth" => serde_value(self.load_settings_snapshot()?), + "login_with_oauth" => { + let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; + if provider_id == "worldagent" { + self.run_puffer_cli_auth_subcommand(&["auth", "login", "worldagent"], 180)?; + } + // For other providers (puffer/codex/claude), the existing behavior was a + // no-op — keep it as such; native CLI integrations handle their own login. + serde_value(self.load_settings_snapshot()?) + } "login_with_api_key" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; let api_key = string_param(¶ms, &["apiKey", "api_key"])?; - self.store_api_key(&provider_id, &api_key)?; + if provider_id == "worldagent" { + self.run_puffer_cli_auth_subcommand( + &["auth", "set-api-key", "worldagent", &api_key], + 30, + )?; + } else { + self.store_api_key(&provider_id, &api_key)?; + } serde_value(self.load_settings_snapshot()?) } "logout_provider" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; - self.remove_api_key(&provider_id)?; + if provider_id == "worldagent" { + self.run_puffer_cli_auth_subcommand(&["auth", "clear", "worldagent"], 10)?; + } else { + self.remove_api_key(&provider_id)?; + } serde_value(self.load_settings_snapshot()?) } "list_external_credentials" => serde_value(self.list_external_credentials()?), @@ -562,6 +581,20 @@ impl BackendState { }); } } + // worldagent lives in puffer-cli's AuthStore (~/.puffer/auth.json), + // not in corbina's own credentials file. Surface it so the GUI shows + // "Connected" after a successful `puffer auth login worldagent`. + if let Some(entry) = read_puffer_cli_credential("worldagent") { + out.push(AuthProviderStatusDto { + provider_id: "worldagent".to_string(), + kind: entry.kind, + email: None, + expires_at_ms: None, + scopes: Vec::new(), + plan_type: Some(entry.summary), + organization_name: None, + }); + } Ok(out) } @@ -1020,6 +1053,54 @@ impl BackendState { fn save_sessions(&self, sessions: &[SessionRecord]) -> Result<()> { write_json(&sessions_file()?, sessions) } + + /// Runs a `puffer auth …` subcommand using the same binary + /// `daemon_launcher::resolve_puffer_binary()` would spawn. Inherits + /// stdio so the user sees the login URL print + any error output; + /// blocks until the subcommand exits. + fn run_puffer_cli_auth_subcommand( + &self, + args: &[&str], + timeout_secs: u64, + ) -> Result<()> { + let binary = crate::daemon_launcher::resolve_puffer_binary() + .context("failed to resolve puffer binary for auth subcommand")?; + let mut child = Command::new(&binary) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .with_context(|| format!("failed to spawn {} {:?}", binary.display(), args))?; + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + anyhow::bail!("`puffer {}` exited with {}", args.join(" "), status); + } + return Ok(()); + } + Ok(None) => { + if started.elapsed().as_secs() >= timeout_secs { + let _ = child.kill(); + anyhow::bail!( + "`puffer {}` did not finish within {}s", + args.join(" "), + timeout_secs + ); + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + Err(error) => { + return Err(anyhow::Error::new(error).context(format!( + "wait failed for `puffer {}`", + args.join(" ") + ))); + } + } + } + } } fn ensure_session_cwd(cwd: &Path) -> Result<()> { @@ -2200,6 +2281,16 @@ fn provider_summaries() -> Vec { source_kind: "builtin".to_string(), source_path: None, }, + ProviderSummaryDto { + id: "worldagent".to_string(), + display_name: "WorldAgent".to_string(), + base_url: "https://inference-api.worldrouter.ai".to_string(), + default_api: "openai-completions".to_string(), + model_count: provider_models("worldagent").len(), + auth_modes: vec!["oauth".to_string(), "api_key".to_string()], + source_kind: "builtin".to_string(), + source_path: None, + }, ] } @@ -2207,6 +2298,10 @@ fn provider_models(provider_id: &str) -> Vec { match canonical_backend_provider_id(provider_id).as_str() { "puffer" => vec![model("default", "Default", "puffer", false)], "claude" => claude_models(), + "worldagent" => vec![ + model("kimi-k2.6", "Kimi K2.6", "worldagent", true), + model("qwen3.5-flash", "Qwen 3.5 Flash", "worldagent", true), + ], _ => codex_app_server_models().unwrap_or_default(), } } @@ -2910,3 +3005,33 @@ enum ProcessLine { Stdout(String), Stderr(String), } + +struct PufferCliCredentialSummary { + kind: String, + summary: String, +} + +fn read_puffer_cli_credential(provider_id: &str) -> Option { + let path = home_dir().join(".puffer/auth.json"); + let raw = std::fs::read_to_string(&path).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?; + let provider = parsed.get("providers")?.get(provider_id)?; + let kind = provider.get("kind")?.as_str()?.to_string(); + let summary = match kind.as_str() { + "api_key" => { + let key = provider.get("key").and_then(|v| v.as_str()).unwrap_or(""); + let tail = key + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::(); + format!("api_key \u{2026}{}", tail) + } + "oauth" => "oauth credential stored".to_string(), + other => other.to_string(), + }; + Some(PufferCliCredentialSummary { kind, summary }) +} diff --git a/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs b/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs index 691064ab1..d07f54235 100644 --- a/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs +++ b/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs @@ -465,7 +465,7 @@ fn shell_quote(s: &str) -> String { /// sibling `puffer` binary next to the Tauri process (i.e. `cargo run`'s /// target directory); in release builds we fall back to the first `puffer` /// on `PATH`. -fn resolve_puffer_binary() -> Result { +pub(crate) fn resolve_puffer_binary() -> Result { let bin_name = if cfg!(windows) { "puffer.exe" } else { "puffer" }; if let Ok(explicit) = std::env::var("PUFFER_BINARY") { let path = PathBuf::from(explicit); From ec8dba9b51654e44e1223c3ac88a477f46b35ec9 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 00:58:06 +0800 Subject: [PATCH 26/39] fix(desktop): detach worldagent OAuth subprocess to avoid GUI freeze The previous helper blocked the Tauri command handler for up to 180 s waiting for puffer auth login to complete (which itself waits up to 120 s on the localhost callback listener). Result: GUI froze the moment the user clicked Connect with OAuth. Split behavior: `wait = false` for OAuth (spawn-and-detach, frontend re-polls load_settings_snapshot via the Refresh button), `wait = true` for set-api-key / clear (fast, sub-second operations). --- apps/puffer-desktop/src-tauri/src/backend.rs | 41 +++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index 34d21be32..cd1af7340 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -110,7 +110,12 @@ impl BackendState { "login_with_oauth" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; if provider_id == "worldagent" { - self.run_puffer_cli_auth_subcommand(&["auth", "login", "worldagent"], 180)?; + // Detach: OAuth blocks up to 120 s on the localhost + // callback listener, which would freeze the GUI. + self.run_puffer_cli_auth_subcommand( + &["auth", "login", "worldagent"], + false, + )?; } // For other providers (puffer/codex/claude), the existing behavior was a // no-op — keep it as such; native CLI integrations handle their own login. @@ -122,7 +127,7 @@ impl BackendState { if provider_id == "worldagent" { self.run_puffer_cli_auth_subcommand( &["auth", "set-api-key", "worldagent", &api_key], - 30, + true, )?; } else { self.store_api_key(&provider_id, &api_key)?; @@ -132,7 +137,7 @@ impl BackendState { "logout_provider" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; if provider_id == "worldagent" { - self.run_puffer_cli_auth_subcommand(&["auth", "clear", "worldagent"], 10)?; + self.run_puffer_cli_auth_subcommand(&["auth", "clear", "worldagent"], true)?; } else { self.remove_api_key(&provider_id)?; } @@ -1056,13 +1061,17 @@ impl BackendState { /// Runs a `puffer auth …` subcommand using the same binary /// `daemon_launcher::resolve_puffer_binary()` would spawn. Inherits - /// stdio so the user sees the login URL print + any error output; - /// blocks until the subcommand exits. - fn run_puffer_cli_auth_subcommand( - &self, - args: &[&str], - timeout_secs: u64, - ) -> Result<()> { + /// stdio so the user sees the login URL print + any error output. + /// + /// `wait = true` blocks until the subcommand exits (with a 30 s cap) + /// — only safe for fast commands (`set-api-key`, `clear`). + /// `wait = false` detaches: spawn and return immediately. Required + /// for `auth login`, which sits on a localhost listener for up to + /// 120 s waiting for the browser callback; blocking the Tauri + /// command handler that long freezes the GUI. The frontend has to + /// re-poll `load_settings_snapshot` (manual Refresh button or its + /// own polling) to see the new credential. + fn run_puffer_cli_auth_subcommand(&self, args: &[&str], wait: bool) -> Result<()> { let binary = crate::daemon_launcher::resolve_puffer_binary() .context("failed to resolve puffer binary for auth subcommand")?; let mut child = Command::new(&binary) @@ -1072,7 +1081,11 @@ impl BackendState { .stderr(std::process::Stdio::inherit()) .spawn() .with_context(|| format!("failed to spawn {} {:?}", binary.display(), args))?; + if !wait { + return Ok(()); + } let started = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(30); loop { match child.try_wait() { Ok(Some(status)) => { @@ -1082,15 +1095,15 @@ impl BackendState { return Ok(()); } Ok(None) => { - if started.elapsed().as_secs() >= timeout_secs { + if started.elapsed() >= timeout { let _ = child.kill(); anyhow::bail!( - "`puffer {}` did not finish within {}s", + "`puffer {}` did not finish within {:?}", args.join(" "), - timeout_secs + timeout ); } - std::thread::sleep(std::time::Duration::from_millis(250)); + std::thread::sleep(std::time::Duration::from_millis(100)); } Err(error) => { return Err(anyhow::Error::new(error).context(format!( From ea95d145c7e88f2315cfc1f10076bf505e5ebbde Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 01:05:30 +0800 Subject: [PATCH 27/39] fix(desktop): open OAuth URL from corbina, not the puffer subprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The puffer CLI invokes macOS `open` to launch the browser, but when puffer runs as a corbina-spawned subprocess it lacks the GUI context LaunchServices needs — the `open` call silently no-ops even though the subprocess itself is fine. corbina is a proper macOS app and its own `open` invocations route through LaunchServices correctly. Pipe the OAuth child's stdout, tail it line-by-line in a background thread, and when we see the `https://…` URL line, invoke `open` from corbina itself. Other stdout lines forward to stderr so dev terminal visibility is preserved. Also reap the detached child in another thread to avoid zombies. --- apps/puffer-desktop/src-tauri/src/backend.rs | 71 +++++++++++++++++--- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index cd1af7340..f69eb08f3 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -1060,28 +1060,49 @@ impl BackendState { } /// Runs a `puffer auth …` subcommand using the same binary - /// `daemon_launcher::resolve_puffer_binary()` would spawn. Inherits - /// stdio so the user sees the login URL print + any error output. + /// `daemon_launcher::resolve_puffer_binary()` would spawn. /// - /// `wait = true` blocks until the subcommand exits (with a 30 s cap) - /// — only safe for fast commands (`set-api-key`, `clear`). - /// `wait = false` detaches: spawn and return immediately. Required - /// for `auth login`, which sits on a localhost listener for up to - /// 120 s waiting for the browser callback; blocking the Tauri - /// command handler that long freezes the GUI. The frontend has to - /// re-poll `load_settings_snapshot` (manual Refresh button or its - /// own polling) to see the new credential. + /// `wait = true` blocks until the subcommand exits (with a 30 s + /// cap). Inherits stdio so the user sees output in the dev + /// terminal. Only safe for fast commands (`set-api-key`, `clear`). + /// + /// `wait = false` detaches: spawn, return immediately, and tail + /// the child's stdout in a background thread. When the child + /// prints a `https://…` URL on its own line (puffer's `Open this + /// URL in your browser:` block), corbina itself opens the URL via + /// macOS `open`. The CLI's own `open` invocation runs in a + /// non-GUI context (subprocess of a Tauri host) and on macOS that + /// path silently fails to route through LaunchServices; corbina + /// itself is a proper GUI app so its `open` call works. + /// + /// Required for `auth login`, which sits on a localhost listener + /// for up to 120 s waiting for the browser callback; blocking the + /// Tauri command handler that long freezes the GUI. The frontend + /// has to re-poll `load_settings_snapshot` (manual Refresh button + /// or its own polling) to see the new credential. fn run_puffer_cli_auth_subcommand(&self, args: &[&str], wait: bool) -> Result<()> { let binary = crate::daemon_launcher::resolve_puffer_binary() .context("failed to resolve puffer binary for auth subcommand")?; + let stdio_stdout = if wait { + std::process::Stdio::inherit() + } else { + std::process::Stdio::piped() + }; let mut child = Command::new(&binary) .args(args) .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) + .stdout(stdio_stdout) .stderr(std::process::Stdio::inherit()) .spawn() .with_context(|| format!("failed to spawn {} {:?}", binary.display(), args))?; if !wait { + if let Some(stdout) = child.stdout.take() { + std::thread::spawn(move || tail_auth_subcommand_stdout(stdout)); + } + // Reap the child in the background so we don't leave a zombie. + std::thread::spawn(move || { + let _ = child.wait(); + }); return Ok(()); } let started = std::time::Instant::now(); @@ -1130,6 +1151,34 @@ fn ensure_session_cwd(cwd: &Path) -> Result<()> { .with_context(|| format!("failed to create session cwd {}", cwd.display())) } +/// Reads the detached `puffer auth login` child's stdout line by +/// line and, when it sees a URL (puffer's `Open this URL in your +/// browser:` block emits one `https://…` line followed by an empty +/// line), opens that URL via macOS `open` from corbina's own +/// process. Other stdout lines are forwarded to corbina's stderr so +/// they remain visible in dev logs without contending with the +/// detached child's own ttyless context. +fn tail_auth_subcommand_stdout(stdout: std::process::ChildStdout) { + let reader = BufReader::new(stdout); + let mut opened = false; + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if !opened && trimmed.starts_with("https://") { + opened = true; + let target = trimmed.to_string(); + eprintln!("[worldagent oauth] opening browser at {target}"); + match Command::new("open").arg(&target).spawn() { + Ok(_) => {} + Err(error) => { + eprintln!("[worldagent oauth] failed to launch `open`: {error}"); + } + } + continue; + } + eprintln!("[worldagent oauth] {line}"); + } +} + fn run_agent_turn_thread( events: EventEmitter, browsers: browser::BrowserRegistry, From 0a892272a2477e06d8da3bf658bd21c8698badc9 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 01:09:00 +0800 Subject: [PATCH 28/39] fix(cli/authflow): handle nonblocking stream read race on macOS callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallbackListener::wait_for_callback_url called accept() on a nonblocking listener and then immediately read from the accepted stream. On macOS the accepted stream inherits the listener's nonblocking flag, so if the browser's HTTP request hasn't fully arrived yet the first read returns EAGAIN (`Resource temporarily unavailable, os error 35`) and the whole login flow bails out. The race showed up when puffer is spawned as a subprocess of corbina (slightly slower scheduling than direct CLI exec) — the detached GUI worldagent OAuth flow hit it every time even though the equivalent direct `puffer auth login worldagent` had never reproduced it. Switch the accepted stream back to blocking with a 5 s read timeout so we wait for the request bytes instead of aborting. --- crates/puffer-cli/src/authflow.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/puffer-cli/src/authflow.rs b/crates/puffer-cli/src/authflow.rs index d8244700b..3ebbe4956 100644 --- a/crates/puffer-cli/src/authflow.rs +++ b/crates/puffer-cli/src/authflow.rs @@ -60,6 +60,13 @@ impl CallbackListener { while Instant::now() < deadline { match self.listener.accept() { Ok((mut stream, _)) => { + // The listener is nonblocking, which is inherited by the + // accepted stream on some platforms (macOS). Switch the + // stream back to blocking + impose a read timeout so we + // don't hit EAGAIN on the first read just because the + // browser's HTTP request hasn't fully flushed yet. + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; let mut buffer = [0_u8; 4096]; let bytes_read = stream.read(&mut buffer)?; let request = String::from_utf8_lossy(&buffer[..bytes_read]).to_string(); From 1316b835bf779b19a219daa067e1a92374808ab4 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 01:29:38 +0800 Subject: [PATCH 29/39] =?UTF-8?q?feat(desktop):=20worldagent=20OAuth=20UX?= =?UTF-8?q?=20=E2=80=94=20completion=20events,=20locked=20input=20states,?= =?UTF-8?q?=20disconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detached `puffer auth login worldagent` subprocess now reports its exit status back to the GUI via a new `worldagent:oauth-completed` Tauri event, so the LoginView keeps showing "Waiting for browser login…" until the user finishes (or cancels) the browser flow instead of optimistically flipping to the workspace screen the moment the child is spawned. After a successful login the provider card hides the API-key input and OAuth button and shows a "Disconnect" affordance plus a Connected badge; the API-key field and OAuth button are mutually exclusive when one of them is in use; and Settings now forwards `onLogout` into the per-provider LoginView so users can disconnect without leaving the page. Backend also distinguishes worldrouter-minted `sk-worldrouter-…` tokens (kind = "oauth") from manually pasted api keys so the GUI can render the right affordance. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/puffer-desktop/src-tauri/src/backend.rs | 94 +++++++-- apps/puffer-desktop/src/App.svelte | 73 ++++++- .../src/lib/components/LoginView.svelte | 186 ++++++++++++------ .../src/lib/screens/Settings.svelte | 1 + 4 files changed, 287 insertions(+), 67 deletions(-) diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index f69eb08f3..9fd970087 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -111,10 +111,15 @@ impl BackendState { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; if provider_id == "worldagent" { // Detach: OAuth blocks up to 120 s on the localhost - // callback listener, which would freeze the GUI. + // callback listener, which would freeze the GUI. The + // event emitter lets the spawned reaper thread fire + // `worldagent:oauth-completed` so the GUI can swap + // the spinner for the "Connected" affordance the + // moment the puffer subprocess exits. self.run_puffer_cli_auth_subcommand( &["auth", "login", "worldagent"], false, + Some(events.clone()), )?; } // For other providers (puffer/codex/claude), the existing behavior was a @@ -128,6 +133,7 @@ impl BackendState { self.run_puffer_cli_auth_subcommand( &["auth", "set-api-key", "worldagent", &api_key], true, + None, )?; } else { self.store_api_key(&provider_id, &api_key)?; @@ -137,7 +143,11 @@ impl BackendState { "logout_provider" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; if provider_id == "worldagent" { - self.run_puffer_cli_auth_subcommand(&["auth", "clear", "worldagent"], true)?; + self.run_puffer_cli_auth_subcommand( + &["auth", "clear", "worldagent"], + true, + None, + )?; } else { self.remove_api_key(&provider_id)?; } @@ -589,10 +599,21 @@ impl BackendState { // worldagent lives in puffer-cli's AuthStore (~/.puffer/auth.json), // not in corbina's own credentials file. Surface it so the GUI shows // "Connected" after a successful `puffer auth login worldagent`. + // + // We override `kind` to "oauth" when the stored key is an + // `sk-worldrouter-…` token (minted via the /auth/exchange flow), + // even though AuthStore tags it as `api_key`. This lets the + // LoginView hide the "Paste API key" affordance for OAuth users + // and only show "Disconnect". if let Some(entry) = read_puffer_cli_credential("worldagent") { + let surfaced_kind = if entry.oauth_derived { + "oauth".to_string() + } else { + entry.kind + }; out.push(AuthProviderStatusDto { provider_id: "worldagent".to_string(), - kind: entry.kind, + kind: surfaced_kind, email: None, expires_at_ms: None, scopes: Vec::new(), @@ -1077,10 +1098,17 @@ impl BackendState { /// /// Required for `auth login`, which sits on a localhost listener /// for up to 120 s waiting for the browser callback; blocking the - /// Tauri command handler that long freezes the GUI. The frontend - /// has to re-poll `load_settings_snapshot` (manual Refresh button - /// or its own polling) to see the new credential. - fn run_puffer_cli_auth_subcommand(&self, args: &[&str], wait: bool) -> Result<()> { + /// Tauri command handler that long freezes the GUI. When `events` + /// is supplied with `wait = false`, the reaper thread fires + /// `worldagent:oauth-completed` once the child exits so the + /// frontend can clear its "Opening browser…" spinner without + /// having to poll `load_settings_snapshot`. + fn run_puffer_cli_auth_subcommand( + &self, + args: &[&str], + wait: bool, + events: Option, + ) -> Result<()> { let binary = crate::daemon_launcher::resolve_puffer_binary() .context("failed to resolve puffer binary for auth subcommand")?; let stdio_stdout = if wait { @@ -1099,9 +1127,29 @@ impl BackendState { if let Some(stdout) = child.stdout.take() { std::thread::spawn(move || tail_auth_subcommand_stdout(stdout)); } - // Reap the child in the background so we don't leave a zombie. + // Reap the child in the background so we don't leave a + // zombie, and (when we have an emitter) fan out the exit + // status as a Tauri event so the GUI can react. + let args_label = args.join(" "); std::thread::spawn(move || { - let _ = child.wait(); + let outcome = child.wait(); + if let Some(events) = events { + let (success, error) = match outcome { + Ok(status) if status.success() => (true, None), + Ok(status) => ( + false, + Some(format!("puffer {} exited with {}", args_label, status)), + ), + Err(err) => ( + false, + Some(format!("wait failed for puffer {}: {}", args_label, err)), + ), + }; + events.emit( + "worldagent:oauth-completed", + json!({"success": success, "error": error}), + ); + } }); return Ok(()); } @@ -3069,8 +3117,16 @@ enum ProcessLine { } struct PufferCliCredentialSummary { + /// Raw `kind` field from puffer-cli's AuthStore (`"api_key"` or + /// `"oauth"` today). Preserved verbatim for callers that want the + /// untouched on-disk value. kind: String, + /// Human-friendly one-liner suitable for the GUI's plan-type slot. summary: String, + /// True when the stored credential is an `sk-worldrouter-…` token + /// minted by the corbina OAuth exchange flow (worldrouter writes + /// these into the same `api_key` slot as a manually pasted key). + oauth_derived: bool, } fn read_puffer_cli_credential(provider_id: &str) -> Option { @@ -3079,9 +3135,17 @@ fn read_puffer_cli_credential(provider_id: &str) -> Option { let key = provider.get("key").and_then(|v| v.as_str()).unwrap_or(""); + // worldrouter mints OAuth-exchanged tokens with the + // `sk-worldrouter-` prefix and persists them through the + // same `api_key` slot as manually pasted keys. Tagging them + // as OAuth lets the GUI render the right affordance. + if key.starts_with("sk-worldrouter-") { + oauth_derived = true; + } let tail = key .chars() .rev() @@ -3090,10 +3154,18 @@ fn read_puffer_cli_credential(provider_id: &str) -> Option(); - format!("api_key \u{2026}{}", tail) + if oauth_derived { + format!("oauth \u{2026}{}", tail) + } else { + format!("api_key \u{2026}{}", tail) + } } "oauth" => "oauth credential stored".to_string(), other => other.to_string(), }; - Some(PufferCliCredentialSummary { kind, summary }) + Some(PufferCliCredentialSummary { + kind, + summary, + oauth_derived, + }) } diff --git a/apps/puffer-desktop/src/App.svelte b/apps/puffer-desktop/src/App.svelte index 83670e43c..a2474c69a 100644 --- a/apps/puffer-desktop/src/App.svelte +++ b/apps/puffer-desktop/src/App.svelte @@ -185,6 +185,7 @@ let turnQuestionLookup = $state>({}); let replayTextByTurn: Record = {}; let sessionEventUnlisten: UnlistenFn | null = null; + let worldagentOauthUnsubscribe: (() => void) | null = null; let subscribedSessionId: string | null = null; let sessionSubscriptionGeneration = 0; let liveSidebarSessionEventUnlisteners: Record = {}; @@ -1044,6 +1045,10 @@ window.removeEventListener("blur", armRecapBlurTimer); window.removeEventListener("focus", cancelRecapBlurTimer); window.removeEventListener("keydown", handleShellKeydown, true); + if (worldagentOauthUnsubscribe) { + worldagentOauthUnsubscribe(); + worldagentOauthUnsubscribe = null; + } }; }); @@ -1067,6 +1072,18 @@ void ensureLocalDaemonClient() .then((client) => { attachDaemonClient(client); + // Fired by the Rust reaper thread when `puffer auth login + // worldagent` exits. The handler that kicked off the flow + // (`handleOauthLogin`) keeps `authBusyProviderId` set so the + // GUI shows "Waiting for browser login…" until this event + // arrives. + if (worldagentOauthUnsubscribe) worldagentOauthUnsubscribe(); + worldagentOauthUnsubscribe = client.on<{ success?: boolean; error?: string | null }>( + "worldagent:oauth-completed", + (payload) => { + void handleWorldagentOauthCompleted(payload); + } + ); }) .catch(() => { /* connection may be unavailable (web preview); stay idle */ @@ -1171,6 +1188,19 @@ authBusyProviderId = providerId; authError = null; try { + if (providerId === "worldagent") { + // The Tauri handler returns immediately after spawning the + // detached `puffer auth login worldagent` subprocess (it + // blocks up to 120 s on the localhost callback). The reaper + // thread emits `worldagent:oauth-completed` when the child + // exits; that listener clears `authBusyProviderId`, refreshes + // the snapshot, and decides whether to navigate. + statusMessage = "Opening browser — finish the login to continue."; + await loginWithOauth(providerId, remoteConnection); + // Intentionally leave `authBusyProviderId` set; the event + // handler will clear it. + return; + } settingsSnapshot = await loginWithOauth(providerId, remoteConnection); onboardingCompleted = hasAvailableAgentProvider(settingsSnapshot); onboarding = shouldShowOnboarding(settingsSnapshot); @@ -1182,8 +1212,49 @@ } catch (error) { authError = String(error); statusMessage = authError; + if (providerId === "worldagent") { + authBusyProviderId = null; + } } finally { - authBusyProviderId = null; + if (providerId !== "worldagent") { + authBusyProviderId = null; + } + } + } + + async function handleWorldagentOauthCompleted(payload: { + success?: boolean; + error?: string | null; + }) { + try { + if (payload?.success) { + statusMessage = "WorldAgent connected."; + await refreshSnapshot(); + if ((settingsSnapshot?.auth?.length ?? 0) > 0) { + onboarding = false; + } + await refreshGroups(); + } else { + const reason = payload?.error?.trim(); + authError = reason && reason.length > 0 + ? reason + : "WorldAgent login failed. Please try again."; + statusMessage = authError; + } + } finally { + // The OAuth flow has reached a terminal state regardless of + // success — clear the spinner so the user can retry. + if (authBusyProviderId === "worldagent") { + authBusyProviderId = null; + } + } + } + + async function refreshSnapshot() { + try { + settingsSnapshot = await loadSettingsSnapshot(remoteConnection); + } catch (error) { + statusMessage = `Failed to refresh settings: ${error}`; } } diff --git a/apps/puffer-desktop/src/lib/components/LoginView.svelte b/apps/puffer-desktop/src/lib/components/LoginView.svelte index a304b90cf..1ed68e395 100644 --- a/apps/puffer-desktop/src/lib/components/LoginView.svelte +++ b/apps/puffer-desktop/src/lib/components/LoginView.svelte @@ -21,6 +21,7 @@ export let onLoginOauth: (providerId: string) => void = () => {}; export let onLoginApiKey: (providerId: string, apiKey: string) => void = () => {}; export let onImportExternal: (providerId: string, source: "claude" | "codex") => void = () => {}; + export let onLogout: (providerId: string) => void = () => {}; export let onRefresh: () => void = () => {}; let apiKeys: Record = {}; @@ -47,6 +48,11 @@ onLoginOauth(providerId); } + function submitLogout(providerId: string) { + if (busyProviderId) return; + onLogout(providerId); + } + function supports(provider: ProviderSummary, mode: string): boolean { return provider.authModes.includes(mode); } @@ -206,6 +212,8 @@ {@const candidates = importsByProvider[provider.id] ?? []} {@const auth = authForProvider(provider.id)} {@const authFree = providerRunsWithoutAuth(provider)} + {@const isBusy = busyProviderId === provider.id} + {@const pendingKey = (apiKeys[provider.id] ?? "").trim()}
- {#if candidates.length} -
- {#each candidates as candidate (importKey(candidate.providerId, candidate.source))} - - {/each} -
- {/if} - -
- {#if supports(provider, "oauth")} + {#if auth} +
+
+ + {connectedHint(auth)} + {#if auth.planType} + · {auth.planType} + {/if} +
+
+ {:else} + {#if candidates.length} +
+ {#each candidates as candidate (importKey(candidate.providerId, candidate.source))} + + {/each} +
{/if} - {#if supports(provider, "api_key")} -
- - updateApiKey(provider.id, (event.currentTarget as HTMLInputElement).value)} - on:keydown={(event) => { - if (event.key === "Enter") submitApiKey(provider.id); - }} - /> +
+ {#if supports(provider, "oauth")} -
- {/if} -
+ {/if} + + {#if supports(provider, "api_key")} +
+ + updateApiKey(provider.id, (event.currentTarget as HTMLInputElement).value)} + on:keydown={(event) => { + if (event.key === "Enter") submitApiKey(provider.id); + }} + /> + +
+ {/if} +
+ {/if}

{auth @@ -459,6 +487,50 @@ align-items: center; gap: 0.7rem; } + .connected-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: var(--text); + } + .connected-detail { + color: var(--text-muted); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.78rem; + } + .status-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--text-muted); + flex: 0 0 auto; + } + .status-dot[data-connected="true"] { + background: #4caf50; + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.18); + } + .logout-btn { + border: 1px solid rgba(111, 101, 89, 0.22); + border-radius: 10px; + padding: 0.5rem 0.85rem; + background: rgba(255, 255, 255, 0.92); + color: var(--text); + font: inherit; + font-weight: 500; + cursor: pointer; + justify-self: start; + } + .logout-btn:hover:not(:disabled) { + background: rgba(247, 225, 220, 0.45); + border-color: rgba(157, 58, 43, 0.3); + color: var(--danger, #9d3a2b); + } + .logout-btn:disabled { + opacity: 0.6; + cursor: progress; + } .logo { width: 36px; height: 36px; @@ -590,6 +662,10 @@ outline-offset: 1px; border-color: var(--provider-accent); } + .api-key-row input:disabled { + opacity: 0.6; + cursor: not-allowed; + } .hint { margin: 0; diff --git a/apps/puffer-desktop/src/lib/screens/Settings.svelte b/apps/puffer-desktop/src/lib/screens/Settings.svelte index d5a69ff64..d6b4a96d7 100644 --- a/apps/puffer-desktop/src/lib/screens/Settings.svelte +++ b/apps/puffer-desktop/src/lib/screens/Settings.svelte @@ -694,6 +694,7 @@ onLoginOauth={props.onLoginOauth ?? (() => {})} onLoginApiKey={props.onApiKeyLogin ?? (() => {})} onImportExternal={props.onImportExternal ?? (() => {})} + onLogout={props.onLogout} onRefresh={props.onRefresh} /> From c763c2b89a11d97572f4016e7049d4343f371679 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 01:47:12 +0800 Subject: [PATCH 30/39] docs(specs): align worldagent provider spec with shipped behaviour Code review (PR #/branch feat/worldagent-provider) flagged the spec as the only blocker for merge: - PUFFER_WORLDAGENT_TEAM_ID env var was dropped in the two-hop implementation (default_team_id arrives in /auth/exchange response). - Default control-api URL is production, not the preview deployment. - Surface description for exchange_jwt_for_api_key referenced only one hop; document both hops and the litellm DB split that motivates defaulting to production control-api. --- specs/puffer-provider-worldagent/00.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/puffer-provider-worldagent/00.md b/specs/puffer-provider-worldagent/00.md index 6cdb90e6f..4570989f9 100644 --- a/specs/puffer-provider-worldagent/00.md +++ b/specs/puffer-provider-worldagent/00.md @@ -9,14 +9,14 @@ - `parse_callback_input(&str) -> WorldAgentCallback` - `decode_jwt_profile(&str) -> WorldAgentJwtProfile` - `refresh_oauth_token(&str, Option<&str>) -> Result` -- `exchange_jwt_for_api_key(&str) -> Result` — implemented: POSTs to control-api preview endpoint to mint a `sk-worldrouter-…` key +- `exchange_jwt_for_api_key(&str) -> Result` — two-hop: `POST {control-api}/auth/exchange` trades the Auth Station JWT for an Infer session token (HS256, `iss=infer-session`) and returns the user's `default_team_id`; `POST {control-api}/platform/v1/teams/{default_team_id}/keys` then mints a `sk-worldrouter-…` inference key. ## Configuration - Default Auth Station URL: `https://auth.worldrouter.ai` (Production; `http://127.0.0.1:1456` allow-listed on prod as of 2026-05-20). Sandbox `https://auth-worldrouter.vercel.app` is reachable via `PUFFER_WORLDAGENT_AUTH_URL` override. -- Default control-api URL: `https://control-api-pre-7f819c.worldrouter.ai` (preview). Override via `PUFFER_WORLDAGENT_CONTROL_URL`. +- Default control-api URL: `https://control-api.worldrouter.ai` (Production). Preview `https://control-api-pre-7f819c.worldrouter.ai` is reachable via `PUFFER_WORLDAGENT_CONTROL_URL` override — beware that preview-control and production-inference do not share a LiteLLM DB, so keys minted on preview return 401 against `inference-api.worldrouter.ai`. - Fixed loopback redirect: `http://127.0.0.1:1456/callback`. -- Temporary team_id workaround: `PUFFER_WORLDAGENT_TEAM_ID` env var. Auth Station JWTs do not currently carry `default_team_id` — set this to your team UUID until backend either adds the claim or moves to a teamless endpoint. +- `default_team_id` comes from the `/auth/exchange` response envelope; no env-var fallback needed. ## Compatibility -- The fixed redirect URI must be allow-listed in Auth Station `ALLOWED_REDIRECT_ORIGINS` on both Sandbox and Production. -- `exchange_jwt_for_api_key` is now implemented against the control-api preview deployment (`control-api-pre-7f819c.worldrouter.ai`). The OAuth login path mints a `sk-worldrouter-…` inference key and stores it as `StoredCredential::ApiKey`; the JWT is not persisted. The inference path reuses the standard OpenAI-completions transport with the freshly minted key. Backend explicitly noted the API shape will change — a TODO block in `auth.rs` tracks open questions (idempotency, revoke endpoint, stable URL, team_id resolution). +- The fixed redirect URI must be allow-listed in Auth Station `ALLOWED_REDIRECT_ORIGINS` on both Sandbox and Production. Production confirmed 2026-05-20; Sandbox status to be verified before shipping a Sandbox build. +- `exchange_jwt_for_api_key` is implemented against the production control-api deployment. The OAuth login path mints a `sk-worldrouter-…` inference key and stores it as `StoredCredential::ApiKey`; the Auth Station JWT is single-use and not persisted. The inference path reuses the standard OpenAI-completions transport with the freshly minted key. Backend explicitly noted the API shape will change — a TODO block in `auth.rs` tracks open questions (idempotency, revoke endpoint, stable production URL). From 37dcb04019a0ed3ea59ced4b52d77844aa3a0858 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 12:28:18 +0800 Subject: [PATCH 31/39] chore: rename worldagent -> worldrouter across codebase Mechanical rebrand of the WorldAgent provider integration to WorldRouter prior to merging feat/worldagent-provider to master. Covers: - Crate dir: crates/puffer-provider-worldagent -> puffer-provider-worldrouter (Cargo.toml name, workspace members, puffer-cli dependency, extern crate references all updated) - Provider yaml: resources/providers/worldagent.yaml -> worldrouter.yaml with id/display_name/oauth_family flipped - Rust: OauthFamily::WorldAgent -> WorldRouter, *_worldagent_* fns + WORLDAGENT_* constants, "worldagent" provider-id match arms in auth_provider.rs / backend.rs - Env vars: PUFFER_WORLDAGENT_AUTH_URL / _CONTROL_URL -> PUFFER_WORLDROUTER_* - Tauri events: worldagent:oauth-completed -> worldrouter:oauth-completed, log prefix [worldagent oauth] -> [worldrouter oauth] - Desktop TS/Svelte literals (App.svelte, LoginView.svelte, providerVisuals.ts) - Specs (specs/puffer-provider-worldagent dir renamed, plus references in puffer-cli/desktop/registry/resources specs) - docs/superpowers spec/plan/notes files renamed Marketing URL https://inference-api.worldrouter.ai untouched (already WorldRouter). No backwards-compat shim for auth.json keyed by "worldagent" - existing users will need to re-login. cargo build green; cargo test -p puffer-provider-worldrouter 6/6 passing; pnpm check (svelte-check) 0 errors / 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 4 +- Cargo.toml | 2 +- apps/puffer-desktop/src-tauri/src/backend.rs | 44 +- apps/puffer-desktop/src/App.svelte | 36 +- .../src/lib/components/LoginView.svelte | 2 +- .../puffer-desktop/src/lib/providerVisuals.ts | 4 +- crates/puffer-cli/Cargo.toml | 2 +- crates/puffer-cli/src/auth_credentials.rs | 14 +- crates/puffer-cli/src/auth_provider.rs | 26 +- crates/puffer-cli/src/authflow.rs | 2 +- crates/puffer-cli/src/daemon.rs | 28 +- crates/puffer-cli/src/main.rs | 44 +- crates/puffer-provider-registry/src/model.rs | 6 +- crates/puffer-provider-worldagent/src/lib.rs | 20 - .../Cargo.toml | 2 +- .../src/auth.rs | 110 ++-- crates/puffer-provider-worldrouter/src/lib.rs | 20 + crates/puffer-resources/src/model.rs | 14 +- ...2026-05-20-worldrouter-backend-handoff.md} | 34 +- ....md => 2026-05-20-worldrouter-provider.md} | 504 +++++++++--------- ...2026-05-20-worldrouter-provider-design.md} | 112 ++-- .../{worldagent.yaml => worldrouter.yaml} | 10 +- specs/puffer-desktop/458.md | 4 +- specs/puffer-provider-registry/06.md | 2 +- .../00.md | 16 +- specs/puffer-resources/01.md | 6 +- 26 files changed, 534 insertions(+), 534 deletions(-) delete mode 100644 crates/puffer-provider-worldagent/src/lib.rs rename crates/{puffer-provider-worldagent => puffer-provider-worldrouter}/Cargo.toml (88%) rename crates/{puffer-provider-worldagent => puffer-provider-worldrouter}/src/auth.rs (80%) create mode 100644 crates/puffer-provider-worldrouter/src/lib.rs rename docs/superpowers/notes/{2026-05-20-worldagent-backend-handoff.md => 2026-05-20-worldrouter-backend-handoff.md} (87%) rename docs/superpowers/plans/{2026-05-20-worldagent-provider.md => 2026-05-20-worldrouter-provider.md} (71%) rename docs/superpowers/specs/{2026-05-20-worldagent-provider-design.md => 2026-05-20-worldrouter-provider-design.md} (81%) rename resources/providers/{worldagent.yaml => worldrouter.yaml} (87%) rename specs/{puffer-provider-worldagent => puffer-provider-worldrouter}/00.md (72%) diff --git a/Cargo.lock b/Cargo.lock index dc2bf04b2..d32343384 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4257,7 +4257,7 @@ dependencies = [ "puffer-observability", "puffer-provider-openai", "puffer-provider-registry", - "puffer-provider-worldagent", + "puffer-provider-worldrouter", "puffer-resources", "puffer-runner-api", "puffer-runner-grpc", @@ -4575,7 +4575,7 @@ dependencies = [ ] [[package]] -name = "puffer-provider-worldagent" +name = "puffer-provider-worldrouter" version = "0.1.0" dependencies = [ "anyhow", diff --git a/Cargo.toml b/Cargo.toml index b7250d337..fbc32cdf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ "crates/puffer-observability", "crates/puffer-provider-openai", "crates/puffer-provider-registry", - "crates/puffer-provider-worldagent", + "crates/puffer-provider-worldrouter", "crates/puffer-resources", "crates/puffer-runner-api", "crates/puffer-runner-local", diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index 9fd970087..eaa057577 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -109,15 +109,15 @@ impl BackendState { "load_settings_snapshot" => serde_value(self.load_settings_snapshot()?), "login_with_oauth" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; - if provider_id == "worldagent" { + if provider_id == "worldrouter" { // Detach: OAuth blocks up to 120 s on the localhost // callback listener, which would freeze the GUI. The // event emitter lets the spawned reaper thread fire - // `worldagent:oauth-completed` so the GUI can swap + // `worldrouter:oauth-completed` so the GUI can swap // the spinner for the "Connected" affordance the // moment the puffer subprocess exits. self.run_puffer_cli_auth_subcommand( - &["auth", "login", "worldagent"], + &["auth", "login", "worldrouter"], false, Some(events.clone()), )?; @@ -129,9 +129,9 @@ impl BackendState { "login_with_api_key" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; let api_key = string_param(¶ms, &["apiKey", "api_key"])?; - if provider_id == "worldagent" { + if provider_id == "worldrouter" { self.run_puffer_cli_auth_subcommand( - &["auth", "set-api-key", "worldagent", &api_key], + &["auth", "set-api-key", "worldrouter", &api_key], true, None, )?; @@ -142,9 +142,9 @@ impl BackendState { } "logout_provider" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; - if provider_id == "worldagent" { + if provider_id == "worldrouter" { self.run_puffer_cli_auth_subcommand( - &["auth", "clear", "worldagent"], + &["auth", "clear", "worldrouter"], true, None, )?; @@ -596,23 +596,23 @@ impl BackendState { }); } } - // worldagent lives in puffer-cli's AuthStore (~/.puffer/auth.json), + // worldrouter lives in puffer-cli's AuthStore (~/.puffer/auth.json), // not in corbina's own credentials file. Surface it so the GUI shows - // "Connected" after a successful `puffer auth login worldagent`. + // "Connected" after a successful `puffer auth login worldrouter`. // // We override `kind` to "oauth" when the stored key is an // `sk-worldrouter-…` token (minted via the /auth/exchange flow), // even though AuthStore tags it as `api_key`. This lets the // LoginView hide the "Paste API key" affordance for OAuth users // and only show "Disconnect". - if let Some(entry) = read_puffer_cli_credential("worldagent") { + if let Some(entry) = read_puffer_cli_credential("worldrouter") { let surfaced_kind = if entry.oauth_derived { "oauth".to_string() } else { entry.kind }; out.push(AuthProviderStatusDto { - provider_id: "worldagent".to_string(), + provider_id: "worldrouter".to_string(), kind: surfaced_kind, email: None, expires_at_ms: None, @@ -1100,7 +1100,7 @@ impl BackendState { /// for up to 120 s waiting for the browser callback; blocking the /// Tauri command handler that long freezes the GUI. When `events` /// is supplied with `wait = false`, the reaper thread fires - /// `worldagent:oauth-completed` once the child exits so the + /// `worldrouter:oauth-completed` once the child exits so the /// frontend can clear its "Opening browser…" spinner without /// having to poll `load_settings_snapshot`. fn run_puffer_cli_auth_subcommand( @@ -1146,7 +1146,7 @@ impl BackendState { ), }; events.emit( - "worldagent:oauth-completed", + "worldrouter:oauth-completed", json!({"success": success, "error": error}), ); } @@ -1214,16 +1214,16 @@ fn tail_auth_subcommand_stdout(stdout: std::process::ChildStdout) { if !opened && trimmed.starts_with("https://") { opened = true; let target = trimmed.to_string(); - eprintln!("[worldagent oauth] opening browser at {target}"); + eprintln!("[worldrouter oauth] opening browser at {target}"); match Command::new("open").arg(&target).spawn() { Ok(_) => {} Err(error) => { - eprintln!("[worldagent oauth] failed to launch `open`: {error}"); + eprintln!("[worldrouter oauth] failed to launch `open`: {error}"); } } continue; } - eprintln!("[worldagent oauth] {line}"); + eprintln!("[worldrouter oauth] {line}"); } } @@ -2392,11 +2392,11 @@ fn provider_summaries() -> Vec { source_path: None, }, ProviderSummaryDto { - id: "worldagent".to_string(), - display_name: "WorldAgent".to_string(), + id: "worldrouter".to_string(), + display_name: "WorldRouter".to_string(), base_url: "https://inference-api.worldrouter.ai".to_string(), default_api: "openai-completions".to_string(), - model_count: provider_models("worldagent").len(), + model_count: provider_models("worldrouter").len(), auth_modes: vec!["oauth".to_string(), "api_key".to_string()], source_kind: "builtin".to_string(), source_path: None, @@ -2408,9 +2408,9 @@ fn provider_models(provider_id: &str) -> Vec { match canonical_backend_provider_id(provider_id).as_str() { "puffer" => vec![model("default", "Default", "puffer", false)], "claude" => claude_models(), - "worldagent" => vec![ - model("kimi-k2.6", "Kimi K2.6", "worldagent", true), - model("qwen3.5-flash", "Qwen 3.5 Flash", "worldagent", true), + "worldrouter" => vec![ + model("kimi-k2.6", "Kimi K2.6", "worldrouter", true), + model("qwen3.5-flash", "Qwen 3.5 Flash", "worldrouter", true), ], _ => codex_app_server_models().unwrap_or_default(), } diff --git a/apps/puffer-desktop/src/App.svelte b/apps/puffer-desktop/src/App.svelte index a2474c69a..71c4a83c6 100644 --- a/apps/puffer-desktop/src/App.svelte +++ b/apps/puffer-desktop/src/App.svelte @@ -185,7 +185,7 @@ let turnQuestionLookup = $state>({}); let replayTextByTurn: Record = {}; let sessionEventUnlisten: UnlistenFn | null = null; - let worldagentOauthUnsubscribe: (() => void) | null = null; + let worldrouterOauthUnsubscribe: (() => void) | null = null; let subscribedSessionId: string | null = null; let sessionSubscriptionGeneration = 0; let liveSidebarSessionEventUnlisteners: Record = {}; @@ -1045,9 +1045,9 @@ window.removeEventListener("blur", armRecapBlurTimer); window.removeEventListener("focus", cancelRecapBlurTimer); window.removeEventListener("keydown", handleShellKeydown, true); - if (worldagentOauthUnsubscribe) { - worldagentOauthUnsubscribe(); - worldagentOauthUnsubscribe = null; + if (worldrouterOauthUnsubscribe) { + worldrouterOauthUnsubscribe(); + worldrouterOauthUnsubscribe = null; } }; }); @@ -1073,15 +1073,15 @@ .then((client) => { attachDaemonClient(client); // Fired by the Rust reaper thread when `puffer auth login - // worldagent` exits. The handler that kicked off the flow + // worldrouter` exits. The handler that kicked off the flow // (`handleOauthLogin`) keeps `authBusyProviderId` set so the // GUI shows "Waiting for browser login…" until this event // arrives. - if (worldagentOauthUnsubscribe) worldagentOauthUnsubscribe(); - worldagentOauthUnsubscribe = client.on<{ success?: boolean; error?: string | null }>( - "worldagent:oauth-completed", + if (worldrouterOauthUnsubscribe) worldrouterOauthUnsubscribe(); + worldrouterOauthUnsubscribe = client.on<{ success?: boolean; error?: string | null }>( + "worldrouter:oauth-completed", (payload) => { - void handleWorldagentOauthCompleted(payload); + void handleWorldrouterOauthCompleted(payload); } ); }) @@ -1188,11 +1188,11 @@ authBusyProviderId = providerId; authError = null; try { - if (providerId === "worldagent") { + if (providerId === "worldrouter") { // The Tauri handler returns immediately after spawning the - // detached `puffer auth login worldagent` subprocess (it + // detached `puffer auth login worldrouter` subprocess (it // blocks up to 120 s on the localhost callback). The reaper - // thread emits `worldagent:oauth-completed` when the child + // thread emits `worldrouter:oauth-completed` when the child // exits; that listener clears `authBusyProviderId`, refreshes // the snapshot, and decides whether to navigate. statusMessage = "Opening browser — finish the login to continue."; @@ -1212,23 +1212,23 @@ } catch (error) { authError = String(error); statusMessage = authError; - if (providerId === "worldagent") { + if (providerId === "worldrouter") { authBusyProviderId = null; } } finally { - if (providerId !== "worldagent") { + if (providerId !== "worldrouter") { authBusyProviderId = null; } } } - async function handleWorldagentOauthCompleted(payload: { + async function handleWorldrouterOauthCompleted(payload: { success?: boolean; error?: string | null; }) { try { if (payload?.success) { - statusMessage = "WorldAgent connected."; + statusMessage = "WorldRouter connected."; await refreshSnapshot(); if ((settingsSnapshot?.auth?.length ?? 0) > 0) { onboarding = false; @@ -1238,13 +1238,13 @@ const reason = payload?.error?.trim(); authError = reason && reason.length > 0 ? reason - : "WorldAgent login failed. Please try again."; + : "WorldRouter login failed. Please try again."; statusMessage = authError; } } finally { // The OAuth flow has reached a terminal state regardless of // success — clear the spinner so the user can retry. - if (authBusyProviderId === "worldagent") { + if (authBusyProviderId === "worldrouter") { authBusyProviderId = null; } } diff --git a/apps/puffer-desktop/src/lib/components/LoginView.svelte b/apps/puffer-desktop/src/lib/components/LoginView.svelte index 1ed68e395..1e1520a74 100644 --- a/apps/puffer-desktop/src/lib/components/LoginView.svelte +++ b/apps/puffer-desktop/src/lib/components/LoginView.svelte @@ -277,7 +277,7 @@ : undefined} > {isBusy - ? provider.id === "worldagent" + ? provider.id === "worldrouter" ? "Waiting for browser login…" : "Opening browser…" : remoteEnabled diff --git a/apps/puffer-desktop/src/lib/providerVisuals.ts b/apps/puffer-desktop/src/lib/providerVisuals.ts index e776e86c5..e72ef8326 100644 --- a/apps/puffer-desktop/src/lib/providerVisuals.ts +++ b/apps/puffer-desktop/src/lib/providerVisuals.ts @@ -23,7 +23,7 @@ const PROVIDER_ACCENTS: Record = { openrouter: "#06b6d4", "vercel-ai-gateway": "#0f172a", vllm: "#16a34a", - worldagent: "#1f6feb", + worldrouter: "#1f6feb", xai: "#0f172a" }; @@ -43,7 +43,7 @@ const PROVIDER_ICONS: Record = { openrouter: "llm", "vercel-ai-gateway": "vercel", vllm: "llm", - worldagent: "ai", + worldrouter: "ai", xai: "ai" }; diff --git a/crates/puffer-cli/Cargo.toml b/crates/puffer-cli/Cargo.toml index b304e9e4f..7bda0fd45 100644 --- a/crates/puffer-cli/Cargo.toml +++ b/crates/puffer-cli/Cargo.toml @@ -24,7 +24,7 @@ puffer-core = { path = "../puffer-core" } puffer-mcp-oauth = { path = "../puffer-mcp-oauth" } puffer-observability = { path = "../puffer-observability" } puffer-provider-openai = { path = "../puffer-provider-openai" } -puffer-provider-worldagent = { path = "../puffer-provider-worldagent" } +puffer-provider-worldrouter = { path = "../puffer-provider-worldrouter" } puffer-provider-registry = { path = "../puffer-provider-registry" } puffer-resources = { path = "../puffer-resources" } puffer-runner-api = { path = "../puffer-runner-api" } diff --git a/crates/puffer-cli/src/auth_credentials.rs b/crates/puffer-cli/src/auth_credentials.rs index 56ee3f549..7be5bbdc4 100644 --- a/crates/puffer-cli/src/auth_credentials.rs +++ b/crates/puffer-cli/src/auth_credentials.rs @@ -118,15 +118,15 @@ pub(crate) fn store_ready_credential_from_anthropic( } } -/// Converts worldagent OAuth credentials into the registry storage shape. +/// Converts worldrouter OAuth credentials into the registry storage shape. /// The Auth Station `sub` claim is stored as `account_id` so the /// existing AuthStore reuse path (organization_id, plan_type, etc.) /// stays untouched. `name` is intentionally not persisted yet — the /// existing `OAuthCredential` shape has no slot for it; if the UI /// needs the display name later, we can either reuse `email` or /// extend the struct. -pub(crate) fn to_registry_oauth_credential_worldagent( - credential: puffer_provider_worldagent::WorldAgentOAuthCredentials, +pub(crate) fn to_registry_oauth_credential_worldrouter( + credential: puffer_provider_worldrouter::WorldRouterOAuthCredentials, ) -> puffer_provider_registry::OAuthCredential { puffer_provider_registry::OAuthCredential { access_token: credential.access_token, @@ -159,11 +159,11 @@ pub(crate) fn set_stored_credential( #[cfg(test)] mod tests { use super::*; - use puffer_provider_worldagent::WorldAgentOAuthCredentials; + use puffer_provider_worldrouter::WorldRouterOAuthCredentials; #[test] - fn worldagent_credential_maps_email_and_account_id() { - let credential = WorldAgentOAuthCredentials { + fn worldrouter_credential_maps_email_and_account_id() { + let credential = WorldRouterOAuthCredentials { access_token: "acc".to_string(), refresh_token: "ref".to_string(), expires_at_ms: 42, @@ -171,7 +171,7 @@ mod tests { email: Some("dev@example.com".to_string()), name: Some("Dev".to_string()), }; - let stored = to_registry_oauth_credential_worldagent(credential); + let stored = to_registry_oauth_credential_worldrouter(credential); assert_eq!(stored.access_token, "acc"); assert_eq!(stored.refresh_token, "ref"); assert_eq!(stored.expires_at_ms, 42); diff --git a/crates/puffer-cli/src/auth_provider.rs b/crates/puffer-cli/src/auth_provider.rs index ab08f9a58..2df27192d 100644 --- a/crates/puffer-cli/src/auth_provider.rs +++ b/crates/puffer-cli/src/auth_provider.rs @@ -14,7 +14,7 @@ use puffer_transport_anthropic::{ pub(crate) enum OauthFamily { Anthropic, OpenAi, - WorldAgent, + WorldRouter, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -40,7 +40,7 @@ pub(crate) fn oauth_family_for_provider( return match family { "openai" => Some(OauthFamily::OpenAi), "anthropic" => Some(OauthFamily::Anthropic), - "worldagent" => Some(OauthFamily::WorldAgent), + "worldrouter" => Some(OauthFamily::WorldRouter), _ => None, }; } @@ -99,10 +99,10 @@ pub(crate) fn oauth_start_bundle_for_provider( manual_redirect_uri: Some(ANTHROPIC_MANUAL_REDIRECT_URL.to_string()), }) } - Some(OauthFamily::WorldAgent) => { - let config = puffer_provider_worldagent::WorldAgentLoginConfig::default(); + Some(OauthFamily::WorldRouter) => { + let config = puffer_provider_worldrouter::WorldRouterLoginConfig::default(); Ok(OauthStartBundle { - authorization_url: puffer_provider_worldagent::build_login_url(&config), + authorization_url: puffer_provider_worldrouter::build_login_url(&config), automatic_authorization_url: None, verifier: String::new(), state: config.client_state, @@ -164,13 +164,13 @@ pub(crate) fn oauth_login_bundle_for_provider( manual_redirect_uri: Some(manual.redirect_uri), }) } - Some(OauthFamily::WorldAgent) => { - let config = puffer_provider_worldagent::WorldAgentLoginConfig { + Some(OauthFamily::WorldRouter) => { + let config = puffer_provider_worldrouter::WorldRouterLoginConfig { redirect_uri: redirect_uri.to_string(), - ..puffer_provider_worldagent::WorldAgentLoginConfig::default() + ..puffer_provider_worldrouter::WorldRouterLoginConfig::default() }; Ok(OauthStartBundle { - authorization_url: puffer_provider_worldagent::build_login_url(&config), + authorization_url: puffer_provider_worldrouter::build_login_url(&config), automatic_authorization_url: None, verifier: String::new(), state: config.client_state, @@ -270,15 +270,15 @@ mod tests { fn oauth_family_uses_explicit_oauth_family_field() { let mut providers = ProviderRegistry::new(); let mut descriptor = provider( - "worldagent", + "worldrouter", "openai-completions", vec![AuthMode::OAuth, AuthMode::ApiKey], ); - descriptor.oauth_family = Some("worldagent".to_string()); + descriptor.oauth_family = Some("worldrouter".to_string()); providers.register(descriptor); assert_eq!( - oauth_family_for_provider(&providers, "worldagent"), - Some(OauthFamily::WorldAgent) + oauth_family_for_provider(&providers, "worldrouter"), + Some(OauthFamily::WorldRouter) ); } diff --git a/crates/puffer-cli/src/authflow.rs b/crates/puffer-cli/src/authflow.rs index 3ebbe4956..e9d3a8850 100644 --- a/crates/puffer-cli/src/authflow.rs +++ b/crates/puffer-cli/src/authflow.rs @@ -34,7 +34,7 @@ impl CallbackListener { /// Binds a fixed loopback port. Used for redirect URIs that must /// match an Auth Station allow-list entry exactly (such as the - /// worldagent provider). Returns an error if the port is in use. + /// worldrouter provider). Returns an error if the port is in use. pub(crate) fn bind_localhost_port(path: &str, port: u16) -> Result { let listener = TcpListener::bind(("127.0.0.1", port)).with_context(|| { format!("failed to bind callback listener on 127.0.0.1:{port} for {path}") diff --git a/crates/puffer-cli/src/daemon.rs b/crates/puffer-cli/src/daemon.rs index ccd173564..d29342b95 100644 --- a/crates/puffer-cli/src/daemon.rs +++ b/crates/puffer-cli/src/daemon.rs @@ -49,10 +49,10 @@ use puffer_provider_openai::{ use puffer_provider_registry::{ AuthStore, ModelDescriptor, ProviderDescriptor, ProviderRegistry, StoredCredential, }; -use puffer_provider_worldagent::{ - exchange_jwt_for_api_key as exchange_worldagent_jwt_for_api_key, - parse_callback_input as parse_worldagent_callback_input, - WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, +use puffer_provider_worldrouter::{ + exchange_jwt_for_api_key as exchange_worldrouter_jwt_for_api_key, + parse_callback_input as parse_worldrouter_callback_input, + WORLDROUTER_CALLBACK_PATH, WORLDROUTER_CALLBACK_PORT, }; use puffer_resources::{load_resources, LoadedResources, McpServerSpec}; use puffer_session_store::{MessageActor, SessionStore, TranscriptEvent}; @@ -1099,11 +1099,11 @@ fn handle_login_with_oauth(state: &DaemonState, params: &Value) -> Result let auth_path = state.paths.user_config_dir.join("auth.json"); let listener = if matches!( oauth_family_for_provider(&inputs.providers, &provider_id), - Some(OauthFamily::WorldAgent) + Some(OauthFamily::WorldRouter) ) { crate::authflow::CallbackListener::bind_localhost_port( - WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, + WORLDROUTER_CALLBACK_PATH, + WORLDROUTER_CALLBACK_PORT, )? } else { crate::authflow::CallbackListener::bind_localhost("/callback")? @@ -1159,20 +1159,20 @@ fn handle_login_with_oauth(state: &DaemonState, params: &Value) -> Result )?; store_anthropic_credential(&mut inputs.auth_store, &provider_id, credential)?; } - Some(OauthFamily::WorldAgent) => { - let parsed = parse_worldagent_callback_input(&callback); + Some(OauthFamily::WorldRouter) => { + let parsed = parse_worldrouter_callback_input(&callback); if let Some(err) = parsed.error.as_deref() { let desc = parsed.error_description.as_deref().unwrap_or(""); - anyhow::bail!("worldagent login failed: {err} {desc}"); + anyhow::bail!("worldrouter login failed: {err} {desc}"); } if parsed.state.as_deref() != Some(bundle.state.as_str()) { - anyhow::bail!("oauth state mismatch for worldagent"); + anyhow::bail!("oauth state mismatch for worldrouter"); } let access_token = parsed .token - .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; - let exchanged = exchange_worldagent_jwt_for_api_key(&access_token) - .context("worldagent JWT→api_key exchange failed")?; + .ok_or_else(|| anyhow::anyhow!("worldrouter callback missing token"))?; + let exchanged = exchange_worldrouter_jwt_for_api_key(&access_token) + .context("worldrouter JWT→api_key exchange failed")?; inputs .auth_store .set_api_key(provider_id.to_string(), exchanged.api_key); diff --git a/crates/puffer-cli/src/main.rs b/crates/puffer-cli/src/main.rs index 0ec698c30..bcd145fe2 100644 --- a/crates/puffer-cli/src/main.rs +++ b/crates/puffer-cli/src/main.rs @@ -47,11 +47,11 @@ use puffer_provider_openai::{ use puffer_provider_registry::{ canonical_provider_id, AuthMode, AuthStore, ProviderRegistry, StoredCredential, }; -use puffer_provider_worldagent::{ - exchange_jwt_for_api_key as exchange_worldagent_jwt_for_api_key, - parse_callback_input as parse_worldagent_callback_input, - refresh_oauth_token as refresh_worldagent_oauth_token, - WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, +use puffer_provider_worldrouter::{ + exchange_jwt_for_api_key as exchange_worldrouter_jwt_for_api_key, + parse_callback_input as parse_worldrouter_callback_input, + refresh_oauth_token as refresh_worldrouter_oauth_token, + WORLDROUTER_CALLBACK_PATH, WORLDROUTER_CALLBACK_PORT, }; use puffer_resources::load_resources; use puffer_session_store::{SessionMetadata, SessionStore}; @@ -73,7 +73,7 @@ use crate::auth_credentials::{ anthropic_refresh_scopes, inferred_anthropic_redirect_uri, registry_to_anthropic_oauth_credential, set_stored_credential, store_anthropic_credential, store_ready_credential_from_anthropic, to_registry_oauth_credential_openai, - to_registry_oauth_credential_worldagent, + to_registry_oauth_credential_worldrouter, }; use crate::auth_provider::{ oauth_family_for_provider, oauth_login_bundle_for_provider, oauth_start_bundle_for_provider, @@ -970,10 +970,10 @@ fn run_auth_command( )?; store_anthropic_credential(auth_store, &provider, credential)?; } - Some(OauthFamily::WorldAgent) => { + Some(OauthFamily::WorldRouter) => { anyhow::bail!( - "worldagent does not use the OAuth code-exchange flow; \ - run `puffer auth login worldagent` instead" + "worldrouter does not use the OAuth code-exchange flow; \ + run `puffer auth login worldrouter` instead" ); } None => anyhow::bail!("oauth exchange is not implemented for {provider}"), @@ -1003,9 +1003,9 @@ fn run_auth_command( Some(®istry_to_anthropic_oauth_credential(existing)), )?)? } - Some(OauthFamily::WorldAgent) => { - StoredCredential::OAuth(to_registry_oauth_credential_worldagent( - refresh_worldagent_oauth_token(&existing.refresh_token, None)?, + Some(OauthFamily::WorldRouter) => { + StoredCredential::OAuth(to_registry_oauth_credential_worldrouter( + refresh_worldrouter_oauth_token(&existing.refresh_token, None)?, )) } None => anyhow::bail!("oauth refresh is not implemented for {provider}"), @@ -1048,11 +1048,11 @@ fn run_login_flow( None } else if matches!( oauth_family_for_provider(providers, provider), - Some(OauthFamily::WorldAgent) + Some(OauthFamily::WorldRouter) ) { Some(authflow::CallbackListener::bind_localhost_port( - WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, + WORLDROUTER_CALLBACK_PATH, + WORLDROUTER_CALLBACK_PORT, )?) } else { Some(authflow::CallbackListener::bind_localhost("/callback")?) @@ -1124,20 +1124,20 @@ fn run_login_flow( )?; store_anthropic_credential(auth_store, provider, credential)?; } - Some(OauthFamily::WorldAgent) => { - let parsed = parse_worldagent_callback_input(&input); + Some(OauthFamily::WorldRouter) => { + let parsed = parse_worldrouter_callback_input(&input); if let Some(err) = parsed.error.as_deref() { let desc = parsed.error_description.as_deref().unwrap_or(""); - anyhow::bail!("worldagent login failed: {err} {desc}"); + anyhow::bail!("worldrouter login failed: {err} {desc}"); } if parsed.state.as_deref() != Some(bundle.state.as_str()) { - anyhow::bail!("oauth state mismatch for worldagent"); + anyhow::bail!("oauth state mismatch for worldrouter"); } let access_token = parsed .token - .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; - let exchanged = exchange_worldagent_jwt_for_api_key(&access_token) - .context("worldagent JWT→api_key exchange failed")?; + .ok_or_else(|| anyhow::anyhow!("worldrouter callback missing token"))?; + let exchanged = exchange_worldrouter_jwt_for_api_key(&access_token) + .context("worldrouter JWT→api_key exchange failed")?; auth_store.set_api_key(provider.to_string(), exchanged.api_key); } None => anyhow::bail!("oauth login is not implemented for {provider}"), diff --git a/crates/puffer-provider-registry/src/model.rs b/crates/puffer-provider-registry/src/model.rs index 7dfaba8ee..4f55792a4 100644 --- a/crates/puffer-provider-registry/src/model.rs +++ b/crates/puffer-provider-registry/src/model.rs @@ -335,7 +335,7 @@ pub struct ProviderDescriptor { /// callers infer the family from `default_api` (preserving every /// yaml that did not opt in). When `Some`, callers use the named /// family directly. Known values today: `"openai"`, `"anthropic"`, - /// `"worldagent"`. This is the seam that lets a provider whose + /// `"worldrouter"`. This is the seam that lets a provider whose /// transport is `openai-completions` use a non-OpenAI OAuth flow. #[serde(default)] pub oauth_family: Option, @@ -372,13 +372,13 @@ id: example display_name: Example base_url: https://example.invalid default_api: openai-completions -oauth_family: worldagent +oauth_family: worldrouter auth_modes: - oauth "#; let provider: ProviderDescriptor = serde_yaml::from_str(yaml).expect("provider yaml parses"); - assert_eq!(provider.oauth_family.as_deref(), Some("worldagent")); + assert_eq!(provider.oauth_family.as_deref(), Some("worldrouter")); } #[test] diff --git a/crates/puffer-provider-worldagent/src/lib.rs b/crates/puffer-provider-worldagent/src/lib.rs deleted file mode 100644 index 6ea6dfc8f..000000000 --- a/crates/puffer-provider-worldagent/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Auth Station OAuth helpers for the `worldagent` provider. -//! -//! Auth Station's `/login` flow returns the final `token` and -//! `refresh_token` directly in the callback URL. There is no PKCE, -//! no code exchange. This crate owns URL building, callback -//! parsing, JWT-payload decoding, and refresh. - -mod auth; - -pub use auth::{ - build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, - generate_client_state, parse_callback_input, refresh_oauth_token, - worldagent_access_token_expires_at_ms, - ExchangedApiKey, WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, - WorldAgentOAuthCredentials, WORLDAGENT_AUTH_BASE_URL, - WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, WORLDAGENT_CONTROL_BASE_URL, - WORLDAGENT_CONTROL_URL_OVERRIDE_ENV, WORLDAGENT_DEFAULT_REDIRECT_URI, - WORLDAGENT_KEY_ALIAS_PREFIX, -}; diff --git a/crates/puffer-provider-worldagent/Cargo.toml b/crates/puffer-provider-worldrouter/Cargo.toml similarity index 88% rename from crates/puffer-provider-worldagent/Cargo.toml rename to crates/puffer-provider-worldrouter/Cargo.toml index 6070f850a..3804bfbaa 100644 --- a/crates/puffer-provider-worldagent/Cargo.toml +++ b/crates/puffer-provider-worldrouter/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "puffer-provider-worldagent" +name = "puffer-provider-worldrouter" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/puffer-provider-worldagent/src/auth.rs b/crates/puffer-provider-worldrouter/src/auth.rs similarity index 80% rename from crates/puffer-provider-worldagent/src/auth.rs rename to crates/puffer-provider-worldrouter/src/auth.rs index 45cae3678..2bef2a919 100644 --- a/crates/puffer-provider-worldagent/src/auth.rs +++ b/crates/puffer-provider-worldrouter/src/auth.rs @@ -10,19 +10,19 @@ use std::time::{SystemTime, UNIX_EPOCH}; /// Default Auth Station base URL (Production). Sandbox is /// `https://auth-worldrouter.vercel.app`. The env var named by -/// [`WORLDAGENT_AUTH_URL_OVERRIDE_ENV`] overrides this at runtime. +/// [`WORLDROUTER_AUTH_URL_OVERRIDE_ENV`] overrides this at runtime. /// `http://127.0.0.1:1456` must be in the target environment's /// `ALLOWED_REDIRECT_ORIGINS` allow-list (confirmed for Production /// 2026-05-20). -pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth.worldrouter.ai"; +pub const WORLDROUTER_AUTH_BASE_URL: &str = "https://auth.worldrouter.ai"; /// Env var name that overrides the Auth Station base URL. -pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; +pub const WORLDROUTER_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDROUTER_AUTH_URL"; /// Default WR control-api base URL (Production). Backend's preview /// deployment lives at `https://control-api-pre-7f819c.worldrouter.ai` /// and supports the same `/auth/exchange` shape — set the env -/// override [`WORLDAGENT_CONTROL_URL_OVERRIDE_ENV`] to swap. +/// override [`WORLDROUTER_CONTROL_URL_OVERRIDE_ENV`] to swap. /// /// Important: preview control-api mints keys into a preview /// LiteLLM DB that the production `inference-api.worldrouter.ai` @@ -30,29 +30,29 @@ pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; /// against prod inference. Always pair preview-control with the /// matching preview-inference (when one exists) or use production /// control. Verified 2026-05-21. -pub const WORLDAGENT_CONTROL_BASE_URL: &str = "https://control-api.worldrouter.ai"; +pub const WORLDROUTER_CONTROL_BASE_URL: &str = "https://control-api.worldrouter.ai"; /// Env var name that overrides the control-api base URL. -pub const WORLDAGENT_CONTROL_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_CONTROL_URL"; +pub const WORLDROUTER_CONTROL_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDROUTER_CONTROL_URL"; /// Prefix applied to every generated `key_alias` so backend can /// distinguish keys minted for the puffer desktop client. -pub const WORLDAGENT_KEY_ALIAS_PREFIX: &str = "puffer-"; +pub const WORLDROUTER_KEY_ALIAS_PREFIX: &str = "puffer-"; /// Fixed loopback callback path used by Puffer desktop. The auth /// team must allow-list the full URI on both Sandbox and Production. -pub const WORLDAGENT_CALLBACK_PATH: &str = "/callback"; +pub const WORLDROUTER_CALLBACK_PATH: &str = "/callback"; /// Fixed loopback callback port used by Puffer desktop. See -/// [`WORLDAGENT_CALLBACK_PATH`] for the path component. -pub const WORLDAGENT_CALLBACK_PORT: u16 = 1456; +/// [`WORLDROUTER_CALLBACK_PATH`] for the path component. +pub const WORLDROUTER_CALLBACK_PORT: u16 = 1456; /// Concatenated fixed loopback redirect URI. -pub const WORLDAGENT_DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:1456/callback"; +pub const WORLDROUTER_DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:1456/callback"; /// Parameters needed to build an Auth Station login URL. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorldAgentLoginConfig { +pub struct WorldRouterLoginConfig { /// Base URL of Auth Station, no trailing slash. pub auth_base_url: String, /// Full redirect URI for the desktop callback listener. @@ -61,15 +61,15 @@ pub struct WorldAgentLoginConfig { pub client_state: String, } -impl Default for WorldAgentLoginConfig { +impl Default for WorldRouterLoginConfig { fn default() -> Self { - let auth_base_url = std::env::var(WORLDAGENT_AUTH_URL_OVERRIDE_ENV) + let auth_base_url = std::env::var(WORLDROUTER_AUTH_URL_OVERRIDE_ENV) .ok() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| WORLDAGENT_AUTH_BASE_URL.to_string()); + .unwrap_or_else(|| WORLDROUTER_AUTH_BASE_URL.to_string()); Self { auth_base_url, - redirect_uri: WORLDAGENT_DEFAULT_REDIRECT_URI.to_string(), + redirect_uri: WORLDROUTER_DEFAULT_REDIRECT_URI.to_string(), client_state: generate_client_state(), } } @@ -83,7 +83,7 @@ pub fn generate_client_state() -> String { } /// Builds the Auth Station `/login` URL for the given config. -pub fn build_login_url(config: &WorldAgentLoginConfig) -> String { +pub fn build_login_url(config: &WorldRouterLoginConfig) -> String { let trimmed = config.auth_base_url.trim_end_matches('/'); let mut url = url::Url::parse(&format!("{trimmed}/login")) .expect("auth_base_url must be a valid URL"); @@ -96,7 +96,7 @@ pub fn build_login_url(config: &WorldAgentLoginConfig) -> String { /// Parsed callback fields. Each field is `None` when its parameter /// was absent from the callback URL. #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WorldAgentCallback { +pub struct WorldRouterCallback { /// `token` query parameter — the access token JWT. pub token: Option, /// `refresh_token` query parameter — the refresh token JWT. @@ -111,12 +111,12 @@ pub struct WorldAgentCallback { /// Extracts `token`, `refresh_token`, `state`, `error`, and /// `error_description` from a callback URL or raw query string. -pub fn parse_callback_input(input: &str) -> WorldAgentCallback { +pub fn parse_callback_input(input: &str) -> WorldRouterCallback { let trimmed = input.trim(); if trimmed.is_empty() { - return WorldAgentCallback::default(); + return WorldRouterCallback::default(); } - let mut callback = WorldAgentCallback::default(); + let mut callback = WorldRouterCallback::default(); let pairs: Box> = if let Ok(url) = url::Url::parse(trimmed) { Box::new( @@ -148,7 +148,7 @@ pub fn parse_callback_input(input: &str) -> WorldAgentCallback { /// Decoded JWT profile fields, best-effort. #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WorldAgentJwtProfile { +pub struct WorldRouterJwtProfile { /// JWT `sub` claim — Auth Station user id (WorkOS user id). pub sub: Option, /// JWT `email` claim. @@ -162,17 +162,17 @@ pub struct WorldAgentJwtProfile { /// Decodes `sub` / `email` / `name` from the access token JWT /// payload. Any decode/parse failure yields an empty profile. -pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile { +pub fn decode_jwt_profile(access_token: &str) -> WorldRouterJwtProfile { let Some(payload_b64) = access_token.split('.').nth(1) else { - return WorldAgentJwtProfile::default(); + return WorldRouterJwtProfile::default(); }; let Ok(payload_bytes) = URL_SAFE_NO_PAD.decode(payload_b64.as_bytes()) else { - return WorldAgentJwtProfile::default(); + return WorldRouterJwtProfile::default(); }; let Ok(value) = serde_json::from_slice::(&payload_bytes) else { - return WorldAgentJwtProfile::default(); + return WorldRouterJwtProfile::default(); }; - WorldAgentJwtProfile { + WorldRouterJwtProfile { sub: value .get("sub") .and_then(serde_json::Value::as_str) @@ -192,9 +192,9 @@ pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile { } } -/// Persisted Auth Station credentials for the worldagent provider. +/// Persisted Auth Station credentials for the worldrouter provider. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorldAgentOAuthCredentials { +pub struct WorldRouterOAuthCredentials { /// Auth Station access token (24h validity per current docs). pub access_token: String, /// Auth Station refresh token (7d validity). @@ -216,41 +216,41 @@ pub struct WorldAgentOAuthCredentials { pub fn refresh_oauth_token( refresh_token: &str, auth_base_url: Option<&str>, -) -> Result { +) -> Result { let base = auth_base_url .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) .unwrap_or_else(|| { - std::env::var(WORLDAGENT_AUTH_URL_OVERRIDE_ENV) + std::env::var(WORLDROUTER_AUTH_URL_OVERRIDE_ENV) .ok() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| WORLDAGENT_AUTH_BASE_URL.to_string()) + .unwrap_or_else(|| WORLDROUTER_AUTH_BASE_URL.to_string()) }); let url = format!("{}/token/refresh", base.trim_end_matches('/')); let response = Client::new() .post(&url) .json(&serde_json::json!({ "refresh_token": refresh_token })) .send() - .context("failed to send worldagent refresh request")?; + .context("failed to send worldrouter refresh request")?; let status = response.status(); let payload: RefreshResponse = response .json() - .context("failed to parse worldagent refresh response")?; + .context("failed to parse worldrouter refresh response")?; if !status.is_success() { return Err(anyhow!( - "worldagent token refresh failed with status {status}: {}", + "worldrouter token refresh failed with status {status}: {}", payload.error.unwrap_or_default() )); } let access_token = payload .token - .ok_or_else(|| anyhow!("worldagent refresh response missing token"))?; + .ok_or_else(|| anyhow!("worldrouter refresh response missing token"))?; let profile = decode_jwt_profile(&access_token); - Ok(WorldAgentOAuthCredentials { + Ok(WorldRouterOAuthCredentials { access_token, refresh_token: refresh_token.to_string(), - expires_at_ms: worldagent_access_token_expires_at_ms(), + expires_at_ms: worldrouter_access_token_expires_at_ms(), sub: profile.sub, email: profile.email, name: profile.name, @@ -283,7 +283,7 @@ pub struct ExchangedApiKey { /// with `Authorization: Bearer ` and a fresh /// `key_alias = "puffer-"` body → returns `{ key: "sk-worldrouter-..." }`. /// -/// TODO(worldagent): preview-stage shape. Open questions: +/// TODO(worldrouter): preview-stage shape. Open questions: /// - idempotent get-or-create per (user, device) instead of always /// minting a new key (avoid leaking dead keys when user re-logins /// on the same machine) @@ -291,10 +291,10 @@ pub struct ExchangedApiKey { /// - production base URL (currently `control-api-pre-7f819c…`) /// Re-evaluate when backend lands the stable shape. pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { - let base = std::env::var(WORLDAGENT_CONTROL_URL_OVERRIDE_ENV) + let base = std::env::var(WORLDROUTER_CONTROL_URL_OVERRIDE_ENV) .ok() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| WORLDAGENT_CONTROL_BASE_URL.to_string()); + .unwrap_or_else(|| WORLDROUTER_CONTROL_BASE_URL.to_string()); let base = base.trim_end_matches('/').to_string(); let client = Client::new(); @@ -304,18 +304,18 @@ pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { .post(&exchange_url) .json(&serde_json::json!({ "access_token": access_token })) .send() - .context("failed to send worldagent /auth/exchange request")?; + .context("failed to send worldrouter /auth/exchange request")?; let exchange_status = exchange_response.status(); let exchange_body = exchange_response .text() - .context("failed to read worldagent /auth/exchange response body")?; + .context("failed to read worldrouter /auth/exchange response body")?; if !exchange_status.is_success() { return Err(anyhow!( - "worldagent /auth/exchange failed: POST {exchange_url} -> {exchange_status}\nresponse body: {exchange_body}" + "worldrouter /auth/exchange failed: POST {exchange_url} -> {exchange_status}\nresponse body: {exchange_body}" )); } let envelope: SessionEnvelopeResponse = serde_json::from_str(&exchange_body).with_context( - || format!("failed to parse worldagent /auth/exchange response body: {exchange_body}"), + || format!("failed to parse worldrouter /auth/exchange response body: {exchange_body}"), )?; // Hop 2: Infer Session JWT → WR inference api_key @@ -325,7 +325,7 @@ pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { ); let key_alias = format!( "{}{}", - WORLDAGENT_KEY_ALIAS_PREFIX, + WORLDROUTER_KEY_ALIAS_PREFIX, uuid::Uuid::new_v4() .as_hyphenated() .to_string() @@ -336,25 +336,25 @@ pub fn exchange_jwt_for_api_key(access_token: &str) -> Result { .bearer_auth(&envelope.session_token) .json(&serde_json::json!({ "key_alias": key_alias })) .send() - .context("failed to send worldagent key creation request")?; + .context("failed to send worldrouter key creation request")?; let key_status = key_response.status(); let key_body = key_response .text() - .context("failed to read worldagent key creation response body")?; + .context("failed to read worldrouter key creation response body")?; if !key_status.is_success() { return Err(anyhow!( - "worldagent key creation failed: POST {key_url} -> {key_status}\nresponse body: {key_body}" + "worldrouter key creation failed: POST {key_url} -> {key_status}\nresponse body: {key_body}" )); } let payload: KeyCreationResponse = serde_json::from_str(&key_body).with_context(|| { - format!("failed to parse worldagent key creation response body: {key_body}") + format!("failed to parse worldrouter key creation response body: {key_body}") })?; let api_key = payload .key - .ok_or_else(|| anyhow!("worldagent key creation response missing `key`"))?; + .ok_or_else(|| anyhow!("worldrouter key creation response missing `key`"))?; let token_id = payload .token_id - .ok_or_else(|| anyhow!("worldagent key creation response missing `token_id`"))?; + .ok_or_else(|| anyhow!("worldrouter key creation response missing `token_id`"))?; Ok(ExchangedApiKey { api_key, token_id, @@ -380,7 +380,7 @@ struct KeyCreationResponse { /// access token issued **now** expires. Auth Station access tokens /// have a 24-hour lifetime per the public API doc; this helper /// centralizes that constant. -pub fn worldagent_access_token_expires_at_ms() -> u64 { +pub fn worldrouter_access_token_expires_at_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis() as u64 + 24 * 3600 * 1000) @@ -401,7 +401,7 @@ mod tests { #[test] fn build_login_url_contains_redirect_uri_and_client_state() { - let config = WorldAgentLoginConfig { + let config = WorldRouterLoginConfig { auth_base_url: "https://auth-worldrouter.vercel.app".to_string(), redirect_uri: "http://127.0.0.1:1456/callback".to_string(), client_state: "state-xyz".to_string(), @@ -469,5 +469,5 @@ mod tests { // (`/auth/exchange` then `/platform/v1/teams/{team_id}/keys`). // Unit tests would need an HTTP mock server; we exercise the // function through the manual e2e probe in `puffer auth login - // worldagent` instead. + // worldrouter` instead. } diff --git a/crates/puffer-provider-worldrouter/src/lib.rs b/crates/puffer-provider-worldrouter/src/lib.rs new file mode 100644 index 000000000..9e30df04d --- /dev/null +++ b/crates/puffer-provider-worldrouter/src/lib.rs @@ -0,0 +1,20 @@ +//! Auth Station OAuth helpers for the `worldrouter` provider. +//! +//! Auth Station's `/login` flow returns the final `token` and +//! `refresh_token` directly in the callback URL. There is no PKCE, +//! no code exchange. This crate owns URL building, callback +//! parsing, JWT-payload decoding, and refresh. + +mod auth; + +pub use auth::{ + build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, + generate_client_state, parse_callback_input, refresh_oauth_token, + worldrouter_access_token_expires_at_ms, + ExchangedApiKey, WorldRouterCallback, WorldRouterJwtProfile, WorldRouterLoginConfig, + WorldRouterOAuthCredentials, WORLDROUTER_AUTH_BASE_URL, + WORLDROUTER_AUTH_URL_OVERRIDE_ENV, WORLDROUTER_CALLBACK_PATH, + WORLDROUTER_CALLBACK_PORT, WORLDROUTER_CONTROL_BASE_URL, + WORLDROUTER_CONTROL_URL_OVERRIDE_ENV, WORLDROUTER_DEFAULT_REDIRECT_URI, + WORLDROUTER_KEY_ALIAS_PREFIX, +}; diff --git a/crates/puffer-resources/src/model.rs b/crates/puffer-resources/src/model.rs index 46a5ae60d..1ee93425d 100644 --- a/crates/puffer-resources/src/model.rs +++ b/crates/puffer-resources/src/model.rs @@ -559,18 +559,18 @@ mod tests { ); } - /// Confirms the bundled `worldagent.yaml` parses as a + /// Confirms the bundled `worldrouter.yaml` parses as a /// `ProviderPack` and that the `oauth_family` field round-trips /// through `into_descriptor`. Without this end-to-end wiring the /// runtime would silently fall back to OpenAI OAuth. #[test] - fn worldagent_yaml_parses_with_oauth_family() { - let yaml = include_str!("../../../resources/providers/worldagent.yaml"); - let pack: ProviderPack = serde_yaml::from_str(yaml).expect("worldagent.yaml parses"); - assert_eq!(pack.id, "worldagent"); - assert_eq!(pack.oauth_family.as_deref(), Some("worldagent")); + fn worldrouter_yaml_parses_with_oauth_family() { + let yaml = include_str!("../../../resources/providers/worldrouter.yaml"); + let pack: ProviderPack = serde_yaml::from_str(yaml).expect("worldrouter.yaml parses"); + assert_eq!(pack.id, "worldrouter"); + assert_eq!(pack.oauth_family.as_deref(), Some("worldrouter")); let descriptor = pack.into_descriptor(); - assert_eq!(descriptor.oauth_family.as_deref(), Some("worldagent")); + assert_eq!(descriptor.oauth_family.as_deref(), Some("worldrouter")); assert!(descriptor.auth_modes.contains(&AuthMode::ApiKey)); assert!(descriptor.auth_modes.contains(&AuthMode::OAuth)); } diff --git a/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md b/docs/superpowers/notes/2026-05-20-worldrouter-backend-handoff.md similarity index 87% rename from docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md rename to docs/superpowers/notes/2026-05-20-worldrouter-backend-handoff.md index 1f037f4fb..6f66d9814 100644 --- a/docs/superpowers/notes/2026-05-20-worldagent-backend-handoff.md +++ b/docs/superpowers/notes/2026-05-20-worldrouter-backend-handoff.md @@ -1,4 +1,4 @@ -# worldagent 端到端联调 — 待解决问题 +# worldrouter 端到端联调 — 待解决问题 Date: 2026-05-20 Author: sean (with Claude) @@ -8,19 +8,19 @@ Status: 阻塞中,待 Auth + Infer 后端处理 ## 背景 -puffer 桌面端新增 `worldagent` provider,OAuth 登录流程: +puffer 桌面端新增 `worldrouter` provider,OAuth 登录流程: ``` -puffer-cli auth login worldagent +puffer-cli auth login worldrouter → 浏览器打开 https://auth.worldrouter.ai/login?redirect_uri=http://127.0.0.1:1456/callback&client_state=... → 用户登录(Auth Station + WorkOS AuthKit) → Auth Station 302 回 http://127.0.0.1:1456/callback?token=&refresh_token=...&state=... → puffer 监听器抓回调 → puffer 用 token JWT 调 control-api 创建 WR api_key - → 把 api_key 落本地 AuthStore,worldagent 就能跑 inference + → 把 api_key 落本地 AuthStore,worldrouter 就能跑 inference ``` -代码已经实现并 push 到 `feat/worldagent-provider` 分支(commit `1132c94` 及之前),单测 7/7 通过,workspace 编译干净。下面是**联调时发现的两个上游问题**,puffer 这边已经做不了,需要后端改。 +代码已经实现并 push 到 `feat/worldrouter-provider` 分支(commit `1132c94` 及之前),单测 7/7 通过,workspace 编译干净。下面是**联调时发现的两个上游问题**,puffer 这边已经做不了,需要后端改。 --- @@ -146,7 +146,7 @@ Body: {"key_alias": "puffer-"} ### 含义 -桌面端只能拿到 Auth Station JWT(OIDC 标准、面向所有接入方)。**桌面端没法直接拿到 infer-session JWT**——那是 worldagent dashboard / infer BFF 自己签的,浏览器侧只有 cookie,没有原始 JWT。 +桌面端只能拿到 Auth Station JWT(OIDC 标准、面向所有接入方)。**桌面端没法直接拿到 infer-session JWT**——那是 worldrouter dashboard / infer BFF 自己签的,浏览器侧只有 cookie,没有原始 JWT。 所以 puffer 现在的实现里 `exchange_jwt_for_api_key()` 第一行 `decode_jwt_profile(jwt).default_team_id.ok_or_else(...)` 会立刻失败,整个流程在那一步炸。 @@ -162,7 +162,7 @@ Body: {"key_alias": "puffer-"} ### 2026-05-20 实测:完整跑通 e2e 流程,得到决定性证据 -puffer 端加了 `PUFFER_WORLDAGENT_TEAM_ID` env 兜底(先不依赖 JWT 的 `default_team_id`),实际触发了一次完整 OAuth → control-api 调用: +puffer 端加了 `PUFFER_WORLDROUTER_TEAM_ID` env 兜底(先不依赖 JWT 的 `default_team_id`),实际触发了一次完整 OAuth → control-api 调用: ``` POST https://control-api-pre-7f819c.worldrouter.ai/platform/v1/teams/6afdef35-ea87-54a9-9662-8b8bf090c0fd/keys @@ -183,23 +183,23 @@ Body: {"key_alias":"puffer-"} ```bash cd /Users/shun/Data/Code/tomo/agentenv/puffer -PUFFER_WORLDAGENT_TEAM_ID= # 方案 A 落地后可去掉 - cargo run -p puffer-cli -- auth login worldagent +PUFFER_WORLDROUTER_TEAM_ID= # 方案 A 落地后可去掉 + cargo run -p puffer-cli -- auth login worldrouter ``` -期望 stdout 末行:`stored oauth credentials for worldagent`;`~/Library/Application Support/com.tomo.puffer/auth.json` 应该包含 `worldagent → {kind: api_key, key: sk-worldrouter-...}`。 +期望 stdout 末行:`stored oauth credentials for worldrouter`;`~/Library/Application Support/com.tomo.puffer/auth.json` 应该包含 `worldrouter → {kind: api_key, key: sk-worldrouter-...}`。 浏览器打开 puffer 打印的 URL → 用一个 Production 已有的账号登录 → puffer 终端打印: ``` -stored oauth credentials for worldagent # 或类似 "stored api key for worldagent" +stored oauth credentials for worldrouter # 或类似 "stored api key for worldrouter" ``` 然后立刻验证 api_key 真能用: ```bash cat ~/Library/Application\ Support/com.tomo.puffer/auth.json \ - | jq '.providers.worldagent' + | jq '.providers.worldrouter' # 期望看到 { "kind": "api_key", "key": "sk-worldrouter-..." } @@ -215,10 +215,10 @@ curl -sS https://inference-api.worldrouter.ai/v1/models \ | 项目 | 位置 | |---|---| -| puffer 实现 | `feat/worldagent-provider` 分支,head `1132c94`(spec → impl → cleanup) | -| `exchange_jwt_for_api_key` 实现 | `crates/puffer-provider-worldagent/src/auth.rs:279`(带 TODO 说明 API 会调整) | -| 设计文档 | `docs/superpowers/specs/2026-05-20-worldagent-provider-design.md` | -| 实现 plan | `docs/superpowers/plans/2026-05-20-worldagent-provider.md` | +| puffer 实现 | `feat/worldrouter-provider` 分支,head `1132c94`(spec → impl → cleanup) | +| `exchange_jwt_for_api_key` 实现 | `crates/puffer-provider-worldrouter/src/auth.rs:279`(带 TODO 说明 API 会调整) | +| 设计文档 | `docs/superpowers/specs/2026-05-20-worldrouter-provider-design.md` | +| 实现 plan | `docs/superpowers/plans/2026-05-20-worldrouter-provider.md` | | Auth Station 源码 | `/Users/shun/Data/Code/tomo/worldclaw/infer-monorepo/auth/` | | 白名单校验逻辑 | `auth/src/lib/sanitize.ts::validateRedirectUri` | | 白名单 env 读取 | `auth/src/lib/config.ts::allowedRedirectOrigins` | @@ -228,7 +228,7 @@ curl -sS https://inference-api.worldrouter.ai/v1/models \ | Inference API(OpenAI 兼容) | `https://inference-api.worldrouter.ai/v1/...` | | Control API(创建 key 用) | `https://control-api-pre-7f819c.worldrouter.ai`(预览,会变) | | Vercel project | `nubit/auth-worldrouter`,prj id `prj_4Mi7OqkeMQ5bOaNzzMOiLHsPRDGl` | -| puffer 端 env override | `PUFFER_WORLDAGENT_AUTH_URL`、`PUFFER_WORLDAGENT_CONTROL_URL` | +| puffer 端 env override | `PUFFER_WORLDROUTER_AUTH_URL`、`PUFFER_WORLDROUTER_CONTROL_URL` | ## 附录 B:puffer 端固定 callback diff --git a/docs/superpowers/plans/2026-05-20-worldagent-provider.md b/docs/superpowers/plans/2026-05-20-worldrouter-provider.md similarity index 71% rename from docs/superpowers/plans/2026-05-20-worldagent-provider.md rename to docs/superpowers/plans/2026-05-20-worldrouter-provider.md index efa200185..65f2ea1a8 100644 --- a/docs/superpowers/plans/2026-05-20-worldagent-provider.md +++ b/docs/superpowers/plans/2026-05-20-worldrouter-provider.md @@ -1,25 +1,25 @@ -# worldagent Provider Implementation Plan +# worldrouter Provider Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add a new `worldagent` provider entry to Puffer that supports both API-key paste and an Auth-Station OAuth login flow (opens https://auth-worldrouter.vercel.app/login in the system browser, captures token+refresh on a fixed localhost callback, stores credential). +**Goal:** Add a new `worldrouter` provider entry to Puffer that supports both API-key paste and an Auth-Station OAuth login flow (opens https://auth-worldrouter.vercel.app/login in the system browser, captures token+refresh on a fixed localhost callback, stores credential). -**Architecture:** A new minimal Rust crate `puffer-provider-worldagent` implements Auth Station's "token-in-redirect" flow (no PKCE, no code exchange). `ProviderDescriptor`/`ProviderPack` gain an optional `oauth_family` field that lets a provider opt into a non-`default_api`-derived OAuth handler. `puffer-cli`'s OAuth dispatch (`auth_provider.rs` + `daemon.rs` + `main.rs`) grows a third arm for `OauthFamily::WorldAgent`. `authflow::CallbackListener` grows a `bind_localhost_port` variant for fixed-port binds. A new `resources/providers/worldagent.yaml` ships the provider. The desktop Svelte UI registers a visual entry; LoginView is unchanged. +**Architecture:** A new minimal Rust crate `puffer-provider-worldrouter` implements Auth Station's "token-in-redirect" flow (no PKCE, no code exchange). `ProviderDescriptor`/`ProviderPack` gain an optional `oauth_family` field that lets a provider opt into a non-`default_api`-derived OAuth handler. `puffer-cli`'s OAuth dispatch (`auth_provider.rs` + `daemon.rs` + `main.rs`) grows a third arm for `OauthFamily::WorldRouter`. `authflow::CallbackListener` grows a `bind_localhost_port` variant for fixed-port binds. A new `resources/providers/worldrouter.yaml` ships the provider. The desktop Svelte UI registers a visual entry; LoginView is unchanged. **Tech Stack:** Rust (workspace crates), reqwest blocking client, base64 + serde_json (JWT payload decode), serde_yaml (provider yaml), Svelte 5 + TypeScript (desktop visual entry). -**Spec:** `docs/superpowers/specs/2026-05-20-worldagent-provider-design.md` +**Spec:** `docs/superpowers/specs/2026-05-20-worldrouter-provider-design.md` --- ## File Structure **Created:** -- `crates/puffer-provider-worldagent/Cargo.toml` -- `crates/puffer-provider-worldagent/src/lib.rs` -- `crates/puffer-provider-worldagent/src/auth.rs` -- `resources/providers/worldagent.yaml` -- `specs/puffer-provider-worldagent/00.md` +- `crates/puffer-provider-worldrouter/Cargo.toml` +- `crates/puffer-provider-worldrouter/src/lib.rs` +- `crates/puffer-provider-worldrouter/src/auth.rs` +- `resources/providers/worldrouter.yaml` +- `specs/puffer-provider-worldrouter/00.md` - `specs/puffer-provider-registry/06.md` - `specs/puffer-cli/.md` — exact filename picked in Task 8 @@ -27,13 +27,13 @@ - `Cargo.toml` (workspace members + workspace deps) - `crates/puffer-provider-registry/src/model.rs` — `oauth_family` field - `crates/puffer-resources/src/model.rs` — `ProviderPack.oauth_family` mirror + `into_descriptor` pass-through -- `crates/puffer-cli/Cargo.toml` — dep on `puffer-provider-worldagent` -- `crates/puffer-cli/src/auth_provider.rs` — `OauthFamily::WorldAgent` arm -- `crates/puffer-cli/src/auth_credentials.rs` — `to_registry_oauth_credential_worldagent` +- `crates/puffer-cli/Cargo.toml` — dep on `puffer-provider-worldrouter` +- `crates/puffer-cli/src/auth_provider.rs` — `OauthFamily::WorldRouter` arm +- `crates/puffer-cli/src/auth_credentials.rs` — `to_registry_oauth_credential_worldrouter` - `crates/puffer-cli/src/authflow.rs` — `bind_localhost_port` helper - `crates/puffer-cli/src/main.rs` — login flow + `run_login_flow` arm - `crates/puffer-cli/src/daemon.rs` — `handle_login_with_oauth` arm -- `apps/puffer-desktop/src/lib/providerVisuals.ts` — visual entry for `worldagent` +- `apps/puffer-desktop/src/lib/providerVisuals.ts` — visual entry for `worldrouter` --- @@ -60,13 +60,13 @@ id: example display_name: Example base_url: https://example.invalid default_api: openai-completions -oauth_family: worldagent +oauth_family: worldrouter auth_modes: - oauth "#; let provider: ProviderDescriptor = serde_yaml::from_str(yaml).expect("provider yaml parses"); - assert_eq!(provider.oauth_family.as_deref(), Some("worldagent")); + assert_eq!(provider.oauth_family.as_deref(), Some("worldrouter")); } #[test] @@ -116,7 +116,7 @@ In `crates/puffer-provider-registry/src/model.rs`, inside the `ProviderDescripto /// callers infer the family from `default_api` (preserving every /// yaml that did not opt in). When `Some`, callers use the named /// family directly. Known values today: `"openai"`, `"anthropic"`, - /// `"worldagent"`. This is the seam that lets a provider whose + /// `"worldrouter"`. This is the seam that lets a provider whose /// transport is `openai-completions` use a non-OpenAI OAuth flow. #[serde(default)] pub oauth_family: Option, @@ -186,20 +186,20 @@ git commit -m "feat(provider-registry): add optional oauth_family to ProviderDes --- -## Task 2: Bootstrap the `puffer-provider-worldagent` crate +## Task 2: Bootstrap the `puffer-provider-worldrouter` crate **Files:** -- Create: `crates/puffer-provider-worldagent/Cargo.toml` -- Create: `crates/puffer-provider-worldagent/src/lib.rs` +- Create: `crates/puffer-provider-worldrouter/Cargo.toml` +- Create: `crates/puffer-provider-worldrouter/src/lib.rs` - Modify: `Cargo.toml` (workspace `members`) - [ ] **Step 1: Create the crate Cargo.toml** -`crates/puffer-provider-worldagent/Cargo.toml`: +`crates/puffer-provider-worldrouter/Cargo.toml`: ```toml [package] -name = "puffer-provider-worldagent" +name = "puffer-provider-worldrouter" version.workspace = true edition.workspace = true license.workspace = true @@ -218,10 +218,10 @@ No need to depend on `puffer-provider-registry` — this crate only owns the Aut - [ ] **Step 2: Create a stub lib.rs** -`crates/puffer-provider-worldagent/src/lib.rs`: +`crates/puffer-provider-worldrouter/src/lib.rs`: ```rust -//! Auth Station OAuth helpers for the `worldagent` provider. +//! Auth Station OAuth helpers for the `worldrouter` provider. //! //! Auth Station's `/login` flow returns the final `token` and //! `refresh_token` directly in the callback URL. There is no PKCE, @@ -233,16 +233,16 @@ mod auth; pub use auth::{ build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, generate_client_state, parse_callback_input, refresh_oauth_token, - WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, - WorldAgentOAuthCredentials, WORLDAGENT_AUTH_BASE_URL, - WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, + WorldRouterCallback, WorldRouterJwtProfile, WorldRouterLoginConfig, + WorldRouterOAuthCredentials, WORLDROUTER_AUTH_BASE_URL, + WORLDROUTER_AUTH_URL_OVERRIDE_ENV, WORLDROUTER_CALLBACK_PATH, + WORLDROUTER_CALLBACK_PORT, WORLDROUTER_DEFAULT_REDIRECT_URI, }; ``` - [ ] **Step 3: Create an empty auth.rs so the crate compiles** -`crates/puffer-provider-worldagent/src/auth.rs`: +`crates/puffer-provider-worldrouter/src/auth.rs`: ```rust //! Auth Station login URL building, callback parsing, JWT decoding. @@ -250,12 +250,12 @@ pub use auth::{ - [ ] **Step 4: Register the crate in the workspace** -In root `Cargo.toml`, find the `members = [` block and add `"crates/puffer-provider-worldagent",` keeping the list alphabetically grouped (insert right after `"crates/puffer-provider-registry",`). +In root `Cargo.toml`, find the `members = [` block and add `"crates/puffer-provider-worldrouter",` keeping the list alphabetically grouped (insert right after `"crates/puffer-provider-registry",`). - [ ] **Step 5: Build empty crate to confirm Cargo wiring** ```bash -cargo build -p puffer-provider-worldagent +cargo build -p puffer-provider-worldrouter ``` Expected: builds with "unresolved import" errors because lib.rs re-exports items that don't exist. That's a problem — the next task implements them. For this commit, **temporarily comment out the re-exports** in lib.rs so it builds; we'll restore them in Task 3 step 6. @@ -263,7 +263,7 @@ Expected: builds with "unresolved import" errors because lib.rs re-exports items Replace lib.rs body for now: ```rust -//! Auth Station OAuth helpers for the `worldagent` provider. +//! Auth Station OAuth helpers for the `worldrouter` provider. //! //! Auth Station's `/login` flow returns the final `token` and //! `refresh_token` directly in the callback URL. There is no PKCE, @@ -276,7 +276,7 @@ mod auth; ``` ```bash -cargo build -p puffer-provider-worldagent +cargo build -p puffer-provider-worldrouter ``` Expected: SUCCESS (empty crate). @@ -284,21 +284,21 @@ Expected: SUCCESS (empty crate). - [ ] **Step 6: Commit** ```bash -git add crates/puffer-provider-worldagent Cargo.toml -git commit -m "feat(provider-worldagent): bootstrap empty crate" +git add crates/puffer-provider-worldrouter Cargo.toml +git commit -m "feat(provider-worldrouter): bootstrap empty crate" ``` --- -## Task 3: Implement `WorldAgentLoginConfig` + `build_login_url` +## Task 3: Implement `WorldRouterLoginConfig` + `build_login_url` **Files:** -- Modify: `crates/puffer-provider-worldagent/src/auth.rs` -- Modify: `crates/puffer-provider-worldagent/src/lib.rs` +- Modify: `crates/puffer-provider-worldrouter/src/auth.rs` +- Modify: `crates/puffer-provider-worldrouter/src/lib.rs` - [ ] **Step 1: Write the failing test** -Append to `crates/puffer-provider-worldagent/src/auth.rs`: +Append to `crates/puffer-provider-worldrouter/src/auth.rs`: ```rust #[cfg(test)] @@ -307,7 +307,7 @@ mod tests { #[test] fn build_login_url_contains_redirect_uri_and_client_state() { - let config = WorldAgentLoginConfig { + let config = WorldRouterLoginConfig { auth_base_url: "https://auth-worldrouter.vercel.app".to_string(), redirect_uri: "http://127.0.0.1:1456/callback".to_string(), client_state: "state-xyz".to_string(), @@ -323,14 +323,14 @@ mod tests { - [ ] **Step 2: Run test to verify it fails** ```bash -cargo test -p puffer-provider-worldagent build_login_url +cargo test -p puffer-provider-worldrouter build_login_url ``` -Expected: FAIL — `WorldAgentLoginConfig` / `build_login_url` do not exist. +Expected: FAIL — `WorldRouterLoginConfig` / `build_login_url` do not exist. - [ ] **Step 3: Implement the types and function** -Replace the body of `crates/puffer-provider-worldagent/src/auth.rs` (above the test module) with: +Replace the body of `crates/puffer-provider-worldrouter/src/auth.rs` (above the test module) with: ```rust //! Auth Station login URL building, callback parsing, JWT decoding. @@ -345,26 +345,26 @@ use std::time::{SystemTime, UNIX_EPOCH}; /// Default Auth Station base URL (Sandbox). Production is /// `https://auth.worldrouter.ai`. The env var named by -/// [`WORLDAGENT_AUTH_URL_OVERRIDE_ENV`] overrides this at runtime. -pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app"; +/// [`WORLDROUTER_AUTH_URL_OVERRIDE_ENV`] overrides this at runtime. +pub const WORLDROUTER_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app"; /// Env var name that overrides the Auth Station base URL. -pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; +pub const WORLDROUTER_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDROUTER_AUTH_URL"; /// Fixed loopback callback path used by Puffer desktop. The auth /// team must allow-list the full URI on both Sandbox and Production. -pub const WORLDAGENT_CALLBACK_PATH: &str = "/callback"; +pub const WORLDROUTER_CALLBACK_PATH: &str = "/callback"; /// Fixed loopback callback port used by Puffer desktop. See -/// [`WORLDAGENT_CALLBACK_PATH`] for the path component. -pub const WORLDAGENT_CALLBACK_PORT: u16 = 1456; +/// [`WORLDROUTER_CALLBACK_PATH`] for the path component. +pub const WORLDROUTER_CALLBACK_PORT: u16 = 1456; /// Concatenated fixed loopback redirect URI. -pub const WORLDAGENT_DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:1456/callback"; +pub const WORLDROUTER_DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:1456/callback"; /// Parameters needed to build an Auth Station login URL. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorldAgentLoginConfig { +pub struct WorldRouterLoginConfig { /// Base URL of Auth Station, no trailing slash. pub auth_base_url: String, /// Full redirect URI for the desktop callback listener. @@ -373,15 +373,15 @@ pub struct WorldAgentLoginConfig { pub client_state: String, } -impl Default for WorldAgentLoginConfig { +impl Default for WorldRouterLoginConfig { fn default() -> Self { - let auth_base_url = std::env::var(WORLDAGENT_AUTH_URL_OVERRIDE_ENV) + let auth_base_url = std::env::var(WORLDROUTER_AUTH_URL_OVERRIDE_ENV) .ok() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| WORLDAGENT_AUTH_BASE_URL.to_string()); + .unwrap_or_else(|| WORLDROUTER_AUTH_BASE_URL.to_string()); Self { auth_base_url, - redirect_uri: WORLDAGENT_DEFAULT_REDIRECT_URI.to_string(), + redirect_uri: WORLDROUTER_DEFAULT_REDIRECT_URI.to_string(), client_state: generate_client_state(), } } @@ -395,7 +395,7 @@ pub fn generate_client_state() -> String { } /// Builds the Auth Station `/login` URL for the given config. -pub fn build_login_url(config: &WorldAgentLoginConfig) -> String { +pub fn build_login_url(config: &WorldRouterLoginConfig) -> String { let trimmed = config.auth_base_url.trim_end_matches('/'); let mut url = url::Url::parse(&format!("{trimmed}/login")) .expect("auth_base_url must be a valid URL"); @@ -408,10 +408,10 @@ pub fn build_login_url(config: &WorldAgentLoginConfig) -> String { - [ ] **Step 4: Restore the public re-exports in lib.rs** -Replace `crates/puffer-provider-worldagent/src/lib.rs`: +Replace `crates/puffer-provider-worldrouter/src/lib.rs`: ```rust -//! Auth Station OAuth helpers for the `worldagent` provider. +//! Auth Station OAuth helpers for the `worldrouter` provider. //! //! Auth Station's `/login` flow returns the final `token` and //! `refresh_token` directly in the callback URL. There is no PKCE, @@ -421,19 +421,19 @@ Replace `crates/puffer-provider-worldagent/src/lib.rs`: mod auth; pub use auth::{ - build_login_url, generate_client_state, WorldAgentLoginConfig, - WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, - WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, - WORLDAGENT_DEFAULT_REDIRECT_URI, + build_login_url, generate_client_state, WorldRouterLoginConfig, + WORLDROUTER_AUTH_BASE_URL, WORLDROUTER_AUTH_URL_OVERRIDE_ENV, + WORLDROUTER_CALLBACK_PATH, WORLDROUTER_CALLBACK_PORT, + WORLDROUTER_DEFAULT_REDIRECT_URI, }; ``` -(Other re-exports — `parse_callback_input`, `decode_jwt_profile`, `refresh_oauth_token`, `exchange_jwt_for_api_key`, `WorldAgentCallback`, `WorldAgentJwtProfile`, `WorldAgentOAuthCredentials` — are added in later tasks.) +(Other re-exports — `parse_callback_input`, `decode_jwt_profile`, `refresh_oauth_token`, `exchange_jwt_for_api_key`, `WorldRouterCallback`, `WorldRouterJwtProfile`, `WorldRouterOAuthCredentials` — are added in later tasks.) - [ ] **Step 5: Run test** ```bash -cargo test -p puffer-provider-worldagent build_login_url +cargo test -p puffer-provider-worldrouter build_login_url ``` Expected: PASS. @@ -441,17 +441,17 @@ Expected: PASS. - [ ] **Step 6: Commit** ```bash -git add crates/puffer-provider-worldagent/src -git commit -m "feat(provider-worldagent): implement build_login_url + config" +git add crates/puffer-provider-worldrouter/src +git commit -m "feat(provider-worldrouter): implement build_login_url + config" ``` --- -## Task 4: Implement `parse_callback_input` + `WorldAgentCallback` +## Task 4: Implement `parse_callback_input` + `WorldRouterCallback` **Files:** -- Modify: `crates/puffer-provider-worldagent/src/auth.rs` -- Modify: `crates/puffer-provider-worldagent/src/lib.rs` +- Modify: `crates/puffer-provider-worldrouter/src/auth.rs` +- Modify: `crates/puffer-provider-worldrouter/src/lib.rs` - [ ] **Step 1: Write the failing tests** @@ -490,7 +490,7 @@ Append to the existing test module in `auth.rs`: - [ ] **Step 2: Run tests to verify they fail** ```bash -cargo test -p puffer-provider-worldagent parse_callback_input +cargo test -p puffer-provider-worldrouter parse_callback_input ``` Expected: FAIL — function does not exist. @@ -503,7 +503,7 @@ Append below `build_login_url` (before the `#[cfg(test)]` block): /// Parsed callback fields. Each field is `None` when its parameter /// was absent from the callback URL. #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WorldAgentCallback { +pub struct WorldRouterCallback { pub token: Option, pub refresh_token: Option, pub state: Option, @@ -513,12 +513,12 @@ pub struct WorldAgentCallback { /// Extracts `token`, `refresh_token`, `state`, `error`, and /// `error_description` from a callback URL or raw query string. -pub fn parse_callback_input(input: &str) -> WorldAgentCallback { +pub fn parse_callback_input(input: &str) -> WorldRouterCallback { let trimmed = input.trim(); if trimmed.is_empty() { - return WorldAgentCallback::default(); + return WorldRouterCallback::default(); } - let mut callback = WorldAgentCallback::default(); + let mut callback = WorldRouterCallback::default(); let pairs: Box> = if let Ok(url) = url::Url::parse(trimmed) { Box::new( @@ -551,22 +551,22 @@ pub fn parse_callback_input(input: &str) -> WorldAgentCallback { - [ ] **Step 4: Add re-exports** -In `crates/puffer-provider-worldagent/src/lib.rs`, extend the `pub use` block: +In `crates/puffer-provider-worldrouter/src/lib.rs`, extend the `pub use` block: ```rust pub use auth::{ build_login_url, generate_client_state, parse_callback_input, - WorldAgentCallback, WorldAgentLoginConfig, - WORLDAGENT_AUTH_BASE_URL, WORLDAGENT_AUTH_URL_OVERRIDE_ENV, - WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, - WORLDAGENT_DEFAULT_REDIRECT_URI, + WorldRouterCallback, WorldRouterLoginConfig, + WORLDROUTER_AUTH_BASE_URL, WORLDROUTER_AUTH_URL_OVERRIDE_ENV, + WORLDROUTER_CALLBACK_PATH, WORLDROUTER_CALLBACK_PORT, + WORLDROUTER_DEFAULT_REDIRECT_URI, }; ``` - [ ] **Step 5: Run tests** ```bash -cargo test -p puffer-provider-worldagent parse_callback_input +cargo test -p puffer-provider-worldrouter parse_callback_input ``` Expected: PASS (3 tests). @@ -574,8 +574,8 @@ Expected: PASS (3 tests). - [ ] **Step 6: Commit** ```bash -git add crates/puffer-provider-worldagent/src -git commit -m "feat(provider-worldagent): parse callback URL into typed fields" +git add crates/puffer-provider-worldrouter/src +git commit -m "feat(provider-worldrouter): parse callback URL into typed fields" ``` --- @@ -583,8 +583,8 @@ git commit -m "feat(provider-worldagent): parse callback URL into typed fields" ## Task 5: Implement `decode_jwt_profile` **Files:** -- Modify: `crates/puffer-provider-worldagent/src/auth.rs` -- Modify: `crates/puffer-provider-worldagent/src/lib.rs` +- Modify: `crates/puffer-provider-worldrouter/src/auth.rs` +- Modify: `crates/puffer-provider-worldrouter/src/lib.rs` - [ ] **Step 1: Write the failing test** @@ -618,7 +618,7 @@ Append to the test module in `auth.rs`: - [ ] **Step 2: Run tests to verify they fail** ```bash -cargo test -p puffer-provider-worldagent decode_jwt_profile +cargo test -p puffer-provider-worldrouter decode_jwt_profile ``` Expected: FAIL — function does not exist. @@ -630,7 +630,7 @@ Append below `parse_callback_input` (before the test module): ```rust /// Decoded JWT profile fields, best-effort. #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WorldAgentJwtProfile { +pub struct WorldRouterJwtProfile { pub sub: Option, pub email: Option, pub name: Option, @@ -638,17 +638,17 @@ pub struct WorldAgentJwtProfile { /// Decodes `sub` / `email` / `name` from the access token JWT /// payload. Any decode/parse failure yields an empty profile. -pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile { +pub fn decode_jwt_profile(access_token: &str) -> WorldRouterJwtProfile { let Some(payload_b64) = access_token.split('.').nth(1) else { - return WorldAgentJwtProfile::default(); + return WorldRouterJwtProfile::default(); }; let Ok(payload_bytes) = URL_SAFE_NO_PAD.decode(payload_b64.as_bytes()) else { - return WorldAgentJwtProfile::default(); + return WorldRouterJwtProfile::default(); }; let Ok(value) = serde_json::from_slice::(&payload_bytes) else { - return WorldAgentJwtProfile::default(); + return WorldRouterJwtProfile::default(); }; - WorldAgentJwtProfile { + WorldRouterJwtProfile { sub: value .get("sub") .and_then(serde_json::Value::as_str) @@ -672,17 +672,17 @@ Extend the `pub use` block in `lib.rs`: ```rust pub use auth::{ build_login_url, decode_jwt_profile, generate_client_state, - parse_callback_input, WorldAgentCallback, WorldAgentJwtProfile, - WorldAgentLoginConfig, WORLDAGENT_AUTH_BASE_URL, - WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, + parse_callback_input, WorldRouterCallback, WorldRouterJwtProfile, + WorldRouterLoginConfig, WORLDROUTER_AUTH_BASE_URL, + WORLDROUTER_AUTH_URL_OVERRIDE_ENV, WORLDROUTER_CALLBACK_PATH, + WORLDROUTER_CALLBACK_PORT, WORLDROUTER_DEFAULT_REDIRECT_URI, }; ``` - [ ] **Step 5: Run tests** ```bash -cargo test -p puffer-provider-worldagent decode_jwt_profile +cargo test -p puffer-provider-worldrouter decode_jwt_profile ``` Expected: PASS. @@ -690,17 +690,17 @@ Expected: PASS. - [ ] **Step 6: Commit** ```bash -git add crates/puffer-provider-worldagent/src -git commit -m "feat(provider-worldagent): decode JWT profile (sub/email/name)" +git add crates/puffer-provider-worldrouter/src +git commit -m "feat(provider-worldrouter): decode JWT profile (sub/email/name)" ``` --- -## Task 6: Implement `WorldAgentOAuthCredentials` + `refresh_oauth_token` + `exchange_jwt_for_api_key` stub +## Task 6: Implement `WorldRouterOAuthCredentials` + `refresh_oauth_token` + `exchange_jwt_for_api_key` stub **Files:** -- Modify: `crates/puffer-provider-worldagent/src/auth.rs` -- Modify: `crates/puffer-provider-worldagent/src/lib.rs` +- Modify: `crates/puffer-provider-worldrouter/src/auth.rs` +- Modify: `crates/puffer-provider-worldrouter/src/lib.rs` - [ ] **Step 1: Write the failing test (for the stub)** @@ -721,7 +721,7 @@ Append to the test module: - [ ] **Step 2: Run test to verify it fails** ```bash -cargo test -p puffer-provider-worldagent exchange_jwt_for_api_key +cargo test -p puffer-provider-worldrouter exchange_jwt_for_api_key ``` Expected: FAIL — function doesn't exist. @@ -731,9 +731,9 @@ Expected: FAIL — function doesn't exist. Append below `decode_jwt_profile`: ```rust -/// Persisted Auth Station credentials for the worldagent provider. +/// Persisted Auth Station credentials for the worldrouter provider. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorldAgentOAuthCredentials { +pub struct WorldRouterOAuthCredentials { pub access_token: String, pub refresh_token: String, pub expires_at_ms: u64, @@ -750,38 +750,38 @@ pub struct WorldAgentOAuthCredentials { pub fn refresh_oauth_token( refresh_token: &str, auth_base_url: Option<&str>, -) -> Result { +) -> Result { let base = auth_base_url .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) .unwrap_or_else(|| { - std::env::var(WORLDAGENT_AUTH_URL_OVERRIDE_ENV) + std::env::var(WORLDROUTER_AUTH_URL_OVERRIDE_ENV) .ok() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| WORLDAGENT_AUTH_BASE_URL.to_string()) + .unwrap_or_else(|| WORLDROUTER_AUTH_BASE_URL.to_string()) }); let url = format!("{}/token/refresh", base.trim_end_matches('/')); let response = Client::new() .post(&url) .json(&serde_json::json!({ "refresh_token": refresh_token })) .send() - .context("failed to send worldagent refresh request")?; + .context("failed to send worldrouter refresh request")?; let status = response.status(); let payload: RefreshResponse = response .json() - .context("failed to parse worldagent refresh response")?; + .context("failed to parse worldrouter refresh response")?; if !status.is_success() { return Err(anyhow!( - "worldagent token refresh failed with status {status}: {}", + "worldrouter token refresh failed with status {status}: {}", payload.error.unwrap_or_default() )); } let access_token = payload .token - .ok_or_else(|| anyhow!("worldagent refresh response missing token"))?; + .ok_or_else(|| anyhow!("worldrouter refresh response missing token"))?; let profile = decode_jwt_profile(&access_token); - Ok(WorldAgentOAuthCredentials { + Ok(WorldRouterOAuthCredentials { access_token, refresh_token: refresh_token.to_string(), expires_at_ms: now_ms() + 24 * 3600 * 1000, @@ -801,7 +801,7 @@ pub fn refresh_oauth_token( /// the stored credential to an `ApiKey { key }` variant. pub fn exchange_jwt_for_api_key(_access_token: &str) -> Result { Err(anyhow!( - "worldagent JWT-to-api-key exchange is not yet implemented; \ + "worldrouter JWT-to-api-key exchange is not yet implemented; \ paste your WorldRouter API key for now" )) } @@ -830,17 +830,17 @@ Final `pub use` block: pub use auth::{ build_login_url, decode_jwt_profile, exchange_jwt_for_api_key, generate_client_state, parse_callback_input, refresh_oauth_token, - WorldAgentCallback, WorldAgentJwtProfile, WorldAgentLoginConfig, - WorldAgentOAuthCredentials, WORLDAGENT_AUTH_BASE_URL, - WORLDAGENT_AUTH_URL_OVERRIDE_ENV, WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, WORLDAGENT_DEFAULT_REDIRECT_URI, + WorldRouterCallback, WorldRouterJwtProfile, WorldRouterLoginConfig, + WorldRouterOAuthCredentials, WORLDROUTER_AUTH_BASE_URL, + WORLDROUTER_AUTH_URL_OVERRIDE_ENV, WORLDROUTER_CALLBACK_PATH, + WORLDROUTER_CALLBACK_PORT, WORLDROUTER_DEFAULT_REDIRECT_URI, }; ``` - [ ] **Step 5: Run all crate tests** ```bash -cargo test -p puffer-provider-worldagent +cargo test -p puffer-provider-worldrouter ``` Expected: PASS (build_login_url, parse_callback_input x3, decode_jwt_profile x2, exchange_jwt_for_api_key — at least 7 tests pass). @@ -848,8 +848,8 @@ Expected: PASS (build_login_url, parse_callback_input x3, decode_jwt_profile x2, - [ ] **Step 6: Commit** ```bash -git add crates/puffer-provider-worldagent/src -git commit -m "feat(provider-worldagent): add credentials + refresh + exchange stub" +git add crates/puffer-provider-worldrouter/src +git commit -m "feat(provider-worldrouter): add credentials + refresh + exchange stub" ``` --- @@ -895,7 +895,7 @@ In `crates/puffer-cli/src/authflow.rs`, inside `impl CallbackListener`, right af ```rust /// Binds a fixed loopback port. Used for redirect URIs that must /// match an Auth Station allow-list entry exactly (such as the - /// worldagent provider). Returns an error if the port is in use. + /// worldrouter provider). Returns an error if the port is in use. pub(crate) fn bind_localhost_port(path: &str, port: u16) -> Result { let listener = TcpListener::bind(("127.0.0.1", port)).with_context(|| { format!("failed to bind callback listener on 127.0.0.1:{port} for {path}") @@ -928,18 +928,18 @@ git commit -m "feat(cli/authflow): add bind_localhost_port for fixed-port callba --- -## Task 8: Wire `OauthFamily::WorldAgent` into `auth_provider.rs` +## Task 8: Wire `OauthFamily::WorldRouter` into `auth_provider.rs` **Files:** - Modify: `crates/puffer-cli/Cargo.toml` - Modify: `crates/puffer-cli/src/auth_provider.rs` -- [ ] **Step 1: Add `puffer-provider-worldagent` as a dep of puffer-cli** +- [ ] **Step 1: Add `puffer-provider-worldrouter` as a dep of puffer-cli** In `crates/puffer-cli/Cargo.toml`, find the `[dependencies]` block where the other `puffer-provider-*` entries live and add (alphabetically near `puffer-provider-openai`): ```toml -puffer-provider-worldagent = { path = "../puffer-provider-worldagent" } +puffer-provider-worldrouter = { path = "../puffer-provider-worldrouter" } ``` - [ ] **Step 2: Write the failing test** @@ -951,15 +951,15 @@ Append to the `tests` module in `crates/puffer-cli/src/auth_provider.rs`: fn oauth_family_uses_explicit_oauth_family_field() { let mut providers = ProviderRegistry::new(); let mut descriptor = provider( - "worldagent", + "worldrouter", "openai-completions", vec![AuthMode::OAuth, AuthMode::ApiKey], ); - descriptor.oauth_family = Some("worldagent".to_string()); + descriptor.oauth_family = Some("worldrouter".to_string()); providers.register(descriptor); assert_eq!( - oauth_family_for_provider(&providers, "worldagent"), - Some(OauthFamily::WorldAgent) + oauth_family_for_provider(&providers, "worldrouter"), + Some(OauthFamily::WorldRouter) ); } @@ -991,7 +991,7 @@ Also update the existing `provider` test helper at the bottom of `auth_provider. cargo test -p puffer-cli auth_provider::tests::oauth_family_uses_explicit ``` -Expected: FAIL — `OauthFamily::WorldAgent` does not exist. +Expected: FAIL — `OauthFamily::WorldRouter` does not exist. - [ ] **Step 4: Add the enum variant and dispatch logic** @@ -1004,7 +1004,7 @@ In `crates/puffer-cli/src/auth_provider.rs`: pub(crate) enum OauthFamily { Anthropic, OpenAi, - WorldAgent, + WorldRouter, } ``` @@ -1023,7 +1023,7 @@ pub(crate) fn oauth_family_for_provider( return match family { "openai" => Some(OauthFamily::OpenAi), "anthropic" => Some(OauthFamily::Anthropic), - "worldagent" => Some(OauthFamily::WorldAgent), + "worldrouter" => Some(OauthFamily::WorldRouter), _ => None, }; } @@ -1038,17 +1038,17 @@ pub(crate) fn oauth_family_for_provider( } ``` -3. Add a `WorldAgent` arm to both `oauth_start_bundle_for_provider` and `oauth_login_bundle_for_provider`. Insert after the `Anthropic` arm in each: +3. Add a `WorldRouter` arm to both `oauth_start_bundle_for_provider` and `oauth_login_bundle_for_provider`. Insert after the `Anthropic` arm in each: ```rust - Some(OauthFamily::WorldAgent) => { - let mut config = puffer_provider_worldagent::WorldAgentLoginConfig::default(); + Some(OauthFamily::WorldRouter) => { + let mut config = puffer_provider_worldrouter::WorldRouterLoginConfig::default(); // for oauth_login_bundle_for_provider only: override redirect_uri. // For oauth_start_bundle_for_provider, leave the default (the // fixed loopback URI baked into the crate). // …see step 5 for the exact code. Ok(OauthStartBundle { - authorization_url: puffer_provider_worldagent::build_login_url(&config), + authorization_url: puffer_provider_worldrouter::build_login_url(&config), automatic_authorization_url: None, verifier: String::new(), state: config.client_state, @@ -1063,10 +1063,10 @@ pub(crate) fn oauth_family_for_provider( For `oauth_start_bundle_for_provider` (no explicit redirect_uri): ```rust - Some(OauthFamily::WorldAgent) => { - let config = puffer_provider_worldagent::WorldAgentLoginConfig::default(); + Some(OauthFamily::WorldRouter) => { + let config = puffer_provider_worldrouter::WorldRouterLoginConfig::default(); Ok(OauthStartBundle { - authorization_url: puffer_provider_worldagent::build_login_url(&config), + authorization_url: puffer_provider_worldrouter::build_login_url(&config), automatic_authorization_url: None, verifier: String::new(), state: config.client_state, @@ -1079,13 +1079,13 @@ For `oauth_start_bundle_for_provider` (no explicit redirect_uri): For `oauth_login_bundle_for_provider` (caller provides the bound redirect_uri): ```rust - Some(OauthFamily::WorldAgent) => { - let config = puffer_provider_worldagent::WorldAgentLoginConfig { + Some(OauthFamily::WorldRouter) => { + let config = puffer_provider_worldrouter::WorldRouterLoginConfig { redirect_uri: redirect_uri.to_string(), - ..puffer_provider_worldagent::WorldAgentLoginConfig::default() + ..puffer_provider_worldrouter::WorldRouterLoginConfig::default() }; Ok(OauthStartBundle { - authorization_url: puffer_provider_worldagent::build_login_url(&config), + authorization_url: puffer_provider_worldrouter::build_login_url(&config), automatic_authorization_url: None, verifier: String::new(), state: config.client_state, @@ -1107,12 +1107,12 @@ Expected: PASS (both new tests + the existing two unchanged). ```bash git add crates/puffer-cli/Cargo.toml crates/puffer-cli/src/auth_provider.rs -git commit -m "feat(cli/auth_provider): dispatch worldagent OAuth family" +git commit -m "feat(cli/auth_provider): dispatch worldrouter OAuth family" ``` --- -## Task 9: Add `to_registry_oauth_credential_worldagent` helper +## Task 9: Add `to_registry_oauth_credential_worldrouter` helper **Files:** - Modify: `crates/puffer-cli/src/auth_credentials.rs` @@ -1125,11 +1125,11 @@ Append (or create) a `#[cfg(test)] mod tests` block at the bottom of `auth_crede #[cfg(test)] mod tests { use super::*; - use puffer_provider_worldagent::WorldAgentOAuthCredentials; + use puffer_provider_worldrouter::WorldRouterOAuthCredentials; #[test] - fn worldagent_credential_maps_email_and_account_id() { - let credential = WorldAgentOAuthCredentials { + fn worldrouter_credential_maps_email_and_account_id() { + let credential = WorldRouterOAuthCredentials { access_token: "acc".to_string(), refresh_token: "ref".to_string(), expires_at_ms: 42, @@ -1137,7 +1137,7 @@ mod tests { email: Some("dev@example.com".to_string()), name: Some("Dev".to_string()), }; - let stored = to_registry_oauth_credential_worldagent(credential); + let stored = to_registry_oauth_credential_worldrouter(credential); assert_eq!(stored.access_token, "acc"); assert_eq!(stored.refresh_token, "ref"); assert_eq!(stored.expires_at_ms, 42); @@ -1150,7 +1150,7 @@ mod tests { - [ ] **Step 2: Run test to verify it fails** ```bash -cargo test -p puffer-cli auth_credentials::tests::worldagent_credential +cargo test -p puffer-cli auth_credentials::tests::worldrouter_credential ``` Expected: FAIL — function doesn't exist. @@ -1160,15 +1160,15 @@ Expected: FAIL — function doesn't exist. Append (above the test module) in `crates/puffer-cli/src/auth_credentials.rs`: ```rust -/// Converts worldagent OAuth credentials into the registry storage shape. +/// Converts worldrouter OAuth credentials into the registry storage shape. /// The Auth Station `sub` claim is stored as `account_id` so the /// existing AuthStore reuse path (organization_id, plan_type, etc.) /// stays untouched. `name` is intentionally not persisted yet — the /// existing `OAuthCredential` shape has no slot for it; if the UI /// needs the display name later, we can either reuse `email` or /// extend the struct. -pub(crate) fn to_registry_oauth_credential_worldagent( - credential: puffer_provider_worldagent::WorldAgentOAuthCredentials, +pub(crate) fn to_registry_oauth_credential_worldrouter( + credential: puffer_provider_worldrouter::WorldRouterOAuthCredentials, ) -> puffer_provider_registry::OAuthCredential { puffer_provider_registry::OAuthCredential { access_token: credential.access_token, @@ -1190,7 +1190,7 @@ pub(crate) fn to_registry_oauth_credential_worldagent( - [ ] **Step 4: Run test** ```bash -cargo test -p puffer-cli auth_credentials::tests::worldagent_credential +cargo test -p puffer-cli auth_credentials::tests::worldrouter_credential ``` Expected: PASS. @@ -1199,12 +1199,12 @@ Expected: PASS. ```bash git add crates/puffer-cli/src/auth_credentials.rs -git commit -m "feat(cli/auth_credentials): map worldagent credential into registry shape" +git commit -m "feat(cli/auth_credentials): map worldrouter credential into registry shape" ``` --- -## Task 10: Wire worldagent into `run_login_flow` (CLI path) +## Task 10: Wire worldrouter into `run_login_flow` (CLI path) **Files:** - Modify: `crates/puffer-cli/src/main.rs` @@ -1214,17 +1214,17 @@ git commit -m "feat(cli/auth_credentials): map worldagent credential into regist Near the existing `use puffer_provider_openai::{…}` and `use puffer_transport_anthropic::{…}` blocks, add: ```rust -use puffer_provider_worldagent::{ - decode_jwt_profile as decode_worldagent_jwt_profile, - parse_callback_input as parse_worldagent_callback_input, - WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, +use puffer_provider_worldrouter::{ + decode_jwt_profile as decode_worldrouter_jwt_profile, + parse_callback_input as parse_worldrouter_callback_input, + WorldRouterOAuthCredentials, WORLDROUTER_CALLBACK_PATH, WORLDROUTER_CALLBACK_PORT, }; ``` And in the `use crate::auth_credentials::{…}` block (around line 65), add: ```rust -use crate::auth_credentials::to_registry_oauth_credential_worldagent; +use crate::auth_credentials::to_registry_oauth_credential_worldrouter; ``` - [ ] **Step 2: Replace the localhost listener bind in `run_login_flow`** @@ -1238,47 +1238,47 @@ the listener-creation block (around line 1017–1021) with: None } else if matches!( oauth_family_for_provider(providers, provider), - Some(OauthFamily::WorldAgent) + Some(OauthFamily::WorldRouter) ) { Some(authflow::CallbackListener::bind_localhost_port( - WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, + WORLDROUTER_CALLBACK_PATH, + WORLDROUTER_CALLBACK_PORT, )?) } else { Some(authflow::CallbackListener::bind_localhost("/callback")?) }; ``` -- [ ] **Step 3: Add the `WorldAgent` arm to the outer `match`** +- [ ] **Step 3: Add the `WorldRouter` arm to the outer `match`** Inside `run_login_flow`, after the `OauthFamily::Anthropic` arm and before the `None =>` bail, insert: ```rust - Some(OauthFamily::WorldAgent) => { - let parsed = parse_worldagent_callback_input(&input); + Some(OauthFamily::WorldRouter) => { + let parsed = parse_worldrouter_callback_input(&input); if let Some(err) = parsed.error.as_deref() { let desc = parsed.error_description.as_deref().unwrap_or(""); - anyhow::bail!("worldagent login failed: {err} {desc}"); + anyhow::bail!("worldrouter login failed: {err} {desc}"); } if parsed.state.as_deref() != Some(bundle.state.as_str()) { - anyhow::bail!("oauth state mismatch for worldagent"); + anyhow::bail!("oauth state mismatch for worldrouter"); } let access_token = parsed .token - .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; + .ok_or_else(|| anyhow::anyhow!("worldrouter callback missing token"))?; let refresh_token = parsed.refresh_token.unwrap_or_default(); - let profile = decode_worldagent_jwt_profile(&access_token); - let credential = WorldAgentOAuthCredentials { + let profile = decode_worldrouter_jwt_profile(&access_token); + let credential = WorldRouterOAuthCredentials { access_token, refresh_token, - expires_at_ms: now_ms_for_worldagent_credential(), + expires_at_ms: now_ms_for_worldrouter_credential(), sub: profile.sub, email: profile.email, name: profile.name, }; auth_store.set_oauth( provider.to_string(), - to_registry_oauth_credential_worldagent(credential), + to_registry_oauth_credential_worldrouter(credential), ); } ``` @@ -1286,7 +1286,7 @@ Inside `run_login_flow`, after the `OauthFamily::Anthropic` arm and before the ` Add this helper near the bottom of `main.rs` (sibling of `resolve_provider_id`): ```rust -fn now_ms_for_worldagent_credential() -> u64 { +fn now_ms_for_worldrouter_credential() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1308,12 +1308,12 @@ Expected: build + tests SUCCESS. ```bash git add crates/puffer-cli/src/main.rs -git commit -m "feat(cli): handle worldagent OAuth in run_login_flow" +git commit -m "feat(cli): handle worldrouter OAuth in run_login_flow" ``` --- -## Task 11: Wire worldagent into the desktop daemon `handle_login_with_oauth` +## Task 11: Wire worldrouter into the desktop daemon `handle_login_with_oauth` **Files:** - Modify: `crates/puffer-cli/src/daemon.rs` @@ -1323,17 +1323,17 @@ git commit -m "feat(cli): handle worldagent OAuth in run_login_flow" Near the top of `daemon.rs`, in the `use puffer_provider_openai::{…}` import block (or wherever the OpenAI/Anthropic imports live), add: ```rust -use puffer_provider_worldagent::{ - decode_jwt_profile as decode_worldagent_jwt_profile, - parse_callback_input as parse_worldagent_callback_input, - WorldAgentOAuthCredentials, WORLDAGENT_CALLBACK_PATH, WORLDAGENT_CALLBACK_PORT, +use puffer_provider_worldrouter::{ + decode_jwt_profile as decode_worldrouter_jwt_profile, + parse_callback_input as parse_worldrouter_callback_input, + WorldRouterOAuthCredentials, WORLDROUTER_CALLBACK_PATH, WORLDROUTER_CALLBACK_PORT, }; ``` In the `use crate::auth_credentials::{…}` block: ```rust -use crate::auth_credentials::to_registry_oauth_credential_worldagent; +use crate::auth_credentials::to_registry_oauth_credential_worldrouter; ``` - [ ] **Step 2: Branch the listener bind** @@ -1349,41 +1349,41 @@ with: ```rust let listener = if matches!( oauth_family_for_provider(&inputs.providers, &provider_id), - Some(OauthFamily::WorldAgent) + Some(OauthFamily::WorldRouter) ) { crate::authflow::CallbackListener::bind_localhost_port( - WORLDAGENT_CALLBACK_PATH, - WORLDAGENT_CALLBACK_PORT, + WORLDROUTER_CALLBACK_PATH, + WORLDROUTER_CALLBACK_PORT, )? } else { crate::authflow::CallbackListener::bind_localhost("/callback")? }; ``` -- [ ] **Step 3: Add the `WorldAgent` arm to the `match`** +- [ ] **Step 3: Add the `WorldRouter` arm to the `match`** After the `OauthFamily::Anthropic` arm (around line 1101–1121) and before the `None =>` bail: ```rust - Some(OauthFamily::WorldAgent) => { - let parsed = parse_worldagent_callback_input(&callback); + Some(OauthFamily::WorldRouter) => { + let parsed = parse_worldrouter_callback_input(&callback); if let Some(err) = parsed.error.as_deref() { let desc = parsed.error_description.as_deref().unwrap_or(""); - anyhow::bail!("worldagent login failed: {err} {desc}"); + anyhow::bail!("worldrouter login failed: {err} {desc}"); } if parsed.state.as_deref() != Some(bundle.state.as_str()) { - anyhow::bail!("oauth state mismatch for worldagent"); + anyhow::bail!("oauth state mismatch for worldrouter"); } let access_token = parsed .token - .ok_or_else(|| anyhow::anyhow!("worldagent callback missing token"))?; + .ok_or_else(|| anyhow::anyhow!("worldrouter callback missing token"))?; let refresh_token = parsed.refresh_token.unwrap_or_default(); - let profile = decode_worldagent_jwt_profile(&access_token); + let profile = decode_worldrouter_jwt_profile(&access_token); let expires_at_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64 + 24 * 3600 * 1000) .unwrap_or(24 * 3600 * 1000); - let credential = WorldAgentOAuthCredentials { + let credential = WorldRouterOAuthCredentials { access_token, refresh_token, expires_at_ms, @@ -1394,7 +1394,7 @@ After the `OauthFamily::Anthropic` arm (around line 1101–1121) and before the set_stored_credential( &mut inputs.auth_store, provider_id.to_string(), - StoredCredential::OAuth(to_registry_oauth_credential_worldagent(credential)), + StoredCredential::OAuth(to_registry_oauth_credential_worldrouter(credential)), ); } ``` @@ -1419,33 +1419,33 @@ Expected: PASS. ```bash git add crates/puffer-cli/src/daemon.rs -git commit -m "feat(cli/daemon): handle worldagent OAuth in handle_login_with_oauth" +git commit -m "feat(cli/daemon): handle worldrouter OAuth in handle_login_with_oauth" ``` --- -## Task 12: Ship the `worldagent.yaml` provider resource +## Task 12: Ship the `worldrouter.yaml` provider resource **Files:** -- Create: `resources/providers/worldagent.yaml` +- Create: `resources/providers/worldrouter.yaml` - [ ] **Step 1: Write the failing test** Append to the test module in `crates/puffer-resources/src/model.rs` (right next to `zhipu_yaml_parses_with_chat_completions_path_override`): ```rust - /// Confirms the bundled `worldagent.yaml` parses as a + /// Confirms the bundled `worldrouter.yaml` parses as a /// `ProviderPack` and that the `oauth_family` field round-trips /// through `into_descriptor`. Without this end-to-end wiring the /// runtime would silently fall back to OpenAI OAuth. #[test] - fn worldagent_yaml_parses_with_oauth_family() { - let yaml = include_str!("../../../resources/providers/worldagent.yaml"); - let pack: ProviderPack = serde_yaml::from_str(yaml).expect("worldagent.yaml parses"); - assert_eq!(pack.id, "worldagent"); - assert_eq!(pack.oauth_family.as_deref(), Some("worldagent")); + fn worldrouter_yaml_parses_with_oauth_family() { + let yaml = include_str!("../../../resources/providers/worldrouter.yaml"); + let pack: ProviderPack = serde_yaml::from_str(yaml).expect("worldrouter.yaml parses"); + assert_eq!(pack.id, "worldrouter"); + assert_eq!(pack.oauth_family.as_deref(), Some("worldrouter")); let descriptor = pack.into_descriptor(); - assert_eq!(descriptor.oauth_family.as_deref(), Some("worldagent")); + assert_eq!(descriptor.oauth_family.as_deref(), Some("worldrouter")); assert!(descriptor.auth_modes.contains(&AuthMode::ApiKey)); assert!(descriptor.auth_modes.contains(&AuthMode::OAuth)); } @@ -1454,21 +1454,21 @@ Append to the test module in `crates/puffer-resources/src/model.rs` (right next - [ ] **Step 2: Run test to verify it fails** ```bash -cargo test -p puffer-resources worldagent_yaml_parses_with_oauth_family +cargo test -p puffer-resources worldrouter_yaml_parses_with_oauth_family ``` Expected: FAIL — file doesn't exist (`include_str!` won't compile). - [ ] **Step 3: Create the yaml** -`resources/providers/worldagent.yaml`: +`resources/providers/worldrouter.yaml`: ```yaml -id: worldagent -display_name: WorldAgent +id: worldrouter +display_name: WorldRouter base_url: https://inference-api.worldrouter.ai default_api: openai-completions -oauth_family: worldagent +oauth_family: worldrouter auth_modes: - api_key - oauth @@ -1482,7 +1482,7 @@ discovery: models: - id: gpt-5 display_name: GPT-5 (via WorldRouter) - provider: worldagent + provider: worldrouter api: openai-completions context_window: 200000 max_output_tokens: 8192 @@ -1492,7 +1492,7 @@ models: - [ ] **Step 4: Run test** ```bash -cargo test -p puffer-resources worldagent_yaml_parses_with_oauth_family +cargo test -p puffer-resources worldrouter_yaml_parses_with_oauth_family ``` Expected: PASS. @@ -1509,13 +1509,13 @@ Expected: SUCCESS / PASS. Note: tests that hit the network (Auth Station, OpenAI - [ ] **Step 6: Commit** ```bash -git add resources/providers/worldagent.yaml crates/puffer-resources/src/model.rs -git commit -m "feat(resources): ship worldagent provider yaml" +git add resources/providers/worldrouter.yaml crates/puffer-resources/src/model.rs +git commit -m "feat(resources): ship worldrouter provider yaml" ``` --- -## Task 13: Add desktop visual entry for worldagent +## Task 13: Add desktop visual entry for worldrouter **Files:** - Modify: `apps/puffer-desktop/src/lib/providerVisuals.ts` @@ -1528,19 +1528,19 @@ grep -n "openai\|anthropic\|groq\|kimi\|providerVisual\|export" apps/puffer-desk Read the file once to understand the registration shape (icon path, accent color, fallback handling). -- [ ] **Step 2: Add a `worldagent` entry** +- [ ] **Step 2: Add a `worldrouter` entry** Match the shape used by existing providers. If entries are keyed by provider id in a record, insert (alphabetically): ```typescript - worldagent: { - icon: "/icons/providers/worldagent.svg", // or use the existing generic fallback + worldrouter: { + icon: "/icons/providers/worldrouter.svg", // or use the existing generic fallback accent: "#1f6feb", - displayName: "WorldAgent", + displayName: "WorldRouter", }, ``` -If your repo doesn't ship a `worldagent.svg`, **use the existing fallback icon** rather than fabricating an asset. Pick whatever the file already does for unknown providers (likely a default monogram or a tinted placeholder). Do not create new SVG/PNG files in this task — the design says "designer can replace later". +If your repo doesn't ship a `worldrouter.svg`, **use the existing fallback icon** rather than fabricating an asset. Pick whatever the file already does for unknown providers (likely a default monogram or a tinted placeholder). Do not create new SVG/PNG files in this task — the design says "designer can replace later". - [ ] **Step 3: Smoke-build the frontend** @@ -1550,13 +1550,13 @@ pnpm install --frozen-lockfile pnpm check ``` -Expected: `svelte-check` reports zero errors related to your edit. (Pre-existing warnings unrelated to worldagent are fine.) +Expected: `svelte-check` reports zero errors related to your edit. (Pre-existing warnings unrelated to worldrouter are fine.) - [ ] **Step 4: Commit** ```bash git add apps/puffer-desktop/src/lib/providerVisuals.ts -git commit -m "feat(desktop): register worldagent provider visuals" +git commit -m "feat(desktop): register worldrouter provider visuals" ``` --- @@ -1564,7 +1564,7 @@ git commit -m "feat(desktop): register worldagent provider visuals" ## Task 14: Write per-component update specs **Files:** -- Create: `specs/puffer-provider-worldagent/00.md` +- Create: `specs/puffer-provider-worldrouter/00.md` - Create: `specs/puffer-provider-registry/06.md` - Create: `specs/puffer-cli/.md` (use the next free `NN.md`, list the directory first) - Create: `specs/puffer-resources/.md` @@ -1584,25 +1584,25 @@ Use the next unused two-digit prefix per AGENTS.md ("do not overwrite prior numb Each file is ≤ 60 lines, follows the existing terse style (see `specs/puffer-provider-openai/01.md`): -`specs/puffer-provider-worldagent/00.md`: +`specs/puffer-provider-worldrouter/00.md`: ```markdown -# WorldAgent Provider Crate +# WorldRouter Provider Crate ## Summary -- New crate `puffer-provider-worldagent` owning the Auth Station login flow for the `worldagent` provider. +- New crate `puffer-provider-worldrouter` owning the Auth Station login flow for the `worldrouter` provider. - Auth Station returns final `token` and `refresh_token` directly in the callback URL; this crate models that flow (no PKCE, no code exchange). ## Surface -- `build_login_url(&WorldAgentLoginConfig) -> String` -- `parse_callback_input(&str) -> WorldAgentCallback` -- `decode_jwt_profile(&str) -> WorldAgentJwtProfile` -- `refresh_oauth_token(&str, Option<&str>) -> Result` +- `build_login_url(&WorldRouterLoginConfig) -> String` +- `parse_callback_input(&str) -> WorldRouterCallback` +- `decode_jwt_profile(&str) -> WorldRouterJwtProfile` +- `refresh_oauth_token(&str, Option<&str>) -> Result` - `exchange_jwt_for_api_key(&str) -> Result` — TODO stub, waits on worldrouter backend ## Configuration - Default Auth Station URL: `https://auth-worldrouter.vercel.app` (Sandbox). -- Override via env var `PUFFER_WORLDAGENT_AUTH_URL`. +- Override via env var `PUFFER_WORLDROUTER_AUTH_URL`. - Fixed loopback redirect: `http://127.0.0.1:1456/callback`. ## Compatibility @@ -1618,39 +1618,39 @@ Each file is ≤ 60 lines, follows the existing terse style (see `specs/puffer-p ## Summary - `ProviderDescriptor` gains `oauth_family: Option`. - When `None`, callers infer the OAuth family from `default_api` (no behavior change for existing yaml). -- When `Some`, callers dispatch directly to the named family. Known values today: `openai`, `anthropic`, `worldagent`. +- When `Some`, callers dispatch directly to the named family. Known values today: `openai`, `anthropic`, `worldrouter`. ## Compatibility - Default value preserves every existing provider yaml. - `ProviderPack` (in `puffer-resources`) mirrors the field and threads it through `into_descriptor`. ``` -`specs/puffer-cli/.md` (worldagent OAuth dispatch): +`specs/puffer-cli/.md` (worldrouter OAuth dispatch): ```markdown -# WorldAgent OAuth Dispatch +# WorldRouter OAuth Dispatch ## Summary -- `OauthFamily` grows a `WorldAgent` variant. +- `OauthFamily` grows a `WorldRouter` variant. - `oauth_family_for_provider` prefers `descriptor.oauth_family` when set, otherwise falls back to `default_api`. -- `oauth_login_bundle_for_provider` builds the bundle from `WorldAgentLoginConfig`; `verifier` is empty (no PKCE), `automatic_authorization_url` is `None`. -- `handle_login_with_oauth` (daemon) and `run_login_flow` (cli) both gain a `WorldAgent` arm: parse callback, verify `state`, decode JWT for `sub`/`email`/`name`, store as `StoredCredential::OAuth`. +- `oauth_login_bundle_for_provider` builds the bundle from `WorldRouterLoginConfig`; `verifier` is empty (no PKCE), `automatic_authorization_url` is `None`. +- `handle_login_with_oauth` (daemon) and `run_login_flow` (cli) both gain a `WorldRouter` arm: parse callback, verify `state`, decode JWT for `sub`/`email`/`name`, store as `StoredCredential::OAuth`. - `CallbackListener::bind_localhost_port` lets the daemon bind the fixed `127.0.0.1:1456` Auth-Station-whitelist port. ## Compatibility - Existing OpenAI / Anthropic flows are unchanged (`oauth_family` unset → falls back to `default_api` map). -- `WorldAgentOAuthCredentials.name` is not yet persisted (no slot on `OAuthCredential`); only `sub`/`email` survive into the registry shape. +- `WorldRouterOAuthCredentials.name` is not yet persisted (no slot on `OAuthCredential`); only `sub`/`email` survive into the registry shape. ``` `specs/puffer-resources/.md`: ```markdown -# WorldAgent Provider Yaml +# WorldRouter Provider Yaml ## Summary -- Bundled `resources/providers/worldagent.yaml` adds the `worldagent` provider entry. +- Bundled `resources/providers/worldrouter.yaml` adds the `worldrouter` provider entry. - `default_api: openai-completions` (inference goes through the existing OpenAI chat-completions transport). -- `oauth_family: worldagent` opts into the new login dispatch. +- `oauth_family: worldrouter` opts into the new login dispatch. - `auth_modes: [api_key, oauth]` exposes both LoginView paths. - Model catalog is seeded minimally; `/v1/models` discovery populates the rest at runtime. @@ -1661,10 +1661,10 @@ Each file is ≤ 60 lines, follows the existing terse style (see `specs/puffer-p `specs/puffer-desktop/.md`: ```markdown -# WorldAgent Visuals +# WorldRouter Visuals ## Summary -- `providerVisuals.ts` registers a `worldagent` entry (display name + accent). +- `providerVisuals.ts` registers a `worldrouter` entry (display name + accent). - No bespoke icon ships in this change; the fallback icon is reused until design provides one. ## Compatibility @@ -1674,9 +1674,9 @@ Each file is ≤ 60 lines, follows the existing terse style (see `specs/puffer-p - [ ] **Step 3: Commit** ```bash -git add specs/puffer-provider-worldagent specs/puffer-provider-registry \ +git add specs/puffer-provider-worldrouter specs/puffer-provider-registry \ specs/puffer-cli specs/puffer-resources specs/puffer-desktop -git commit -m "docs(specs): document worldagent provider integration" +git commit -m "docs(specs): document worldrouter provider integration" ``` --- @@ -1689,7 +1689,7 @@ git commit -m "docs(specs): document worldagent provider integration" cargo test --workspace ``` -Expected: all tests green, including the new worldagent tests and the resource yaml parse test. +Expected: all tests green, including the new worldrouter tests and the resource yaml parse test. - [ ] **Step 2: Workspace build** @@ -1714,12 +1714,12 @@ If a daemon is reachable and `http://127.0.0.1:1456/callback` is allow-listed: 1. Start the daemon: `cargo run -p puffer-cli -- daemon` 2. Open the desktop app, navigate to Login screen. -3. Find the **WorldAgent** card. +3. Find the **WorldRouter** card. 4. Click "Connect with OAuth". 5. Verify the browser opens `https://auth-worldrouter.vercel.app/login?redirect_uri=http://127.0.0.1:1456/callback&client_state=...` 6. Sign in. Browser should redirect to a success page. -7. Confirm `~/.config/puffer/auth.json` (or equivalent platform path) shows a `worldagent` entry with `kind: oauth`. -8. Paste a real WorldRouter API key in the WorldAgent card too — verify the stored credential flips to `kind: api_key`. +7. Confirm `~/.config/puffer/auth.json` (or equivalent platform path) shows a `worldrouter` entry with `kind: oauth`. +8. Paste a real WorldRouter API key in the WorldRouter card too — verify the stored credential flips to `kind: api_key`. If the manual smoke fails because the redirect URI is not allow-listed yet, **that is the expected failure mode** until the auth maintainer adds `http://127.0.0.1:1456/callback` to `ALLOWED_REDIRECT_ORIGINS` on Sandbox + Production. Note the failure mode in the PR description. @@ -1729,7 +1729,7 @@ If the manual smoke fails because the redirect URI is not allow-listed yet, **th git status # If no further changes, skip. Otherwise: git add -p -git commit -m "chore(worldagent): verification follow-ups" +git commit -m "chore(worldrouter): verification follow-ups" ``` --- @@ -1742,7 +1742,7 @@ git commit -m "chore(worldagent): verification follow-ups" - §5 ProviderDescriptor field → Task 1 - §6 new crate surface → Tasks 2–6 - §7 daemon login dispatch + `bind_localhost_port` → Tasks 7, 11 -- §8 TODO JWT→api_key + UI banner — Task 6 (stub) + Task 13 (visuals). The visible banner copy described in §8 of the spec is currently surfaced by `statusMessage = "Connected to worldagent."` plus the user manually pasting an api_key when ready. Adding a worldagent-specific banner above the OAuth button is **deferred** (it requires LoginView changes, which the spec explicitly said were "no LoginView component change needed"). If the user wants a banner, raise it during plan review. +- §8 TODO JWT→api_key + UI banner — Task 6 (stub) + Task 13 (visuals). The visible banner copy described in §8 of the spec is currently surfaced by `statusMessage = "Connected to worldrouter."` plus the user manually pasting an api_key when ready. Adding a worldrouter-specific banner above the OAuth button is **deferred** (it requires LoginView changes, which the spec explicitly said were "no LoginView component change needed"). If the user wants a banner, raise it during plan review. - §9 desktop UI → Task 13 - §10 tests → Tasks 1, 3, 4, 5, 6, 7, 8, 9, 12 each ship the tests they own - §11 per-component specs → Task 14 @@ -1751,10 +1751,10 @@ git commit -m "chore(worldagent): verification follow-ups" **Placeholder scan:** all code blocks contain literal source; the only "TODO" lives in `exchange_jwt_for_api_key`, which is intentional and tested (asserts the function `bail!`s with "not yet implemented"). The `.md` filename placeholder in Task 14 is resolved at step 1 by listing the directory. **Type consistency:** -- `WorldAgentLoginConfig` / `WorldAgentCallback` / `WorldAgentJwtProfile` / `WorldAgentOAuthCredentials` all defined in Task 3/4/5/6 and consumed in Tasks 8/10/11 by the same names. -- `OauthFamily::WorldAgent` defined in Task 8 and consumed in Tasks 10/11. -- `to_registry_oauth_credential_worldagent` defined in Task 9, consumed in Tasks 10/11. +- `WorldRouterLoginConfig` / `WorldRouterCallback` / `WorldRouterJwtProfile` / `WorldRouterOAuthCredentials` all defined in Task 3/4/5/6 and consumed in Tasks 8/10/11 by the same names. +- `OauthFamily::WorldRouter` defined in Task 8 and consumed in Tasks 10/11. +- `to_registry_oauth_credential_worldrouter` defined in Task 9, consumed in Tasks 10/11. - `bind_localhost_port` defined in Task 7, consumed in Tasks 10/11. -- `WORLDAGENT_CALLBACK_PATH` / `WORLDAGENT_CALLBACK_PORT` defined in Task 3, consumed in Tasks 10/11. +- `WORLDROUTER_CALLBACK_PATH` / `WORLDROUTER_CALLBACK_PORT` defined in Task 3, consumed in Tasks 10/11. No gaps. diff --git a/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md b/docs/superpowers/specs/2026-05-20-worldrouter-provider-design.md similarity index 81% rename from docs/superpowers/specs/2026-05-20-worldagent-provider-design.md rename to docs/superpowers/specs/2026-05-20-worldrouter-provider-design.md index e16c7ef74..083bb57c3 100644 --- a/docs/superpowers/specs/2026-05-20-worldagent-provider-design.md +++ b/docs/superpowers/specs/2026-05-20-worldrouter-provider-design.md @@ -1,4 +1,4 @@ -# worldagent Provider — Design +# worldrouter Provider — Design Date: 2026-05-20 Status: Draft (awaiting user approval) @@ -17,7 +17,7 @@ provider entry so users can: `https://auth-worldrouter.vercel.app`), opening the auth website in the default browser and capturing a callback locally. -Long-term framing (from user): worldagent is a **brand entry point**. +Long-term framing (from user): worldrouter is a **brand entry point**. The provider role is the minimum-impact form for the current Puffer flow (provider + model + routing + auth all reuse existing plumbing). Future iterations will let the OAuth session resolve to either a @@ -34,7 +34,7 @@ defined backend-side** and is a clearly marked TODO in code. - Replacing existing OpenAI provider crate functionality. - Reworking LoginView UI. The component is already generic over `authModes`. -- Renaming the provider id (`worldagent` vs `worldclaw`). Pick one id +- Renaming the provider id (`worldrouter` vs `worldclaw`). Pick one id now and keep it stable; display name can change later via yaml. ## 3. High-level architecture @@ -48,7 +48,7 @@ defined backend-side** and is a clearly marked TODO in code. │ dispatch by oauth_family ▼ ┌────────────────────────────────────────┐ - │ puffer-provider-worldagent │ + │ puffer-provider-worldrouter │ │ build_login_url + parse_callback + │ │ decode_jwt + refresh_token │ └────────────┬───────────────────────────┘ @@ -73,14 +73,14 @@ API-key path is unchanged: LoginView submits api_key → existing ## 4. Provider yaml -`resources/providers/worldagent.yaml`: +`resources/providers/worldrouter.yaml`: ```yaml -id: worldagent -display_name: WorldAgent +id: worldrouter +display_name: WorldRouter base_url: https://inference-api.worldrouter.ai default_api: openai-completions -oauth_family: worldagent +oauth_family: worldrouter auth_modes: - api_key - oauth @@ -95,7 +95,7 @@ models: # Seed list — actual catalog comes from /v1/models discovery. - id: gpt-5 display_name: GPT-5 (via WorldRouter) - provider: worldagent + provider: worldrouter api: openai-completions context_window: 200000 max_output_tokens: 8192 @@ -105,7 +105,7 @@ models: Notes: - `default_api: openai-completions` → reuses the existing OpenAI Chat-Completions transport (Bearer api_key, `/v1/chat/completions`). -- `oauth_family: worldagent` is a new field (§5). When unset, the +- `oauth_family: worldrouter` is a new field (§5). When unset, the registry falls back to the existing API-family inference. - The model seed list is intentionally minimal; `discovery` will populate the rest at runtime against `/v1/models`. @@ -133,18 +133,18 @@ pub struct ProviderDescriptor { 1. Read `descriptor.oauth_family` first; map known strings to enum: - `"openai"` → `OauthFamily::OpenAi` - `"anthropic"` → `OauthFamily::Anthropic` - - `"worldagent"` → `OauthFamily::WorldAgent` (new) + - `"worldrouter"` → `OauthFamily::WorldRouter` (new) 2. If unset, fall back to the existing `default_api` switch (no behavior change for any existing yaml). -`OauthFamily` enum grows one variant: `WorldAgent`. +`OauthFamily` enum grows one variant: `WorldRouter`. -## 6. New crate: `puffer-provider-worldagent` +## 6. New crate: `puffer-provider-worldrouter` Layout: ``` -crates/puffer-provider-worldagent/ +crates/puffer-provider-worldrouter/ ├── Cargo.toml └── src/ ├── lib.rs — public re-exports @@ -155,21 +155,21 @@ Public surface (`src/auth.rs`): ```rust /// Default Auth Station base URL (Sandbox). -pub const WORLDAGENT_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app"; +pub const WORLDROUTER_AUTH_BASE_URL: &str = "https://auth-worldrouter.vercel.app"; /// Env var that overrides the Auth Station base URL. -pub const WORLDAGENT_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDAGENT_AUTH_URL"; +pub const WORLDROUTER_AUTH_URL_OVERRIDE_ENV: &str = "PUFFER_WORLDROUTER_AUTH_URL"; /// Fixed loopback callback used by Puffer desktop. The auth team /// must allow-list this redirect URI on both Sandbox and Production /// `ALLOWED_REDIRECT_ORIGINS`. -pub const WORLDAGENT_CALLBACK_PATH: &str = "/callback"; -pub const WORLDAGENT_CALLBACK_PORT: u16 = 1456; -pub const WORLDAGENT_DEFAULT_REDIRECT_URI: &str = +pub const WORLDROUTER_CALLBACK_PATH: &str = "/callback"; +pub const WORLDROUTER_CALLBACK_PORT: u16 = 1456; +pub const WORLDROUTER_DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:1456/callback"; -/// Persisted Auth Station credentials for the worldagent provider. -pub struct WorldAgentOAuthCredentials { +/// Persisted Auth Station credentials for the worldrouter provider. +pub struct WorldRouterOAuthCredentials { pub access_token: String, pub refresh_token: String, pub expires_at_ms: u64, @@ -179,23 +179,23 @@ pub struct WorldAgentOAuthCredentials { } /// Parameters required to build the Auth Station login URL. -pub struct WorldAgentLoginConfig { +pub struct WorldRouterLoginConfig { pub auth_base_url: String, pub redirect_uri: String, pub client_state: String, } -impl Default for WorldAgentLoginConfig { /* env override + defaults */ } +impl Default for WorldRouterLoginConfig { /* env override + defaults */ } /// Generate an opaque random client_state. pub fn generate_client_state() -> String; /// Build the GET URL for `/login?redirect_uri=&client_state=`. -pub fn build_login_url(config: &WorldAgentLoginConfig) -> String; +pub fn build_login_url(config: &WorldRouterLoginConfig) -> String; /// Parsed callback fields. Each field is `None` when the parameter /// was absent from the callback URL. -pub struct WorldAgentCallback { +pub struct WorldRouterCallback { pub token: Option, pub refresh_token: Option, pub state: Option, @@ -205,10 +205,10 @@ pub struct WorldAgentCallback { /// Extract `token`, `refresh_token`, `state`, `error`, /// `error_description` from a callback URL. -pub fn parse_callback_input(input: &str) -> WorldAgentCallback; +pub fn parse_callback_input(input: &str) -> WorldRouterCallback; /// Decoded JWT profile fields, best-effort. -pub struct WorldAgentJwtProfile { +pub struct WorldRouterJwtProfile { pub sub: Option, pub email: Option, pub name: Option, @@ -216,14 +216,14 @@ pub struct WorldAgentJwtProfile { /// Decode `sub`/`email`/`name` from the access token JWT payload /// (best-effort; failures yield empty fields). -pub fn decode_jwt_profile(access_token: &str) -> WorldAgentJwtProfile; +pub fn decode_jwt_profile(access_token: &str) -> WorldRouterJwtProfile; /// Exchange a stored refresh token for a new access token via /// `POST /token/refresh`. pub fn refresh_oauth_token( refresh_token: &str, auth_base_url: Option<&str>, -) -> Result; +) -> Result; ``` Auth Station's `/login` flow is **simpler than OAuth**: there is no @@ -239,21 +239,21 @@ including tests). third arm: ```rust -Some(OauthFamily::WorldAgent) => { +Some(OauthFamily::WorldRouter) => { let parsed = parse_callback_input(&callback); if let Some(err) = parsed.error.as_deref() { let desc = parsed.error_description.as_deref().unwrap_or(""); - bail!("worldagent login failed: {err} {desc}"); + bail!("worldrouter login failed: {err} {desc}"); } if parsed.state.as_deref() != Some(bundle.state.as_str()) { - bail!("oauth state mismatch for worldagent"); + bail!("oauth state mismatch for worldrouter"); } let token = parsed .token - .ok_or_else(|| anyhow!("worldagent callback missing token"))?; + .ok_or_else(|| anyhow!("worldrouter callback missing token"))?; let refresh = parsed.refresh_token.unwrap_or_default(); let profile = decode_jwt_profile(&token); - let credential = WorldAgentOAuthCredentials { + let credential = WorldRouterOAuthCredentials { access_token: token, refresh_token: refresh, expires_at_ms: now_ms() + 24 * 3600 * 1000, // matches Auth Station spec @@ -264,19 +264,19 @@ Some(OauthFamily::WorldAgent) => { set_stored_credential( &mut inputs.auth_store, provider_id.to_string(), - StoredCredential::OAuth(to_registry_oauth_credential_worldagent(credential)), + StoredCredential::OAuth(to_registry_oauth_credential_worldrouter(credential)), ); } ``` `oauth_login_bundle_for_provider` (auth_provider.rs) likewise gains a -`WorldAgent` arm that builds the bundle from -`WorldAgentLoginConfig`. The bundle's `verifier` is unused for -worldagent — we set it to an empty string. `automatic_authorization_url` +`WorldRouter` arm that builds the bundle from +`WorldRouterLoginConfig`. The bundle's `verifier` is unused for +worldrouter — we set it to an empty string. `automatic_authorization_url` is `None` (single URL, no manual fallback). `puffer-cli/src/main.rs::run_login_flow` adds the matching arm so the -CLI path (`puffer auth login worldagent`) works the same way. +CLI path (`puffer auth login worldrouter`) works the same way. `puffer-cli/src/authflow.rs` is unchanged. The `CallbackListener::bind_localhost` helper accepts the fixed port via a new optional binder @@ -286,7 +286,7 @@ default — we only branch when the caller asks for a fixed port). ## 8. TODO: JWT → api_key exchange A clearly named module placeholder is added in -`puffer-provider-worldagent/src/lib.rs`: +`puffer-provider-worldrouter/src/lib.rs`: ```rust /// TODO (waiting on worldrouter backend): @@ -300,7 +300,7 @@ pub fn exchange_jwt_for_api_key( _access_token: &str, ) -> Result { anyhow::bail!( - "worldagent JWT-to-api-key exchange is not yet implemented; \ + "worldrouter JWT-to-api-key exchange is not yet implemented; \ paste your WorldRouter API key for now." ) } @@ -322,10 +322,10 @@ LoginView already supports the API-key + OAuth dual layout. The only desktop-side change is: - `apps/puffer-desktop/src/lib/providerVisuals.ts` — register a - `worldagent` entry (icon path + accent color). A simple text-based + `worldrouter` entry (icon path + accent color). A simple text-based monogram icon is acceptable for v1; designer can replace later. - A short banner above the OAuth button when the active provider is - worldagent and only an OAuth credential exists (no api_key): "Auto + worldrouter and only an OAuth credential exists (no api_key): "Auto api-key exchange is not yet enabled. Paste a WorldRouter API key to start running models." @@ -334,7 +334,7 @@ No new Tauri commands. No new daemon RPCs beyond reusing ## 10. Tests -- `puffer-provider-worldagent`: +- `puffer-provider-worldrouter`: - `build_login_url_contains_redirect_uri_and_client_state` - `parse_callback_input_extracts_token_refresh_state` - `parse_callback_input_returns_error_when_present` @@ -346,9 +346,9 @@ No new Tauri commands. No new daemon RPCs beyond reusing - `puffer-cli/auth_provider`: - `oauth_family_uses_explicit_field_when_set` - `oauth_family_falls_back_to_default_api_when_unset` - - `oauth_family_recognizes_worldagent` + - `oauth_family_recognizes_worldrouter` - `puffer-cli/daemon` (with `tokio::test`): - - Smoke test for `handle_login_with_oauth` with a fake worldagent + - Smoke test for `handle_login_with_oauth` with a fake worldrouter callback URL passed through the bundle path. `cargo test --workspace` must stay green. @@ -357,17 +357,17 @@ No new Tauri commands. No new daemon RPCs beyond reusing No moves. New files: -- `resources/providers/worldagent.yaml` -- `crates/puffer-provider-worldagent/Cargo.toml` -- `crates/puffer-provider-worldagent/src/lib.rs` -- `crates/puffer-provider-worldagent/src/auth.rs` +- `resources/providers/worldrouter.yaml` +- `crates/puffer-provider-worldrouter/Cargo.toml` +- `crates/puffer-provider-worldrouter/src/lib.rs` +- `crates/puffer-provider-worldrouter/src/auth.rs` New per-component spec files (per AGENTS.md convention): -- `specs/puffer-provider-worldagent/00.md` — crate overview +- `specs/puffer-provider-worldrouter/00.md` — crate overview - `specs/puffer-provider-registry/06.md` — `oauth_family` field - `specs/puffer-cli/.md` — auth_provider dispatch + daemon arm -- `specs/puffer-resources/.md` — worldagent.yaml entry +- `specs/puffer-resources/.md` — worldrouter.yaml entry - `specs/puffer-desktop/.md` — providerVisuals entry + banner Each component spec is concise (≤ 60 lines) per existing style. @@ -378,9 +378,9 @@ Each component spec is concise (≤ 60 lines) per existing style. `http://127.0.0.1:1456/callback` on **both** Sandbox and Production `ALLOWED_REDIRECT_ORIGINS`. - Confirm `aud=worldclaw` is the correct audience claim for the - worldagent product (the current docs use `worldclaw`; if a + worldrouter product (the current docs use `worldclaw`; if a separate audience is preferred for this product, surface it now). -- Confirm the final brand name (`worldagent` vs `worldclaw`) for the +- Confirm the final brand name (`worldrouter` vs `worldclaw`) for the yaml `id`. If you want to switch later, the cost is one yaml rename plus a credentials migration step. @@ -393,8 +393,8 @@ Each component spec is concise (≤ 60 lines) per existing style. - Profile UI showing the authenticated email / org from the JWT. - Refresh token rotation when access_token expires (one-line cron in daemon: call `refresh_oauth_token` and re-store the credential). -- "Switch account" button = `puffer auth logout worldagent` + repeat +- "Switch account" button = `puffer auth logout worldrouter` + repeat the OAuth flow. - If/when the brand becomes the primary entry point: hoist the - worldagent OAuth flow to the onboarding root, push the + worldrouter OAuth flow to the onboarding root, push the raw-OpenAI/Anthropic providers into an "Advanced" sub-screen. diff --git a/resources/providers/worldagent.yaml b/resources/providers/worldrouter.yaml similarity index 87% rename from resources/providers/worldagent.yaml rename to resources/providers/worldrouter.yaml index 03fb9e8a0..0f8a7757b 100644 --- a/resources/providers/worldagent.yaml +++ b/resources/providers/worldrouter.yaml @@ -1,8 +1,8 @@ -id: worldagent -display_name: WorldAgent +id: worldrouter +display_name: WorldRouter base_url: https://inference-api.worldrouter.ai default_api: openai-completions -oauth_family: worldagent +oauth_family: worldrouter auth_modes: - api_key - oauth @@ -19,14 +19,14 @@ models: # (kimi-k2.6 and qwen3.5-flash both return 200 on /v1/chat/completions). - id: kimi-k2.6 display_name: Kimi K2.6 - provider: worldagent + provider: worldrouter api: openai-completions context_window: 128000 max_output_tokens: 8192 supports_reasoning: true - id: qwen3.5-flash display_name: Qwen 3.5 Flash - provider: worldagent + provider: worldrouter api: openai-completions context_window: 128000 max_output_tokens: 8192 diff --git a/specs/puffer-desktop/458.md b/specs/puffer-desktop/458.md index e4faf1ce8..bfe5d5e7c 100644 --- a/specs/puffer-desktop/458.md +++ b/specs/puffer-desktop/458.md @@ -1,7 +1,7 @@ -# WorldAgent Visuals +# WorldRouter Visuals ## Summary -- `providerVisuals.ts` registers a `worldagent` entry (display accent + icon). +- `providerVisuals.ts` registers a `worldrouter` entry (display accent + icon). - No bespoke icon ships in this change; the fallback `ai` icon is reused until design provides one. ## Compatibility diff --git a/specs/puffer-provider-registry/06.md b/specs/puffer-provider-registry/06.md index 4f6cd6233..0753203aa 100644 --- a/specs/puffer-provider-registry/06.md +++ b/specs/puffer-provider-registry/06.md @@ -3,7 +3,7 @@ ## Summary - `ProviderDescriptor` gains `oauth_family: Option`. - When `None`, callers infer the OAuth family from `default_api` (no behavior change for existing yaml). -- When `Some`, callers dispatch directly to the named family. Known values today: `openai`, `anthropic`, `worldagent`. +- When `Some`, callers dispatch directly to the named family. Known values today: `openai`, `anthropic`, `worldrouter`. ## Compatibility - Default value preserves every existing provider yaml. diff --git a/specs/puffer-provider-worldagent/00.md b/specs/puffer-provider-worldrouter/00.md similarity index 72% rename from specs/puffer-provider-worldagent/00.md rename to specs/puffer-provider-worldrouter/00.md index 4570989f9..efe78a613 100644 --- a/specs/puffer-provider-worldagent/00.md +++ b/specs/puffer-provider-worldrouter/00.md @@ -1,19 +1,19 @@ -# WorldAgent Provider Crate +# WorldRouter Provider Crate ## Summary -- New crate `puffer-provider-worldagent` owning the Auth Station login flow for the `worldagent` provider. +- New crate `puffer-provider-worldrouter` owning the Auth Station login flow for the `worldrouter` provider. - Auth Station returns the final `token` and `refresh_token` directly in the callback URL; this crate models that flow (no PKCE, no code exchange). ## Surface -- `build_login_url(&WorldAgentLoginConfig) -> String` -- `parse_callback_input(&str) -> WorldAgentCallback` -- `decode_jwt_profile(&str) -> WorldAgentJwtProfile` -- `refresh_oauth_token(&str, Option<&str>) -> Result` +- `build_login_url(&WorldRouterLoginConfig) -> String` +- `parse_callback_input(&str) -> WorldRouterCallback` +- `decode_jwt_profile(&str) -> WorldRouterJwtProfile` +- `refresh_oauth_token(&str, Option<&str>) -> Result` - `exchange_jwt_for_api_key(&str) -> Result` — two-hop: `POST {control-api}/auth/exchange` trades the Auth Station JWT for an Infer session token (HS256, `iss=infer-session`) and returns the user's `default_team_id`; `POST {control-api}/platform/v1/teams/{default_team_id}/keys` then mints a `sk-worldrouter-…` inference key. ## Configuration -- Default Auth Station URL: `https://auth.worldrouter.ai` (Production; `http://127.0.0.1:1456` allow-listed on prod as of 2026-05-20). Sandbox `https://auth-worldrouter.vercel.app` is reachable via `PUFFER_WORLDAGENT_AUTH_URL` override. -- Default control-api URL: `https://control-api.worldrouter.ai` (Production). Preview `https://control-api-pre-7f819c.worldrouter.ai` is reachable via `PUFFER_WORLDAGENT_CONTROL_URL` override — beware that preview-control and production-inference do not share a LiteLLM DB, so keys minted on preview return 401 against `inference-api.worldrouter.ai`. +- Default Auth Station URL: `https://auth.worldrouter.ai` (Production; `http://127.0.0.1:1456` allow-listed on prod as of 2026-05-20). Sandbox `https://auth-worldrouter.vercel.app` is reachable via `PUFFER_WORLDROUTER_AUTH_URL` override. +- Default control-api URL: `https://control-api.worldrouter.ai` (Production). Preview `https://control-api-pre-7f819c.worldrouter.ai` is reachable via `PUFFER_WORLDROUTER_CONTROL_URL` override — beware that preview-control and production-inference do not share a LiteLLM DB, so keys minted on preview return 401 against `inference-api.worldrouter.ai`. - Fixed loopback redirect: `http://127.0.0.1:1456/callback`. - `default_team_id` comes from the `/auth/exchange` response envelope; no env-var fallback needed. diff --git a/specs/puffer-resources/01.md b/specs/puffer-resources/01.md index 8c15c954f..347e908c7 100644 --- a/specs/puffer-resources/01.md +++ b/specs/puffer-resources/01.md @@ -1,9 +1,9 @@ -# WorldAgent Provider Yaml +# WorldRouter Provider Yaml ## Summary -- Bundled `resources/providers/worldagent.yaml` adds the `worldagent` provider entry. +- Bundled `resources/providers/worldrouter.yaml` adds the `worldrouter` provider entry. - `default_api: openai-completions` (inference goes through the existing OpenAI chat-completions transport). -- `oauth_family: worldagent` opts into the new login dispatch. +- `oauth_family: worldrouter` opts into the new login dispatch. - `auth_modes: [api_key, oauth]` exposes both LoginView paths. - Model catalog is seeded minimally; `/v1/models` discovery populates the rest at runtime. From c278fbe7b1cdc69f671233932c38e754752779b8 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 12:34:20 +0800 Subject: [PATCH 32/39] feat(worldrouter): expose as chat provider in desktop GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - puffer-cli: add top-level `--provider` flag and apply it to config.default_provider for the TUI launch paths (None / Resume / Fork) so the in-process registry picks WorldRouter as the active provider. - puffer-desktop backend: add `"worldrouter"` arm to build_provider_command that spawns `puffer --no-alt-screen --provider worldrouter `. - puffer-desktop validators: accept `"worldrouter"` in validate_provider_id, map it through canonical_backend_provider_id, and seed default_model_for with `kimi-k2.6` to match the listed provider_models entry. - puffer-desktop frontend: include `worldrouter` in BUILTIN_AGENT_PROVIDER_IDS so the NewSessionModal renders it and add a "WorldRouter inference" detail label. Auth continues to flow through puffer-cli's AuthStore — no env-var injection from the desktop wrapper, so the stored sk-worldrouter-… key remains the source of truth. --- apps/puffer-desktop/src-tauri/src/backend.rs | 18 +++++++++++++++++- apps/puffer-desktop/src/lib/providerIds.ts | 2 +- .../screens/workspace/NewSessionModal.svelte | 1 + crates/puffer-cli/src/cli_args.rs | 4 ++++ crates/puffer-cli/src/main.rs | 16 +++++++++++++++- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index eaa057577..cfe655007 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -1649,6 +1649,20 @@ fn build_provider_command( json_stream: false, }) } + "worldrouter" => { + let command = ensure_provider_command("puffer")?; + Ok(ProviderLaunch { + label: "WorldRouter".to_string(), + command, + args: vec![ + "--no-alt-screen".to_string(), + "--provider".to_string(), + "worldrouter".to_string(), + message.to_string(), + ], + json_stream: false, + }) + } other => bail!("unknown provider `{other}`"), } } @@ -2560,6 +2574,7 @@ fn default_model_for(provider: &str) -> Option { match canonical_backend_provider_id(provider).as_str() { "claude" => Some(DEFAULT_CLAUDE_MODEL.to_string()), "puffer" => Some(DEFAULT_PUFFER_MODEL.to_string()), + "worldrouter" => Some("kimi-k2.6".to_string()), _ => codex_app_server_catalog() .ok() .and_then(|catalog| catalog.default_model), @@ -2568,7 +2583,7 @@ fn default_model_for(provider: &str) -> Option { fn validate_provider_id(provider: &str) -> Result<()> { match canonical_backend_provider_id(provider).as_str() { - "puffer" | "codex" | "claude" => Ok(()), + "puffer" | "codex" | "claude" | "worldrouter" => Ok(()), other => bail!("unknown provider `{other}`"), } } @@ -2579,6 +2594,7 @@ fn canonical_backend_provider_id(provider: &str) -> String { "openai" | "codex" => "codex".to_string(), "anthropic" | "claude" => "claude".to_string(), "puffer" => "puffer".to_string(), + "worldrouter" => "worldrouter".to_string(), _ => trimmed.to_string(), } } diff --git a/apps/puffer-desktop/src/lib/providerIds.ts b/apps/puffer-desktop/src/lib/providerIds.ts index 6a636e791..817687697 100644 --- a/apps/puffer-desktop/src/lib/providerIds.ts +++ b/apps/puffer-desktop/src/lib/providerIds.ts @@ -6,7 +6,7 @@ export function canonicalDaemonProviderId(providerId: string): string { return providerId; } -const BUILTIN_AGENT_PROVIDER_IDS = new Set(["openai", "anthropic", "puffer"]); +const BUILTIN_AGENT_PROVIDER_IDS = new Set(["openai", "anthropic", "puffer", "worldrouter"]); const NON_AGENT_PROVIDER_IDS = new Set(["github"]); const NON_AGENT_APIS = new Set(["", "oauth", "none", "disabled"]); diff --git a/apps/puffer-desktop/src/lib/screens/workspace/NewSessionModal.svelte b/apps/puffer-desktop/src/lib/screens/workspace/NewSessionModal.svelte index cb362de3a..ffb7e84a4 100644 --- a/apps/puffer-desktop/src/lib/screens/workspace/NewSessionModal.svelte +++ b/apps/puffer-desktop/src/lib/screens/workspace/NewSessionModal.svelte @@ -52,6 +52,7 @@ if (provider.id === "codex" || provider.id === "openai") return "OpenAI Codex CLI"; if (provider.id === "claude" || provider.id === "anthropic") return "Claude Code CLI"; if (provider.id === "puffer") return "Puffer CLI"; + if (provider.id === "worldrouter") return "WorldRouter inference"; return provider.defaultApi ? `${provider.defaultApi} provider` : "Model provider"; } diff --git a/crates/puffer-cli/src/cli_args.rs b/crates/puffer-cli/src/cli_args.rs index 5ff872aaa..ce7b67634 100644 --- a/crates/puffer-cli/src/cli_args.rs +++ b/crates/puffer-cli/src/cli_args.rs @@ -28,6 +28,10 @@ pub(crate) struct Cli { /// Disable alternate-screen mode for the TUI. #[arg(long = "no-alt-screen", default_value_t = false)] pub(crate) no_alt_screen: bool, + + /// Override the active provider for the TUI launch path (also applied to `--resume`/fork). + #[arg(long = "provider")] + pub(crate) provider: Option, } #[derive(Debug, Subcommand)] diff --git a/crates/puffer-cli/src/main.rs b/crates/puffer-cli/src/main.rs index bcd145fe2..460e307c1 100644 --- a/crates/puffer-cli/src/main.rs +++ b/crates/puffer-cli/src/main.rs @@ -95,7 +95,7 @@ fn main() -> Result<()> { let cwd = std::env::current_dir()?; let paths = ConfigPaths::discover(&cwd); ensure_workspace_dirs(&paths)?; - let config = load_config(&paths)?; + let mut config = load_config(&paths)?; let auth_path = paths.user_config_dir.join("auth.json"); let mut auth_store = AuthStore::load(&auth_path)?; let mut resources = load_resources(&paths, &puffer_runner_local::LocalToolRunner::new())?; @@ -185,6 +185,20 @@ fn main() -> Result<()> { None }; + // When the user passes `--provider ` on the top-level CLI, override + // the configured default for the TUI launch paths (`None`, `Resume`, + // `Fork`). This lets the desktop GUI pick which provider to start a chat + // session with without persisting a config change. + if let Some(provider_override) = cli.provider.as_deref() { + if matches!( + cli.subcommand, + None | Some(Command::Resume { .. }) | Some(Command::Fork { .. }) + ) { + let resolved = resolve_provider_id(&providers, provider_override); + config.default_provider = Some(resolved); + } + } + let result = match cli.subcommand { Some(Command::Subscriber { .. }) => { // Already handled above; here only to satisfy exhaustiveness. From b5312f15444fa3e5446637f99a93f446f7492f94 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 14:03:46 +0800 Subject: [PATCH 33/39] fix(login): keep provider controls visible when connected + add worldrouter UI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c6a9c10b collapsed the connected-state provider card down to just a Disconnect button, hiding the OAuth and api_key controls. That broke 10 settings-ui tests that expect Reconnect / Update key controls to remain visible once a provider is connected. Restore master's flat layout for the card body (imports + actions with OAuth and api_key sub-blocks rendered regardless of auth state), while preserving the WorldRouter UX additions: - OAuth button: still uses "Waiting for browser login…" busy label when the provider is worldrouter (master used "Opening browser…" universally). - OAuth button: still disabled when an api_key is pending, with the "Clear the API key field to use OAuth" tooltip (mutual-exclusion). - Disconnect button (new in c6a9c10b) now lives inside .actions, gated on {#if auth}, and disables to "Disconnecting…" while busy. Also stage the previously-untracked tests/worldrouter-ui.spec.ts which covers the WorldRouter provider card and new-agent modal radio. --- .../src/lib/components/LoginView.svelte | 143 +++++++-------- .../tests/worldrouter-ui.spec.ts | 173 ++++++++++++++++++ 2 files changed, 242 insertions(+), 74 deletions(-) create mode 100644 apps/puffer-desktop/tests/worldrouter-ui.spec.ts diff --git a/apps/puffer-desktop/src/lib/components/LoginView.svelte b/apps/puffer-desktop/src/lib/components/LoginView.svelte index 1e1520a74..e7c1c1b98 100644 --- a/apps/puffer-desktop/src/lib/components/LoginView.svelte +++ b/apps/puffer-desktop/src/lib/components/LoginView.svelte @@ -228,15 +228,75 @@ - {#if auth} -

-
- - {connectedHint(auth)} - {#if auth.planType} - · {auth.planType} - {/if} + {#if candidates.length} +
+ {#each candidates as candidate (importKey(candidate.providerId, candidate.source))} + + {/each} +
+ {/if} + +
+ {#if supports(provider, "oauth")} + + {/if} + + {#if supports(provider, "api_key")} +
+ + updateApiKey(provider.id, (event.currentTarget as HTMLInputElement).value)} + on:keydown={(event) => { + if (event.key === "Enter") submitApiKey(provider.id); + }} + /> +
+ {/if} + + {#if auth} -
- {:else} - {#if candidates.length} -
- {#each candidates as candidate (importKey(candidate.providerId, candidate.source))} - - {/each} -
{/if} - -
- {#if supports(provider, "oauth")} - - {/if} - - {#if supports(provider, "api_key")} -
- - updateApiKey(provider.id, (event.currentTarget as HTMLInputElement).value)} - on:keydown={(event) => { - if (event.key === "Enter") submitApiKey(provider.id); - }} - /> - -
- {/if} -
- {/if} +

{auth diff --git a/apps/puffer-desktop/tests/worldrouter-ui.spec.ts b/apps/puffer-desktop/tests/worldrouter-ui.spec.ts new file mode 100644 index 000000000..570a56d9a --- /dev/null +++ b/apps/puffer-desktop/tests/worldrouter-ui.spec.ts @@ -0,0 +1,173 @@ +import { expect, test } from "@playwright/test"; +import { FakeDaemon } from "./support/fakeDaemon"; + +const WORLDROUTER_PROVIDER = { + id: "worldrouter", + displayName: "WorldRouter", + baseUrl: "https://inference-api.worldrouter.ai", + defaultApi: "openai-completions", + modelCount: 2, + authModes: ["api_key", "oauth"], + sourceKind: "builtin", + sourcePath: "resources/providers/worldrouter.yaml" +}; + +const BUILTIN_PROVIDERS = [ + { + id: "openai", + displayName: "OpenAI", + baseUrl: "https://api.openai.com", + defaultApi: "openai-responses", + modelCount: 1, + authModes: ["oauth", "api_key"], + sourceKind: "builtin", + sourcePath: "resources/providers/openai.yaml" + }, + { + id: "anthropic", + displayName: "Anthropic", + baseUrl: "https://api.anthropic.com", + defaultApi: "anthropic-messages", + modelCount: 1, + authModes: ["api_key"], + sourceKind: "builtin", + sourcePath: "resources/providers/anthropic.yaml" + }, + { + id: "puffer", + displayName: "Puffer", + baseUrl: "local-cli://puffer", + defaultApi: "cli", + modelCount: 1, + authModes: ["native", "api_key"], + sourceKind: "builtin", + sourcePath: "resources/providers/puffer.yaml" + }, + WORLDROUTER_PROVIDER +]; + +test("settings provider page shows WorldRouter card with both auth modes", async ({ page }) => { + const daemon = new FakeDaemon({ + auth: [], + providers: [WORLDROUTER_PROVIDER] + }); + await daemon.install(page); + await daemon.open(page, { allowUnauthenticatedWorkspace: true }); + + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Providers" }).click(); + + const card = page.locator(".provider-card").filter({ hasText: "WorldRouter" }); + await expect(card).toBeVisible(); + await expect(card.getByRole("heading", { name: "WorldRouter" })).toBeVisible(); + + // Both auth modes should be offered (OAuth button + API key input). + await expect(card.getByRole("button", { name: "Connect with OAuth" })).toBeVisible(); + await expect(card.getByLabel("API key for WorldRouter")).toBeVisible(); +}); + +test("new agent modal lists WorldRouter alongside the other builtin providers", async ({ page }) => { + const daemon = new FakeDaemon({ + auth: [ + { + providerId: "openai", + kind: "oauth", + email: "tester@example.com", + expiresAtMs: null, + scopes: [], + planType: "test", + organizationName: null + }, + { + providerId: "anthropic", + kind: "api_key", + email: null, + expiresAtMs: null, + scopes: [], + planType: null, + organizationName: null + }, + { + providerId: "worldrouter", + kind: "oauth", + email: null, + expiresAtMs: null, + scopes: [], + planType: "WorldRouter OAuth", + organizationName: null + } + ], + providers: BUILTIN_PROVIDERS + }); + await daemon.install(page); + await daemon.open(page); + + await page.getByRole("button", { name: /^New agent in / }).first().click(); + const dialog = page.getByRole("dialog", { name: "New agent" }); + await expect(dialog).toBeVisible(); + + const radioGroup = dialog.locator(".pf-provider-choice"); + await expect(radioGroup.getByRole("radio", { name: /OpenAI/ })).toBeVisible(); + await expect(radioGroup.getByRole("radio", { name: /Anthropic/ })).toBeVisible(); + await expect(radioGroup.getByRole("radio", { name: /Puffer/ })).toBeVisible(); + await expect(radioGroup.getByRole("radio", { name: /WorldRouter/ })).toBeVisible(); + await expect(radioGroup.locator('input[type="radio"]')).toHaveCount(4); + + const worldrouterLabel = radioGroup.locator("label").filter({ hasText: "WorldRouter" }); + await expect(worldrouterLabel.locator(".meta")).toHaveText("WorldRouter inference"); +}); + +test("selecting WorldRouter in the new agent modal toggles the radio without error", async ({ page }) => { + const daemon = new FakeDaemon({ + auth: [ + { + providerId: "openai", + kind: "oauth", + email: "tester@example.com", + expiresAtMs: null, + scopes: [], + planType: "test", + organizationName: null + }, + { + providerId: "anthropic", + kind: "api_key", + email: null, + expiresAtMs: null, + scopes: [], + planType: null, + organizationName: null + }, + { + providerId: "worldrouter", + kind: "oauth", + email: null, + expiresAtMs: null, + scopes: [], + planType: "WorldRouter OAuth", + organizationName: null + } + ], + providers: BUILTIN_PROVIDERS + }); + await daemon.install(page); + await daemon.open(page); + + await page.getByRole("button", { name: /^New agent in / }).first().click(); + const dialog = page.getByRole("dialog", { name: "New agent" }); + await expect(dialog).toBeVisible(); + + const radioGroup = dialog.locator(".pf-provider-choice"); + const worldrouterLabel = radioGroup.locator("label").filter({ hasText: "WorldRouter" }); + const worldrouterRadio = worldrouterLabel.locator('input[type="radio"]'); + + await worldrouterLabel.click(); + + await expect(worldrouterRadio).toBeChecked(); + await expect(worldrouterLabel).toHaveAttribute("data-active", "true"); + + // No error banner should be raised by simply picking the provider. + await expect(dialog.getByRole("alert")).toHaveCount(0); + // Start agent should still be enabled (we don't click it: would spawn real session). + await expect(dialog.getByRole("button", { name: "Start agent" })).toBeEnabled(); +}); From 5c897c6b9ede7a2e7b8f88cec3ac78af9fea4143 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 14:05:30 +0800 Subject: [PATCH 34/39] chore(login): remove dead CSS for connected-summary block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup orphan CSS selectors left over from the c6a9c10b connected-state refactor — those rules were unreachable after restoring master's flat provider-card layout in 7a0e9c54. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/LoginView.svelte | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/apps/puffer-desktop/src/lib/components/LoginView.svelte b/apps/puffer-desktop/src/lib/components/LoginView.svelte index e7c1c1b98..1e1960e46 100644 --- a/apps/puffer-desktop/src/lib/components/LoginView.svelte +++ b/apps/puffer-desktop/src/lib/components/LoginView.svelte @@ -482,30 +482,6 @@ align-items: center; gap: 0.7rem; } - .connected-summary { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.4rem; - font-size: 0.85rem; - color: var(--text); - } - .connected-detail { - color: var(--text-muted); - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 0.78rem; - } - .status-dot { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--text-muted); - flex: 0 0 auto; - } - .status-dot[data-connected="true"] { - background: #4caf50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.18); - } .logout-btn { border: 1px solid rgba(111, 101, 89, 0.22); border-radius: 10px; From f2867a676115b2eb1727308ed57962f76c657ec6 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 14:20:21 +0800 Subject: [PATCH 35/39] fix(login): gate worldrouter OAuth event path on Tauri host Web mode (vite + WebSocket daemon) doesn't have the Tauri event system, so the worldrouter branch was leaving authBusyProviderId set forever and the LoginView stayed stuck on "Waiting for browser login..." after a successful login. Gate the detached/event-driven path on isTauri() so web sessions go through the standard awaited RPC path, while the Tauri desktop app keeps its detached-subprocess flow unchanged. Verified end-to-end against a real puffer daemon in headed Chromium: OAuth completes, Settings card flips to Connected, NewSessionModal picks WorldRouter, and a qwen3.5-flash turn round-trips to inference-api.worldrouter.ai and back. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/puffer-desktop/src/App.svelte | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/puffer-desktop/src/App.svelte b/apps/puffer-desktop/src/App.svelte index 71c4a83c6..2d34ea423 100644 --- a/apps/puffer-desktop/src/App.svelte +++ b/apps/puffer-desktop/src/App.svelte @@ -3,6 +3,7 @@ import TitleBar from "./lib/shell/TitleBar.svelte"; import Sidebar, { type ActiveAgent, type UserChip } from "./lib/shell/Sidebar.svelte"; + import { isTauri } from "./lib/shell/platform"; import { applyTweaksToDocument, defaultTweaks, @@ -1188,13 +1189,10 @@ authBusyProviderId = providerId; authError = null; try { - if (providerId === "worldrouter") { - // The Tauri handler returns immediately after spawning the - // detached `puffer auth login worldrouter` subprocess (it - // blocks up to 120 s on the localhost callback). The reaper - // thread emits `worldrouter:oauth-completed` when the child - // exits; that listener clears `authBusyProviderId`, refreshes - // the snapshot, and decides whether to navigate. + if (providerId === "worldrouter" && isTauri()) { + // Tauri: the host returns immediately after spawning the + // detached `puffer auth login worldrouter` subprocess and emits + // `worldrouter:oauth-completed` when the child exits. statusMessage = "Opening browser — finish the login to continue."; await loginWithOauth(providerId, remoteConnection); // Intentionally leave `authBusyProviderId` set; the event @@ -1212,11 +1210,11 @@ } catch (error) { authError = String(error); statusMessage = authError; - if (providerId === "worldrouter") { + if (providerId === "worldrouter" && isTauri()) { authBusyProviderId = null; } } finally { - if (providerId !== "worldrouter") { + if (!(providerId === "worldrouter" && isTauri())) { authBusyProviderId = null; } } From dca6d21bb334fced0eaf4b9653a96179772ba472 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 14:31:13 +0800 Subject: [PATCH 36/39] fix(daemon-client): subscribe to Tauri event channel even on WS daemons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on() helper used to dispatch by useWebSocket: WS daemons skipped the Tauri listen() path entirely. That broke corbina-emitted events like worldrouter:oauth-completed, which travel on app.emit("corbina:event") and never reach the puffer daemon's WebSocket — so the GUI stayed stuck on "Waiting for browser login..." even after a successful login. Subscribe to both channels when Tauri is available. Each channel has its own cleanup; unsubscribe runs them all. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/api/daemonClient.ts | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/puffer-desktop/src/lib/api/daemonClient.ts b/apps/puffer-desktop/src/lib/api/daemonClient.ts index c5c847b9c..18d05d480 100644 --- a/apps/puffer-desktop/src/lib/api/daemonClient.ts +++ b/apps/puffer-desktop/src/lib/api/daemonClient.ts @@ -152,39 +152,50 @@ export class DaemonClient { } on(event: string, handler: (payload: T) => void): () => void { + const cleanups: (() => void)[] = []; + if (this.useWebSocket) { const wrapped = handler as (payload: unknown) => void; const listeners = this.eventListeners.get(event) ?? new Set(); listeners.add(wrapped); this.eventListeners.set(event, listeners); void this.connect().catch(() => {}); - return () => { + cleanups.push(() => { listeners.delete(wrapped); if (listeners.size === 0) this.eventListeners.delete(event); - }; + }); } - let active = true; - let unlisten: UnlistenFn | null = null; - const pending = listen("corbina:event", (nativeEvent) => { - if (!active) return; - const payload = nativeEvent.payload; - if (payload?.event === event) { - handler(payload.payload as T); - } - }); - void pending.then((next) => { - unlisten = next; - if (!active) unlisten(); - }); + // Tauri host events (e.g. corbina-emitted `worldrouter:oauth-completed`) + // arrive on the `corbina:event` channel and never traverse the daemon's + // WebSocket. Subscribe regardless of `useWebSocket` so Tauri-only events + // still reach handlers when the daemon happens to be a ws:// endpoint. + if (canInvokeTauri()) { + let active = true; + let unlisten: UnlistenFn | null = null; + const pending = listen("corbina:event", (nativeEvent) => { + if (!active) return; + const payload = nativeEvent.payload; + if (payload?.event === event) { + handler(payload.payload as T); + } + }); + void pending.then((next) => { + unlisten = next; + if (!active) unlisten(); + }); + cleanups.push(() => { + active = false; + if (unlisten) { + unlisten(); + } else { + void pending.then((next) => next()); + } + }); + } return () => { - active = false; - if (unlisten) { - unlisten(); - } else { - void pending.then((next) => next()); - } + for (const cleanup of cleanups) cleanup(); }; } From b9d09078903557660291e511be47f8af8f20b002 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 18:05:48 +0800 Subject: [PATCH 37/39] fix(desktop): fall back to sibling-aware puffer resolver for chat spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `provider_command("puffer")` resolved the binary via env override / PATH only, while `run_puffer_cli_auth_subcommand` already routed through `daemon_launcher::resolve_puffer_binary()` which walks up to `target//puffer` and checks siblings of the Tauri host. That mismatch meant a packaged corbina build (which bundles `puffer` next to `puffer-desktop` but typically does not put it on PATH) could complete worldrouter OAuth login yet fail every chat turn with `` `puffer` is not installed ``. Hook the sibling-aware resolver into `provider_command` for the puffer arm only, after the env override and before falling back to the bare `"puffer"` PATH lookup. Guard on `path.exists()` so the resolver's own last-resort `PathBuf::from("puffer")` return doesn't mask the existing PATH-based default and the informative error in `ensure_provider_command`. Codex/Claude arms are untouched — their CLIs are external and should continue to error when missing from PATH. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/puffer-desktop/src-tauri/src/backend.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index cfe655007..e192609ff 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -2693,6 +2693,21 @@ fn provider_command(provider: &str) -> String { return trimmed.to_string(); } } + // For `puffer`, prefer the sibling-aware resolver used by the auth + // subcommand path. Packaged corbina ships `puffer` alongside the Tauri + // host but typically without adding it to PATH; without this fallback, + // chat-turn spawns would error even though OAuth login (which goes + // through `resolve_puffer_binary` directly) works. Only use the resolver + // result when it points at an actually-existing file, otherwise fall + // through to the PATH-based default so the error message in + // `ensure_provider_command` stays informative. + if provider == "puffer" { + if let Ok(path) = crate::daemon_launcher::resolve_puffer_binary() { + if path.exists() { + return path.display().to_string(); + } + } + } match provider { "claude" => "claude".to_string(), "puffer" => "puffer".to_string(), From e03f7d7293eaa77c60349ee9790c08b59666c6c5 Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 18:14:58 +0800 Subject: [PATCH 38/39] feat(desktop): use live daemon registry for worldrouter model list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corbina backend's `list_provider_models` Tauri/WS handler used to return a hardcoded `[kimi-k2.6, qwen3.5-flash]` pair for worldrouter — the same two seeds we ship in `resources/providers/worldrouter.yaml`. That hid the ~60 extra models the relay actually exposes via `/v1/models`, which the puffer daemon already discovers and merges into its `ProviderRegistry` on startup (or on demand inside `handle_list_provider_models`). This wires corbina to that existing daemon RPC instead: * `DaemonLauncher::current_handshake()` returns the spawned daemon's URL+token without forcing a fresh spawn — `ensure_started`'s side-effecting variant is not what we want from a per-request code path. * `daemon_launcher::query_daemon_rpc()` is a small synchronous tungstenite client that does one JSON-RPC round trip: builds the `?token=…` URL the same way the frontend's DaemonClient does, sends `{ id, method, params }`, and waits for the matching `{ id, ok, result | error }` (event frames in between are skipped). Sets read/write timeouts so a hung daemon can't pin the ModelPicker. * `BackendState` now optionally holds an `Arc`. When `list_provider_models` is invoked for worldrouter, we forward to the daemon; for any other provider (puffer/codex/claude/openai) we keep the existing hardcoded `provider_models()` path so their catalogs are unchanged. * On any failure (daemon down, network timeout, parse error) we fall back to the descriptor seed — better to show two seed models than spin forever. Verified with `cargo build` (workspace) and `cd apps/puffer-desktop && pnpm check` (0 errors, 0 warnings). All 55 existing corbina unit tests still pass, and the daemon-side `list_provider_models_uses_fresh_discovery_over_static_models` test already covers the discovery-merge behavior on the other side of the RPC. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/puffer-desktop/src-tauri/src/backend.rs | 56 +++++++++- .../src-tauri/src/daemon_launcher.rs | 105 +++++++++++++++++- apps/puffer-desktop/src-tauri/src/lib.rs | 5 +- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index e192609ff..b1b050120 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -42,10 +42,22 @@ pub(crate) struct BackendState { fs_watches: Arc, browsers: browser::BrowserRegistry, turns: Mutex>>, + /// Optional handle to the puffer daemon child so we can forward + /// catalog-style RPCs (live `/v1/models`, etc.) to the daemon that owns + /// the real ProviderRegistry. None during plain unit tests where no + /// daemon was spawned — those code paths fall back to the hardcoded + /// descriptors instead. + daemon_launcher: Option>, } impl BackendState { pub(crate) fn new() -> Self { + Self::new_with_launcher(None) + } + + pub(crate) fn new_with_launcher( + daemon_launcher: Option>, + ) -> Self { let ptys = Arc::new(pty::PtyRegistry::new()); ptys.spawn_idle_pruner(); let browser_profile_root = app_home() @@ -56,6 +68,7 @@ impl BackendState { fs_watches: Arc::new(fs_watch::FsWatchRegistry::new()), browsers: browser::BrowserRegistry::new(browser_profile_root), turns: Mutex::new(HashMap::new()), + daemon_launcher, } } @@ -231,9 +244,10 @@ impl BackendState { "add_mcp_server" => serde_value(json!({"servers": self.list_mcp_servers()?})), "list_provider_models" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; + let models = self.resolve_provider_models(&provider_id); serde_value(json!({ "providerId": provider_id, - "models": self.provider_models(&provider_id), + "models": models, })) } "list_permissions" => serde_value(json!({ @@ -549,10 +563,48 @@ impl BackendState { }) } - fn provider_models(&self, provider_id: &str) -> Vec { + /// Returns the model catalog for `provider_id`. For most providers this + /// is just the hardcoded list in the free-function `provider_models`, + /// but providers whose catalog is too large to hardcode (currently + /// worldrouter — the relay exposes ~66 models discovered via live + /// `/v1/models`) are forwarded to the running puffer daemon's + /// `list_provider_models` RPC, which holds the merged ProviderRegistry. + /// Falls back to the hardcoded seed if the daemon isn't running or the + /// call fails — better to show the two seed models than a spinner that + /// never resolves. + fn resolve_provider_models(&self, provider_id: &str) -> Vec { + let canonical = canonical_backend_provider_id(provider_id); + if canonical == "worldrouter" { + if let Some(models) = self.fetch_daemon_provider_models(&canonical) { + return models; + } + } provider_models(provider_id) } + fn fetch_daemon_provider_models(&self, provider_id: &str) -> Option> { + let launcher = self.daemon_launcher.as_ref()?; + let handshake = launcher.current_handshake()?; + match crate::daemon_launcher::query_daemon_rpc( + &handshake, + "list_provider_models", + json!({ "providerId": provider_id }), + std::time::Duration::from_secs(8), + ) { + Ok(result) => result + .get("models") + .and_then(Value::as_array) + .cloned(), + Err(error) => { + eprintln!( + "corbina: daemon list_provider_models({provider_id}) failed: {error}; \ + falling back to descriptor seed" + ); + None + } + } + } + fn provider_auth_statuses(&self) -> Result> { let credentials = self.load_credentials()?; let mut out = Vec::new(); diff --git a/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs b/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs index d07f54235..8a53387c3 100644 --- a/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs +++ b/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs @@ -6,14 +6,17 @@ //! opens a local port-forward to the remote WebSocket so the frontend can //! continue to connect to `ws://127.0.0.1:/ws` transparently. -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::io::{BufRead, BufReader}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use tungstenite::{connect, Message}; +use url::Url; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -83,6 +86,15 @@ impl DaemonLauncher { Self::default() } + /// Returns the handshake for the currently-running local daemon, if any. + /// Unlike `ensure_started`, this never spawns a new daemon — callers that + /// just want to talk to the existing daemon (e.g. forwarding a one-off + /// RPC) shouldn't trigger a fresh spawn as a side effect. + pub(crate) fn current_handshake(&self) -> Option { + let guard = self.child.lock().unwrap(); + guard.as_ref().map(|child| child.handshake.clone()) + } + /// Returns the handshake for the local daemon, starting it if needed. #[allow(dead_code)] pub(crate) fn ensure_started(&self) -> Result { @@ -220,6 +232,97 @@ check that sshd allows TCP forwarding and that the remote daemon really bound" } } +/// Synchronously runs a single JSON-RPC round trip against the puffer +/// daemon's WebSocket endpoint described by `handshake`. The daemon's wire +/// protocol is one JSON object per text frame; we send a `{ id, method, +/// params }` request and wait for the matching `{ id, ok, result | error }` +/// response (event frames in between are skipped). +/// +/// This is intentionally narrow: it's used by corbina to forward specific +/// catalog-style lookups (e.g. worldrouter's live `/v1/models` list) to the +/// daemon that already holds the live registry, without us having to +/// re-implement discovery here. Anything more chatty should use the +/// frontend's persistent DaemonClient instead. +pub(crate) fn query_daemon_rpc( + handshake: &DaemonHandshake, + method: &str, + params: Value, + timeout: Duration, +) -> Result { + // Bolt the auth token onto the URL the way the frontend client does + // (`?token=…`). Without this the daemon returns 401 on upgrade. + let mut url = Url::parse(&handshake.url).context("parsing daemon handshake URL")?; + if !handshake.token.is_empty() { + let has_token = url + .query_pairs() + .any(|(name, _)| name == "token"); + if !has_token { + url.query_pairs_mut().append_pair("token", &handshake.token); + } + } + + let (mut socket, _response) = + connect(url.as_str()).with_context(|| format!("connecting to daemon at {}", handshake.url))?; + + // Constrain the underlying TCP socket so a hung daemon can't lock the + // model picker forever. tungstenite::connect returns a MaybeTlsStream + // wrapping the raw stream — best-effort apply read/write timeouts. + if let tungstenite::stream::MaybeTlsStream::Plain(stream) = socket.get_ref() { + let _ = stream.set_read_timeout(Some(timeout)); + let _ = stream.set_write_timeout(Some(timeout)); + } + + let id = uuid::Uuid::new_v4().to_string(); + let request = json!({ + "id": id, + "method": method, + "params": params, + }); + socket + .send(Message::Text(request.to_string())) + .with_context(|| format!("sending {method} to daemon"))?; + + let deadline = Instant::now() + timeout; + loop { + if Instant::now() >= deadline { + let _ = socket.close(None); + return Err(anyhow!("daemon {method} timed out after {:?}", timeout)); + } + let message = socket + .read() + .with_context(|| format!("reading daemon response for {method}"))?; + let text = match message { + Message::Text(text) => text, + // Pings/Pongs/Binary aren't part of our request/response protocol — + // just keep reading until we see our text reply. + Message::Ping(payload) => { + let _ = socket.send(Message::Pong(payload)); + continue; + } + Message::Pong(_) | Message::Binary(_) | Message::Frame(_) => continue, + Message::Close(_) => { + return Err(anyhow!("daemon closed the WebSocket before responding")) + } + }; + let value: Value = serde_json::from_str(&text) + .with_context(|| format!("parsing daemon response: {text}"))?; + // Server-pushed events have an `event` field and no `id`; skip them. + let response_id = value.get("id").and_then(Value::as_str); + if response_id != Some(id.as_str()) { + continue; + } + let _ = socket.close(None); + if value.get("ok").and_then(Value::as_bool) == Some(false) { + let err = value + .get("error") + .map(|e| e.to_string()) + .unwrap_or_else(|| "(no error message)".to_string()); + return Err(anyhow!("daemon {method} failed: {err}")); + } + return Ok(value.get("result").cloned().unwrap_or(Value::Null)); + } +} + // try_wait returns Result> — a thin wrapper that ignores // ECHILD on platforms where the subprocess has already been reaped. #[allow(dead_code)] diff --git a/apps/puffer-desktop/src-tauri/src/lib.rs b/apps/puffer-desktop/src-tauri/src/lib.rs index 2a1d53b27..48384074b 100644 --- a/apps/puffer-desktop/src-tauri/src/lib.rs +++ b/apps/puffer-desktop/src-tauri/src/lib.rs @@ -382,8 +382,11 @@ fn cancel_turn( } pub fn run() { - let backend = Arc::new(BackendState::new()); let launcher = Arc::new(DaemonLauncher::new()); + // Give the backend a handle to the launcher so it can forward + // catalog-style RPCs (e.g. worldrouter's live `/v1/models` list) to + // the daemon that already holds the merged provider registry. + let backend = Arc::new(BackendState::new_with_launcher(Some(launcher.clone()))); websocket::start_backend_ws(backend.clone()); Builder::default() From 52e3820d7a8cfad4dc1073bc916cd6903f4129ed Mon Sep 17 00:00:00 2001 From: sean Date: Thu, 21 May 2026 21:03:31 +0800 Subject: [PATCH 39/39] chore: sync Cargo.lock after rebase onto master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transitive `rand` bump (0.9.2 → 0.9.4) picked up during post-rebase build. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d32343384..e8824be77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4580,7 +4580,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", - "rand 0.9.2", + "rand 0.9.4", "reqwest", "serde", "serde_json",