From 2f75a794009077069ab6b22f0f910a0198b1ec9f Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 18:30:01 +0900 Subject: [PATCH 1/5] fix(settings): apply the Desktop switches and report effective state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4809. --desktop-authless and --client-compaction only mean anything through the injected config.toml, so persisting them was never applying them. PUT /api/settings persisted and then converged the catalog, under a comment asserting that the injector re-read config and rewrote the form. convergeCodexCatalog rejects any scope but "catalog" and never reaches injectCodexConfig, so the injected shape stayed as it was until a separate ocx sync. The CLI discarded the response body and printed a fixed "System settings updated.", so nothing said a second step was owed. A quieter failure sat beside it. On a non-loopback bind without unauthenticatedLoopbackListener, standaloneCodexRoutingTarget drops the authless flag, while both GET and PUT reported the configured true. The user read back the value they set and got the behavior they did not. The route now runs the real injection, after catalog convergence and after the config mutation transaction has closed. That ordering is not incidental: coordinated Codex writes take the Codex write lock before the config mutation lock, so awaiting the injector inside that transaction would invert it, and the injected model_catalog_json should point at a catalog that has settled. Each switch is reported as three separate facts — the stored value, the effective value this bind and role will actually produce, and whether config.toml was applied, with the reason and whether a retry is worth trying. Effective values come from isEffectiveCodexDesktopAuthless and a new isEffectiveCodexClientCompaction beside it, rather than a second copy of the predicate, because the reporting answer and the injection answer diverging is the defect itself. The report also states the auth-source consequence. The flag decides requires_openai_auth in the injected provider table, which upstream reads to decide whether to ask the user to sign in at all, so flipping it changes whose identity is in use. The user is told while they are making the change. CodexInjectResult gains a declared retryable field. codexInjectLockOutcome has always set it for a busy write lock and the type has always omitted it, so a caller wanting to separate "someone else is writing right now" from "this will fail the same way forever" had to reach for an undeclared field. history_paginated_requires_native_writer is not a failure on this path: apply already stands the relabel down and writes the config half, so a paginated home applies normally. The pre-existing top-level codexDesktopAuthless and codexClientCompaction booleans keep reporting the configured value; the report is additive. The stale comment is rewritten to describe what the code does. Verification: hosted CI only. The local suite, typecheck, build and the ocx binary were deliberately not run for this change. --- scripts/test-layout/layout.json | 1 + src/cli/system-command.ts | 71 +++++- src/codex/desktop-switches.ts | 145 +++++++++++ src/codex/inject.ts | 2 + src/codex/loopback-target.ts | 9 + src/server/management/config-routes.ts | 32 ++- structure/config.md | 28 +++ tests/cli/cli-headless-parity.test.ts | 78 +++++- .../settings-desktop-switch-apply.test.ts | 75 ++++++ tests/config/settings-stream-mode.test.ts | 231 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 11 files changed, 666 insertions(+), 7 deletions(-) create mode 100644 src/codex/desktop-switches.ts create mode 100644 tests/config/settings-desktop-switch-apply.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 291729e77c..84bb642961 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1284,6 +1284,7 @@ "service.test.ts": "service", "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", + "settings-desktop-switch-apply.test.ts": "config", "settings-main-account-hard-lock.test.ts": "config", "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config", diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index a3b46b49d6..2bdd798d07 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -42,6 +42,72 @@ async function status(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, summaryLines(result)); } +function recordValue(value: unknown): Record | undefined { + return value !== null && typeof value === "object" ? value as Record : undefined; +} + +function desktopSwitchInertReason(reason: unknown): string { + if (reason === "client_role") return "this proxy is running in the client role"; + if (reason === "non_loopback_bind_requires_admission_token") { + return "a non-loopback bind requires an admission token, so this flag is inert"; + } + return "the stored setting is not effective in the current runtime configuration"; +} + +function desktopSwitchApplyReason(reason: unknown): string { + if (reason === "not_requested") return "no desktop switch rewrite was requested"; + if (reason === "proxy_not_running") return "the proxy is not running"; + if (reason === "integration_disabled") return "Codex integration is disabled"; + if (reason === "write_lock_busy") return "the Codex config write lock is busy"; + if (reason === "injection_refused") return "Codex config injection was refused"; + return "the rewrite could not be completed"; +} + +function settingsUpdateLines( + result: unknown, + changed: { desktopAuthless: boolean; clientCompaction: boolean }, +): string[] { + if (!changed.desktopAuthless && !changed.clientCompaction) return ["System settings updated."]; + const switches = recordValue(recordValue(result)?.codexDesktopSwitches); + if (!switches) return ["System settings updated."]; + + const lines: string[] = []; + const appendSwitch = (key: string, label: string): boolean => { + const state = recordValue(switches[key]); + if (!state || typeof state.stored !== "boolean" || typeof state.effective !== "boolean") return false; + lines.push(`${label}: stored ${state.stored ? "on" : "off"}.`); + // The effective value is always stated, even when it matches. Printing it only on a + // mismatch would make silence ambiguous — the reader could not tell "the stored value is + // in force" from "this build does not report effective state", and that ambiguity is a + // smaller version of the defect being fixed. + lines.push(state.effective === state.stored + ? `${label}: effective ${state.effective ? "on" : "off"}.` + : `${label}: effective ${state.effective ? "on" : "off"} because ${desktopSwitchInertReason(state.inertReason)}.`); + return true; + }; + + if (changed.desktopAuthless && !appendSwitch("codexDesktopAuthless", "Codex desktop authless")) { + return ["System settings updated."]; + } + if (changed.clientCompaction && !appendSwitch("codexClientCompaction", "Codex client compaction")) { + return ["System settings updated."]; + } + + const apply = recordValue(switches.apply); + const authSource = recordValue(switches.authSource); + if (!apply || typeof apply.applied !== "boolean" || !authSource || typeof authSource.summary !== "string") { + return ["System settings updated."]; + } + if (apply.applied) { + lines.push("Codex config: ~/.codex/config.toml was rewritten."); + } else { + const detail = typeof apply.detail === "string" && apply.detail.length > 0 ? ` Details: ${apply.detail}` : ""; + lines.push(`Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail} Run 'ocx sync' to apply the stored settings.`); + } + lines.push(`Auth source: ${authSource.summary}`); + return lines; +} + async function settings(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const wantsJson = takeFlag(args, "--json"); @@ -63,7 +129,10 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise { ...(clientCompaction !== undefined ? { codexClientCompaction: clientCompaction } : {}), }; const result = await runtimeRequest("/api/settings", { method: "PUT", body: JSON.stringify(body) }, deps); - printData(result, wantsJson, ["System settings updated."]); + printData(result, wantsJson, settingsUpdateLines(result, { + desktopAuthless: desktopAuthless !== undefined, + clientCompaction: clientCompaction !== undefined, + })); } async function startup(argv: string[], deps: RuntimeApiDeps): Promise { diff --git a/src/codex/desktop-switches.ts b/src/codex/desktop-switches.ts new file mode 100644 index 0000000000..4e8126c075 --- /dev/null +++ b/src/codex/desktop-switches.ts @@ -0,0 +1,145 @@ +import type { OcxConfig } from "../types"; +import { shouldSyncCodexOnStart } from "./desired-state"; +import { + isEffectiveCodexClientCompaction, + isEffectiveCodexDesktopAuthless, +} from "./loopback-target"; + +export type CodexDesktopSwitchInertReason = + | "client_role" + | "non_loopback_bind_requires_admission_token"; + +export interface CodexDesktopSwitchState { + stored: boolean; + effective: boolean; + inertReason?: CodexDesktopSwitchInertReason; +} + +export type CodexDesktopSwitchApplyReason = + | "not_requested" + | "proxy_not_running" + | "integration_disabled" + | "write_lock_busy" + | "injection_refused"; + +export type CodexDesktopSwitchApply = + | { applied: true } + | { + applied: false; + reason: CodexDesktopSwitchApplyReason; + retryable: boolean; + detail?: string; + }; + +export interface CodexDesktopSwitchReport { + codexDesktopAuthless: CodexDesktopSwitchState; + codexClientCompaction: CodexDesktopSwitchState; + apply: CodexDesktopSwitchApply; + authSource: { presentsCodexAccount: boolean; summary: string }; +} + +type DesktopSwitchConfig = Pick< + OcxConfig, + | "clientIntegrations" + | "runtimeRole" + | "hostname" + | "unauthenticatedLoopbackListener" + | "codexDesktopAuthless" + | "codexClientCompaction" +>; + +function describeSwitch( + stored: boolean, + effective: boolean, + config: Pick, +): CodexDesktopSwitchState { + if (!stored || effective) return { stored, effective }; + return { + stored, + effective, + inertReason: config.runtimeRole === "client" + ? "client_role" + : "non_loopback_bind_requires_admission_token", + }; +} + +export function describeCodexDesktopSwitches( + config: DesktopSwitchConfig, + apply: CodexDesktopSwitchApply, +): CodexDesktopSwitchReport { + const authlessStored = config.codexDesktopAuthless === true; + const authlessEffective = isEffectiveCodexDesktopAuthless(config); + const compactionStored = config.codexClientCompaction === true; + const compactionEffective = isEffectiveCodexClientCompaction(config); + + return { + codexDesktopAuthless: describeSwitch(authlessStored, authlessEffective, config), + codexClientCompaction: describeSwitch(compactionStored, compactionEffective, config), + apply, + authSource: authlessEffective + ? { + presentsCodexAccount: false, + summary: "The Codex app will not require its own account sign-in.", + } + : { + presentsCodexAccount: true, + summary: "The Codex app will require its own account sign-in.", + }, + }; +} + +export async function applyCodexDesktopSwitches( + config: OcxConfig, +): Promise { + if (!shouldSyncCodexOnStart(config)) { + return { applied: false, reason: "integration_disabled", retryable: false }; + } + + const { readRuntimePort } = await import("../config/process-state"); + const runtime = readRuntimePort(process.pid); + if (!runtime) { + return { applied: false, reason: "proxy_not_running", retryable: true }; + } + + try { + // Imported at call time, not module load. The settings route reaches this module on + // every GET, and pulling the whole injection graph in just to report stored-versus- + // effective state would put it on a read path that never writes anything. + const { injectCodexConfig } = await import("./inject"); + const result = await injectCodexConfig(runtime.port, config); + if (result.status === "skipped") { + return { + applied: false, + reason: "integration_disabled", + retryable: false, + detail: result.message, + }; + } + if (result.success) { + // history_paginated_requires_native_writer stands down only the legacy relabel; + // apply still writes the routing and catalog half for paginated Codex homes. + return { applied: true }; + } + if (result.retryable === true) { + return { + applied: false, + reason: "write_lock_busy", + retryable: true, + detail: result.message, + }; + } + return { + applied: false, + reason: "injection_refused", + retryable: false, + detail: result.message, + }; + } catch (error) { + return { + applied: false, + reason: "injection_refused", + retryable: false, + detail: error instanceof Error ? error.message : "Codex config injection failed.", + }; + } +} diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 3318a6a28d..152f4bb75b 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -158,6 +158,8 @@ export interface CodexInjectResult { */ historyPreflightFailureReason?: string; status?: "skipped"; + /** Busy write lock, emitted by `codexInjectLockOutcome` and undeclared here until #4809. */ + retryable?: boolean; /** `hub-gated` is the hub-role gate (#4236), distinct from the user's own OFF switch. */ skippedReason?: "desired_disabled" | "desired_enabled" | "hub-gated"; nativeSubagentDefaultsWarning?: string; diff --git a/src/codex/loopback-target.ts b/src/codex/loopback-target.ts index 6c4f242e09..133de28d0d 100644 --- a/src/codex/loopback-target.ts +++ b/src/codex/loopback-target.ts @@ -97,3 +97,12 @@ export function isEffectiveCodexDesktopAuthless( && config.runtimeRole !== "client" && !shouldInjectApiAuthHeader(config); } + +/** Keep reporting aligned with the admission-token gate used by standalone injection. */ +export function isEffectiveCodexClientCompaction( + config: Pick | undefined, +): boolean { + return config?.codexClientCompaction === true + && config.runtimeRole !== "client" + && !shouldInjectApiAuthHeader(config); +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 03d549a24a..75a106cd84 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -3,6 +3,11 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { + applyCodexDesktopSwitches, + describeCodexDesktopSwitches, + type CodexDesktopSwitchApply, +} from "../../codex/desktop-switches"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -328,6 +333,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { }); }); -describe("ocx system settings client compaction", () => { +describe("ocx system settings desktop switches", () => { test("persists the explicit boolean through the shared settings endpoint", async () => { const { requests, deps } = fakeRuntime((_req, body) => ({ ok: true, ...body })); const logSpy = spyOn(console, "log").mockImplementation(() => {}); @@ -69,6 +69,82 @@ describe("ocx system settings client compaction", () => { logSpy.mockRestore(); } }); + + test("prints stored and effective state, a deferred apply, and the auth-source consequence", async () => { + const { deps } = fakeRuntime(() => ({ + ok: true, + codexDesktopAuthless: true, + codexDesktopSwitches: { + codexDesktopAuthless: { + stored: true, + effective: false, + inertReason: "non_loopback_bind_requires_admission_token", + }, + codexClientCompaction: { stored: false, effective: false }, + apply: { + applied: false, + reason: "write_lock_busy", + retryable: true, + detail: "another Codex config writer owns the lock", + }, + authSource: { + presentsCodexAccount: true, + summary: "The Codex app will require its own account sign-in.", + }, + }, + })); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleSystemCommand(["settings", "--desktop-authless", "on"], deps)).toBe(0); + const output = logSpy.mock.calls.flat().join("\n"); + expect(output).toContain("Codex desktop authless: stored on."); + expect(output).toContain("Codex desktop authless: effective off because a non-loopback bind requires an admission token"); + expect(output).toContain("Codex config: ~/.codex/config.toml was not rewritten because the Codex config write lock is busy."); + expect(output).toContain("Details: another Codex config writer owns the lock"); + expect(output).toContain("Run 'ocx sync' to apply the stored settings."); + expect(output).toContain("Auth source: The Codex app will require its own account sign-in."); + } finally { + logSpy.mockRestore(); + } + }); + + test("prints a completed inline apply and the authless identity consequence", async () => { + const { deps } = fakeRuntime(() => ({ + ok: true, + codexDesktopAuthless: true, + codexDesktopSwitches: { + codexDesktopAuthless: { stored: true, effective: true }, + codexClientCompaction: { stored: false, effective: false }, + apply: { applied: true }, + authSource: { + presentsCodexAccount: false, + summary: "The Codex app will not require its own account sign-in.", + }, + }, + })); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleSystemCommand(["settings", "--desktop-authless", "on"], deps)).toBe(0); + const output = logSpy.mock.calls.flat().join("\n"); + expect(output).toContain("Codex desktop authless: stored on."); + expect(output).toContain("Codex desktop authless: effective on."); + expect(output).toContain("Codex config: ~/.codex/config.toml was rewritten."); + expect(output).toContain("Auth source: The Codex app will not require its own account sign-in."); + } finally { + logSpy.mockRestore(); + } + }); + + test("keeps the legacy success line when an older server omits the switch report", async () => { + const { deps } = fakeRuntime((_req, body) => ({ ok: true, ...body })); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleSystemCommand(["settings", "--desktop-authless", "on"], deps)).toBe(0); + expect(logSpy.mock.calls.flat().join("\n")).toBe("System settings updated."); + } finally { + logSpy.mockRestore(); + } + }); }); describe("ocx agent sidecar --list (#2188)", () => { diff --git a/tests/config/settings-desktop-switch-apply.test.ts b/tests/config/settings-desktop-switch-apply.test.ts new file mode 100644 index 0000000000..20e7782001 --- /dev/null +++ b/tests/config/settings-desktop-switch-apply.test.ts @@ -0,0 +1,75 @@ +import { expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +test("PUT /api/settings reports Codex write-lock contention as retryable", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-settings-desktop-switch-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + const previousOcxHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = codexHome; + + const codexInject = await import("../../src/codex/inject"); + const injectionSpy = spyOn(codexInject, "injectCodexConfig").mockResolvedValue({ + success: false, + retryable: true, + message: "another Codex config writer owns the lock", + }); + + try { + const [{ writeRuntimePort }, { handleManagementAPI }, { catalogConvergenceFactory }, { startupHealthFixture }] = await Promise.all([ + import("../../src/config/process-state"), + import("../../src/server/management-api"), + import("../helpers/catalog-convergence"), + import("../helpers/startup-health"), + ]); + const config = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-chat" as const, + baseUrl: "https://api.example.test/v1", + apiKey: "sk-secret-value", + defaultModel: "gpt-test", + }, + }, + }; + writeRuntimePort({ pid: process.pid, port: config.port }); + const request = new Request("http://127.0.0.1:10100/api/settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ codexDesktopAuthless: true }), + }); + const response = await handleManagementAPI(request, new URL(request.url), config, { + saveConfigPreservingClaudeCode: () => {}, + getCachedStartupHealth: async () => startupHealthFixture(), + createManagementConvergeCodex: catalogConvergenceFactory(() => {}), + }); + + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ + codexDesktopAuthless: true, + codexDesktopSwitches: { + apply: { + applied: false, + reason: "write_lock_busy", + retryable: true, + detail: "another Codex config writer owns the lock", + }, + }, + }); + expect(injectionSpy).toHaveBeenCalledTimes(1); + } finally { + injectionSpy.mockRestore(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(root); + } +}); diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index fb2f01579f..0ea11a8b75 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -8,10 +8,13 @@ * codexAutoStart-only PUTs keep working). */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import { writeRuntimePort } from "../../src/config/process-state"; import { handleManagementAPI, type ManagementApiDeps } from "../../src/server/management-api"; import { invalidateStartupHealthCache } from "../../src/server/startup-health-cache"; import { USAGE_RANGES, USAGE_SURFACES } from "../../src/usage/summary"; @@ -31,6 +34,7 @@ import { } from "../../src/server/management/usage-summary-cache"; import { resetUsageAggregateCacheForTests } from "../../src/server/management/usage-aggregate-cache"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { repoRoot } from "../helpers/repo-root"; import { startupHealthFixture } from "../helpers/startup-health"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -78,6 +82,51 @@ function getSettings(config: OcxConfig): Promise { }); } +function putDesktopSwitchInIsolatedHome( + codexHome: string, + config: OcxConfig, + body: Record, +): { status: number; body: Record } { + const script = ` + const { writeRuntimePort } = await import("./src/config/process-state"); + const { handleManagementAPI } = await import("./src/server/management-api"); + const { catalogConvergenceFactory } = await import("./tests/helpers/catalog-convergence"); + const { startupHealthFixture } = await import("./tests/helpers/startup-health"); + const config = JSON.parse(process.env.OCX_TEST_ROUTE_CONFIG); + const requestBody = JSON.parse(process.env.OCX_TEST_ROUTE_BODY); + writeRuntimePort({ pid: process.pid, port: config.port }); + const request = new Request("http://127.0.0.1:10100/api/settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }); + const response = await handleManagementAPI(request, new URL(request.url), config, { + saveConfigPreservingClaudeCode: () => {}, + getCachedStartupHealth: async () => startupHealthFixture(), + createManagementConvergeCodex: catalogConvergenceFactory(() => {}), + }); + console.log(JSON.stringify({ status: response.status, body: await response.json() })); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: join(TEST_DIR, "child-opencodex"), + OCX_TEST_ROUTE_CONFIG: JSON.stringify(config), + OCX_TEST_ROUTE_BODY: JSON.stringify(body), + }, + encoding: "utf8", + timeout: 30_000, + }); + if (child.status !== 0) { + throw new Error(`isolated settings route failed: ${child.stderr || child.stdout}`); + } + const line = child.stdout.trim().split("\n").filter(Boolean).at(-1); + expect(line).toBeDefined(); + return JSON.parse(line!) as { status: number; body: Record }; +} + beforeEach(() => { resetAppOwnedMemoryForTests(); resetUsageSummaryCacheForTests(); @@ -123,6 +172,39 @@ describe("GET /api/settings", () => { expect(body.appOwnedMemoryBudgetMb).toBe(256); }); + test("separates stored and effective desktop state on an authenticated non-loopback bind", async () => { + const body = await (await getSettings({ + ...baseConfig(), + hostname: "192.168.1.20", + codexDesktopAuthless: true, + codexClientCompaction: true, + }))!.json() as { + codexDesktopAuthless?: boolean; + codexClientCompaction?: boolean; + codexDesktopSwitches?: unknown; + }; + + expect(body.codexDesktopAuthless).toBe(true); + expect(body.codexClientCompaction).toBe(true); + expect(body.codexDesktopSwitches).toEqual({ + codexDesktopAuthless: { + stored: true, + effective: false, + inertReason: "non_loopback_bind_requires_admission_token", + }, + codexClientCompaction: { + stored: true, + effective: false, + inertReason: "non_loopback_bind_requires_admission_token", + }, + apply: { applied: false, reason: "not_requested", retryable: false }, + authSource: { + presentsCodexAccount: true, + summary: "The Codex app will require its own account sign-in.", + }, + }); + }); + test("reports the effective account-picker state", async () => { const absent = await (await getSettings(baseConfig()))!.json() as { codexAccountPickerEnabled?: boolean; @@ -415,6 +497,155 @@ describe("PUT /api/settings", () => { expect(bad!.status).toBe(400); }); + test.each([ + { + field: "codexDesktopAuthless" as const, + expectedAuth: "requires_openai_auth = false", + presentsCodexAccount: false, + authSummary: "The Codex app will not require its own account sign-in.", + }, + { + field: "codexClientCompaction" as const, + expectedAuth: "requires_openai_auth = true", + presentsCodexAccount: true, + authSummary: "The Codex app will require its own account sign-in.", + }, + ])("$field rewrites the live Codex config before PUT returns", async ({ + field, + expectedAuth, + presentsCodexAccount, + authSummary, + }) => { + const config = baseConfig(); + const codexHome = join(TEST_DIR, `codex-${field}`); + mkdirSync(codexHome, { recursive: true }); + const codexConfigPath = join(codexHome, "config.toml"); + writeFileSync(codexConfigPath, 'model = "gpt-5.5"\n', "utf8"); + const response = putDesktopSwitchInIsolatedHome(codexHome, config, { [field]: true }); + + expect(response.status).toBe(200); + const body = response.body as { + codexDesktopSwitches?: { + codexDesktopAuthless?: { stored?: boolean; effective?: boolean }; + codexClientCompaction?: { stored?: boolean; effective?: boolean }; + apply?: unknown; + authSource?: { presentsCodexAccount?: boolean; summary?: string }; + }; + }; + expect(body.codexDesktopSwitches?.apply).toEqual({ applied: true }); + expect(body.codexDesktopSwitches?.[field]).toEqual({ stored: true, effective: true }); + expect(body.codexDesktopSwitches?.authSource?.presentsCodexAccount).toBe(presentsCodexAccount); + expect(body.codexDesktopSwitches?.authSource?.summary).toBe(authSummary); + const injected = readFileSync(codexConfigPath, "utf8"); + expect(injected).toContain("[model_providers.opencodex]"); + expect(injected).toContain(expectedAuth); + }); + + test.each([ + { + reason: "integration_disabled" as const, + retryable: false, + configPatch: { clientIntegrations: { codex: false } }, + live: true, + }, + { + reason: "proxy_not_running" as const, + retryable: true, + configPatch: {}, + live: false, + }, + ])("reports an unapplied desktop switch as $reason with retryable=$retryable", async ({ + reason, + retryable, + configPatch, + live, + }) => { + const config = { ...baseConfig(), ...configPatch } as OcxConfig; + if (live) writeRuntimePort({ pid: process.pid, port: config.port }); + const response = await putSettings(config, { codexDesktopAuthless: true }, { + saveConfigPreservingClaudeCode: () => {}, + createManagementConvergeCodex: catalogConvergenceFactory(() => {}), + }); + + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ + codexDesktopAuthless: true, + codexDesktopSwitches: { + codexDesktopAuthless: { stored: true, effective: true }, + apply: { applied: false, reason, retryable }, + authSource: { presentsCodexAccount: false }, + }, + }); + }); + + test("reports a non-retryable injection refusal without touching the ambient Codex home", () => { + const codexHome = join(TEST_DIR, "codex-missing-config"); + mkdirSync(codexHome, { recursive: true }); + const response = putDesktopSwitchInIsolatedHome( + codexHome, + baseConfig(), + { codexDesktopAuthless: true }, + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + codexDesktopSwitches: { + apply: { + applied: false, + reason: "injection_refused", + retryable: false, + }, + }, + }); + }); + + test("a paginated Codex home still applies the switch while native history relabeling stands down", async () => { + const config = baseConfig(); + const codexHome = join(TEST_DIR, "codex-paginated"); + mkdirSync(codexHome, { recursive: true }); + const configPath = join(codexHome, "config.toml"); + const rolloutPath = join(codexHome, "paginated.jsonl"); + const rollout = JSON.stringify({ + ordinal: 0, + type: "session_meta", + payload: { + id: "paginated", + history_mode: "paginated", + model_provider: "opencodex", + }, + }) + "\n"; + writeFileSync(configPath, [ + 'model_provider = "opencodex"', + "[model_providers.opencodex]", + 'name = "OpenCodex"', + 'base_url = "http://127.0.0.1:10100/v1"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "", + ].join("\n"), "utf8"); + writeFileSync(rolloutPath, rollout, "utf8"); + const database = new Database(join(codexHome, "state_5.sqlite")); + database.run("CREATE TABLE threads (id TEXT, rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); + database.run("INSERT INTO threads VALUES ('paginated', ?, 'opencodex', 'paginated')", rolloutPath); + database.close(); + const response = putDesktopSwitchInIsolatedHome( + codexHome, + config, + { codexDesktopAuthless: true }, + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + codexDesktopSwitches: { apply: { applied: true } }, + }); + expect(readFileSync(configPath, "utf8")).toContain("requires_openai_auth = false"); + expect(readFileSync(rolloutPath, "utf8")).toBe(rollout); + const verifier = new Database(join(codexHome, "state_5.sqlite"), { readonly: true }); + expect(verifier.query("SELECT model_provider FROM threads WHERE id = 'paginated'").get()) + .toEqual({ model_provider: "opencodex" }); + verifier.close(); + }); + test("account-picker disable does not initialize an empty namespace map", async () => { const config = baseConfig(); let convergences = 0; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 815361c4c7..9c3963d531 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1112,6 +1112,7 @@ "service.test.ts": "service", "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", + "settings-desktop-switch-apply.test.ts": "config", "settings-main-account-hard-lock.test.ts": "config", "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config", From 3b6c693e5d9f81c6d16978cdad1c734f1b03f03f Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 19:46:20 +0900 Subject: [PATCH 2/5] test(settings): send the Host header the management origin check requires CI caught it: the new write-lock-busy test got 403 instead of 200, so the settings handler was never reached. managementRequestOrigin derives the allowed origin from the Host header, and an in-process `new Request` carries none, so isAllowedManagementOrigin rejected it as cross-origin before any route ran. The passing in-process pattern in settings-main-account-hard-lock.test.ts sets `host` explicitly; this now matches it. --- tests/config/settings-desktop-switch-apply.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/config/settings-desktop-switch-apply.test.ts b/tests/config/settings-desktop-switch-apply.test.ts index 20e7782001..1eeb84067c 100644 --- a/tests/config/settings-desktop-switch-apply.test.ts +++ b/tests/config/settings-desktop-switch-apply.test.ts @@ -42,7 +42,10 @@ test("PUT /api/settings reports Codex write-lock contention as retryable", async writeRuntimePort({ pid: process.pid, port: config.port }); const request = new Request("http://127.0.0.1:10100/api/settings", { method: "PUT", - headers: { "content-type": "application/json" }, + // `host` is not optional here. `managementRequestOrigin` derives the allowed origin + // from the Host header, and an in-process `new Request` carries none, so the settings + // handler is never reached and the response is a 403 cross-origin rejection. + headers: { host: "127.0.0.1:10100", "content-type": "application/json" }, body: JSON.stringify({ codexDesktopAuthless: true }), }); const response = await handleManagementAPI(request, new URL(request.url), config, { From 90035f3d6ef17417a895077686ed9936b344401e Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 20:00:45 +0900 Subject: [PATCH 3/5] test(settings): call repoRoot instead of passing the function as cwd CI caught it: four isolated-home settings tests died with ERR_INVALID_ARG_TYPE, "Received function repoRoot", before the subprocess ever started. repoRoot is a function in tests/helpers/repo-root.ts; spawnSync needs its return value. --- tests/config/settings-stream-mode.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index 0ea11a8b75..5039c77460 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -108,7 +108,7 @@ function putDesktopSwitchInIsolatedHome( console.log(JSON.stringify({ status: response.status, body: await response.json() })); `; const child = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, + cwd: repoRoot(), env: { ...process.env, CODEX_HOME: codexHome, From c1378dd01d22847b3bc22dd31a5d80e41f12d427 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 20:10:37 +0900 Subject: [PATCH 4/5] test(settings): send Host from the isolated-home settings subprocess too CI caught it: the four isolated-home cases still returned 403 once the cwd bug was out of the way, for the same reason the in-process case did. managementRequestOrigin derives the allowed origin from the Host header, a constructed Request carries none, and isAllowedManagementOrigin rejects the request before any route runs. The in-process case was fixed one commit ago; this is the same fix inside the subprocess script. --- tests/config/settings-stream-mode.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index 5039c77460..06eb3c41fa 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -97,7 +97,10 @@ function putDesktopSwitchInIsolatedHome( writeRuntimePort({ pid: process.pid, port: config.port }); const request = new Request("http://127.0.0.1:10100/api/settings", { method: "PUT", - headers: { "content-type": "application/json" }, + // Same requirement as the in-process cases: managementRequestOrigin derives the + // allowed origin from the Host header, and a constructed Request carries none, so + // without this the handler is never reached and the response is a 403. + headers: { host: "127.0.0.1:10100", "content-type": "application/json" }, body: JSON.stringify(requestBody), }); const response = await handleManagementAPI(request, new URL(request.url), config, { From 4d0ee75e45b5a9c8624cb940ad5ee7ddd10cf96b Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 20:31:46 +0900 Subject: [PATCH 5/5] test(status): guard the human status probe the way the json one already is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-record fixture spawns the CLI twice and knew the hazard: a /healthz probe can abort without ECONNREFUSED while the port is genuinely empty, which leaves staleProcessState false with nothing having taken the port. It retried on that for the --json run and not for the human run. The asymmetry was the defect. The human run makes the identical probe and can abort the identical way, so a human probe that timed out instead of being refused printed no stale line and was read as a lost signal — the same misreading the existing comment describes, one run later. This is what failed test 2/4 on a branch that touches no status code, and it is a dev defect rather than one this branch introduced. The guard is now symmetric, and it stays conditioned on an observed abort rather than on the assertion outcome. After the human run the structured verdict is re-sampled under the conditions that run just saw: true means the probe path was reaching a refusal, so the human output is a valid sample and the assertions judge it, and a human path that genuinely stopped reporting the stale line still fails. False while the port is still refusing is the documented abort, so that attempt is discarded. All three assertions are unchanged. --- tests/cli/cli-status-json.test.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/cli/cli-status-json.test.ts b/tests/cli/cli-status-json.test.ts index 6f8cb2cac3..517c0f9aff 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -915,6 +915,12 @@ describe("status reports stale process records end to end", () => { // // Confirm refusal around every probe and re-allocate when something takes it, so a stolen // port retries the setup instead of failing an assertion it never exercised. + // + // That guard was applied to the --json run only, and the asymmetry was the remaining + // defect: the human run makes the identical `/healthz` probe and can abort the identical + // way, so a human probe that timed out instead of being refused printed no stale line and + // was read as a lost signal — the same misreading this comment already describes, one run + // later. Both runs are now guarded the same way. let parsed: { proxy?: { staleProcessState?: unknown } } | undefined; let humanStdout: string | undefined; for (let attempt = 0; attempt < 5 && humanStdout === undefined; attempt++) { @@ -937,11 +943,25 @@ describe("status reports stale process records end to end", () => { encoding: "utf8", }); if (!await refusesConnection(port)) continue; + // Re-sample the structured verdict under the conditions the human run just saw. A + // `true` here means the probe path was reaching a refusal at that moment, so the human + // output is a valid sample and the assertions below judge it — a human path that + // genuinely stopped reporting the stale line still fails. A `false` while the port is + // still refusing is the documented abort, observed rather than assumed, so this attempt + // is discarded instead of being asserted against. + const confirm = runStatusJson(home); + if (confirm.status !== 0) continue; + const confirmed = JSON.parse(confirm.stdout) as { proxy?: { staleProcessState?: unknown } }; + if (!await refusesConnection(port)) continue; + if (confirmed?.proxy?.staleProcessState !== true) continue; parsed = observed; humanStdout = human.stdout; } - expect(humanStdout, "no allocated port stayed refused across both status probes").toBeDefined(); + expect( + humanStdout, + "no allocated port stayed refused, with the stale verdict reached, across every status probe", + ).toBeDefined(); expect(parsed?.proxy?.staleProcessState).toBe(true); expect(humanStdout).toContain("may have exited unexpectedly"); } finally {