Skip to content
Merged
27 changes: 25 additions & 2 deletions src/cli/chatgpt-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/chatgpt-desktop";
import {
chatgptShimLauncherPath,
resolveChatgptCodexBinary,
Expand All @@ -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,
Expand Down Expand Up @@ -90,7 +106,10 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat
const install = discoverApp();
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 configIssue = config.chatgptDesktop?.appServerShim === true ? null : chatgptDesktopIssueInFile();
const flag = config.chatgptDesktop?.appServerShim === true ? "on"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: 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"}`);
Expand All @@ -104,7 +123,11 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`);
let binary: string | undefined;
if (sub === "launch") {
if (config.chatgptDesktop?.appServerShim !== true) {
console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.');
// 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.");
return 1;
}
binary = resolveChatgptCodexBinary(install.root) ?? undefined;
Expand Down
5 changes: 5 additions & 0 deletions src/config/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
runtimeRoleSchema,
spendSchema,
chatgptDesktopSchema,
chatgptDesktopConfigIssue,
compactionRoutingSchema,
skillsConfigSchema,
memoryModelsSchema,
Expand Down Expand Up @@ -110,6 +111,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));
Expand Down Expand Up @@ -743,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",
Expand Down
3 changes: 3 additions & 0 deletions src/config/load-degrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
transientRetryOn5xxPolicySchema,
runtimeRoleSchema,
spendSchema,
chatgptDesktopConfigIssue,
} from "./schema/leaf-validators";
import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo";

Expand Down Expand Up @@ -124,6 +125,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);
Expand Down
22 changes: 22 additions & 0 deletions src/config/schema/chatgpt-desktop.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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"}`);
}
5 changes: 3 additions & 2 deletions src/config/schema/leaf-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +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();
// 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 {
Expand Down
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 29 additions & 2 deletions tests/clients/desktop-app-server-shim-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
};
Expand Down Expand Up @@ -237,6 +237,33 @@ 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("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 }) });
const input = `configRaw=${configRaw === undefined ? "<no file>" : JSON.stringify(configRaw)}`;
expect(result.code, input).toBe(0);
expect(result.stdout, input).toContain("app-server shim (experimental): off\n");
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 });
Expand Down
47 changes: 46 additions & 1 deletion tests/clients/desktop-chatgpt-config.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -25,6 +27,49 @@ 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("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();
Expand Down
3 changes: 3 additions & 0 deletions tests/helpers/desktop-app-server-shim-command-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down
Loading