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
27 changes: 13 additions & 14 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { npmInvocation } from "../src/update/npm-invocation.mjs";
import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs";

const PKG = "@bitkyc08/opencodex";
Expand All @@ -29,10 +30,6 @@ function isBunGlobalInstall() {
return /[\\/]\.bun[\\/]/.test(here);
}

function npmBin() {
return process.platform === "win32" ? "npm.cmd" : "npm";
}

function currentPackageVersion() {
try {
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version ?? "?";
Expand Down Expand Up @@ -115,15 +112,17 @@ function runTrayLifecycle(launcher, action) {
function runNpmSelfUpdate() {
const current = currentPackageVersion();
const tag = updateTag(current);
const npm = npmBin();
// Node ≥18.20/20.12 refuses to spawn .cmd/.bat without a shell (CVE-2024-27980
// hardening) — spawning "npm.cmd" shell-less throws EINVAL on Windows.
const winShell = process.platform === "win32";
const latestResult = spawnSync(npm, ["view", `${PKG}@${tag}`, "version"], {
const latestInvocation = npmInvocation(["view", `${PKG}@${tag}`, "version"]);
const installInvocation = npmInvocation(["install", "-g", `${PKG}@${tag}`]);
if (!latestInvocation || !installInvocation) {
console.error("opencodex: could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy.");
process.exit(1);
}
const latestResult = spawnSync(latestInvocation.file, latestInvocation.args, {
encoding: "utf8",
timeout: 12000,
windowsHide: true,
shell: winShell,
...latestInvocation.options,
});
const latest = latestResult.status === 0 ? latestResult.stdout.trim() : "";

Expand Down Expand Up @@ -221,12 +220,12 @@ function runNpmSelfUpdate() {
}
}

console.log(`Updating${latest ? ` to v${latest}` : ""}...\n$ ${npm} install -g ${PKG}@${tag}`);
const res = spawnSync(npm, ["install", "-g", `${PKG}@${tag}`], {
console.log(`Updating${latest ? ` to v${latest}` : ""}...\n$ npm install -g ${PKG}@${tag}`);
const res = spawnSync(installInvocation.file, installInvocation.args, {
stdio: "inherit",
timeout: 180000,
windowsHide: true,
shell: winShell,
...installInvocation.options,
});
if (res.status === 0) {
console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`);
Expand Down Expand Up @@ -278,7 +277,7 @@ function runNpmSelfUpdate() {
process.exit(0);
}
if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
console.error(`\nUpdate failed (${npm} exit ${res.status ?? "?"}). Try manually: ${npm} install -g ${PKG}@${tag}`);
console.error(`\nUpdate failed (npm exit ${res.status ?? "?"}). Try manually: npm install -g ${PKG}@${tag}`);
process.exit(1);
}

Expand Down
27 changes: 27 additions & 0 deletions docs-site/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,33 @@ inert: it does not create model-picker entries, pin sessions, or alter Pool or D
`max_concurrent_threads_per_session` value under `[features.multi_agent_v2]` in Codex's
`$CODEX_HOME/config.toml`; enable v2 first so that table exists.

## Provider diagnostic outbound safety

The dashboard provider connection test and live model discovery use a bounded GET-only outbound
transport. Without an outbound proxy, opencodex resolves the provider hostname once and connects
only to that validated address. HTTPS keeps the original hostname for Host, SNI, and certificate
verification; certificate verification cannot be disabled by provider config.

When `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` applies, these two operations keep Bun's native fetch
so existing proxy behavior is not silently bypassed. URL/literal checks still run. Successful local DNS answers
are classified, but a local DNS failure is allowed through because proxy-only networks commonly
delegate name resolution to the proxy. The proxy chooses the final route, DNS answer, and peer, so
opencodex logs that this path cannot pin or verify the proxy-selected peer. This is an explicit
security limitation, not equivalent protection against DNS rebinding.

Private/local provider destinations require both `allowPrivateNetwork: true` and a matching
`NO_PROXY` entry whenever an outbound proxy is configured. Loopback entries are added to `NO_PROXY`
automatically. A LAN provider such as `192.168.1.50` must be added explicitly; otherwise connection
tests and model discovery reject it with an actionable message instead of sending it to the proxy.
Metadata and link-local destinations remain blocked even when `allowPrivateNetwork` is enabled.
The safety guard accepts exact hosts, domain suffixes, optional ports, bracketed IPv6, and `*` in
`NO_PROXY`; it does not interpret CIDR entries, so list each private provider host or address explicitly.

Both direct and proxied diagnostic paths reject redirects and report a credential-stripped target;
configure the final provider URL directly. Ordinary provider requests, streaming responses, and
retry paths are not migrated in this phase. Their redirect handling and per-hop destination review
remain deferred, so this phase does not close the main-request redirect finding.

## Combos (`config.combos`)

Failover / round-robin aliases live under `combos.<id>` with `targets` (provider + model), optional
Expand Down
33 changes: 32 additions & 1 deletion gui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function needsApiAuth(input: RequestInfo | URL): boolean {
const url = new URL(raw, window.location.href);
// Absolute cross-origin URLs must never get the local API token or 401 prompt.
if (url.origin !== window.location.origin) return false;
return url.pathname.startsWith("/api/") || url.pathname.startsWith("/v1/");
return url.pathname.startsWith("/api/");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load remote API-page models through the management plane

On a non-loopback dashboard, this deliberately stops attaching the management token to /v1/*, but gui/src/pages/ApiKeys.tsx:86-95 still fetches /v1/models. That endpoint requires a separate data-plane credential remotely, and the dashboard neither stores nor sends one, so the API Access page always clears its model list and reports a load failure even after successful admin authentication. Fetch the catalog through the existing authenticated /api/models surface or add an equivalent management endpoint.

AGENTS.md reference: gui/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

} catch {
return false;
}
Expand All @@ -25,6 +25,8 @@ const LEGACY_TOKEN_KEY = "opencodex-api-token";

/** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */
let memoryToken: string | null = null;
let memoryCsrfToken: string | null = null;
let memorySessionOrigin: string | null = null;

function readToken(): string | null {
return memoryToken;
Expand All @@ -36,6 +38,25 @@ function storeToken(token: string): void {

function clearToken(): void {
memoryToken = null;
memoryCsrfToken = null;
memorySessionOrigin = null;
}

function takeMetaContent(name: string): string | null {
const element = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement | null;
const content = element?.content.trim() || null;
element?.remove();
return content;
}

function loadInjectedSession(): void {
const token = takeMetaContent("opencodex-session-token");
const csrfToken = takeMetaContent("opencodex-session-csrf");
const origin = takeMetaContent("opencodex-session-origin");
if (!token?.startsWith("ocx_session_") || !csrfToken || origin !== window.location.origin) return;
memoryToken = token;
memoryCsrfToken = csrfToken;
memorySessionOrigin = origin;
}

/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */
Expand All @@ -54,6 +75,13 @@ function clearLegacySessionToken(): void {
function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] {
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
headers.set("X-OpenCodex-API-Key", token);
if (memorySessionOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) {
headers.set("X-OpenCodex-GUI-Origin", memorySessionOrigin);
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
if (method !== "GET" && method !== "HEAD") {
headers.set("X-OpenCodex-CSRF-Token", memoryCsrfToken);
}
}
if (input instanceof Request) return [new Request(input, { headers }), init ? { ...init, headers } : undefined];
return [input, { ...init, headers }];
}
Expand Down Expand Up @@ -91,6 +119,7 @@ export function installApiAuthFetch(): void {
installed = true;
// Drop any leftover XSS-readable token; new tokens stay memory-only (no read/migrate).
clearLegacySessionToken();
loadInjectedSession();
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
if (!needsApiAuth(input)) return originalFetch(input, init);
Expand Down Expand Up @@ -125,6 +154,8 @@ export function installApiAuthFetch(): void {
export function resetApiAuthFetchForTests(): void {
installed = false;
memoryToken = null;
memoryCsrfToken = null;
memorySessionOrigin = null;
promptInFlight = null;
promptCancelled = false;
}
7 changes: 4 additions & 3 deletions gui/tests/api-auth-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ test("canceling the token prompt once does not reopen it for the rest of the 401
expect([...new Set(statuses)]).toEqual([401]);
});

test("cross-origin /v1/* requests do not receive the API key or token prompt", async () => {
test("data-plane requests never receive the management token or prompt", async () => {
let promptCalls = 0;
let phase: "seed" | "cross" = "seed";
const seenHeaders: Array<string | null> = [];
Expand All @@ -298,8 +298,9 @@ test("cross-origin /v1/* requests do not receive the API key or token prompt", a
};
await installMockAuthFetch(stateful);

expect((await fetch("/v1/models")).status).toBe(200);
expect(promptCalls).toBe(1);
expect((await fetch("/v1/models")).status).toBe(401);
expect(seenHeaders).toEqual([null]);
expect(promptCalls).toBe(0);

phase = "cross";
const beforeCrossPrompts = promptCalls;
Expand Down
2 changes: 1 addition & 1 deletion gui/tests/models-empty-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async function providerDto(
): Promise<Record<string, unknown>> {
const requestUrl = new URL("http://127.0.0.1/api/providers");
const response = await handleManagementAPI(
new Request(requestUrl),
new Request(requestUrl, { headers: { Host: requestUrl.host } }),
requestUrl,
{
providers: {
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/mimo-free.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config";
import { recordOwnedConfigPath } from "../lib/config-ownership";
import type { OcxProviderConfig, OcxParsedRequest } from "../types";
import { createOpenAIChatAdapter } from "./openai-chat";
import type { ProviderAdapter, AdapterRequest } from "./base";
Expand Down Expand Up @@ -59,6 +60,7 @@ export function getMimoClientId(): string {
} catch { /* fall through to regenerate */ }
const fresh = randomUUID();
try {
recordOwnedConfigPath(dir, file);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(file, `${fresh}\n`, "utf8");
} catch { /* persist best-effort; still usable for this process */ }
Expand Down
6 changes: 3 additions & 3 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
import { commandInvocation } from "../lib/win-exec";
import { findLiveProxy } from "../server/proxy-liveness";
import type { OcxConfig } from "../types";
import { configuredAdminToken } from "../lib/admin-secrets";
import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
import { resolveClaudeAuthMode } from "../claude/auth-mode";

Expand Down Expand Up @@ -149,14 +150,13 @@ export function buildClaudeEnv(

/**
* Context-window map from the RUNNING proxy's management API (warm TTL cache; the
* daemon registers every selector form — audit R3#1). 3s bound + auth header
* (OPENCODEX_API_AUTH_TOKEN first, config key fallback — audit R4#1). Failure → {}
* daemon registers every selector form — audit R3#1). 3s bound + management auth header.
* (no [1m] marking, conservative).
*/
export async function fetchClaudeContextWindows(config: OcxConfig, port: number, timeoutMs = 3_000): Promise<Record<string, number>> {
try {
const headers = new Headers();
const token = process.env.OPENCODEX_API_AUTH_TOKEN || config.apiKeys?.[0]?.key;
const token = configuredAdminToken();
if (token) headers.set("x-opencodex-api-key", token);
const res = await fetch(`http://127.0.0.1:${port}/api/claude-code`, {
headers,
Expand Down
13 changes: 6 additions & 7 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntim
import { findLiveProxy } from "../server/proxy-liveness";
import { gracefulStopHost } from "../lib/process-control";
import { maskAccountId } from "../lib/privacy";
import { loadServiceTokenFromFile } from "../lib/service-secrets";
import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env";
import { configuredAdminToken } from "../lib/admin-secrets";
import { readCodexTokens } from "../codex/auth-collision";
import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
Expand Down Expand Up @@ -286,17 +287,15 @@ export function collectWslDualInstall(deps: WslDualInstallDeps = {}): WslDualIns
};
}

const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;

export type ProxyEnvRow = { key: string; present: boolean };
export type EnvMap = Record<string, string | undefined>;

/** Report only presence/absence of proxy env vars - never the value (it may
* embed credentials). Checks both upper- and lower-case forms. */
export function collectProxyEnv(env: EnvMap = process.env): ProxyEnvRow[] {
return PROXY_KEYS.map(key => ({
return PROXY_ENV_KEYS.map(key => ({
key,
present: !!(env[key]?.trim() || env[key.toLowerCase()]?.trim()),
present: proxyEnvPresent(key, env),
}));
}

Expand Down Expand Up @@ -572,7 +571,7 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
const lines: string[] = [];
lines.push(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
if (report.status === "unauthorized") {
lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_API_AUTH_TOKEN to match the service");
lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_ADMIN_AUTH_TOKEN to match the service");
return lines;
}
if (report.status === "unreachable") {
Expand Down Expand Up @@ -762,7 +761,7 @@ export async function runDoctor(args: string[] = []): Promise<void> {
console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
console.log(" -- no running ocx proxy found (no live pid/runtime record)");
} else {
const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env);
const token = configuredAdminToken();
const report = await fetchServiceMemory(gracefulStopHost(runtime.hostname), runtime.port, token);
for (const line of formatServiceMemoryLines(report)) console.log(line);
}
Expand Down
10 changes: 8 additions & 2 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,18 @@ const helpEntries: Record<string, HelpEntry> = {
uninstall: {
usage: "ocx uninstall",
summary: "Remove service/shim/config and restore native Codex.",
details: ["Alias: ocx remove"],
details: [
"Alias: ocx remove",
"Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.",
],
},
remove: {
usage: "ocx remove",
summary: "Remove service/shim/config and restore native Codex.",
details: ["Alias of: ocx uninstall"],
details: [
"Alias of: ocx uninstall",
"Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.",
],
Comment on lines +33 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Describe the cleanup contract precisely.

The cleanup implementation requires valid ownership metadata and removes only manifest-listed paths; recordOwnedConfigPath can create that metadata lazily during managed writes, not only during a fresh install. The current wording may mislead users of upgraded or legacy configuration directories.

Use wording such as: “Config cleanup requires valid ownership metadata; only manifest-listed paths are removed, and unowned files remain.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/help.ts` around lines 33 - 44, Update the cleanup descriptions in the
help entries for remove and its related command to state that cleanup requires
valid ownership metadata, removes only manifest-listed paths, and leaves unowned
files in place; avoid claiming the metadata comes only from a fresh install.

},
service: {
usage: "ocx service [install|start|stop|status|uninstall|remove]",
Expand Down
10 changes: 8 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env bun
import { spawn } from "node:child_process";
import { rmSync } from "node:fs";
import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject";
import { stripGrokConfig } from "../grok/inject";
import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
Expand Down Expand Up @@ -43,6 +42,7 @@ import { maybeShowUpdatePrompt } from "../update/notify";
import { syncModelsToCodex } from "../codex/sync";
import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
import { removeOwnedConfigState } from "../lib/config-ownership";

const args = process.argv.slice(2);
const command = args[0];
Expand Down Expand Up @@ -608,7 +608,13 @@ async function handleUninstall() {

if (failures.length === 0) {
await runStep("opencodex config removed", () => {
rmSync(getConfigDir(), { recursive: true, force: true });
const result = removeOwnedConfigState(getConfigDir());
if (result.status === "absent") return false;
if (result.status === "removed") return true;
const residual = result.residualPaths.length > 0
? ` Residual path(s): ${result.residualPaths.join(", ")}`
: "";
throw new Error(`${result.status} uninstall: ${result.reason ?? "config state was not removed"}.${residual}`);
});
} else {
console.error("Leaving opencodex config/backups in place so the failed restore step can be retried.");
Expand Down
7 changes: 6 additions & 1 deletion src/cli/star-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { getConfigDir } from "../config";
import { recordOwnedConfigPath } from "../lib/config-ownership";
import { isAgentDriven } from "./agent-driven";
import { interactiveConfirm } from "./interactive-confirm";

Expand Down Expand Up @@ -85,7 +86,11 @@ export async function maybeShowStarPrompt(): Promise<void> {
printAgentDeferral();
return;
}
try { mkdirSync(dir, { recursive: true }); writeFileSync(marker, new Date().toISOString()); } catch { /* best-effort */ }
try {
recordOwnedConfigPath(dir, marker);
mkdirSync(dir, { recursive: true });
writeFileSync(marker, new Date().toISOString());
} catch { /* best-effort */ }

const yes = await interactiveConfirm({
question: "\n \x1b[38;5;141m⭐ Enjoying opencodex? Star it on GitHub (via gh)?\x1b[0m",
Expand Down
Loading
Loading