Skip to content
Merged
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
23 changes: 23 additions & 0 deletions docs/plugins/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 58 additions & 13 deletions plugins/codex/plugin.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions plugins/codex/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions plugins/test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
53 changes: 53 additions & 0 deletions src-tauri/src/plugin_engine/host_api.rs
Original file line number Diff line number Diff line change
@@ -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<char> = value.chars().collect();
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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<String> {
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();
Expand Down Expand Up @@ -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<String> = 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<String> = 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<String> = 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<String> = 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");
Expand Down