From 0a4ef4a128a0b215a8ed42313c5801e0fa7eba5b Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 31 Mar 2026 17:30:02 +0700 Subject: [PATCH 1/2] Support custom Claude OAuth config and credentials --- .gitignore | 1 + plugins/claude/plugin.js | 345 ++++++++++++++++-------- plugins/claude/plugin.test.js | 147 +++++++++- src-tauri/src/lib.rs | 2 +- src-tauri/src/plugin_engine/host_api.rs | 314 +++++++++++++++++---- 5 files changed, 647 insertions(+), 162 deletions(-) diff --git a/.gitignore b/.gitignore index f4331b0d..131a49b9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ tmpcov-gem # Agent working files docs/choices.md docs/breadcrumbs.md +docs/superpowers/ docs/specs/* plans/* package-lock.json diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index 947d0e48..541c212e 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -1,10 +1,13 @@ (function () { - const CRED_FILE = "~/.claude/.credentials.json" - const KEYCHAIN_SERVICE = "Claude Code-credentials" - const USAGE_URL = "https://api.anthropic.com/api/oauth/usage" - const REFRESH_URL = "https://platform.claude.com/v1/oauth/token" - const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - const SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers" + const DEFAULT_CLAUDE_HOME = "~/.claude" + const CRED_FILE_NAME = ".credentials.json" + const KEYCHAIN_SERVICE_PREFIX = "Claude Code" + const PROD_BASE_API_URL = "https://api.anthropic.com" + const PROD_REFRESH_URL = "https://platform.claude.com/v1/oauth/token" + const PROD_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + const NON_PROD_CLIENT_ID = "22422756-60c9-4084-8eb7-27705fd5cf9a" + const SCOPES = + "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration function utf8DecodeBytes(bytes) { @@ -121,11 +124,88 @@ return null } - function loadCredentials(ctx) { + function readEnvText(ctx, name) { + try { + const value = ctx.host.env.get(name) + if (value === null || value === undefined) return null + const text = String(value).trim() + return text || null + } catch { + return null + } + } + + function readEnvFlag(ctx, name) { + const value = readEnvText(ctx, name) + if (!value) return false + const lower = value.toLowerCase() + return lower !== "0" && lower !== "false" && lower !== "no" && lower !== "off" + } + + function getClaudeHomePath(ctx) { + return readEnvText(ctx, "CLAUDE_CONFIG_DIR") || DEFAULT_CLAUDE_HOME + } + + function getClaudeHomeOverride(ctx) { + return readEnvText(ctx, "CLAUDE_CONFIG_DIR") + } + + function getClaudeCredentialsPath(ctx) { + return getClaudeHomePath(ctx) + "/" + CRED_FILE_NAME + } + + function getOauthConfig(ctx) { + let baseApiUrl = PROD_BASE_API_URL + let refreshUrl = PROD_REFRESH_URL + let clientId = PROD_CLIENT_ID + let oauthFileSuffix = "" + + const isAntUser = readEnvText(ctx, "USER_TYPE") === "ant" + if (isAntUser && readEnvFlag(ctx, "USE_LOCAL_OAUTH")) { + const localApiBase = readEnvText(ctx, "CLAUDE_LOCAL_OAUTH_API_BASE") + baseApiUrl = (localApiBase || "http://localhost:8000").replace(/\/+$/, "") + refreshUrl = baseApiUrl + "/v1/oauth/token" + clientId = NON_PROD_CLIENT_ID + oauthFileSuffix = "-local-oauth" + } else if (isAntUser && readEnvFlag(ctx, "USE_STAGING_OAUTH")) { + baseApiUrl = "https://api-staging.anthropic.com" + refreshUrl = "https://platform.staging.ant.dev/v1/oauth/token" + clientId = NON_PROD_CLIENT_ID + oauthFileSuffix = "-staging-oauth" + } + + const customOauthBase = readEnvText(ctx, "CLAUDE_CODE_CUSTOM_OAUTH_URL") + if (customOauthBase) { + const base = customOauthBase.replace(/\/+$/, "") + baseApiUrl = base + refreshUrl = base + "/v1/oauth/token" + oauthFileSuffix = "-custom-oauth" + } + + const clientIdOverride = readEnvText(ctx, "CLAUDE_CODE_OAUTH_CLIENT_ID") + if (clientIdOverride) { + clientId = clientIdOverride + } + + return { + baseApiUrl: baseApiUrl, + usageUrl: baseApiUrl + "/api/oauth/usage", + refreshUrl: refreshUrl, + clientId: clientId, + oauthFileSuffix: oauthFileSuffix, + } + } + + function getClaudeKeychainService(ctx) { + return KEYCHAIN_SERVICE_PREFIX + getOauthConfig(ctx).oauthFileSuffix + "-credentials" + } + + function loadStoredCredentials(ctx, suppressMissingWarn) { + const credFile = getClaudeCredentialsPath(ctx) // Try file first - if (ctx.host.fs.exists(CRED_FILE)) { + if (ctx.host.fs.exists(credFile)) { try { - const text = ctx.host.fs.readText(CRED_FILE) + const text = ctx.host.fs.readText(credFile) const parsed = tryParseCredentialJSON(ctx, text) if (parsed) { const oauth = parsed.claudeAiOauth @@ -142,7 +222,7 @@ // Try keychain fallback try { - const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE) + const keychainValue = ctx.host.keychain.readGenericPassword(getClaudeKeychainService(ctx)) if (keychainValue) { const parsed = tryParseCredentialJSON(ctx, keychainValue) if (parsed) { @@ -158,23 +238,53 @@ ctx.host.log.info("keychain read failed (may not exist): " + String(e)) } - ctx.host.log.warn("no credentials found") + if (!suppressMissingWarn) { + ctx.host.log.warn("no credentials found") + } return null } + function loadCredentials(ctx) { + const envAccessToken = readEnvText(ctx, "CLAUDE_CODE_OAUTH_TOKEN") + const stored = loadStoredCredentials(ctx, !!envAccessToken) + if (!envAccessToken) { + return stored + } + + const oauth = stored && stored.oauth ? Object.assign({}, stored.oauth) : {} + oauth.accessToken = envAccessToken + return { + oauth: oauth, + source: stored ? stored.source : null, + fullData: stored ? stored.fullData : null, + inferenceOnly: true, + } + } + + function hasProfileScope(creds) { + if (!creds || creds.inferenceOnly) { + return false + } + const scopes = creds.oauth && creds.oauth.scopes + if (Array.isArray(scopes) && scopes.length > 0) { + return scopes.indexOf("user:profile") !== -1 + } + return true + } + function saveCredentials(ctx, source, fullData) { // MUST use minified JSON - macOS `security -w` hex-encodes values with newlines, // which Claude Code can't read back, causing it to invalidate the session. const text = JSON.stringify(fullData) if (source === "file") { try { - ctx.host.fs.writeText(CRED_FILE, text) + ctx.host.fs.writeText(getClaudeCredentialsPath(ctx), text) } catch (e) { ctx.host.log.error("Failed to write Claude credentials file: " + String(e)) } } else if (source === "keychain") { try { - ctx.host.keychain.writeGenericPassword(KEYCHAIN_SERVICE, text) + ctx.host.keychain.writeGenericPassword(getClaudeKeychainService(ctx), text) } catch (e) { ctx.host.log.error("Failed to write Claude credentials keychain: " + String(e)) } @@ -196,16 +306,17 @@ return null } + const oauthConfig = getOauthConfig(ctx) ctx.host.log.info("attempting token refresh") try { const resp = ctx.util.request({ method: "POST", - url: REFRESH_URL, + url: oauthConfig.refreshUrl, headers: { "Content-Type": "application/json" }, bodyText: JSON.stringify({ grant_type: "refresh_token", refresh_token: oauth.refreshToken, - client_id: CLIENT_ID, + client_id: oauthConfig.clientId, scope: SCOPES, }), timeoutMs: 15000, @@ -258,9 +369,10 @@ } function fetchUsage(ctx, accessToken) { + const oauthConfig = getOauthConfig(ctx) return ctx.util.request({ method: "GET", - url: USAGE_URL, + url: oauthConfig.usageUrl, headers: { Authorization: "Bearer " + accessToken.trim(), Accept: "application/json", @@ -272,7 +384,7 @@ }) } - function queryTokenUsage(ctx) { + function queryTokenUsage(ctx, homePath) { const since = new Date() // Inclusive range: today + previous 30 days = 31 calendar days. since.setDate(since.getDate() - 30) @@ -281,7 +393,12 @@ const d = since.getDate() const sinceStr = "" + y + (m < 10 ? "0" : "") + m + (d < 10 ? "0" : "") + d - const result = ctx.host.ccusage.query({ since: sinceStr }) + const queryOpts = { since: sinceStr } + if (homePath) { + queryOpts.homePath = homePath + } + + const result = ctx.host.ccusage.query(queryOpts) if (!result || typeof result !== "object" || typeof result.status !== "string") { return { status: "runner_failed", data: null } } @@ -399,64 +516,70 @@ const nowMs = Date.now() let accessToken = creds.oauth.accessToken - - // Proactively refresh if token is expired or about to expire - if (needsRefresh(ctx, creds.oauth, nowMs)) { - ctx.host.log.info("token needs refresh (expired or expiring soon)") - const refreshed = refreshToken(ctx, creds) - if (refreshed) { - accessToken = refreshed - } else { - ctx.host.log.warn("proactive refresh failed, trying with existing token") + const homePath = getClaudeHomeOverride(ctx) + const canFetchLiveUsage = hasProfileScope(creds) + + let data = null + let lines = [] + if (canFetchLiveUsage) { + // Proactively refresh if token is expired or about to expire + if (needsRefresh(ctx, creds.oauth, nowMs)) { + ctx.host.log.info("token needs refresh (expired or expiring soon)") + const refreshed = refreshToken(ctx, creds) + if (refreshed) { + accessToken = refreshed + } else { + ctx.host.log.warn("proactive refresh failed, trying with existing token") + } } - } - let resp - let didRefresh = false - try { - resp = ctx.util.retryOnceOnAuth({ - request: (token) => { - try { - return fetchUsage(ctx, token || accessToken) - } catch (e) { - ctx.host.log.error("usage request exception: " + String(e)) - if (didRefresh) { - throw "Usage request failed after refresh. Try again." + let resp + let didRefresh = false + try { + resp = ctx.util.retryOnceOnAuth({ + request: (token) => { + try { + return fetchUsage(ctx, token || accessToken) + } catch (e) { + ctx.host.log.error("usage request exception: " + String(e)) + if (didRefresh) { + throw "Usage request failed after refresh. Try again." + } + throw "Usage request failed. Check your connection." } - throw "Usage request failed. Check your connection." - } - }, - refresh: () => { - ctx.host.log.info("usage returned 401, attempting refresh") - didRefresh = true - return refreshToken(ctx, creds) - }, - }) - } catch (e) { - if (typeof e === "string") throw e - ctx.host.log.error("usage request failed: " + String(e)) - throw "Usage request failed. Check your connection." - } + }, + refresh: () => { + ctx.host.log.info("usage returned 401, attempting refresh") + didRefresh = true + return refreshToken(ctx, creds) + }, + }) + } catch (e) { + if (typeof e === "string") throw e + ctx.host.log.error("usage request failed: " + String(e)) + throw "Usage request failed. Check your connection." + } - if (ctx.util.isAuthStatus(resp.status)) { - ctx.host.log.error("usage returned auth error after all retries: status=" + resp.status) - throw "Token expired. Run `claude` to log in again." - } + if (ctx.util.isAuthStatus(resp.status)) { + ctx.host.log.error("usage returned auth error after all retries: status=" + resp.status) + throw "Token expired. Run `claude` to log in again." + } - if (resp.status < 200 || resp.status >= 300) { - ctx.host.log.error("usage returned error: status=" + resp.status) - throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later." - } - - ctx.host.log.info("usage fetch succeeded") + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.error("usage returned error: status=" + resp.status) + throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later." + } - let data - data = ctx.util.tryParseJson(resp.bodyText) - if (data === null) { - throw "Usage response invalid. Try again later." + ctx.host.log.info("usage fetch succeeded") + + data = ctx.util.tryParseJson(resp.bodyText) + if (data === null) { + throw "Usage response invalid. Try again later." + } + } else { + ctx.host.log.info("skipping live usage fetch for inference-only token") } - const lines = [] let plan = null if (creds.oauth.subscriptionType) { const basePlan = ctx.fmt.planLabel(creds.oauth.subscriptionType) @@ -471,53 +594,55 @@ } } - if (data.five_hour && typeof data.five_hour.utilization === "number") { - lines.push(ctx.line.progress({ - label: "Session", - used: data.five_hour.utilization, - limit: 100, - format: { kind: "percent" }, - resetsAt: ctx.util.toIso(data.five_hour.resets_at), - periodDurationMs: 5 * 60 * 60 * 1000 // 5 hours - })) - } - if (data.seven_day && typeof data.seven_day.utilization === "number") { - lines.push(ctx.line.progress({ - label: "Weekly", - used: data.seven_day.utilization, - limit: 100, - format: { kind: "percent" }, - resetsAt: ctx.util.toIso(data.seven_day.resets_at), - periodDurationMs: 7 * 24 * 60 * 60 * 1000 // 7 days - })) - } - if (data.seven_day_sonnet && typeof data.seven_day_sonnet.utilization === "number") { - lines.push(ctx.line.progress({ - label: "Sonnet", - used: data.seven_day_sonnet.utilization, - limit: 100, - format: { kind: "percent" }, - resetsAt: ctx.util.toIso(data.seven_day_sonnet.resets_at), - periodDurationMs: 7 * 24 * 60 * 60 * 1000 // 7 days - })) - } - - if (data.extra_usage && data.extra_usage.is_enabled) { - const used = data.extra_usage.used_credits - const limit = data.extra_usage.monthly_limit - if (typeof used === "number" && typeof limit === "number" && limit > 0) { + if (data) { + if (data.five_hour && typeof data.five_hour.utilization === "number") { lines.push(ctx.line.progress({ - label: "Extra usage spent", - used: ctx.fmt.dollars(used), - limit: ctx.fmt.dollars(limit), - format: { kind: "dollars" } + label: "Session", + used: data.five_hour.utilization, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(data.five_hour.resets_at), + periodDurationMs: 5 * 60 * 60 * 1000 // 5 hours })) - } else if (typeof used === "number" && used > 0) { - lines.push(ctx.line.text({ label: "Extra usage spent", value: "$" + String(ctx.fmt.dollars(used)) })) + } + if (data.seven_day && typeof data.seven_day.utilization === "number") { + lines.push(ctx.line.progress({ + label: "Weekly", + used: data.seven_day.utilization, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(data.seven_day.resets_at), + periodDurationMs: 7 * 24 * 60 * 60 * 1000 // 7 days + })) + } + if (data.seven_day_sonnet && typeof data.seven_day_sonnet.utilization === "number") { + lines.push(ctx.line.progress({ + label: "Sonnet", + used: data.seven_day_sonnet.utilization, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(data.seven_day_sonnet.resets_at), + periodDurationMs: 7 * 24 * 60 * 60 * 1000 // 7 days + })) + } + + if (data.extra_usage && data.extra_usage.is_enabled) { + const used = data.extra_usage.used_credits + const limit = data.extra_usage.monthly_limit + if (typeof used === "number" && typeof limit === "number" && limit > 0) { + lines.push(ctx.line.progress({ + label: "Extra usage spent", + used: ctx.fmt.dollars(used), + limit: ctx.fmt.dollars(limit), + format: { kind: "dollars" } + })) + } else if (typeof used === "number" && used > 0) { + lines.push(ctx.line.text({ label: "Extra usage spent", value: "$" + String(ctx.fmt.dollars(used)) })) + } } } - const usageResult = queryTokenUsage(ctx) + const usageResult = queryTokenUsage(ctx, homePath) if (usageResult.status === "ok") { const usage = usageResult.data const now = new Date() diff --git a/plugins/claude/plugin.test.js b/plugins/claude/plugin.test.js index a3673e98..266a8f95 100644 --- a/plugins/claude/plugin.test.js +++ b/plugins/claude/plugin.test.js @@ -1,15 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { makeCtx } from "../test-helpers.js" +let pluginLoadNonce = 0 + const loadPlugin = async () => { - await import("./plugin.js") + await import(`./plugin.js?test=${pluginLoadNonce++}`) return globalThis.__openusage_plugin } describe("claude plugin", () => { beforeEach(() => { delete globalThis.__openusage_plugin - vi.resetModules() }) it("throws when no credentials", async () => { @@ -76,6 +77,110 @@ describe("claude plugin", () => { expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() }) + it("reads credentials from CLAUDE_CONFIG_DIR and passes it to ccusage", async () => { + const ctx = makeCtx() + const configDir = "/tmp/custom-claude-home" + const configCredFile = configDir + "/.credentials.json" + const credsJson = JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } }) + ctx.host.env.get.mockImplementation((name) => (name === "CLAUDE_CONFIG_DIR" ? configDir : null)) + ctx.host.fs.exists = vi.fn((path) => path === configCredFile) + ctx.host.fs.readText = vi.fn((path) => { + if (path !== configCredFile) { + throw new Error("unexpected readText path: " + path) + } + return credsJson + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" }, + }), + }) + ctx.host.ccusage.query = vi.fn(() => ({ status: "ok", data: { daily: [] } })) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() + expect(ctx.host.fs.readText).toHaveBeenCalledWith(configCredFile) + expect(ctx.host.ccusage.query).toHaveBeenCalledWith( + expect.objectContaining({ homePath: configDir }) + ) + }) + + it("looks up Claude Code-staging-oauth-credentials in keychain", async () => { + const ctx = makeCtx() + ctx.host.fs.exists = () => false + ctx.host.env.get.mockImplementation((name) => { + if (name === "USER_TYPE") return "ant" + if (name === "USE_STAGING_OAUTH") return "1" + return null + }) + ctx.host.keychain.readGenericPassword.mockImplementation((service) => { + if (service === "Claude Code-staging-oauth-credentials") { + return JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } }) + } + if (service === "Claude Code-credentials") { + return JSON.stringify({ claudeAiOauth: { refreshToken: "fallback-only" } }) + } + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" }, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() + expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith( + "Claude Code-staging-oauth-credentials" + ) + }) + + it("uses env-injected OAuth tokens without hitting /api/oauth/usage", async () => { + const ctx = makeCtx() + ctx.host.fs.exists = () => false + ctx.host.fs.readText = () => { + throw new Error("unexpected file read") + } + ctx.host.env.get.mockImplementation((name) => + name === "CLAUDE_CODE_OAUTH_TOKEN" ? "env-oauth-token" : null + ) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" }, + }), + }) + ctx.host.ccusage.query = vi.fn(() => ({ + status: "ok", + data: { + daily: [ + { + date: "2024-01-01", + inputTokens: 100, + outputTokens: 50, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + totalCost: 0.25, + }, + ], + }, + })) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect( + ctx.host.http.request.mock.calls.some((call) => String(call[0]?.url).includes("/api/oauth/usage")) + ).toBe(false) + expect(result.lines.find((line) => line.label === "Last 30 Days")?.value).toContain("150 tokens") + }) + it("renders usage lines from response", async () => { const ctx = makeCtx() ctx.host.fs.readText = () => @@ -510,6 +615,42 @@ describe("claude plugin", () => { expect(ctx.host.fs.writeText).toHaveBeenCalled() }) + it("includes user:file_upload in the OAuth refresh scope", async () => { + const ctx = makeCtx() + ctx.host.fs.exists = () => true + ctx.host.fs.readText = () => + JSON.stringify({ + claudeAiOauth: { + accessToken: "old-token", + refreshToken: "refresh", + expiresAt: Date.now() - 1000, + subscriptionType: "pro", + }, + }) + + let refreshBody = null + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/v1/oauth/token")) { + refreshBody = JSON.parse(opts.bodyText) + return { + status: 200, + bodyText: JSON.stringify({ access_token: "new-token", expires_in: 3600, refresh_token: "refresh2" }), + } + } + return { + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" }, + }), + } + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(refreshBody.scope).toContain("user:file_upload") + }) + it("refreshes keychain credentials and writes back to keychain", async () => { const ctx = makeCtx() ctx.host.fs.exists = () => false @@ -800,8 +941,6 @@ describe("claude plugin", () => { } }) - delete globalThis.__openusage_plugin - vi.resetModules() const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 52b83be8..c1365b04 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -521,7 +521,7 @@ pub fn run() { let app_data_dir = app.path().app_data_dir().expect("no app data dir"); let resource_dir = app.path().resource_dir().expect("no resource dir"); - log::debug!("app_data_dir: {:?}", app_data_dir); + log::debug!("app_data_dir: [PATH]"); let (_, plugins) = plugin_engine::initialize_plugins(&app_data_dir, &resource_dir); let known_plugin_ids: Vec = diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index b86ca140..15ac5665 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -5,8 +5,16 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; -const WHITELISTED_ENV_VARS: [&str; 6] = [ +const WHITELISTED_ENV_VARS: [&str; 14] = [ "CODEX_HOME", + "CLAUDE_CONFIG_DIR", + "CLAUDE_CODE_OAUTH_TOKEN", + "USER_TYPE", + "USE_STAGING_OAUTH", + "USE_LOCAL_OAUTH", + "CLAUDE_CODE_CUSTOM_OAUTH_URL", + "CLAUDE_CODE_OAUTH_CLIENT_ID", + "CLAUDE_LOCAL_OAUTH_API_BASE", "ZAI_API_KEY", "GLM_API_KEY", "MINIMAX_API_KEY", @@ -41,6 +49,36 @@ fn read_env_value_via_command(program: &str, args: &[&str]) -> Option { last_non_empty_trimmed_line(&stdout) } +fn current_macos_keychain_account() -> String { + read_env_from_process("USER") + .or_else(|| read_env_value_via_command("id", &["-un"])) + .unwrap_or_else(|| "openusage-user".to_string()) +} + +fn keychain_find_generic_password_args(service: &str, account: &str) -> Vec { + vec![ + OsString::from("find-generic-password"), + OsString::from("-a"), + OsString::from(account), + OsString::from("-s"), + OsString::from(service), + OsString::from("-w"), + ] +} + +fn keychain_add_generic_password_args(service: &str, account: &str, value: &str) -> Vec { + vec![ + OsString::from("add-generic-password"), + OsString::from("-U"), + OsString::from("-a"), + OsString::from(account), + OsString::from("-s"), + OsString::from(service), + OsString::from("-w"), + OsString::from(value), + ] +} + fn terminal_env_cache() -> &'static Mutex>> { static CACHE: OnceLock>>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) @@ -230,6 +268,10 @@ fn redact_body(body: &str) -> String { "userId", "account_id", "accountId", + "team_id", + "teamId", + "payment_id", + "paymentId", "email", "login", "analytics_tracking_id", @@ -247,6 +289,12 @@ fn redact_body(body: &str) -> String { } } + if let Ok(path_re) = + regex_lite::Regex::new(r#"(/(?:Users|home|opt|private|var|tmp|Applications)/[^\s"')]+)"#) + { + result = path_re.replace_all(&result, "[PATH]").to_string(); + } + result } @@ -268,6 +316,18 @@ fn redact_log_message(msg: &str) -> String { }) .to_string(); } + if let Ok(account_re) = regex_lite::Regex::new(r#"(account=)([^,\s]+)"#) { + result = account_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + format!("{}{}", &caps[1], redact_value(&caps[2])) + }) + .to_string(); + } + if let Ok(path_re) = + regex_lite::Regex::new(r#"(/(?:Users|home|opt|private|var|tmp|Applications)/[^\s"')]+)"#) + { + result = path_re.replace_all(&result, "[PATH]").to_string(); + } result } @@ -305,7 +365,7 @@ pub fn inject_host_api<'js>( inject_fs(ctx, &host)?; inject_env(ctx, &host, plugin_id)?; inject_http(ctx, &host, plugin_id)?; - inject_keychain(ctx, &host)?; + inject_keychain(ctx, &host, plugin_id)?; inject_sqlite(ctx, &host)?; inject_ls(ctx, &host, plugin_id)?; inject_ccusage(ctx, &host, plugin_id)?; @@ -1749,8 +1809,13 @@ pub fn patch_ccusage_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { ) } -fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { +fn inject_keychain<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + plugin_id: &str, +) -> rquickjs::Result<()> { let keychain_obj = Object::new(ctx.clone())?; + let pid_read = plugin_id.to_string(); keychain_obj.set( "readGenericPassword", @@ -1763,8 +1828,17 @@ fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result< "keychain API is only supported on macOS", )); } + let account = current_macos_keychain_account(); + let args = keychain_find_generic_password_args(&service, &account); + let redacted_account = redact_value(&account); + log::info!( + "[plugin:{}] keychain read: service={}, account={}", + pid_read, + service, + redacted_account + ); let output = std::process::Command::new("security") - .args(["find-generic-password", "-s", &service, "-w"]) + .args(&args) .output() .map_err(|e| { Exception::throw_message( @@ -1776,17 +1850,31 @@ fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result< if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let first_line = stderr.lines().next().unwrap_or("").trim(); + log::warn!( + "[plugin:{}] keychain read miss: service={}, account={}, error={}", + pid_read, + service, + redacted_account, + first_line + ); return Err(Exception::throw_message( &ctx_inner, &format!("keychain item not found: {}", first_line), )); } + log::info!( + "[plugin:{}] keychain read hit: service={}, account={}", + pid_read, + service, + redacted_account + ); Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) }, )?, )?; + let pid_write = plugin_id.to_string(); keychain_obj.set( "writeGenericPassword", Function::new( @@ -1798,61 +1886,44 @@ fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result< "keychain API is only supported on macOS", )); } - - // First, try to find existing entry and extract its account - let mut account_arg: Option = None; - let find_output = std::process::Command::new("security") - .args(["find-generic-password", "-s", &service]) - .output(); - - if let Ok(output) = find_output { - if output.status.success() { - // Parse account from output: "acct"="value" - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if let Some(start) = line.find("\"acct\"=\"") { - let rest = &line[start + 14..]; - if let Some(end) = rest.find('"') { - account_arg = Some(rest[..end].to_string()); - break; - } - } - } - } - } - - // Build command with account if found - let output = if let Some(ref acct) = account_arg { - std::process::Command::new("security") - .args([ - "add-generic-password", - "-s", - &service, - "-a", - acct, - "-w", - &value, - "-U", - ]) - .output() - } else { - std::process::Command::new("security") - .args(["add-generic-password", "-s", &service, "-w", &value, "-U"]) - .output() - } - .map_err(|e| { + let account = current_macos_keychain_account(); + let args = keychain_add_generic_password_args(&service, &account, &value); + let redacted_account = redact_value(&account); + log::info!( + "[plugin:{}] keychain write: service={}, account={}", + pid_write, + service, + redacted_account + ); + let output = std::process::Command::new("security") + .args(&args) + .output() + .map_err(|e| { Exception::throw_message(&ctx_inner, &format!("keychain write failed: {}", e)) })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let first_line = stderr.lines().next().unwrap_or("").trim(); + log::warn!( + "[plugin:{}] keychain write failed: service={}, account={}, error={}", + pid_write, + service, + redacted_account, + first_line + ); return Err(Exception::throw_message( &ctx_inner, &format!("keychain write failed: {}", first_line), )); } + log::info!( + "[plugin:{}] keychain write succeeded: service={}, account={}", + pid_write, + service, + redacted_account + ); Ok(()) }, )?, @@ -2023,6 +2094,24 @@ mod tests { #[test] fn env_api_respects_allowlist_in_host_and_js() { + let claude_env_vars = [ + "CLAUDE_CONFIG_DIR", + "CLAUDE_CODE_OAUTH_TOKEN", + "USER_TYPE", + "USE_STAGING_OAUTH", + "USE_LOCAL_OAUTH", + "CLAUDE_CODE_CUSTOM_OAUTH_URL", + "CLAUDE_CODE_OAUTH_CLIENT_ID", + "CLAUDE_LOCAL_OAUTH_API_BASE", + ]; + + for name in claude_env_vars { + assert!( + WHITELISTED_ENV_VARS.contains(&name), + "{name} must be whitelisted for Claude auth compatibility" + ); + } + let rt = Runtime::new().expect("runtime"); let ctx = Context::full(&rt).expect("context"); ctx.with(|ctx| { @@ -2120,6 +2209,83 @@ mod tests { }); } + #[test] + fn current_macos_keychain_account_prefers_user_env() { + struct RestoreEnvVar { + name: &'static str, + old: Option, + } + + impl Drop for RestoreEnvVar { + fn drop(&mut self) { + if let Some(value) = self.old.take() { + // SAFETY: tests serialize env changes via this guard; value is restored on drop. + unsafe { std::env::set_var(self.name, value) }; + } else { + // SAFETY: tests serialize env changes via this guard; var is restored/removed on drop. + unsafe { std::env::remove_var(self.name) }; + } + } + } + + let name = "USER"; + let old = std::env::var(name).ok(); + let _restore = RestoreEnvVar { name, old }; + // SAFETY: this test restores the previous value in `Drop`. + unsafe { std::env::set_var(name, "openusage-test-user") }; + + assert_eq!(current_macos_keychain_account(), "openusage-test-user"); + } + + #[test] + fn keychain_find_generic_password_args_include_account_and_service() { + let args = + keychain_find_generic_password_args("Claude Code-credentials", "openusage-test-user"); + let rendered: Vec = args + .into_iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect(); + + assert_eq!( + rendered, + vec![ + "find-generic-password", + "-a", + "openusage-test-user", + "-s", + "Claude Code-credentials", + "-w", + ] + ); + } + + #[test] + fn keychain_add_generic_password_args_include_update_account_service_and_value() { + let args = keychain_add_generic_password_args( + "Claude Code-credentials", + "openusage-test-user", + "secret-value", + ); + let rendered: Vec = args + .into_iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect(); + + assert_eq!( + rendered, + vec![ + "add-generic-password", + "-U", + "-a", + "openusage-test-user", + "-s", + "Claude Code-credentials", + "-w", + "secret-value", + ] + ); + } + #[test] fn redact_value_shows_first_and_last_four() { assert_eq!(redact_value("sk-1234567890abcdef"), "sk-1...cdef"); @@ -2239,6 +2405,33 @@ mod tests { ); } + #[test] + fn redact_body_redacts_team_id_payment_id_and_paths() { + let body = r#"{"teamId":"cc1ac023-9ff5-4c1f-a5a4-ae2a82df4243","paymentId":"cus_S5m1PGxjLWoc1c","binaryPath":"/opt/homebrew/bin/bunx","homePath":"/Users/rebers/.claude"}"#; + let redacted = redact_body(body); + assert!( + !redacted.contains("cc1ac023-9ff5-4c1f-a5a4-ae2a82df4243"), + "teamId should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("cus_S5m1PGxjLWoc1c"), + "paymentId should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("/opt/homebrew/bin/bunx"), + "path should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("/Users/rebers/.claude"), + "path should be redacted, got: {}", + redacted + ); + assert!(redacted.contains("[PATH]"), "expected path marker, got: {}", redacted); + } + #[test] fn redact_log_message_redacts_jwt_and_api_key() { let msg = "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U key=sk-1234567890abcdef"; @@ -2253,6 +2446,33 @@ mod tests { ); } + #[test] + fn redact_log_message_redacts_account_and_paths() { + let msg = "keychain read: service=Claude Code-credentials, account=rebers path=/opt/homebrew/bin/bunx home=/Users/rebers/.claude"; + let redacted = redact_log_message(msg); + assert!( + !redacted.contains("account=rebers"), + "account should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("/opt/homebrew/bin/bunx"), + "path should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("/Users/rebers/.claude"), + "path should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("account=[REDACTED]"), + "expected redacted account, got: {}", + redacted + ); + assert!(redacted.contains("[PATH]"), "expected redacted path, got: {}", redacted); + } + #[test] fn redact_body_redacts_login_and_analytics_tracking_id() { let body = From cc1d1806e183366e0cb16b866cb082a4960977ea Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 31 Mar 2026 17:43:31 +0700 Subject: [PATCH 2/2] fix: address PR 331 review comments --- plugins/claude/plugin.test.js | 18 +++-- src-tauri/src/lib.rs | 12 +++- src-tauri/src/plugin_engine/host_api.rs | 95 ++++++++++++++----------- 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/plugins/claude/plugin.test.js b/plugins/claude/plugin.test.js index 266a8f95..c599df33 100644 --- a/plugins/claude/plugin.test.js +++ b/plugins/claude/plugin.test.js @@ -1,18 +1,16 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" +import { beforeAll, describe, expect, it, vi } from "vitest" import { makeCtx } from "../test-helpers.js" -let pluginLoadNonce = 0 +let plugin = null -const loadPlugin = async () => { - await import(`./plugin.js?test=${pluginLoadNonce++}`) - return globalThis.__openusage_plugin -} +beforeAll(async () => { + await import("./plugin.js") + plugin = globalThis.__openusage_plugin +}) -describe("claude plugin", () => { - beforeEach(() => { - delete globalThis.__openusage_plugin - }) +const loadPlugin = async () => plugin +describe("claude plugin", () => { it("throws when no credentials", async () => { const ctx = makeCtx() const plugin = await loadPlugin() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c1365b04..c0c37580 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -521,7 +521,17 @@ pub fn run() { let app_data_dir = app.path().app_data_dir().expect("no app data dir"); let resource_dir = app.path().resource_dir().expect("no resource dir"); - log::debug!("app_data_dir: [PATH]"); + let app_data_dir_tail = app_data_dir + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("unknown"); + let redacted_app_data_dir = + plugin_engine::host_api::redact_log_message(&app_data_dir.display().to_string()); + log::debug!( + "app_data_dir: tail={}, path={}", + app_data_dir_tail, + redacted_app_data_dir + ); let (_, plugins) = plugin_engine::initialize_plugins(&app_data_dir, &resource_dir); let known_plugin_ids: Vec = diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 15ac5665..a4ced777 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -49,12 +49,24 @@ fn read_env_value_via_command(program: &str, args: &[&str]) -> Option { last_non_empty_trimmed_line(&stdout) } -fn current_macos_keychain_account() -> String { - read_env_from_process("USER") +fn current_macos_keychain_account_from_user_env(user_env: Option) -> String { + user_env + .and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) .or_else(|| read_env_value_via_command("id", &["-un"])) .unwrap_or_else(|| "openusage-user".to_string()) } +fn current_macos_keychain_account() -> String { + current_macos_keychain_account_from_user_env(read_env_from_process("USER")) +} + fn keychain_find_generic_password_args(service: &str, account: &str) -> Vec { vec![ OsString::from("find-generic-password"), @@ -298,8 +310,8 @@ fn redact_body(body: &str) -> String { result } -/// Lightweight redaction for plugin log messages (JWT + API key patterns only). -fn redact_log_message(msg: &str) -> String { +/// Lightweight redaction for log messages. +pub(crate) fn redact_log_message(msg: &str) -> String { let mut result = msg.to_string(); if let Ok(jwt_re) = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") { @@ -1540,16 +1552,12 @@ fn ccusage_runner_args( let package_spec = ccusage_package_spec(provider); let mut args: Vec = match kind { CcusageRunnerKind::Bunx => vec!["--silent".to_string(), package_spec.clone()], - CcusageRunnerKind::PnpmDlx => vec![ - "-s".to_string(), - "dlx".to_string(), - package_spec.clone(), - ], - CcusageRunnerKind::YarnDlx => vec![ - "dlx".to_string(), - "-q".to_string(), - package_spec.clone(), - ], + CcusageRunnerKind::PnpmDlx => { + vec!["-s".to_string(), "dlx".to_string(), package_spec.clone()] + } + CcusageRunnerKind::YarnDlx => { + vec!["dlx".to_string(), "-q".to_string(), package_spec.clone()] + } CcusageRunnerKind::NpmExec => vec![ "exec".to_string(), "--yes".to_string(), @@ -1624,14 +1632,16 @@ fn run_ccusage_with_runner( if let Some(home_path) = ccusage_home_override(opts, provider) { let config = ccusage_provider_config(provider); - command.env(config.home_env_var, home_path); + command.env(config.home_env_var, expand_path(&home_path)); } + let redacted_program = redact_log_message(program); + log::info!( "[plugin:{}] ccusage query via {} ({})", plugin_id, ccusage_runner_label(kind), - program + redacted_program ); let mut child = match command.spawn() { @@ -1899,8 +1909,11 @@ fn inject_keychain<'js>( .args(&args) .output() .map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("keychain write failed: {}", e)) - })?; + Exception::throw_message( + &ctx_inner, + &format!("keychain write failed: {}", e), + ) + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -2210,31 +2223,19 @@ mod tests { } #[test] - fn current_macos_keychain_account_prefers_user_env() { - struct RestoreEnvVar { - name: &'static str, - old: Option, - } - - impl Drop for RestoreEnvVar { - fn drop(&mut self) { - if let Some(value) = self.old.take() { - // SAFETY: tests serialize env changes via this guard; value is restored on drop. - unsafe { std::env::set_var(self.name, value) }; - } else { - // SAFETY: tests serialize env changes via this guard; var is restored/removed on drop. - unsafe { std::env::remove_var(self.name) }; - } - } - } + fn current_macos_keychain_account_prefers_explicit_user_value() { + assert_eq!( + current_macos_keychain_account_from_user_env(Some("openusage-test-user".to_string())), + "openusage-test-user" + ); + } - let name = "USER"; - let old = std::env::var(name).ok(); - let _restore = RestoreEnvVar { name, old }; - // SAFETY: this test restores the previous value in `Drop`. - unsafe { std::env::set_var(name, "openusage-test-user") }; + #[test] + fn expand_path_expands_tilde_prefix() { + let home = dirs::home_dir().expect("home dir"); + let expected = home.join(".claude-custom").to_string_lossy().to_string(); - assert_eq!(current_macos_keychain_account(), "openusage-test-user"); + assert_eq!(expand_path("~/.claude-custom"), expected); } #[test] @@ -2429,7 +2430,11 @@ mod tests { "path should be redacted, got: {}", redacted ); - assert!(redacted.contains("[PATH]"), "expected path marker, got: {}", redacted); + assert!( + redacted.contains("[PATH]"), + "expected path marker, got: {}", + redacted + ); } #[test] @@ -2470,7 +2475,11 @@ mod tests { "expected redacted account, got: {}", redacted ); - assert!(redacted.contains("[PATH]"), "expected redacted path, got: {}", redacted); + assert!( + redacted.contains("[PATH]"), + "expected redacted path, got: {}", + redacted + ); } #[test]