Files
Kigi-CLI/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs
T
ZacharyZhang-NY 5a9183b08b feat(providers): add Claude Pro/Max subscription OAuth (PKCE-localhost)
27th registry variant, 2nd subscription-OAuth provider. Log in with a Claude
Pro/Max subscription via PKCE authorization-code + S256 (loopback callback on
127.0.0.1:53692, with a manual code-paste fallback), then use it against
api.anthropic.com — reusing the existing Anthropic Messages wire + Anthropic
listing + the multi-provider OAuth foundation (dbce6bf). Sourced from Pi
(earendil-works/pi auth/oauth/anthropic.ts): client 9d1c250a..., authorize
claude.ai/oauth/authorize, token platform.claude.com/v1/oauth/token, scope
'…user:inference user:sessions:claude_code…'.

New machinery (foundation handles token routing — claude-pro-max is a
uses_oauth platform so its bearer/refresh/api_key already route to its own
pooled manager, never Kimi):
- OAuthConfig gains flow{DeviceCode|PkceLocalhost} + token_host + token_body
  {Form|JSON}; xai/kimi rows unchanged (DeviceCode/Form).
- auth/oauth_pkce.rs: PKCE S256 wire — loopback listener with STRICT state
  validation (CSRF, fail-closed), manual-paste fallback, JSON code→token
  exchange + rotating-refresh. Never logs code/verifier/tokens.
- Messages OAuth adaptation gated on SamplerConfig.anthropic_oauth (true only
  for a claude-pro-max managed key): Authorization: Bearer + anthropic-beta
  oauth + user-agent claude-cli + x-app cli, and the required 'You are Claude
  Code' system prefix. API-key anthropic/minimax Messages requests are
  BYTE-IDENTICAL (regression-guarded).
- Live /models under the OAuth Bearer + oauth-beta headers (Anthropic listing,
  enriched from models.dev anthropic); persistent 401 → 0 models + WARN, NO
  hardcoded fallback list (honest failure).

Adversarial review: no blocking findings (secret handling, CSRF/state, the
anthropic_oauth gate, token routing, non-regression all CONFIRMED). Full gate
green. Registry at 27; picker updated. Residual (unverifiable without a real
Claude Pro/Max account): whether GET /v1/models accepts the OAuth bearer, and
the real endpoint's acceptance of the OAuth Messages request.
2026-07-22 02:53:25 -04:00

202 lines
7.5 KiB
Rust

use crate::agent::subagent::SubagentSpawnContext;
use crate::session::SessionCommand;
use agent_client_protocol as acp;
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
use kigi_tools::implementations::kigi::task::types::{SubagentRequest, SubagentResult};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot};
pub(crate) type GatewayOut = <acp::AgentSide as kigi_acp_lib::AcpSide>::OutMessage;
pub(crate) fn test_gateway() -> GatewaySender {
let (tx, _rx) = mpsc::unbounded_channel();
GatewaySender::new(tx)
}
/// Like `test_gateway` but returns the receiver; keep it alive for the test.
pub(crate) fn test_gateway_with_receiver() -> (GatewaySender, mpsc::UnboundedReceiver<GatewayOut>) {
let (tx, rx) = mpsc::unbounded_channel();
(GatewaySender::new(tx), rx)
}
/// `ctx_with_toggle` with a wired `parent_cmd_tx`.
pub(crate) fn ctx_with_toggle_and_cmd_tx(
toggle: HashMap<String, bool>,
) -> (
SubagentSpawnContext,
mpsc::UnboundedReceiver<SessionCommand>,
) {
let mut ctx = ctx_with_toggle(toggle);
let (tx, rx) = mpsc::unbounded_channel();
ctx.parent_cmd_tx = Some(tx);
(ctx, rx)
}
pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnContext {
let (tx, _rx) = mpsc::unbounded_channel();
SubagentSpawnContext {
lsp: None,
parent_max_turns: None,
gateway: test_gateway(),
client_hooks: Default::default(),
sampling_config: kigi_sampler::SamplerConfig {
api_key: None,
base_url: String::new(),
model: String::new(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
extra_headers: Default::default(),
context_window: 256_000,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
reasoning_effort: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
doom_loop_recovery: None,
header_injector: None,
},
alpha_test_key: None,
auth_method_id: acp::AuthMethodId::new("test"),
model_id: acp::ModelId::new("test"),
storage_mode: crate::config::StorageMode::Local,
auth: None,
parent_cwd: PathBuf::from("/tmp"),
parent_session_id: "test-parent".into(),
yolo_mode: false,
subagent_event_tx: tx,
hunk_tracker_handle: kigi_hunk_tracker::HunkTrackerHandle::noop(),
hunk_tracking_enabled: false,
fs: Arc::new(kigi_workspace::file_system::LocalFs::new(PathBuf::from(
"/tmp",
))),
terminal: Arc::new(crate::terminal::TerminalRunner::new(
Arc::new(test_gateway()),
acp::SessionId::new("test"),
)),
session_env: Arc::new(HashMap::new()),
memory_config: None,
web_search_config: Default::default(),
web_fetch_config: Default::default(),
app_builder_deployer_config: Default::default(),
write_file_enabled: true,
goal_enabled: false,
ask_user_question_enabled: true,
parent_cmd_tx: None,
parent_session_info: None,
subagent_roles: HashMap::new(),
subagent_personas: HashMap::new(),
persona_io_summaries: Vec::new(),
parent_chat_state: None,
available_models: indexmap::IndexMap::new(),
subagent_model_overrides: HashMap::new(),
subagent_toggle: toggle,
disable_web_search: false,
todo_gate: false,
remote_settings: None,
laziness_debug_log: None,
backend_tools_enabled: true,
respect_gitignore: false,
path_not_found_hints: false,
plugin_registry: None,
models_manager: Default::default(),
file_tool_overrides: None,
agent_config: None,
hook_registry: None,
hook_workspace_root: String::new(),
parent_depth: 0,
inference_idle_timeout_secs: 600,
auto_compact_threshold_tiers: crate::agent::subagent::AutoCompactThresholdTiers::default(),
permission_handle: None,
worktree_type: crate::util::config::WorktreeType::Linked,
api_key_provider: None,
image_description_model: crate::test_support::TEST_MODEL.to_owned(),
workspace_ops: kigi_workspace::WorkspaceOps::for_test(),
auth_manager: Arc::new(crate::auth::AuthManager::new(
std::path::Path::new("/tmp/nonexistent-kigi-test"),
crate::auth::KimiCodeConfig::default(),
)),
attribution_callback: None,
parent_agent_name: None,
parent_model_agent_type: None,
allowed_subagent_types: None,
parent_mcp_configs: vec![],
parent_mcp_pool: None,
parent_tool_snapshot: None,
parent_skills: None,
parent_skills_config: kigi_agent::prompt::skills::SkillsConfig::default(),
parent_compat: kigi_tools::types::compat::CompatConfig::default(),
auto_wake_delivered: None,
task_output_tool_name: kigi_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL
.to_string(),
auto_wake_enabled: true,
goal_loop_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
parent_blocking_wait_depth: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
parent_terminal_backend: None,
parent_notification_handle: None,
parent_scheduler_handle: None,
}
}
pub(crate) fn make_request(
subagent_type: &str,
) -> (SubagentRequest, oneshot::Receiver<SubagentResult>) {
let (tx, rx) = oneshot::channel();
let req = SubagentRequest {
id: uuid::Uuid::now_v7().to_string(),
prompt: "do something".into(),
description: "test task".into(),
subagent_type: subagent_type.into(),
parent_session_id: "test-parent".into(),
parent_prompt_id: Some("parent-prompt".into()),
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: true,
fork_context: false,
result_tx: tx,
};
(req, rx)
}
#[derive(Default)]
pub(crate) struct DummyLspDispatch;
#[async_trait::async_trait]
impl kigi_tools::implementations::lsp::LspBackend for DummyLspDispatch {
fn ensure_started_background(&self) {}
async fn ensure_ready(&self) -> Result<(), String> {
Ok(())
}
fn is_ready(&self) -> bool {
true
}
async fn dispatch(
&self,
_input: &kigi_tools::implementations::lsp::LspToolInput,
) -> kigi_tools::implementations::lsp::LspToolResult {
kigi_tools::implementations::lsp::LspToolResult {
text: String::new(),
is_error: false,
}
}
async fn drain_diagnostics(
&self,
_timeout: std::time::Duration,
) -> Option<kigi_tools::implementations::lsp::DiagnosticsSummary> {
None
}
async fn notify_file_changed(&self, _path: &std::path::Path, _content: &str) {}
async fn read_diagnostics(
&self,
_paths: &[std::path::PathBuf],
) -> Vec<kigi_tools::implementations::lsp::FileDiagnosticEntry> {
vec![]
}
}