Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
50 changes: 47 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ pub(crate) fn build_fetch_context(
(source_mode, None)
} else {
match cookie_source {
// #433: an explicitly selected, non-empty Claude manual cookie is
// authoritative. Do not let an active OAuth token account silently
// replace it; this keeps tray refresh behavior aligned with diagnose,
// whose Claude Auto path tries the supplied Web cookie before OAuth.
"manual"
if id == ProviderId::Claude
&& stored_cookie
.as_deref()
.is_some_and(|cookie| !cookie.trim().is_empty()) =>
{
(SourceMode::Web, stored_cookie.clone())
}
_ if active_token_env.is_some() => (SourceMode::OAuth, None),
"off" if provider_uses_oauth_without_cookies(id, usage_source) => {
(SourceMode::OAuth, None)
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,34 @@ fn fetch_context_token_account_uses_web_cookie_header() {
);
}

#[test]
fn fetch_context_claude_manual_cookie_beats_active_oauth_token_account() {
let mut settings = Settings::default();
settings.set_cookie_source(ProviderId::Claude, "manual");
settings.set_usage_source(ProviderId::Claude, "auto");
let mut cookies = ManualCookies::default();
cookies.set("claude", "sessionKey=manual-session");
let api_keys = ApiKeys::default();
let mut token_accounts = HashMap::new();
let mut data = ProviderAccountData::new();
data.add_account(TokenAccount::new("Claude OAuth", "[REDACTED_SECRET]"));
token_accounts.insert(ProviderId::Claude, data);

let ctx = super::build_fetch_context(
ProviderId::Claude,
&settings,
&cookies,
&api_keys,
&token_accounts,
);

assert_eq!(ctx.source_mode, SourceMode::Web);
assert_eq!(
ctx.manual_cookie_header.as_deref(),
Some("sessionKey=manual-session")
);
}

#[test]
fn fetch_context_claude_oauth_token_account_uses_oauth() {
let settings = Settings::default();
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,46 @@ describe("AdvancedTab", () => {
});
});


it("keeps proxy text edits local until blur or Enter", () => {
const set = vi.fn();
render(
<AdvancedTab
settings={{
...settings,
httpProxyEnabled: true,
httpProxyUrl: "http://old-proxy:8080",
httpProxyUsername: "old-user",
httpProxyPassword: "old-pass",
}}
set={set}
saving={false}
/>,
);

const url = screen.getByLabelText("NetworkProxyUrlLabel");
fireEvent.change(url, { target: { value: " http://127.0.0.1:7890 " } });
expect(url).toHaveValue(" http://127.0.0.1:7890 ");
expect(set).not.toHaveBeenCalled();
fireEvent.blur(url);
expect(set).toHaveBeenCalledWith({ httpProxyUrl: "http://127.0.0.1:7890" });

set.mockClear();
const user = screen.getByDisplayValue("old-user");
fireEvent.focus(user);
fireEvent.change(user, { target: { value: " alice " } });
expect(set).not.toHaveBeenCalled();
fireEvent.blur(user);
expect(set).toHaveBeenCalledWith({ httpProxyUsername: "alice" });

set.mockClear();
const password = screen.getByDisplayValue("old-pass");
fireEvent.change(password, { target: { value: "secret with spaces" } });
expect(set).not.toHaveBeenCalled();
fireEvent.blur(password);
expect(set).toHaveBeenCalledWith({ httpProxyPassword: "secret with spaces" });
});

it("shows an error when copying diagnostics fails", async () => {
tauriMocks.getSafeDiagnostics.mockRejectedValue(new Error("invoke failed"));
render(<AdvancedTab settings={settings} set={vi.fn()} saving={false} />);
Expand Down
68 changes: 55 additions & 13 deletions apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) {
const [sshHostsDraft, setSshHostsDraft] = useState(() =>
(settings.agentSessionSshHosts ?? []).join(", "),
);
const [proxyUrlDraft, setProxyUrlDraft] = useState(() =>
settings.httpProxyUrl ?? "",
);
const [proxyUsernameDraft, setProxyUsernameDraft] = useState(() =>
settings.httpProxyUsername ?? "",
);
const [proxyPasswordDraft, setProxyPasswordDraft] = useState(() =>
settings.httpProxyPassword ?? "",
);

const copyDiagnostics = useCallback(async () => {
try {
Expand All @@ -61,6 +70,34 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) {
if (!saving) setSshHostsDraft((settings.agentSessionSshHosts ?? []).join(", "));
}, [saving, settings.agentSessionSshHosts]);

useEffect(() => {
if (!saving) setProxyUrlDraft(settings.httpProxyUrl ?? "");
}, [saving, settings.httpProxyUrl]);

useEffect(() => {
if (!saving) setProxyUsernameDraft(settings.httpProxyUsername ?? "");
}, [saving, settings.httpProxyUsername]);

useEffect(() => {
if (!saving) setProxyPasswordDraft(settings.httpProxyPassword ?? "");
}, [saving, settings.httpProxyPassword]);

const commitProxyUrl = useCallback(() => {
const next = proxyUrlDraft.trim();
if (next !== (settings.httpProxyUrl ?? "")) set({ httpProxyUrl: next });
}, [proxyUrlDraft, set, settings.httpProxyUrl]);

const commitProxyUsername = useCallback(() => {
const next = proxyUsernameDraft.trim();
if (next !== (settings.httpProxyUsername ?? "")) set({ httpProxyUsername: next });
}, [proxyUsernameDraft, set, settings.httpProxyUsername]);

const commitProxyPassword = useCallback(() => {
if (proxyPasswordDraft !== (settings.httpProxyPassword ?? "")) {
set({ httpProxyPassword: proxyPasswordDraft });
}
}, [proxyPasswordDraft, set, settings.httpProxyPassword]);

const commitShortcut = useCallback(
async (accelerator: string) => {
setShortcutError(null);
Expand Down Expand Up @@ -235,26 +272,29 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) {
<input
type="text"
className="text-input"
value={settings.httpProxyUrl ?? ""}
value={proxyUrlDraft}
placeholder="http://127.0.0.1:7890"
aria-label={t("NetworkProxyUrlLabel")}
disabled={saving || !settings.httpProxyEnabled}
onChange={(event) => set({ httpProxyUrl: event.target.value })}
onBlur={(event) =>
set({ httpProxyUrl: event.target.value.trim() })
}
onChange={(event) => setProxyUrlDraft(event.target.value)}
onBlur={commitProxyUrl}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
/>
</Field>
<Field label={t("NetworkProxyUserLabel")}>
<input
type="text"
className="text-input"
value={settings.httpProxyUsername ?? ""}
value={proxyUsernameDraft}
autoComplete="off"
disabled={saving || !settings.httpProxyEnabled}
onChange={(event) =>
set({ httpProxyUsername: event.target.value })
}
onChange={(event) => setProxyUsernameDraft(event.target.value)}
onBlur={commitProxyUsername}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
/>
</Field>
<Field
Expand All @@ -264,12 +304,14 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) {
<input
type="password"
className="text-input"
value={settings.httpProxyPassword ?? ""}
value={proxyPasswordDraft}
autoComplete="new-password"
disabled={saving || !settings.httpProxyEnabled}
onChange={(event) =>
set({ httpProxyPassword: event.target.value })
}
onChange={(event) => setProxyPasswordDraft(event.target.value)}
onBlur={commitProxyPassword}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
/>
</Field>
</div>
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ default = []
tokio = { version = "1", features = ["full"] }

# HTTP client
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "stream", "http2"], default-features = false }
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "stream", "http2", "system-proxy"], default-features = false }

# Serialization
serde = { version = "1", features = ["derive"] }
Expand Down
8 changes: 5 additions & 3 deletions rust/src/core/http_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ impl HttpProxySettings {

/// Resolve a reqwest [`Proxy`] from settings.
///
/// - Disabled or empty URL → `Ok(None)` (direct / default).
/// - Invalid URL when enabled → `Err(...)`.
/// - Disabled → `Ok(None)`, leaving reqwest's Windows/macOS system-proxy path intact.
/// - Empty or invalid URL when enabled → `Err(...)`.
/// - Supports `http` and `https` proxy schemes only (MVP).
pub fn resolve_proxy(settings: &HttpProxySettings) -> Result<Option<Proxy>, String> {
if !settings.enabled {
Expand Down Expand Up @@ -90,10 +90,12 @@ pub fn apply_proxy_to_builder(
) -> ClientBuilder {
match resolve_proxy(settings) {
Ok(Some(proxy)) => builder.proxy(proxy),
// Do not call `no_proxy()`: with the reqwest `system-proxy` feature,
// an unchanged builder follows Windows/macOS system proxy settings.
Ok(None) => builder,
Err(err) => {
tracing::warn!(error = %err, "http proxy config ignored; using direct connection");
builder
builder.no_proxy()
}
}
}
Expand Down
Loading