From 9d8bc672645fcf3d246c88379083e906f12c33f2 Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:44:20 +0800 Subject: [PATCH 1/5] fix(chatgpt): say why a dropped chatgptDesktop block reads as off The read path degrades a `chatgptDesktop` block that fails the strict schema to absent. The app- server shim then reads as off, `ocx chatgpt launch` refuses, and nothing says why, while the file still reads `"appServerShim": true`. A key left over from an older or ported config, such as `unblockSend`, is enough (#6196). `chatgptDesktopConfigIssue` names the failing field. The config loader now warns with it like the other top-level opt-in blocks, the config diagnostics list it, `ocx chatgpt status` shows it next to the flag, and `ocx chatgpt launch` gives it as the reason it refuses. Writes still reject the block as before. --- src/cli/chatgpt-command.ts | 25 +++++++++++++++-- src/config/diagnostics.ts | 3 +++ src/config/load-degrade.ts | 3 +++ src/config/schema/leaf-validators.ts | 17 ++++++++++++ tests/clients/desktop-chatgpt-config.test.ts | 28 +++++++++++++++++++- 5 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index de73d6b8f99..2e205fe87bb 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { loadConfig } from "../config"; +import { readConfigFileSnapshot } from "../config/diagnostics"; +import { chatgptDesktopConfigIssue } from "../config/schema/leaf-validators"; import { chatgptShimLauncherPath, resolveChatgptCodexBinary, @@ -21,6 +23,20 @@ function run(command: string, args: string[]) { return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; } +/** + * Why the config file's `chatgptDesktop` block reads as absent, or null when it is valid, absent + * or the file cannot be parsed (the config loader reports that case itself). + */ +function chatgptDesktopIssueInFile(): string | null { + const { raw } = readConfigFileSnapshot(); + if (raw === undefined) return null; + try { + return chatgptDesktopConfigIssue(JSON.parse(raw.replace(/^\uFEFF/, ""))); + } catch { + return null; + } +} + /** * The installed app, found and confirmed by bundle identifier (com.openai.codex) the same way the * desktop restart adapter does. "ChatGPT" is a display name another app can share, so quitting, @@ -88,9 +104,12 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat const config = loadConfig(); const launcher = chatgptShimLauncherPath(); const install = discoverApp(); + const configIssue = config.chatgptDesktop?.appServerShim === true ? null : chatgptDesktopIssueInFile(); if (sub === "status") { const app = install ? appState(install, launcher) : { running: false, shim: false }; - console.log(`app-server shim (experimental): ${config.chatgptDesktop?.appServerShim === true ? "on" : "off"} + const flag = config.chatgptDesktop?.appServerShim === true ? "on" + : configIssue ? `off (config.json ${configIssue}; the whole chatgptDesktop block is ignored)` : "off"; + console.log(`app-server shim (experimental): ${flag} launcher: ${existsSync(launcher) ? "present" : "absent"} app: ${install ? (app.running ? "running" : "not running") : "not installed"} CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); @@ -103,7 +122,9 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); } if (sub === "launch") { if (config.chatgptDesktop?.appServerShim !== true) { - console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.'); + console.error(configIssue + ? `Experimental shim disabled: config.json ${configIssue}. The whole chatgptDesktop block is ignored until that is fixed.` + : "Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching."); return 1; } const binary = resolveChatgptCodexBinary(install.root); diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 9111a890661..195727d16b4 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -65,6 +65,7 @@ import { runtimeRoleSchema, spendSchema, chatgptDesktopSchema, + chatgptDesktopConfigIssue, compactionRoutingSchema, skillsConfigSchema, memoryModelsSchema, @@ -106,6 +107,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") { warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform"); } + const chatgptDesktopIssue = chatgptDesktopConfigIssue(rawParsed); + if (chatgptDesktopIssue) warnings.push(`${chatgptDesktopIssue}; the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`); warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); warnings.push(...degradedListenerWarnings(rawParsed, normalized)); diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index f219587d278..c654770d442 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -40,6 +40,7 @@ import { transientRetryOn5xxPolicySchema, runtimeRoleSchema, spendSchema, + chatgptDesktopConfigIssue, } from "./schema/leaf-validators"; import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo"; @@ -123,6 +124,8 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void { if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved"); if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available"); + const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed); + if (chatgptDesktop) console.warn(`⚠️ config.json ${chatgptDesktop} — the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`); warnDegradedStreamMode(rawParsed, validated); warnDegradedCompactionRouting(rawParsed, validated); warnDegradedMemoryModels(rawParsed, validated); diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index fe9ac2af263..1f5e5e04bf1 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -46,6 +46,23 @@ import { COMPACTION_TRIGGERS } from "./compaction-triggers"; /** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */ export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict(); +/** + * Why a present `chatgptDesktop` block fails the schema, or null when it is valid or absent. The + * read path degrades a failing block to absent, so the integration reads as off while the file + * still says `appServerShim: true`; this names the field so that is never silent. A leftover key + * from an older or ported config (for example `unblockSend`) is the usual cause. + */ +export function chatgptDesktopConfigIssue(rawParsed: unknown): string | null { + if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null; + const block = (rawParsed as Record).chatgptDesktop; + if (block === undefined) return null; + const result = chatgptDesktopSchema.safeParse(block); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return redactSecretString(`chatgptDesktop${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`); +} + /** One definition of "usable secret", shared by the schema and the warnings. */ export function isUsableApiKeySecret(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value === value.trim(); diff --git a/tests/clients/desktop-chatgpt-config.test.ts b/tests/clients/desktop-chatgpt-config.test.ts index 96df45f5e6a..8e4a0a32a2f 100644 --- a/tests/clients/desktop-chatgpt-config.test.ts +++ b/tests/clients/desktop-chatgpt-config.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { configSchema } from "../../src/config/schema/config-schema"; import { validateConfigCandidate, configDiagnosticsFromRaw } from "../../src/config/diagnostics"; +import { warnDegradedTopLevelOptIns } from "../../src/config/load-degrade"; +import { chatgptDesktopConfigIssue } from "../../src/config/schema/leaf-validators"; import { handleChatgptCommand } from "../../src/cli/chatgpt-command"; import { handleInternalCommand } from "../../src/cli/internal-command"; @@ -25,6 +27,30 @@ describe("experimental ChatGPT desktop config", () => { if (!write.ok) expect(write.error).toContain("chatgptDesktop"); } }); + test("a block the read path drops says why instead of reading as silently off", () => { + // A key left over from an older or ported config is the usual cause (#6196). + const leftover = { ...base, chatgptDesktop: { appServerShim: true, unblockSend: true } }; + expect(configSchema.parse(leftover).chatgptDesktop).toBeUndefined(); + expect(chatgptDesktopConfigIssue(leftover)).toContain("unblockSend"); + expect(chatgptDesktopConfigIssue({ ...base, chatgptDesktop: { appServerShim: "true" } })).toContain("chatgptDesktop.appServerShim"); + for (const fine of [base, { ...base, chatgptDesktop: { appServerShim: true } }, { ...base, chatgptDesktop: {} }, null, []]) { + expect(chatgptDesktopConfigIssue(fine)).toBeNull(); + } + const diagnostics = configDiagnosticsFromRaw(JSON.stringify(leftover)); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.warnings?.join(" ")).toContain("unblockSend"); + expect(diagnostics.warnings?.join(" ")).toContain("the whole chatgptDesktop block is ignored"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + warnDegradedTopLevelOptIns(leftover, configSchema.parse(leftover)); + expect(warn.mock.calls.map(call => String(call[0])).join(" ")).toContain("unblockSend"); + warn.mockClear(); + warnDegradedTopLevelOptIns(base, configSchema.parse(base)); + expect(warn.mock.calls.length).toBe(0); + } finally { + warn.mockRestore(); + } + }); test("enabled shim warns on other platforms", () => { const diagnostics = configDiagnosticsFromRaw(JSON.stringify({ ...base, chatgptDesktop: { appServerShim: true } })); expect(diagnostics.error).toBeNull(); From daa6ecc7cba832c93f98e65597cd0524d45d139c Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:12:43 +0800 Subject: [PATCH 2/5] fix(chatgpt): explain a dropped chatgptDesktop block without loading the config validators After merging dev, `ocx chatgpt` read the config file on every subcommand and pulled in all of `leaf-validators.ts` for one helper. The bundle-trust command fixture from dev stubs the config facade, so that import chain reached modules expecting the real facade and every scenario failed to load. Restore scenarios would also have read this machine's own config.json. - `chatgptDesktopSchema` and `chatgptDesktopConfigIssue` move to `src/config/schema/chatgpt-desktop.ts`, which needs only zod and the redactor. `leaf-validators.ts` re-exports both, so the schema and the load and diagnostics warnings are unchanged. - The command reads the file only where it reports the reason: `status`, and a `launch` refused because the shim is off. `restore` and an enabled launch never touch it. - The fixture stubs the config-file snapshot, and a new scenario checks that a refused launch names the failing field without quitting or opening the app. --- src/cli/chatgpt-command.ts | 6 +++-- src/config/schema/chatgpt-desktop.ts | 22 +++++++++++++++++++ src/config/schema/leaf-validators.ts | 22 +++---------------- .../desktop-app-server-shim-launcher.test.ts | 7 ++++++ .../desktop-app-server-shim-command-child.ts | 3 +++ 5 files changed, 39 insertions(+), 21 deletions(-) create mode 100644 src/config/schema/chatgpt-desktop.ts diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index ad3c5097c52..f870e66ff72 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -3,7 +3,7 @@ import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { loadConfig } from "../config"; import { readConfigFileSnapshot } from "../config/diagnostics"; -import { chatgptDesktopConfigIssue } from "../config/schema/leaf-validators"; +import { chatgptDesktopConfigIssue } from "../config/schema/chatgpt-desktop"; import { chatgptShimLauncherPath, resolveChatgptCodexBinary, @@ -104,9 +104,9 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat const config = loadConfig(); const launcher = chatgptShimLauncherPath(); const install = discoverApp(); - const configIssue = config.chatgptDesktop?.appServerShim === true ? null : chatgptDesktopIssueInFile(); if (sub === "status") { const app = install ? appState(install, launcher) : { running: false, shim: false }; + const configIssue = config.chatgptDesktop?.appServerShim === true ? null : chatgptDesktopIssueInFile(); const flag = config.chatgptDesktop?.appServerShim === true ? "on" : configIssue ? `off (config.json ${configIssue}; the whole chatgptDesktop block is ignored)` : "off"; console.log(`app-server shim (experimental): ${flag} @@ -123,6 +123,8 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); let binary: string | undefined; if (sub === "launch") { if (config.chatgptDesktop?.appServerShim !== true) { + // Read the file only to explain a refusal; restore and a valid launch never touch it. + const configIssue = chatgptDesktopIssueInFile(); console.error(configIssue ? `Experimental shim disabled: config.json ${configIssue}. The whole chatgptDesktop block is ignored until that is fixed.` : "Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching."); diff --git a/src/config/schema/chatgpt-desktop.ts b/src/config/schema/chatgpt-desktop.ts new file mode 100644 index 00000000000..fe1e9ff77bb --- /dev/null +++ b/src/config/schema/chatgpt-desktop.ts @@ -0,0 +1,22 @@ +import * as z from "zod/v4"; +import { redactSecretString } from "../../lib/redact"; + +/** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */ +export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict(); + +/** + * Why a present `chatgptDesktop` block fails the schema, or null when it is valid or absent. The + * read path degrades a failing block to absent, so the integration reads as off while the file + * still says `appServerShim: true`; this names the field so that is never silent. A leftover key + * from an older or ported config (for example `unblockSend`) is the usual cause. + */ +export function chatgptDesktopConfigIssue(rawParsed: unknown): string | null { + if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null; + const block = (rawParsed as Record).chatgptDesktop; + if (block === undefined) return null; + const result = chatgptDesktopSchema.safeParse(block); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return redactSecretString(`chatgptDesktop${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`); +} diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index cf0f3338241..a0f8b861fa6 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -43,25 +43,9 @@ import { import { getConfigDir } from "../paths"; import { COMPACTION_TRIGGERS, validCompactionSourceModels } from "./compaction-triggers"; -/** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */ -export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict(); - -/** - * Why a present `chatgptDesktop` block fails the schema, or null when it is valid or absent. The - * read path degrades a failing block to absent, so the integration reads as off while the file - * still says `appServerShim: true`; this names the field so that is never silent. A leftover key - * from an older or ported config (for example `unblockSend`) is the usual cause. - */ -export function chatgptDesktopConfigIssue(rawParsed: unknown): string | null { - if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null; - const block = (rawParsed as Record).chatgptDesktop; - if (block === undefined) return null; - const result = chatgptDesktopSchema.safeParse(block); - if (result.success) return null; - const issue = result.error.issues[0]; - const field = issue?.path.join("."); - return redactSecretString(`chatgptDesktop${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`); -} +// The chatgptDesktop leaf lives in its own zod-only module so the `ocx chatgpt` command can explain +// a dropped block without loading this file's provider and account validators. +export { chatgptDesktopConfigIssue, chatgptDesktopSchema } from "./chatgpt-desktop"; /** One definition of "usable secret", shared by the schema and the warnings. */ export function isUsableApiKeySecret(value: unknown): value is string { diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index 90bc83cd345..fdd40c60543 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -237,6 +237,13 @@ describe("restore validates the discovered app before any quit or open", () => { expect(result.launcherExists).toBe(true); expect(result.calls.map(call => call.command)).toEqual(["pgrep", "ps"]); }); + test("a launch refused over a dropped chatgptDesktop block names the failing field", () => { + const result = run({ sub: "launch", flag: false, configRaw: JSON.stringify({ chatgptDesktop: { appServerShim: "yes" } }) }); + expect(result.code).toBe(1); + expect(result.stderr).toContain("chatgptDesktop.appServerShim"); + expect(result.stderr).toContain("whole chatgptDesktop block is ignored"); + expect(result.calls).toEqual([]); + }); test("launch still requires opt-in and an app-server binary", () => { for (const scenario of [{ flag: false }, { flag: true, missingBinary: true }]) { const result = run({ sub: "launch", ...scenario }); diff --git a/tests/helpers/desktop-app-server-shim-command-child.ts b/tests/helpers/desktop-app-server-shim-command-child.ts index 816fb3acc56..1143cb5dded 100644 --- a/tests/helpers/desktop-app-server-shim-command-child.ts +++ b/tests/helpers/desktop-app-server-shim-command-child.ts @@ -55,6 +55,9 @@ mock.module("node:child_process", () => ({ ...childProcess, spawnSync(command: s return { status: 0, stdout: "", stderr: "" }; } })); mock.module("../../src/config", () => ({ loadConfig: () => ({ chatgptDesktop: { appServerShim: scenario.flag === true } }) })); +// A refused launch explains a dropped chatgptDesktop block from the config file; never read this +// machine's config here. +mock.module("../../src/config/diagnostics", () => ({ readConfigFileSnapshot: () => ({ raw: scenario.configRaw }) })); mock.module("../../src/codex/desktop-app/darwin", () => ({ darwinDefaultExec: () => "", darwinDesktopAppAdapter: { discover: () => scenario.noInstall ? null : { id: "com.openai.codex", root, relaunch: "com.openai.codex" } }, From c1303a85346d4a06883d8f3c7abbf939219ac73c Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:53:35 +0800 Subject: [PATCH 3/5] test(chatgpt): cover the status and launch messages for a dropped chatgptDesktop block The command fixture now returns stdout. New scenarios: status names a dropped block next to the off flag; status shows a plain off for a valid, absent or unreadable block; a refused launch with no dropped block keeps the ordinary opt-in message. The first fails if status stops naming the reason. --- .../desktop-app-server-shim-launcher.test.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index fdd40c60543..ef41dd0946b 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -184,9 +184,9 @@ describe("restore validates the discovered app before any quit or open", () => { }); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); - return { ...JSON.parse(result.stdout.trim().split("\n").at(-1)!), stderr: result.stderr } as { + return { ...JSON.parse(result.stdout.trim().split("\n").at(-1)!), stdout: result.stdout, stderr: result.stderr } as { code: number; calls: Array<{ command: string; args: string[]; override?: string }>; - launcherExists: boolean; root: string; shell: string; binary: string; stderr: string; + launcherExists: boolean; root: string; shell: string; binary: string; stdout: string; stderr: string; }; } finally { removeTreeWithRetry(dir); } }; @@ -244,6 +244,25 @@ describe("restore validates the discovered app before any quit or open", () => { expect(result.stderr).toContain("whole chatgptDesktop block is ignored"); expect(result.calls).toEqual([]); }); + test("status names a dropped chatgptDesktop block next to the off flag", () => { + const result = run({ sub: "status", configRaw: JSON.stringify({ chatgptDesktop: { appServerShim: true, unblockSend: true } }) }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('app-server shim (experimental): off (config.json chatgptDesktop: Unrecognized key: "unblockSend"'); + expect(result.stdout).toContain("the whole chatgptDesktop block is ignored)"); + }); + test("status shows a plain off for a valid, absent or unreadable block", () => { + for (const configRaw of [undefined, "{}", JSON.stringify({ chatgptDesktop: { appServerShim: false } }), "{not json"]) { + const result = run({ sub: "status", ...(configRaw === undefined ? {} : { configRaw }) }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("app-server shim (experimental): off\n"); + } + }); + test("a launch refused with no dropped block keeps the ordinary opt-in message", () => { + const result = run({ sub: "launch", flag: false, configRaw: "{}" }); + expect(result.code).toBe(1); + expect(result.stderr).toContain("set chatgptDesktop.appServerShim: true before launching"); + expect(result.stderr).not.toContain("whole chatgptDesktop block is ignored"); + }); test("launch still requires opt-in and an app-server binary", () => { for (const scenario of [{ flag: false }, { flag: true, missingBinary: true }]) { const result = run({ sub: "launch", ...scenario }); From 9791dab792cb009f0e13d7e4dd268974e03c9761 Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:19:12 +0800 Subject: [PATCH 4/5] test(chatgpt): name the config input in the plain-off status loop --- tests/clients/desktop-app-server-shim-launcher.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index ef41dd0946b..0f89aeea61d 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -253,8 +253,9 @@ describe("restore validates the discovered app before any quit or open", () => { test("status shows a plain off for a valid, absent or unreadable block", () => { for (const configRaw of [undefined, "{}", JSON.stringify({ chatgptDesktop: { appServerShim: false } }), "{not json"]) { const result = run({ sub: "status", ...(configRaw === undefined ? {} : { configRaw }) }); - expect(result.code).toBe(0); - expect(result.stdout).toContain("app-server shim (experimental): off\n"); + const input = `configRaw=${configRaw === undefined ? "" : JSON.stringify(configRaw)}`; + expect(result.code, input).toBe(0); + expect(result.stdout, input).toContain("app-server shim (experimental): off\n"); } }); test("a launch refused with no dropped block keeps the ordinary opt-in message", () => { From 6ded1ee2045aeb7cea513f8f87126def24449f9a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 23:34:12 +0900 Subject: [PATCH 5/5] fix(config): report dropped chatgptDesktop reason on salvaged diagnostics --- src/config/diagnostics.ts | 2 ++ structure/config.md | 2 +- tests/clients/desktop-chatgpt-config.test.ts | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 04a22768ed9..e086acce8ae 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -746,6 +746,8 @@ export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { if (salvaged) { const config = normalizeApiKeyIds(salvaged.parsed); const warnings = degradedListenerWarnings(parsed, config); + const chatgptDesktopIssue = chatgptDesktopConfigIssue(parsed); + if (chatgptDesktopIssue) warnings.push(`${chatgptDesktopIssue}; the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`); return { config, source: "fallback", diff --git a/structure/config.md b/structure/config.md index d51f33020c4..888f51a9543 100644 --- a/structure/config.md +++ b/structure/config.md @@ -597,4 +597,4 @@ so wrong types and unknown nested fields are rejected rather than silently saved `apiSurfaces` and `protocols` on `src/types/config.ts` are parsed by `src/protocols/settings.ts` only; [Protocol Paths](data-planes/protocol-paths.md#settings) owns their schema handling, meaning and the one writer (`PATCH /api/protocols/settings`), including why closing Messages also writes `claudeCode.enabled` through `commitClaudeCodeBlock` (`src/claude/claude-code-block.ts`, the sentinel-stamping block writer every management route uses). Stored Direct substitution follows the [credential identity contract](providers/openai-accounts.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. -Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). The experimental `chatgptDesktop` leaf accepts only optional boolean `appServerShim`, default off. Malformed reads disable this leaf; live writes reject malformed values and unknown keys. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). `claudeCode.subagentModelForce` is an optional safe roster-style id. Invalid hand edits degrade with a warning without losing other config; stale but syntactically valid targets remain stored for repair and are skipped at launch. Management force updates are field-scoped against current disk state, preserving concurrent Claude sibling edits. A config absent at request start uses canonical create-only initialization; a racing file or invalid initial state refuses the save, and an existing-file mutation never recreates deleted config. Clearing removes only that leaf. +Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). The experimental `chatgptDesktop` leaf accepts only optional boolean `appServerShim`, default off. Malformed reads disable this leaf; live writes reject malformed values and unknown keys. `src/config/diagnostics.ts` reports the reason a whole `chatgptDesktop` block is dropped on both normal and salvaged reads; salvage retains its fallback source and schema error. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). `claudeCode.subagentModelForce` is an optional safe roster-style id. Invalid hand edits degrade with a warning without losing other config; stale but syntactically valid targets remain stored for repair and are skipped at launch. Management force updates are field-scoped against current disk state, preserving concurrent Claude sibling edits. A config absent at request start uses canonical create-only initialization; a racing file or invalid initial state refuses the save, and an existing-file mutation never recreates deleted config. Clearing removes only that leaf. diff --git a/tests/clients/desktop-chatgpt-config.test.ts b/tests/clients/desktop-chatgpt-config.test.ts index 8e4a0a32a2f..fb4f94ab22a 100644 --- a/tests/clients/desktop-chatgpt-config.test.ts +++ b/tests/clients/desktop-chatgpt-config.test.ts @@ -51,6 +51,25 @@ describe("experimental ChatGPT desktop config", () => { warn.mockRestore(); } }); + test("salvaged configs retain the dropped desktop block reason and fallback error", () => { + const candidate = { ...base, port: 12345, routingProfiles: { bad: { candidates: [] } } }; + const fallback = configDiagnosticsFromRaw(JSON.stringify(candidate)); + expect(fallback.source).toBe("fallback"); + expect(fallback.error).toContain("routingProfiles.bad"); + for (const chatgptDesktop of [{ appServerShim: true, unblockSend: true }, { appServerShim: "true" }]) { + const normal = configDiagnosticsFromRaw(JSON.stringify({ ...base, chatgptDesktop })); + const diagnostics = configDiagnosticsFromRaw(JSON.stringify({ ...candidate, chatgptDesktop })); + expect(normal.warnings?.join(" ")).toContain("the whole chatgptDesktop block is ignored"); + expect(diagnostics.warnings).toEqual(normal.warnings); + expect(diagnostics.source).toBe(fallback.source); + expect(diagnostics.error).toBe(fallback.error); + expect(diagnostics.config.chatgptDesktop).toBeUndefined(); + expect(diagnostics.config.providers).toEqual(fallback.config.providers); + expect(diagnostics.config.providers.xai).toEqual(base.providers.xai); + expect(diagnostics.config.port).toBe(12345); + expect(diagnostics.config.routingProfiles?.bad).toBeUndefined(); + } + }); test("enabled shim warns on other platforms", () => { const diagnostics = configDiagnosticsFromRaw(JSON.stringify({ ...base, chatgptDesktop: { appServerShim: true } })); expect(diagnostics.error).toBeNull();