Skip to content

security: close audit findings 1/2/4/7/9/11 and partially 3/16 - #697

Merged
lidge-jun merged 15 commits into
lidge-jun:devfrom
LeoWang331:security/260728-first-batch
Jul 29, 2026
Merged

security: close audit findings 1/2/4/7/9/11 and partially 3/16#697
lidge-jun merged 15 commits into
lidge-jun:devfrom
LeoWang331:security/260728-first-batch

Conversation

@LeoWang331

@LeoWang331 LeoWang331 commented Jul 29, 2026

Copy link
Copy Markdown

Summary

Hardens local proxy auth, config uninstall, service cleanup, and provider
outbound requests.

Closes multiple items from the private 2026-07-28 security review in full, and
two in part. Full finding IDs, severities, descriptions, and the list of
unfixed items stay in the private report and are not listed here.

All 14 security commits are scoped to one finding each; none touch unrelated
code. One additional merge-only commit, 7d995ef7, synchronizes the branch with
the current dev head without rewriting those commits. Supporting test-only
commits: 43b4f651, 42bdb1f3, 8e221b5d.

Two of the items this PR touches are partially addressed and must not be
treated as closed. Remaining work is tracked privately.

Breaking changes

These describe the behavior after this PR. Operators upgrading will notice:

  1. "hostname": "" is rejected on write and degraded to 127.0.0.1 on read
    with a warning.
  2. Data-plane credentials (OPENCODEX_API_AUTH_TOKEN, service-api-token,
    config.apiKeys) no longer reach /api/*. Operational scripts must move to
    OPENCODEX_ADMIN_AUTH_TOKEN. Model clients are unaffected.
  3. A non-loopback dashboard no longer mints a local session automatically and
    prompts for the management token. The loopback dashboard keeps a seamless
    same-origin session.
  4. Uninstall removes only manifest-owned files. Installs predating the ownership
    manifest refuse to delete, retain their files, and exit non-zero.
  5. The dashboard can no longer be embedded in an iframe.
  6. Windows auto-update aborts when npm cannot be resolved from an absolute
    PATH entry outside the launch directory.
  7. A private-network provider behind an outbound proxy must appear in
    NO_PROXY; otherwise the request is refused with an actionable message.
    localhost, 127.0.0.1, ::1, and [::1] are already excluded
    automatically.
  8. Connection test and model discovery no longer follow redirects. A 3xx returns
    the sanitized target and asks the operator to configure the final URL.

Outbound provider connection test and model discovery now use a two-path
transport: without a proxy, connections are pinned to a pre-validated address
while SNI, Host, and certificate validation are preserved; with a proxy, Bun's
native fetch keeps HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY
semantics rather than silently connecting direct.

Verification

All of the following ran on Windows only.

  • bun run test — 5955 passed, 4 skipped, 0 failed, 429 files, 834.37s, exit 0
  • bun run typecheck — exit 0
  • bun run privacy:scan — exit 0
  • GUI lint and production build — exit 0
  • Conflict-focused suite — 210 passed, 0 failed, exit 0
  • Local stub proxy — requests provably reach the proxy under HTTP_PROXY,
    HTTPS_PROXY, and ALL_PROXY individually; no silent direct connect
  • Non-proxy path — verified to connect to the pinned, pre-validated address

The first full-suite attempt after the dev merge ended with a Bun 1.3.14
internal assertion (exit 3), not a test assertion. A later complete run on the
same code tree produced the successful result above.

Never executed on a real host. CI will be the first three-platform run for:

  • macOS launchctl cleanup (execFileSync("/bin/launchctl", …), 5eafd362)
  • macOS launchd and Linux systemd service definitions
  • the node:https pinned transport on non-Windows

Also not verified: enterprise proxies, authenticated proxies, PAC, and
HTTPS CONNECT tunnels. NO_PROXY CIDR entries are not supported and this is
documented rather than implemented.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Published user documentation still needs to cover every breaking change from
the earlier batches, especially blank-hostname degradation and the operational
migration to the independent management credential.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR separates management and data-plane authentication, adds GUI session and CSRF handling, introduces ownership-aware config cleanup, centralizes provider diagnostic outbound safety, hardens npm updates, and updates platform integrations, tests, and documentation.

Changes

Management authentication and GUI sessions

Layer / File(s) Summary
Management auth boundary
src/server/management-auth.ts, src/server/auth-cors.ts, src/server/index.ts
Admin tokens, origin validation, GUI sessions, CSRF checks, dedicated management CORS, and anti-framing headers are added.
GUI session propagation
src/server/gui-static.ts, gui/src/api.ts
Session metadata is injected into HTML and applied to management requests while excluded from /v1/* requests.
Authentication tests
tests/server-management-auth.test.ts, tests/gui-management-session.test.ts, tests/helpers/management-auth.ts, tests/server-auth.test.ts
Management/data credential separation, session constraints, CORS, WebSocket behavior, and authenticated test requests are covered.

Owned config cleanup

Layer / File(s) Summary
Ownership manifest lifecycle
src/lib/config-ownership.ts, src/cli/index.ts, src/config.ts
Owned paths are recorded in validated manifests, and uninstall removes only safe manifest entries while reporting residual files.
Managed file integration
src/service.ts, src/server/system-env.ts, src/codex/shim.ts, src/oauth/*, src/usage/*, src/tray/windows.ts, src/images/artifacts.ts
Application-managed files and directories now register ownership before persistence.
Cleanup and config validation tests
tests/config-ownership-uninstall.test.ts, tests/config.test.ts, tests/uninstall.test.ts
Ownership, traversal, symlink, legacy-directory, uninstall, and hostname-degradation behavior is tested.

Provider diagnostics and transport hardening

Layer / File(s) Summary
Pinned outbound transport
src/lib/destination-policy.ts, src/lib/pinned-http.ts, src/lib/provider-outbound.ts, src/lib/provider-url.ts, src/lib/proxy-env.ts
Provider diagnostics use bounded GET requests, validated destinations, pinned addresses, proxy/NO_PROXY rules, and credential-safe redirect handling.
Provider integrations
src/codex/catalog/provider-fetch.ts, src/server/management/provider-routes.ts, src/images/artifacts.ts
Model discovery and provider tests use the outbound policy layer; image HTTPS downloads delegate to the shared pinned transport.
Transport validation
tests/provider-outbound.test.ts, tests/provider-live-models.test.ts, tests/provider-connection-test.test.ts, tests/fixtures/provider-outbound-e2e.ts
Direct, proxied, private, metadata, redirect, injected-fetch, and end-to-end discovery scenarios are covered.

Update and platform hardening

Layer / File(s) Summary
Trusted update execution
src/update/*, bin/ocx.mjs
npm commands resolve trusted executables and arguments, avoid shell-based npm routing, fail closed when unresolved, and preserve platform-specific spawn options.
Admin credential adoption
src/lib/admin-secrets.ts, src/cli/claude.ts, src/cli/doctor.ts, src/lib/process-control.ts, src/oauth/login-cli.ts
Management-token environment and file resolution replaces data-token fallbacks in management operations.
Platform validation
src/server/system-env.ts, tests/system-env.test.ts, tests/update-npm-invocation.test.ts, tests/update-stop-first.test.ts
Structured launchctl execution, tracking-key filtering, npm resolution, spawn safety, and stop-before-update ordering are tested.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: bug

Suggested reviewers: lidge-jun, wibias

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.84% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main security-focused change set and matches the audit findings addressed in the PR.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d995ef70f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
try {
const path = adminApiTokenFilePath();
assertSafeDirectory(dirname(path));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Register the generated admin token before creating its directory

On a default first start with no OPENCODEX_ADMIN_AUTH_TOKEN, this creates the config directory and later admin-api-token without calling recordOwnedConfigPath. Because createOwnership() only accepts an empty directory, the subsequent first config write cannot establish ownership; if ownership already exists, the token is still absent from the manifest. Consequently ocx uninstall refuses or reports a partial uninstall and leaves the config and credential behind. Register the token path before directory/file creation and include it among the initially owned paths.

Useful? React with 👍 / 👎.

const session: GuiSessionRecord = {
csrfToken: randomBytes(32).toString("base64url"),
origin,
expiresAt: now + GUI_SESSION_TTL_MS,

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 Renew or automatically refresh active GUI sessions

For every loopback dashboard opened longer than five minutes, this fixed expiration is reached even when the dashboard remains active, and requireManagementAuth() deletes the session without extending it. The GUI then handles the next 401 by prompting for the admin token, which a normal local user was never given; reloading happens to mint another session but is not performed automatically. Use sliding expiration on valid requests or add a safe automatic session-refresh flow so the advertised seamless local dashboard does not stop working after five minutes.

Useful? React with 👍 / 👎.

warnProxyBoundaryOnce();
return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" });
}
if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) {

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 Add loopback bypasses for environment-provided proxies

When HTTP_PROXY, HTTPS_PROXY, or ALL_PROXY is supplied directly by the environment and NO_PROXY is absent, a localhost/private provider with allowPrivateNetwork: true reaches this branch and is rejected. applyProxyEnv() only appends the documented automatic loopback exclusions when config.proxy is set, because it returns immediately otherwise, so common environment-proxy setups cannot run connection tests or model discovery against Ollama and other local providers. Append the loopback entries whenever any outbound proxy is active, or special-case loopback here.

Useful? React with 👍 / 👎.

Comment on lines +195 to +197
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
if (actual && equalSecret(actual, state.token)) return null;

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 Document the separate management credential

This now accepts only the independent admin token for ordinary management requests, but docs-site/src/content/docs/reference/configuration.md:235-256 and its translations still instruct remote users that OPENCODEX_API_AUTH_TOKEN or dashboard-generated apiKeys authenticate both /api/* and the data plane. Users following the published LAN setup will therefore receive 401s from the dashboard and operational scripts. Update the directly affected remote-access and architecture documentation to explain OPENCODEX_ADMIN_AUTH_TOKEN, the generated admin-token file, and which credentials belong to each plane.

AGENTS.md reference: AGENTS.md:L122-L123

Useful? React with 👍 / 👎.

Comment thread src/lib/admin-secrets.ts
Comment on lines +23 to +24
export function configuredAdminToken(configDir = getConfigDir(), env: NodeJS.ProcessEnv = process.env): string | null {
return env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim() || loadAdminTokenFromFile(configDir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist the configured admin token into background services

When a user installs a service with OPENCODEX_ADMIN_AUTH_TOKEN set, none of the launchd, systemd, or Windows service definitions propagate that variable; they persist only OPENCODEX_API_AUTH_TOKEN (src/service.ts:274-279, 329-331, and 908-917). The daemon therefore generates and accepts a different file-backed admin token, while CLI operations switched to this helper continue preferring the shell's configured token, causing management calls such as doctor, OAuth live updates, and graceful control to return 401. Persist the admin credential for services or otherwise ensure the daemon and clients select the same token.

Useful? React with 👍 / 👎.

Comment thread src/server/auth-cors.ts
Comment on lines 195 to +198
export function assertServerAuthConfig(config: OcxConfig): void {
if (isApiAuthRequired(config) && !configuredApiAuthToken(config)) {
throw new Error("OPENCODEX_API_AUTH_TOKEN is required when binding opencodex to a non-loopback hostname");
const hasConfiguredDataCredential = !!configuredApiAuthToken(config)
|| (config.apiKeys ?? []).some(entry => !!entry.key.trim());
if (isApiAuthRequired(config) && !hasConfiguredDataCredential) {

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 Accept configured data keys in the service installation gate

For a non-loopback configuration that relies solely on config.apiKeys, the new server startup predicate accepts the credential, but assertServiceAuthEnvironment() in src/service.ts:244-250 still checks only OPENCODEX_API_AUTH_TOKEN and aborts ocx service install. This makes a configuration explicitly accepted by startServer() unusable as a background service unless the user adds an unnecessary second credential. Reuse the same data-plane credential predicate in the service installer.

Useful? React with 👍 / 👎.

Comment thread gui/src/api.ts
// 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 👍 / 👎.

Comment thread src/server/index.ts
Comment on lines 391 to +393
if (url.pathname.startsWith("/api/")) {
const apiAuthError = requireApiAuth(req, config, "management");
if (apiAuthError) return withCors(apiAuthError, req, config);
const apiAuthError = requireManagementAuth(req, managementAuth, config);
if (apiAuthError) return withManagementCors(apiAuthError, req, config);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Authenticate the OpenCode catalog request with the admin token

Every ocx opencode launch calls fetchOpencodeProxyModels() for /api/models using opencodeApiKey(), which can return only OPENCODEX_API_AUTH_TOKEN, the service data-token file, a configured data key, or the loopback placeholder. This new management gate rejects all of those credentials, so the catalog request returns 401 and cmdOpencode() exits before spawning OpenCode. Use the admin credential for the management catalog fetch while retaining the data credential for the injected OpenCode provider.

Useful? React with 👍 / 👎.

Comment on lines +195 to +197
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
if (actual && equalSecret(actual, state.token)) return null;

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 Use management authentication for live OAuth health

fetchCodexHealthFromLiveProxy() in src/oauth/health.ts:323-346 still authenticates /api/codex-auth/accounts with OPENCODEX_API_AUTH_TOKEN or service-api-token. Those are now data-plane-only credentials, so even a healthy running proxy returns 401 and ocx status/ocx doctor report Codex health as unavailable. Switch this remaining management client to configuredAdminToken().

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 23

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/storage-policy-job-responsive.test.ts (1)

239-259: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Allowlist tracked environment names before unsetting them.

At Line 252, this test cannot pass with src/server/system-env.ts:371-380: revertSystemEnv() iterates every tracking.injectedKeys entry, including UNRELATED_USER_SETTING, and calls unsetLaunchctlEnv() on it. A tampered tracking file can therefore remove unrelated launchctl environment variables.

Filter/reject tracking entries against the canonical opencodex-owned environment-name allowlist before the unset loop; retain this regression test.

🤖 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 `@tests/storage-policy-job-responsive.test.ts` around lines 239 - 259, Update
revertSystemEnv() to filter tracking.injectedKeys against the canonical
opencodex-owned environment-name allowlist before calling unsetLaunchctlEnv().
Ensure untrusted or unrelated tracked names are ignored while valid owned
variables are still reverted, and retain the regression test in
tests/storage-policy-job-responsive.test.ts.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/cli/help.ts`:
- Around line 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.

In `@src/lib/config-ownership.ts`:
- Line 36: Update the append logic in the ownership-recording function around
the manifest path list to enforce MANIFEST_MAX_PATHS before adding a new path.
When the list has reached the ceiling, do not append and return false; otherwise
preserve the existing append behavior and success result.
- Around line 169-194: Update src/lib/config-ownership.ts:169-194 in
createOwnership to adopt non-empty legacy directories only when every entry is
in INITIAL_OWNED_PATHS or the two metadata filenames, while still refusing
unexpected entries; write both metadata files and avoid caching null results so
later adoption can succeed. The src/cli/index.ts:611-617 refusal branch requires
no direct change once adoption works; add a regression test covering uninstall
of a non-empty legacy directory and make residual-path guidance actionable
output if supported by the existing CLI flow.
- Around line 229-243: Make recordOwnedConfigPath non-throwing for all
filesystem and ownership operations, returning false when bookkeeping cannot be
completed. Extract the existing manifest-update tail into appendOwnedPath, and
retry ownership loading/creation once when a concurrent owner-file creation
fails with EEXIST so the losing process reuses the winner’s metadata instead of
caching null. Preserve the existing true result for already-recorded paths and
ensure failures do not propagate to callers.
- Around line 244-249: Update the manifest write flow around ownershipCache.set
to re-read the current on-disk manifest immediately before merging and writing,
rather than merging from the potentially stale ownershipCache snapshot. Preserve
existing paths recorded by other processes while adding rel, then update the
cache with the merged manifest; use the repository’s established locking
mechanism if needed to serialize concurrent read-modify-write operations.

In `@src/lib/destination-policy.ts`:
- Around line 272-288: Update the address validation loop around
validatedAddresses so a resolver result is accepted only when isIP(address)
returns 4 or 6; reject non-IP strings immediately instead of falling back to the
resolver-supplied family. Preserve the existing IPv4/IPv6 classification and
validated family assignment for parseable literals.

In `@src/lib/process-control.ts`:
- Line 70: Verify how resolveConfigDir/getConfigDir maps OPENCODEX_HOME before
constructing the token in the process-control flow. Update the
configuredAdminToken call to pass the resolved config directory rather than
env.OPENCODEX_HOME directly whenever resolution adds a suffix or normalization;
preserve the existing environment-token behavior.

In `@src/lib/provider-outbound.ts`:
- Around line 40-70: Update noProxyMatches to recognize CIDR entries in NO_PROXY
and match them against IP-literal hostnames, including IPv4 ranges such as
10.0.0.0/8 while preserving existing host, wildcard, and port matching. Ensure
providerOutboundGet no longer reports a missing NO_PROXY entry when the request
address is covered by a valid CIDR.
- Around line 153-158: Replace the plain Error thrown in the private-network
rejection branch of the outbound provider validation function with
ProviderOutboundPolicyError, preserving the existing actionable NO_PROXY message
and hostname. Ensure this rejection follows the same typed policy-error path as
the other policy checks so provider discovery classifies and surfaces it
correctly.

In `@src/lib/winsw.ts`:
- Line 137: Check the boolean result of recordOwnedConfigPath(getConfigDir(),
winswDir()) before proceeding with WinSW setup. If ownership registration fails,
stop the installation and surface the error; do not create winswDir() or
download the executable unless registration succeeds.

In `@src/server/gui-static.ts`:
- Around line 59-77: Update htmlResponse to HTML-escape session.token,
session.csrfToken, and session.origin before interpolating them into the meta
tag content attributes. Apply the escaping at this sink while preserving the
existing bootstrap structure and no-store response headers.

In `@src/server/index.ts`:
- Around line 740-743: Restrict the guiSessionCandidate condition in the request
handler to genuine document navigations that serve the SPA shell, rather than
every extensionless GET. Reuse the existing request headers/navigation checks
and the same GUI-route criteria used by serveGuiFile, ensuring issueGuiSession
is not called for arbitrary or missing-resource paths while preserving session
creation for the root and valid GUI document requests.

In `@src/server/management-auth.ts`:
- Around line 62-73: Update readExistingToken and initializeManagementAuthState
to classify invalid token content/shape (oversized, non-regular, symlink, or
regex mismatch) as a recoverable token error, regenerate the proxy-owned token
for that error and ENOENT, while continuing to fail closed on ACL-hardening and
other unsafe-path failures. Preserve the initialization failure reason in the
management auth state, and update requireManagementAuth to include that reason
in the 503 response so operators receive actionable guidance.
- Around line 27-28: Update the GUI session lifecycle around issueGuiSession and
requireManagementAuth so long-lived dashboards do not expire after the fixed
five-minute TTL. Add sliding renewal on each successfully authenticated
management request by extending the session expiry using GUI_SESSION_TTL_MS
before allowing the request, while preserving removeExpiredSessions cleanup and
existing authentication behavior.

In `@src/server/system-env.ts`:
- Around line 134-145: Update MANAGED_SYSTEM_ENV_NAMES to include
ANTHROPIC_MODEL, matching the key written and recorded by the system environment
injection flow so readTracking preserves it for cleanup. Keep the existing
allowlist filtering and other managed names unchanged.

In `@src/tray/windows.ts`:
- Line 480: Move the recordOwnedConfigPath call in the tray installation flow
until after the foreign-resource preflight checks succeed, and reject an
existing trayStatePath unless it represents the existing tray installation.
Ensure failed installations never add the path to the ownership manifest,
preventing removeOwnedConfigState from deleting unowned state.

In `@src/update/npm-invocation.mjs`:
- Around line 38-63: Update resolveNpmCommand to resolve npm through PATH on
POSIX as well as Windows, considering only absolute, non-empty PATH entries and
returning the resolved executable path; preserve the existing Windows extension
handling. Ensure relative or empty entries such as PATH=.:... cannot be
selected, and add regression coverage in the existing npm invocation tests for
both cases.

In `@tests/forward-admission-separation.test.ts`:
- Around line 91-97: Strengthen the “Images” case in the credential-rejection
table by using a supported image model that reaches the authentication gate, or
by asserting the response body/error code identifies an authentication error.
Update the case around the “Images” entry while preserving the existing request
structure and ensuring unrelated model-validation failures cannot satisfy the
test.

In `@tests/provider-live-models.test.ts`:
- Around line 174-199: Add model-cache cleanup to the redirect test’s finally
block by calling clearModelCache(PROVIDER) alongside warning.mockRestore().
Preserve the existing assertions and console warning cleanup, ensuring the
failure/discovery state recorded by gatherRoutedModels is removed after the
test.

In `@tests/provider-outbound.test.ts`:
- Around line 145-158: Make the spawned child environment in the “proxy mode
reaches one real proxy across outbound, connection-test, and model-discovery
paths” test deterministic by clearing HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and
NO_PROXY after spreading process.env. Preserve the fixture’s own proxy
configuration while preventing ambient proxy settings from affecting its routing
assertions.

In `@tests/server-auth.test.ts`:
- Around line 59-65: Remove the local managementHeaders helper from
server-auth.test.ts and import the shared helper from
tests/helpers/management-auth.ts. Preserve fail-fast behavior by adding an
explicit configuredAdminToken assertion at the specific test setup point that
requires initialization, while allowing managementHeaders to retain its shared
lenient and non-overwriting semantics.
- Line 613: Update the management request in the host-rebinding rejection test
to obtain the admin token through the existing fail-fast path used around lines
59-61, or reuse managementHeaders with the overridden host as shown near lines
671-674. Remove the configuredAdminToken() nullish sentinel so the test cannot
pass due to credential rejection instead of the host-rebinding guard.

In `@tests/server-management-auth.test.ts`:
- Around line 42-62: Shorten the fallback timer in websocketHandshakeOpens so it
expires before Bun’s default per-test timeout, then configure the affected
authentication test containing the five handshakes with an explicit timeout long
enough to cover all attempts. Preserve the helper’s false result on stalled
handshakes and the existing assertions for rejected and accepted credentials.

---

Outside diff comments:
In `@tests/storage-policy-job-responsive.test.ts`:
- Around line 239-259: Update revertSystemEnv() to filter tracking.injectedKeys
against the canonical opencodex-owned environment-name allowlist before calling
unsetLaunchctlEnv(). Ensure untrusted or unrelated tracked names are ignored
while valid owned variables are still reverted, and retain the regression test
in tests/storage-policy-job-responsive.test.ts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 90dcc582-5b56-498a-99c7-8c8f7c991f33

📥 Commits

Reviewing files that changed from the base of the PR and between c7e48fb and 7d995ef.

📒 Files selected for processing (117)
  • bin/ocx.mjs
  • docs-site/src/content/docs/reference/configuration.md
  • gui/src/api.ts
  • gui/tests/api-auth-memory.test.ts
  • gui/tests/models-empty-provider.test.tsx
  • src/adapters/mimo-free.ts
  • src/cli/claude.ts
  • src/cli/doctor.ts
  • src/cli/help.ts
  • src/cli/index.ts
  • src/cli/star-prompt.ts
  • src/codex/catalog/provider-fetch.ts
  • src/codex/shim.ts
  • src/config.ts
  • src/images/artifacts.ts
  • src/lib/admin-secrets.ts
  • src/lib/config-ownership.ts
  • src/lib/crash-guard.ts
  • src/lib/destination-policy.ts
  • src/lib/pinned-http.ts
  • src/lib/process-control.ts
  • src/lib/provider-outbound.ts
  • src/lib/provider-url.ts
  • src/lib/proxy-env.ts
  • src/lib/winsw.ts
  • src/oauth/kimi.ts
  • src/oauth/login-cli.ts
  • src/oauth/store.ts
  • src/server/auth-cors.ts
  • src/server/gui-static.ts
  • src/server/index.ts
  • src/server/management-api.ts
  • src/server/management-auth.ts
  • src/server/management/oauth-account-routes.ts
  • src/server/management/provider-routes.ts
  • src/server/management/system-routes.ts
  • src/server/system-env.ts
  • src/service.ts
  • src/tray/windows.ts
  • src/update/index.ts
  • src/update/job.ts
  • src/update/npm-invocation.d.mts
  • src/update/npm-invocation.mjs
  • src/usage/debug.ts
  • src/usage/log.ts
  • structure/02_config-and-codex-home.md
  • structure/04_transports-and-sidecars.md
  • structure/05_gui-and-management-api.md
  • tests/account-pool-management-api.test.ts
  • tests/api-debug.test.ts
  • tests/api-storage-cleanup.test.ts
  • tests/api-storage-policy.test.ts
  • tests/api-storage.test.ts
  • tests/api-usage.test.ts
  • tests/autostart-health.test.ts
  • tests/claude-desktop-config-path.test.ts
  • tests/claude-management-api.test.ts
  • tests/claude-messages-endpoint.test.ts
  • tests/claude-native-passthrough.test.ts
  • tests/claude-system-env-auto.test.ts
  • tests/cli-management-auth.test.ts
  • tests/codex-catalog.test.ts
  • tests/codex-sync-api.test.ts
  • tests/codex-v2-gate.test.ts
  • tests/combo-management-api.test.ts
  • tests/config-ownership-uninstall.test.ts
  • tests/config.test.ts
  • tests/cursor-hardening.test.ts
  • tests/destination-policy-resolved.test.ts
  • tests/effort-policy.test.ts
  • tests/fixtures/provider-outbound-e2e.ts
  • tests/forward-admission-separation.test.ts
  • tests/grok-management-api.test.ts
  • tests/gui-management-session.test.ts
  • tests/helpers/management-auth.ts
  • tests/injection-model-api.test.ts
  • tests/management-api-logs-metrics.test.ts
  • tests/management-provider-validation.test.ts
  • tests/memory-watchdog.test.ts
  • tests/model-visibility-management-api.test.ts
  • tests/native-model-toggle.test.ts
  • tests/oauth-accounts-api.test.ts
  • tests/oauth-login-cli-live-update.test.ts
  • tests/oauth-manual-code.test.ts
  • tests/oauth-public-surface.test.ts
  • tests/oauth-reauth-bind.test.ts
  • tests/ocx-launcher-source.test.ts
  • tests/openai-api-virtual-models.test.ts
  • tests/openai-provider-option-e2e.test.ts
  • tests/opencode-cli.test.ts
  • tests/process-control-graceful.test.ts
  • tests/project-config-warnings.test.ts
  • tests/provider-api-keys.test.ts
  • tests/provider-connection-test.test.ts
  • tests/provider-live-models.test.ts
  • tests/provider-outbound.test.ts
  • tests/server-403-permission-e2e.test.ts
  • tests/server-auth.test.ts
  • tests/server-clickjacking-headers.test.ts
  • tests/server-combo-failover-e2e.test.ts
  • tests/server-management-auth.test.ts
  • tests/settings-stream-mode.test.ts
  • tests/startup-action-control.test.ts
  • tests/storage-mutation-race.test.ts
  • tests/storage-policy-job-responsive.test.ts
  • tests/storage-restore-job-responsive.test.ts
  • tests/subagent-fallback-handle-responses.test.ts
  • tests/subagent-model-fallback-api.test.ts
  • tests/system-env.test.ts
  • tests/system-restart.test.ts
  • tests/uninstall.test.ts
  • tests/update-job.test.ts
  • tests/update-npm-invocation.test.ts
  • tests/update-stop-first.test.ts
  • tests/vision-anthropic.test.ts
  • tests/windows-deploy-close-regressions.test.ts
  • tests/windows-tray.test.ts

Comment thread src/cli/help.ts
Comment on lines +33 to +44
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.",
],

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.

};

const METADATA_MAX_BYTES = 64 * 1024;
const MANIFEST_MAX_PATHS = 1024;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

MANIFEST_MAX_PATHS is enforced on read but not on append — the manifest can invalidate itself.

isManifest (Line 113) rejects manifests with more than 1024 paths, yet Lines 244-248 append without any bound. If the list ever crosses 1024 (e.g. a future call site recording per-file artifact paths), loadOwnership starts returning null and uninstall permanently refuses with "metadata is missing or invalid" — the same dead end as an unowned directory, but self-inflicted. Bound the append and return false at the ceiling.

♻️ Proposed bound on append
   if (ownership.manifest.paths.includes(rel)) return true;
+  if (ownership.manifest.paths.length >= MANIFEST_MAX_PATHS) return false;
   const manifest = {

Also applies to: 244-248

🤖 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/lib/config-ownership.ts` at line 36, Update the append logic in the
ownership-recording function around the manifest path list to enforce
MANIFEST_MAX_PATHS before adding a new path. When the list has reached the
ceiling, do not append and return false; otherwise preserve the existing append
behavior and success result.

Comment on lines +169 to +194
function createOwnership(configDir: string): { owner: ConfigOwner; manifest: ConfigUninstallManifest } | null {
const rootStat = lstatSync(configDir);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || readdirSync(configDir).length !== 0) return null;
const owner: ConfigOwner = {
version: 1,
ownerId: randomUUID(),
root: canonicalRoot(configDir),
};
const manifest: ConfigUninstallManifest = { ...owner, paths: [...INITIAL_OWNED_PATHS] };
writeFileSync(join(configDir, CONFIG_OWNER_FILE), `${JSON.stringify(owner, null, 2)}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o600,
});
try {
writeFileSync(join(configDir, CONFIG_UNINSTALL_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o600,
});
} catch (error) {
try { unlinkSync(join(configDir, CONFIG_OWNER_FILE)); } catch { /* incomplete metadata fails closed */ }
throw error;
}
return { owner, manifest };
}

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 | 🔴 Critical | 🏗️ Heavy lift

Legacy config directories are never adopted, so ocx uninstall hard-fails for every pre-existing installation. The single root cause is createOwnership's requirement that the config dir be empty: an upgraded ~/.opencodex has files but no ownership metadata, so ownership can never be established, and the new uninstall path can only report refused.

  • src/lib/config-ownership.ts#L169-L194: drop the strict readdirSync(configDir).length !== 0 bail-out and adopt a directory whose entries are all in INITIAL_OWNED_PATHS (plus the two metadata files), writing owner + manifest at that point; keep refusing when unexpected entries are present. Also avoid caching the null result so a later successful adoption is not blocked for the process lifetime.
  • src/cli/index.ts#L611-L617: this branch is the user-visible symptom — with no adoption path it always takes the throw at Line 617 (refused uninstall: config ownership metadata is missing or invalid.) and exits 1 where the previous rmSync succeeded. Once adoption lands, add a regression test alongside tests/config-ownership-uninstall.test.ts covering uninstall of a non-empty legacy config dir, and consider printing the residual-path guidance as actionable output rather than only inside the thrown message.
📍 Affects 2 files
  • src/lib/config-ownership.ts#L169-L194 (this comment)
  • src/cli/index.ts#L611-L617
🤖 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/lib/config-ownership.ts` around lines 169 - 194, Update
src/lib/config-ownership.ts:169-194 in createOwnership to adopt non-empty legacy
directories only when every entry is in INITIAL_OWNED_PATHS or the two metadata
filenames, while still refusing unexpected entries; write both metadata files
and avoid caching null results so later adoption can succeed. The
src/cli/index.ts:611-617 refusal branch requires no direct change once adoption
works; add a regression test covering uninstall of a non-empty legacy directory
and make residual-path guidance actionable output if supported by the existing
CLI flow.

Source: Path instructions

Comment on lines +229 to +243
export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean {
const rel = manifestRelativePath(configDir, candidatePath);
if (!rel) return false;
const cacheKey = ownershipCacheKey(configDir);
if (!existsSync(configDir)) {
ownershipCache.delete(cacheKey);
mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
let ownership = ownershipCache.get(cacheKey);
if (ownership === undefined) {
ownership = loadOwnership(configDir) ?? createOwnership(configDir);
ownershipCache.set(cacheKey, ownership);
}
if (!ownership) return false;
if (ownership.manifest.paths.includes(rel)) return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

recordOwnedConfigPath can throw, but several call sites treat it as best-effort bookkeeping on hot/critical paths.

The function performs mkdirSync (Line 235), lstatSync/realpathSync.native (via createOwnership/canonicalRoot), writeFileSync(..., flag: "wx") (Line 178) and writeManifest (Line 248) — all of which can throw: EEXIST when two processes create the owner file concurrently, EACCES/EROFS on a read-only or foreign-owned config dir, ENOENT if the dir disappears between Line 233 and lstatSync. Ownership bookkeeping is metadata, not the caller's job:

  • src/usage/log.ts Line 321 → ensureUsageLogDir() is called from appendUsageEntry with no try/catch, so a manifest write failure now breaks usage recording on the request path.
  • src/images/artifacts.ts Lines 245 and 367 → an unguarded throw turns a successful image download into a hard failure.
  • src/service.ts Lines 150/259/1118/1155/1191/1330 → aborts a service install midway.

Make the recorder non-throwing (and retry once on the concurrent-create race so a losing racer picks up the winner's metadata instead of caching null):

🛡️ Proposed fail-soft recorder
 export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean {
-  const rel = manifestRelativePath(configDir, candidatePath);
-  if (!rel) return false;
-  const cacheKey = ownershipCacheKey(configDir);
-  if (!existsSync(configDir)) {
-    ownershipCache.delete(cacheKey);
-    mkdirSync(configDir, { recursive: true, mode: 0o700 });
-  }
-  let ownership = ownershipCache.get(cacheKey);
-  if (ownership === undefined) {
-    ownership = loadOwnership(configDir) ?? createOwnership(configDir);
-    ownershipCache.set(cacheKey, ownership);
-  }
+  try {
+    const rel = manifestRelativePath(configDir, candidatePath);
+    if (!rel) return false;
+    const cacheKey = ownershipCacheKey(configDir);
+    if (!existsSync(configDir)) {
+      ownershipCache.delete(cacheKey);
+      mkdirSync(configDir, { recursive: true, mode: 0o700 });
+    }
+    let ownership = ownershipCache.get(cacheKey);
+    if (ownership === undefined) {
+      try {
+        ownership = loadOwnership(configDir) ?? createOwnership(configDir);
+      } catch {
+        // lost a concurrent create race — re-read the winner's metadata
+        ownership = loadOwnership(configDir);
+      }
+      ownershipCache.set(cacheKey, ownership);
+    }
+    return appendOwnedPath(configDir, cacheKey, ownership, rel);
+  } catch {
+    return false;
+  }
+}

…with the existing tail (Lines 242-250) moved into appendOwnedPath.

🤖 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/lib/config-ownership.ts` around lines 229 - 243, Make
recordOwnedConfigPath non-throwing for all filesystem and ownership operations,
returning false when bookkeeping cannot be completed. Extract the existing
manifest-update tail into appendOwnedPath, and retry ownership loading/creation
once when a concurrent owner-file creation fails with EEXIST so the losing
process reuses the winner’s metadata instead of caching null. Preserve the
existing true result for already-recorded paths and ensure failures do not
propagate to callers.

Comment on lines +244 to +249
const manifest = {
...ownership.manifest,
paths: [...ownership.manifest.paths, rel].sort(),
};
writeManifest(configDir, manifest);
ownershipCache.set(cacheKey, { owner: ownership.owner, manifest });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Stale-cache manifest writes silently drop paths recorded by other processes.

ownershipCache holds a per-process snapshot of the manifest, and Lines 244-248 rewrite the whole file from that snapshot via renameSync (last writer wins). opencodex routinely has several processes touching the same config dir (server, tray, CLI, update job). Sequence:

  1. Server caches manifest = {paths: [...INITIAL]}.
  2. Tray process records tray-heartbeat.json → file now contains it.
  3. Server records a new path from its stale snapshot → the rename replaces the file and tray-heartbeat.json is gone from the manifest.

The lost entry is never re-added (the tray's own cache says it is already present, Line 243), so removeOwnedConfigState leaves it behind and returns status: "partial" → uninstall fails with residual paths. Re-read the on-disk manifest immediately before merging instead of trusting the cache:

🔀 Proposed merge-before-write
-  if (ownership.manifest.paths.includes(rel)) return true;
-  const manifest = {
-    ...ownership.manifest,
-    paths: [...ownership.manifest.paths, rel].sort(),
-  };
+  if (ownership.manifest.paths.includes(rel)) return true;
+  // Re-read so concurrently recorded paths from other processes are preserved.
+  const current = loadOwnership(configDir)?.manifest ?? ownership.manifest;
+  if (current.paths.includes(rel)) {
+    ownershipCache.set(cacheKey, { owner: ownership.owner, manifest: current });
+    return true;
+  }
+  const manifest = {
+    ...current,
+    paths: [...current.paths, rel].sort(),
+  };

A file lock (the repo already uses auth.store.lock-style locks) would be the fully robust version if you expect real contention.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const manifest = {
...ownership.manifest,
paths: [...ownership.manifest.paths, rel].sort(),
};
writeManifest(configDir, manifest);
ownershipCache.set(cacheKey, { owner: ownership.owner, manifest });
if (ownership.manifest.paths.includes(rel)) return true;
// Re-read so concurrently recorded paths from other processes are preserved.
const current = loadOwnership(configDir)?.manifest ?? ownership.manifest;
if (current.paths.includes(rel)) {
ownershipCache.set(cacheKey, { owner: ownership.owner, manifest: current });
return true;
}
const manifest = {
...current,
paths: [...current.paths, rel].sort(),
};
writeManifest(configDir, manifest);
ownershipCache.set(cacheKey, { owner: ownership.owner, manifest });
🤖 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/lib/config-ownership.ts` around lines 244 - 249, Update the manifest
write flow around ownershipCache.set to re-read the current on-disk manifest
immediately before merging and writing, rather than merging from the potentially
stale ownershipCache snapshot. Preserve existing paths recorded by other
processes while adding rel, then update the cache with the merged manifest; use
the repository’s established locking mechanism if needed to serialize concurrent
read-modify-write operations.

Comment on lines +174 to +199
test("redirect responses log a credential-safe final-URL hint and fall back", async () => {
const warning = spyOn(console, "warn").mockImplementation(() => {});
const redirectTarget = new URL("https://final.example/v1/models?token=secret#fragment");
redirectTarget.username = "user";
redirectTarget.password = "password";
globalThis.fetch = (async () => new Response(null, {
status: 302,
headers: {
location: redirectTarget.toString(),
},
})) as typeof fetch;

try {
const models = await gatherRoutedModels(config());
const ids = models.filter(model => model.provider === PROVIDER).map(model => model.id);
const warningText = warning.mock.calls.flat().join(" ");

expect(ids.sort()).toEqual(["grok-4.3", "grok-4.5"]);
expect(warningText).toContain("returned 302 redirect");
expect(warningText).toContain("https://final.example/v1/models");
expect(warningText).not.toContain("user:password");
expect(warningText).not.toContain("token=secret");
} finally {
warning.mockRestore();
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Asymmetric cache cleanup: this test leaves discovery state for PROVIDER behind while its sibling clears it.

The link-local test at Lines 209-227 wraps its assertions in try { … } finally { clearModelCache(providerName) }, but this redirect test's finally (Lines 196-198) only restores the console.warn spy. gatherRoutedModelsfetchProviderModels records failure/discovery state per provider (failureAt, discoveryStatus in src/codex/model-cache.ts), so the 302 failure recorded for xai-live-test can persist into later tests in this file that reuse PROVIDER and silently mask a real live-discovery call (order-dependent flake). Unless a suite-level beforeEach/afterEach already resets it, add clearModelCache(PROVIDER) to the finally.

♻️ Suggested cleanup
     } finally {
       warning.mockRestore();
+      clearModelCache(PROVIDER);
     }
#!/bin/bash
# Does this suite already reset model-cache/fetch state between tests?
rg -nP -C4 'beforeEach|afterEach|clearModelCache|originalFetch' tests/provider-live-models.test.ts
🤖 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 `@tests/provider-live-models.test.ts` around lines 174 - 199, Add model-cache
cleanup to the redirect test’s finally block by calling
clearModelCache(PROVIDER) alongside warning.mockRestore(). Preserve the existing
assertions and console warning cleanup, ensuring the failure/discovery state
recorded by gatherRoutedModels is removed after the test.

Comment on lines +145 to +158
test("proxy mode reaches one real proxy across outbound, connection-test, and model-discovery paths", async () => {
const childHome = mkdtempSync(join(tmpdir(), "ocx-provider-proxy-e2e-"));
const child = Bun.spawn([
process.execPath,
"tests/fixtures/provider-outbound-e2e.ts",
], {
cwd: process.cwd(),
env: {
...process.env,
OPENCODEX_HOME: childHome,
},
stdout: "pipe",
stderr: "pipe",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The spawned fixture inherits the developer's/CI's ambient proxy env, which can invalidate the routing assertions below.

Lines 152-155 spread ...process.env into the child. Unlike the in-process tests (Lines 48, 94, 121), nothing strips HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY here, so a machine or CI runner that exports a corporate proxy (or a NO_PROXY=*) changes which requests the fixture routes through its own stub proxy — breaking the exact-order assertions at Lines 198-204 with a confusing "proxyRequests mismatch" rather than a clear failure. Make the child env deterministic by clearing the proxy keys the fixture is expected to set itself.

🛡️ Proposed hardening
     const childHome = mkdtempSync(join(tmpdir(), "ocx-provider-proxy-e2e-"));
+    const cleanEnv = { ...process.env };
+    for (const key of proxyKeys) delete cleanEnv[key];
     const child = Bun.spawn([
       process.execPath,
       "tests/fixtures/provider-outbound-e2e.ts",
     ], {
       cwd: process.cwd(),
       env: {
-        ...process.env,
+        ...cleanEnv,
         OPENCODEX_HOME: childHome,
       },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("proxy mode reaches one real proxy across outbound, connection-test, and model-discovery paths", async () => {
const childHome = mkdtempSync(join(tmpdir(), "ocx-provider-proxy-e2e-"));
const child = Bun.spawn([
process.execPath,
"tests/fixtures/provider-outbound-e2e.ts",
], {
cwd: process.cwd(),
env: {
...process.env,
OPENCODEX_HOME: childHome,
},
stdout: "pipe",
stderr: "pipe",
});
test("proxy mode reaches one real proxy across outbound, connection-test, and model-discovery paths", async () => {
const childHome = mkdtempSync(join(tmpdir(), "ocx-provider-proxy-e2e-"));
const cleanEnv = { ...process.env };
for (const key of proxyKeys) delete cleanEnv[key];
const child = Bun.spawn([
process.execPath,
"tests/fixtures/provider-outbound-e2e.ts",
], {
cwd: process.cwd(),
env: {
...cleanEnv,
OPENCODEX_HOME: childHome,
},
🤖 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 `@tests/provider-outbound.test.ts` around lines 145 - 158, Make the spawned
child environment in the “proxy mode reaches one real proxy across outbound,
connection-test, and model-discovery paths” test deterministic by clearing
HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY after spreading process.env.
Preserve the fixture’s own proxy configuration while preventing ambient proxy
settings from affecting its routing assertions.

Comment thread tests/server-auth.test.ts
Comment on lines +59 to +65
function managementHeaders(initial?: HeadersInit): Headers {
const token = configuredAdminToken();
if (!token) throw new Error("management token was not initialized");
const headers = new Headers(initial);
headers.set("x-opencodex-api-key", token);
return headers;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This local managementHeaders duplicates tests/helpers/management-auth.ts:27-32 with divergent semantics.

The shared helper added in this same PR is lenient (if (token && !headers.has(...))), while this copy hard-throws on a missing token and unconditionally overwrites an existing x-opencodex-api-key. Two same-named helpers with different behavior across tests/ will drift the moment one is updated — and the overwrite semantics silently defeat any future test here that wants to send a deliberately wrong key. Import the shared helper and keep the fail-fast assertion explicit at the point where it matters.

♻️ Suggested consolidation
-function managementHeaders(initial?: HeadersInit): Headers {
-  const token = configuredAdminToken();
-  if (!token) throw new Error("management token was not initialized");
-  const headers = new Headers(initial);
-  headers.set("x-opencodex-api-key", token);
-  return headers;
-}
+// tests/helpers/management-auth.ts owns the header shape; assert the token exists
+// so a missing admin token fails loudly instead of turning 200-expectations into 401s.
+function managementHeaders(initial?: HeadersInit): Headers {
+  if (!configuredAdminToken()) throw new Error("management token was not initialized");
+  return sharedManagementHeaders(initial);
+}

plus the import:

+import { managementHeaders as sharedManagementHeaders } from "./helpers/management-auth";

As per path instructions for tests/** ("Tests are flat Bun tests under tests/"), the shared tests/helpers/management-auth.ts module is the right home for this shape.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function managementHeaders(initial?: HeadersInit): Headers {
const token = configuredAdminToken();
if (!token) throw new Error("management token was not initialized");
const headers = new Headers(initial);
headers.set("x-opencodex-api-key", token);
return headers;
}
import { managementHeaders as sharedManagementHeaders } from "./helpers/management-auth";
function managementHeaders(initial?: HeadersInit): Headers {
if (!configuredAdminToken()) throw new Error("management token was not initialized");
return sharedManagementHeaders(initial);
}
🤖 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 `@tests/server-auth.test.ts` around lines 59 - 65, Remove the local
managementHeaders helper from server-auth.test.ts and import the shared helper
from tests/helpers/management-auth.ts. Preserve fail-fast behavior by adding an
explicit configuredAdminToken assertion at the specific test setup point that
requires initialization, while allowing managementHeaders to retain its shared
lenient and non-overwriting semantics.

Source: Path instructions

Comment thread tests/server-auth.test.ts
headers: {
host: `attacker.test:${server.port}`,
origin: attackerOrigin,
"x-opencodex-api-key": configuredAdminToken() ?? "missing-admin-token",

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

The ?? "missing-admin-token" sentinel can make this rejection test pass for the wrong reason.

This test asserts that a management request with a rebound Host header is rejected. If configuredAdminToken() ever returns null (admin token file not yet generated, env var cleared by a preceding test), the request is sent with the bogus sentinel and gets rejected by the credential check instead of the host-rebinding guard — the assertion still goes green while the security control under test is never exercised. Lines 59-61 already chose fail-fast for exactly this reason; use the same path here.

🛡️ Proposed fix
-          "x-opencodex-api-key": configuredAdminToken() ?? "missing-admin-token",
+          ...Object.fromEntries(managementHeaders()),

(or pass headers: managementHeaders({ host: … }) as done at Lines 671-674)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"x-opencodex-api-key": configuredAdminToken() ?? "missing-admin-token",
...Object.fromEntries(managementHeaders()),
🤖 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 `@tests/server-auth.test.ts` at line 613, Update the management request in the
host-rebinding rejection test to obtain the admin token through the existing
fail-fast path used around lines 59-61, or reuse managementHeaders with the
overridden host as shown near lines 671-674. Remove the configuredAdminToken()
nullish sentinel so the test cannot pass due to credential rejection instead of
the host-rebinding guard.

Comment on lines +42 to +62
function websocketHandshakeOpens(url: URL, token: string): Promise<boolean> {
return new Promise(resolve => {
const target = new URL("/v1/responses", url);
target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(target, {
headers: { "X-OpenCodex-API-Key": token },
} as unknown as string[]);
let settled = false;
const finish = (opened: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { socket.close(); } catch { /* already closed */ }
resolve(opened);
};
socket.addEventListener("open", () => finish(true));
socket.addEventListener("error", () => finish(false));
socket.addEventListener("close", () => finish(false));
const timer = setTimeout(() => finish(false), 5_000);
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

The handshake fallback timer (5 000 ms) equals Bun's default per-test timeout, so a slow handshake aborts the test instead of yielding a clean false.

websocketHandshakeOpens arms setTimeout(() => finish(false), 5_000) at Line 60, and the test at Lines 374-396 performs five handshakes (two rejected creds × handshake, plus the accepted data-secret one) with no explicit test(..., timeout) argument. Normally each rejection settles immediately on error/close, but if any single handshake stalls, the helper's own 5 s guard can never fire first — Bun's default 5 s test timeout wins and the failure surfaces as an opaque timeout rather than expect(...).toBe(false). Compare tests/provider-outbound.test.ts Line 209, which sets an explicit 15_000. Shorten the helper's guard and give this test headroom.

🛡️ Proposed fix
-    const timer = setTimeout(() => finish(false), 5_000);
+    // Must stay well below the per-test timeout so a stalled handshake reports
+    // `false` instead of aborting the whole test.
+    const timer = setTimeout(() => finish(false), 2_000);
-  });
+  }, 20_000);

(applied to the test closing at Line 396)

Also applies to: 374-396

🤖 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 `@tests/server-management-auth.test.ts` around lines 42 - 62, Shorten the
fallback timer in websocketHandshakeOpens so it expires before Bun’s default
per-test timeout, then configure the affected authentication test containing the
five handshakes with an explicit timeout long enough to cover all attempts.
Preserve the helper’s false result on stalled handshakes and the existing
assertions for rejected and accepted credentials.

lidge-jun added a commit that referenced this pull request Jul 29, 2026
#697 moved provider model discovery onto the pinned outbound transport,
which resolves and pins the peer address instead of reading
`globalThis.fetch`. Catalog tests that stub the global therefore escaped
their stub and tried real DNS, failing with
DestinationDnsResolutionError — 25 failures across four files after the
merge.

`providerOutboundGet` already supports a caller-owned executor via
`provider.fetch`; #697 used it in two spots. Extend that to the rest:
a shared `withStubbedProviderFetch` helper attaches an executor that
defers to whatever `globalThis.fetch` is installed at call time, so a
test may still swap its stub after building the config.

This changes test wiring only. Production discovery keeps the pinned
transport with no injected executor, which is the security property
#697 added. The suite also gets faster — codex-catalog.test.ts drops
from ~33s to ~2.5s now that four cases no longer wait on real DNS
timeouts.
@lidge-jun
lidge-jun merged commit 43f4429 into lidge-jun:dev Jul 29, 2026
8 checks passed
lidge-jun added a commit that referenced this pull request Jul 29, 2026
The same #697 transport change that broke the root catalog tests also
breaks four cases in gui/tests/models-empty-provider.test.tsx: they stub
`globalThis.fetch`, discovery no longer reads it, and the calls hang on
real DNS until the 5s test timeout.

Reuse the `withStubbedProviderFetch` helper so these configs carry the
caller-owned executor too. Test wiring only.

This one hid from the root suite: `bun run test` covers `tests/`, while
CI runs `cd gui && bun test tests` as a separate step, so the failure
only appeared on the runner.
lidge-jun added a commit that referenced this pull request Jul 29, 2026
Promotes the current dev line to main.

Highlights since v2.7.42:
- Security hardening batch 1 (#697): management/data credential split,
  blank-hostname rejection, pinned provider discovery transports,
  clickjacking and websocket-origin defenses, and safer update/uninstall
  paths.
- 31 bug fixes and 18 features across catalog, Windows service, Cursor
  bridge, Codex account routing, and the GUI.
- Account-pool settings card no longer prints its title twice.

BREAKING CHANGES:
- A blank "hostname" is rejected on write and degraded to 127.0.0.1 on
  read with a warning.
- Data-plane credentials (OPENCODEX_API_AUTH_TOKEN, service-api-token,
  config.apiKeys) no longer reach /api/*. Operational scripts must move
  to OPENCODEX_ADMIN_AUTH_TOKEN. Model clients are unaffected.
- A non-loopback dashboard no longer mints session tokens.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants