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/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 { 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..1eeb84067c --- /dev/null +++ b/tests/config/settings-desktop-switch-apply.test.ts @@ -0,0 +1,78 @@ +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", + // `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, { + 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..06eb3c41fa 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,54 @@ 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", + // 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, { + 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 +175,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 +500,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",