diff --git a/docs/plugins/api.md b/docs/plugins/api.md index 8f651379..f8e4da65 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -99,6 +99,29 @@ state.counter++ ctx.host.fs.writeText(statePath, JSON.stringify(state, null, 2)) ``` +## Environment + +```typescript +host.env.get(name: string): string | null +``` + +Reads an environment variable by name. + +### Behavior + +- Returns variable value as string when set +- Returns `null` when missing +- Variable must be whitelisted first in `src-tauri/src/plugin_engine/host_api.rs` + +### Example + +```javascript +const codexHome = ctx.host.env.get("CODEX_HOME") +const authPath = codexHome + ? codexHome.replace(/\/+$/, "") + "/auth.json" + : "~/.config/codex/auth.json" +``` + ## HTTP ```typescript diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index ad983a31..4ad8ca47 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -1,24 +1,67 @@ (function () { - const AUTH_PATH = "~/.codex/auth.json" + const AUTH_FILE = "auth.json" + const CONFIG_AUTH_PATHS = ["~/.config/codex", "~/.codex"] const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const REFRESH_URL = "https://auth.openai.com/oauth/token" const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" const REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000 + function joinPath(base, leaf) { + return base.replace(/[\\/]+$/, "") + "/" + leaf + } + + function readCodexHome(ctx) { + if (!ctx.host.env || typeof ctx.host.env.get !== "function") { + return null + } + + try { + const value = ctx.host.env.get("CODEX_HOME") + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed || null + } catch (e) { + ctx.host.log.warn("CODEX_HOME read failed: " + String(e)) + return null + } + } + + function resolveAuthPath(ctx) { + const codexHome = readCodexHome(ctx) + + // If CODEX_HOME is set, use it + if (codexHome) { + return joinPath(codexHome, AUTH_FILE) + } + + // Otherwise, return the first existing auth file path + for (const basePath of CONFIG_AUTH_PATHS) { + const authPath = joinPath(basePath, AUTH_FILE) + if (ctx.host.fs.exists(authPath)) { + return authPath + } + } + + return null + } + function loadAuth(ctx) { - if (!ctx.host.fs.exists(AUTH_PATH)) { - ctx.host.log.warn("auth file not found: " + AUTH_PATH) + const authPath = resolveAuthPath(ctx) + + if (!authPath || !ctx.host.fs.exists(authPath)) { + ctx.host.log.warn("auth file not found: " + authPath) return null } + try { - const text = ctx.host.fs.readText(AUTH_PATH) + const text = ctx.host.fs.readText(authPath) const auth = ctx.util.tryParseJson(text) if (auth) { - ctx.host.log.info("auth loaded from file") + ctx.host.log.info("auth loaded from file: " + authPath) } else { ctx.host.log.warn("auth file exists but not valid JSON") } - return auth + return { auth, authPath } } catch (e) { ctx.host.log.warn("auth file read failed: " + String(e)) return null @@ -32,7 +75,7 @@ return nowMs - lastMs > REFRESH_AGE_MS } - function refreshToken(ctx, auth) { + function refreshToken(ctx, auth, authPath) { if (!auth.tokens || !auth.tokens.refresh_token) { ctx.host.log.warn("refresh skipped: no refresh token") return null @@ -91,7 +134,7 @@ auth.last_refresh = new Date().toISOString() try { - ctx.host.fs.writeText(AUTH_PATH, JSON.stringify(auth, null, 2)) + ctx.host.fs.writeText(authPath, JSON.stringify(auth, null, 2)) ctx.host.log.info("refresh succeeded, auth file updated") } catch (e) { ctx.host.log.warn("refresh succeeded but failed to save auth: " + String(e)) @@ -148,11 +191,13 @@ var PERIOD_WEEKLY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days function probe(ctx) { - const auth = loadAuth(ctx) - if (!auth) { + const authState = loadAuth(ctx) + if (!authState || !authState.auth) { ctx.host.log.error("probe failed: not logged in") throw "Not logged in. Run `codex` to authenticate." } + const auth = authState.auth + const authPath = authState.authPath if (auth.tokens && auth.tokens.access_token) { const nowMs = Date.now() @@ -161,7 +206,7 @@ if (needsRefresh(ctx, auth, nowMs)) { ctx.host.log.info("token needs refresh (age > " + (REFRESH_AGE_MS / 1000 / 60 / 60 / 24) + " days)") - const refreshed = refreshToken(ctx, auth) + const refreshed = refreshToken(ctx, auth, authPath) if (refreshed) { accessToken = refreshed } else { @@ -187,7 +232,7 @@ refresh: () => { ctx.host.log.info("usage returned 401, attempting refresh") didRefresh = true - return refreshToken(ctx, auth) + return refreshToken(ctx, auth, authPath) }, }) } catch (e) { @@ -205,7 +250,7 @@ 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") const data = ctx.util.tryParseJson(resp.bodyText) diff --git a/plugins/codex/plugin.test.js b/plugins/codex/plugin.test.js index 1bad9b67..6027bf95 100644 --- a/plugins/codex/plugin.test.js +++ b/plugins/codex/plugin.test.js @@ -18,6 +18,60 @@ describe("codex plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Not logged in") }) + it("uses CODEX_HOME auth path when env var is set", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => (name === "CODEX_HOME" ? "/tmp/codex-home" : null)) + ctx.host.fs.writeText("/tmp/codex-home/auth.json", JSON.stringify({ + tokens: { access_token: "env-token" }, + last_refresh: new Date().toISOString(), + })) + ctx.host.fs.writeText("~/.config/codex/auth.json", JSON.stringify({ + tokens: { access_token: "config-token" }, + last_refresh: new Date().toISOString(), + })) + ctx.host.http.request.mockImplementation((opts) => { + expect(opts.headers.Authorization).toBe("Bearer env-token") + return { status: 200, headers: {}, bodyText: JSON.stringify({}) } + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + }) + + it("uses ~/.config/codex/auth.json before ~/.codex/auth.json when env is not set", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.config/codex/auth.json", JSON.stringify({ + tokens: { access_token: "config-token" }, + last_refresh: new Date().toISOString(), + })) + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "legacy-token" }, + last_refresh: new Date().toISOString(), + })) + ctx.host.http.request.mockImplementation((opts) => { + expect(opts.headers.Authorization).toBe("Bearer config-token") + return { status: 200, headers: {}, bodyText: JSON.stringify({}) } + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + }) + + it("does not fall back when CODEX_HOME is set but missing auth file", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => (name === "CODEX_HOME" ? "/tmp/missing-codex-home" : null)) + ctx.host.fs.writeText("~/.config/codex/auth.json", JSON.stringify({ + tokens: { access_token: "config-token" }, + last_refresh: new Date().toISOString(), + })) + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "legacy-token" }, + last_refresh: new Date().toISOString(), + })) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Not logged in") + }) + it("throws when auth json is invalid", async () => { const ctx = makeCtx() ctx.host.fs.writeText("~/.codex/auth.json", "{bad") diff --git a/plugins/test-helpers.js b/plugins/test-helpers.js index 4f2dab05..cfddec6a 100644 --- a/plugins/test-helpers.js +++ b/plugins/test-helpers.js @@ -17,6 +17,9 @@ export const makeCtx = () => { readText: (path) => files.get(path), writeText: vi.fn((path, text) => files.set(path, text)), }, + env: { + get: vi.fn(() => null), + }, keychain: { readGenericPassword: vi.fn(), writeGenericPassword: vi.fn(), diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 3828c671..7009fc4d 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -1,6 +1,8 @@ use rquickjs::{Ctx, Exception, Function, Object}; use std::path::PathBuf; +const WHITELISTED_ENV_VARS: [&str; 1] = ["CODEX_HOME"]; + /// Redact sensitive value to first4...last4 format (UTF-8 safe) fn redact_value(value: &str) -> String { let chars: Vec = value.chars().collect(); @@ -127,6 +129,7 @@ pub fn inject_host_api<'js>( let host = Object::new(ctx.clone())?; inject_log(ctx, &host, plugin_id)?; inject_fs(ctx, &host)?; + inject_env(ctx, &host)?; inject_http(ctx, &host, plugin_id)?; inject_keychain(ctx, &host)?; inject_sqlite(ctx, &host)?; @@ -214,6 +217,22 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { Ok(()) } +fn inject_env<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { + let env_obj = Object::new(ctx.clone())?; + env_obj.set( + "get", + Function::new(ctx.clone(), move |name: String| -> Option { + if WHITELISTED_ENV_VARS.contains(&name.as_str()) { + std::env::var(&name).ok() + } else { + None + } + })?, + )?; + host.set("env", env_obj)?; + Ok(()) +} + fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { let http_obj = Object::new(ctx.clone())?; let pid = plugin_id.to_string(); @@ -1215,6 +1234,40 @@ mod tests { }); } + #[test] + fn env_api_respects_allowlist_in_host_and_js() { + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); + let globals = ctx.globals(); + let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); + let host: Object = probe_ctx.get("host").expect("host"); + let env: Object = host.get("env").expect("env"); + let get: Function = env.get("get").expect("get"); + + for name in WHITELISTED_ENV_VARS { + let value: Option = get.call((name.to_string(),)).expect("get whitelisted var"); + assert_eq!(value, std::env::var(name).ok(), "{name} should match process env"); + + let js_expr = format!(r#"__openusage_ctx.host.env.get("{}")"#, name); + let js_value: Option = ctx.eval(js_expr).expect("js get whitelisted var"); + assert_eq!(js_value, std::env::var(name).ok(), "{name} should match process env from JS"); + } + + let blocked: Option = get + .call(("__OPENUSAGE_TEST_NOT_WHITELISTED__".to_string(),)) + .expect("get blocked var"); + assert!(blocked.is_none(), "non-whitelisted vars must not be exposed"); + + let js_blocked: Option = ctx + .eval(r#"__openusage_ctx.host.env.get("__OPENUSAGE_TEST_NOT_WHITELISTED__")"#) + .expect("js get blocked var"); + assert!(js_blocked.is_none(), "non-whitelisted vars must not be exposed from JS"); + }); + } + #[test] fn redact_value_shows_first_and_last_four() { assert_eq!(redact_value("sk-1234567890abcdef"), "sk-1...cdef");