Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 151 additions & 26 deletions src-tauri/src/llm/claude_code_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ const DISALLOWED_CLAUDE_BUILTINS: &[&str] = &[
"Workflow",
];

const CLAUDE_CODE_SETTINGS_ENV_ALLOWLIST: &[&str] = &[
"ANTHROPIC_",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
"CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING",
"CLAUDE_CODE_EXTRA_BODY",
"ENABLE_TOOL_SEARCH",
];

fn claude_session_map() -> &'static tokio::sync::Mutex<HashMap<String, String>> {
static STORE: OnceLock<tokio::sync::Mutex<HashMap<String, String>>> = OnceLock::new();
STORE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()))
Expand Down Expand Up @@ -199,12 +210,8 @@ pub enum ClaudeCliLoginState {
/// it (a probe run would either bill a real API call when logged in or burn a
/// subprocess startup per status refresh). Mirrors the CLI's own auth sources:
/// explicit env vars, the OAuth credentials file under the Claude config dir,
/// or an `apiKeyHelper` in settings.json.
///
/// This is only a fast heuristic — `settings.json`-based credentials are NOT
/// honored at runtime because real turns spawn the CLI with
/// `--setting-sources ""`. Use [`run_login_test`] for an authoritative check
/// that exercises the actual endpoint and credentials.
/// or allowlisted auth settings from settings.json. Use [`run_login_test`] for
/// an authoritative check that exercises the actual endpoint and credentials.
pub fn claude_cli_login_status() -> (ClaudeCliLoginState, String) {
// `ANTHROPIC_AUTH_TOKEN` is the bearer-token variable used with custom
// endpoints/gateways (alongside `ANTHROPIC_BASE_URL`); Locus forwards the
Expand All @@ -230,8 +237,8 @@ pub fn claude_cli_login_status() -> (ClaudeCliLoginState, String) {
"subscription login".to_string(),
);
}
if settings_has_api_key_helper(&dir.join("settings.json")) {
return (ClaudeCliLoginState::LoggedIn, "apiKeyHelper".to_string());
if let Some(source) = settings_auth_source(&dir.join("settings.json")) {
return (ClaudeCliLoginState::LoggedIn, source);
}
}

Expand Down Expand Up @@ -273,19 +280,63 @@ fn credentials_file_has_login(path: &Path) -> bool {
})
}

fn settings_has_api_key_helper(path: &Path) -> bool {
fn read_json_settings(path: &Path) -> Option<serde_json::Value> {
let Ok(raw) = std::fs::read_to_string(path) else {
return false;
return None;
};
serde_json::from_str::<serde_json::Value>(&raw)
.ok()
.and_then(|value| {
value
.get("apiKeyHelper")
.and_then(|v| v.as_str())
.map(|helper| !helper.trim().is_empty())
serde_json::from_str(&raw).ok()
}

fn non_empty_string(value: &serde_json::Value) -> Option<&str> {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
}

fn settings_auth_source(path: &Path) -> Option<String> {
let value = read_json_settings(path)?;
if value
.get("apiKeyHelper")
.and_then(non_empty_string)
.is_some()
{
return Some("apiKeyHelper".to_string());
}

let env = value.get("env").and_then(|value| value.as_object())?;
for key in [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
] {
if env.get(key).and_then(non_empty_string).is_some() {
return Some(format!("{} (settings)", key));
}
}
None
}

fn claude_settings_env(path: &Path) -> Vec<(OsString, OsString)> {
read_json_settings(path)
.and_then(|value| value.get("env").and_then(|env| env.as_object()).cloned())
.map(|env| {
env.into_iter()
.filter_map(|(key, value)| {
is_allowed_claude_settings_env_key(&key)
.then(|| non_empty_string(&value))
.flatten()
.map(|value| (OsString::from(key), OsString::from(value)))
})
.collect()
})
.unwrap_or(false)
.unwrap_or_default()
}

fn is_allowed_claude_settings_env_key(key: &str) -> bool {
CLAUDE_CODE_SETTINGS_ENV_ALLOWLIST
.iter()
.any(|allowed| key == *allowed || key.starts_with(allowed))
}

/// How long a resolved (or unresolved) Claude Code CLI location stays cached.
Expand Down Expand Up @@ -724,12 +775,15 @@ pub async fn run_turn<H: ClaudeCodeHost>(
}

/// Environment handed to every Claude Code CLI child: the full Locus process
/// environment (so user-configured `ANTHROPIC_*` variables — including
/// `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` for a custom default endpoint —
/// flow straight through), plus the Locus entrypoint tag and resolved proxy
/// settings. Callers add per-run extras (e.g. `LOCUS_SESSION_ID`) on top.
/// environment plus allowlisted provider/auth variables from Claude settings.
/// Process variables win, and settings never inject hooks such as NODE_OPTIONS.
fn base_child_env() -> HashMap<OsString, OsString> {
let mut envs: HashMap<OsString, OsString> = std::env::vars_os().collect();
if let Some(dir) = claude_config_dir() {
for (key, value) in claude_settings_env(&dir.join("settings.json")) {
envs.entry(key).or_insert(value);
}
}
envs.entry(OsString::from("CLAUDE_CODE_ENTRYPOINT"))
.or_insert_with(|| OsString::from("locus-rs"));
crate::network::extend_proxy_env_map(&mut envs);
Expand All @@ -747,10 +801,10 @@ const LOGIN_TEST_TIMEOUT: Duration = Duration::from_secs(45);
/// same hermetic flags and environment a real Locus turn uses, send a trivial
/// prompt, and report whether the turn completed. Unlike
/// [`claude_cli_login_status`] this exercises the genuine endpoint resolution
/// (`ANTHROPIC_BASE_URL`), credentials, and proxy — so a setup that only works
/// via `settings.json` (which `--setting-sources ""` disables) correctly fails
/// here, matching real turns. Returns the model reply on success, or a
/// human-readable error.
/// (`ANTHROPIC_BASE_URL`), credentials, and proxy. Locus forwards only the
/// allowlisted auth/routing environment from settings.json; hooks, plugins,
/// and external MCP configuration remain disabled. Returns the model reply on
/// success, or a human-readable error.
pub async fn run_login_test() -> Result<String, String> {
let cli_path = find_claude_cli().ok_or_else(|| {
"Claude Code CLI not found. Install `@anthropic-ai/claude-code` and ensure `claude` is available in PATH.".to_string()
Expand Down Expand Up @@ -1683,6 +1737,7 @@ fn cli_binary_names() -> &'static [&'static str] {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;

#[test]
fn strips_sdk_mcp_prefix_from_model_tool_names() {
Expand Down Expand Up @@ -1785,4 +1840,74 @@ mod tests {
assert_eq!(content[1]["data"], "aW1hZ2U=");
assert_eq!(content[1]["mimeType"], "image/png");
}

#[test]
fn settings_auth_source_accepts_auth_token_env() {
let dir = tempfile::tempdir().expect("tempdir");
let settings = dir.path().join("settings.json");
fs::write(
&settings,
r#"{
"env": {
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED",
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}"#,
)
.expect("write settings");

assert_eq!(
settings_auth_source(&settings),
Some("ANTHROPIC_AUTH_TOKEN (settings)".to_string())
);
}

#[test]
fn claude_settings_env_allows_only_provider_routing_env() {
let dir = tempfile::tempdir().expect("tempdir");
let settings = dir.path().join("settings.json");
fs::write(
&settings,
r#"{
"env": {
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED",
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "glm-5.1",
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1",
"NODE_OPTIONS": "--require ./unexpected.js"
}
}"#,
)
.expect("write settings");

let envs: HashMap<String, String> = claude_settings_env(&settings)
.into_iter()
.map(|(key, value)| {
(
key.to_string_lossy().to_string(),
value.to_string_lossy().to_string(),
)
})
.collect();

assert_eq!(
envs.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
Some("PROXY_MANAGED")
);
assert_eq!(
envs.get("ANTHROPIC_BASE_URL").map(String::as_str),
Some("http://127.0.0.1:15721")
);
assert_eq!(
envs.get("ANTHROPIC_DEFAULT_SONNET_MODEL_NAME")
.map(String::as_str),
Some("glm-5.1")
);
assert_eq!(
envs.get("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY")
.map(String::as_str),
Some("1")
);
assert!(!envs.contains_key("NODE_OPTIONS"));
}
}
25 changes: 25 additions & 0 deletions src/__tests__/experimentalFeaturesSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";

function read(relPath: string) {
return readFileSync(resolve(process.cwd(), relPath), "utf8");
}

describe("experimental features settings", () => {
it("uses the persisted Claude Code model flag as the single feature switch", () => {
const settings = read("src/components/SettingsView.vue");
const experimental = read("src/components/settings/ExperimentalFeaturesSettings.vue");
const providers = read("src/components/settings/ApiProviders.vue");
const defaults = read("src/components/settings/ModelDefaults.vue");
const modelStore = read("src/stores/model.ts");

expect(settings).toContain("ExperimentalFeaturesSettings");
expect(settings).toContain("setClaudeCodeEnabled");
expect(experimental).toContain("BaseSwitch");
expect(experimental).toContain("emit('update:claudeCodeEnabled', $event)");
expect(providers).toContain("claudeCodeEnabled && claudeCodeProvider");
expect(defaults).not.toContain("updateClaudeCodeEnabled");
expect(modelStore).toContain("modelDefaults.value.claudeCodeEnabled === true");
});
});
24 changes: 24 additions & 0 deletions src/components/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import ShortcutSettings from "./settings/ShortcutSettings.vue";
import ConsoleSettings from "./settings/ConsoleSettings.vue";
import AboutSettings from "./settings/AboutSettings.vue";
import ProxySettings from "./settings/ProxySettings.vue";
import ExperimentalFeaturesSettings from "./settings/ExperimentalFeaturesSettings.vue";
import ApiProviders from "./settings/ApiProviders.vue";
import CustomProviderModal from "./settings/CustomProviderModal.vue";
import ModelDefaultsPanel from "./settings/ModelDefaults.vue";
Expand Down Expand Up @@ -66,6 +67,11 @@ const {
const uiStore = useUiStore();
const chatStore = useChatStore();

function setClaudeCodeEnabled(enabled: boolean) {
modelDefaults.value = { ...modelDefaults.value, claudeCodeEnabled: enabled };
void saveModelDefaults();
}

watch(
() => uiStore.settingsCategoryHint,
(category) => {
Expand Down Expand Up @@ -176,6 +182,16 @@ watch(
</svg>
<span>{{ t("settings.tab.general") }}</span>
</button>
<button
class="sidebar-item"
:class="{ active: activeCategory === 'experimental' }"
@click="activeCategory = 'experimental'"
>
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
<path d="M6 1a.75.75 0 0 0 0 1.5v3.19l-3.78 6.3A2 2 0 0 0 3.93 15h8.14a2 2 0 0 0 1.71-3.01L10 5.69V2.5A.75.75 0 0 0 10 1H6zm1.5 4.9V2.5h1v3.4l1.26 2.1H6.24L7.5 5.9zM5.34 9.5h5.32l1.84 3.07a.5.5 0 0 1-.43.75H3.93a.5.5 0 0 1-.43-.75L5.34 9.5z"/>
</svg>
<span>{{ t("settings.tab.experimental") }}</span>
</button>
<button
class="sidebar-item"
:class="{ active: activeCategory === 'display' }"
Expand Down Expand Up @@ -281,6 +297,7 @@ watch(
:all-models="allModels"
:custom-providers="customProviders"
:custom-provider-saving="customProviderSaving"
:claude-code-enabled="modelDefaults.claudeCodeEnabled === true"
:claude-code-test-status="claudeCodeTestStatus"
:claude-code-test-result="claudeCodeTestResult"
@test-claude-code="testClaudeCode"
Expand Down Expand Up @@ -371,6 +388,13 @@ watch(
<DisplaySettings />
</template>

<template v-if="activeCategory === 'experimental'">
<ExperimentalFeaturesSettings
:claude-code-enabled="modelDefaults.claudeCodeEnabled === true"
@update:claude-code-enabled="setClaudeCodeEnabled"
/>
</template>

<template v-if="activeCategory === 'notifications'">
<NotificationsSettings />
</template>
Expand Down
3 changes: 2 additions & 1 deletion src/components/settings/ApiProviders.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const props = defineProps<{
allModels: ModelOption[];
customProviders: CustomProvider[];
customProviderSaving?: boolean;
claudeCodeEnabled: boolean;
claudeCodeTestStatus?: "idle" | "testing" | "success" | "error";
claudeCodeTestResult?: string;
mode?: "full" | "onboarding";
Expand Down Expand Up @@ -526,7 +527,7 @@ function resetCreditBusyKey(credit: CodexQuotaResetCreditState): string {
</div>
</div>

<div class="settings-section" v-if="!isOnboardingMode && claudeCodeProvider">
<div class="settings-section" v-if="!isOnboardingMode && claudeCodeEnabled && claudeCodeProvider">
<div class="section-label">{{ t("settings.claudeCode.title") }}</div>
<div class="provider-card">
<div class="provider-header">
Expand Down
Loading