Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 22 additions & 0 deletions docs/plugins/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,28 @@ 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

### 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
28 changes: 28 additions & 0 deletions src-tauri/src/plugin_engine/host_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,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 @@ -213,6 +214,18 @@ 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> {
std::env::var(&name).ok()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
})?,
)?;
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 @@ -919,6 +932,21 @@ mod tests {
});
}

#[test]
fn env_api_exposes_get() {
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");
});
}

#[test]
fn redact_value_shows_first_and_last_four() {
assert_eq!(redact_value("sk-1234567890abcdef"), "sk-1...cdef");
Expand Down