diff --git a/.changeset/desktop-attachment-evidence.md b/.changeset/desktop-attachment-evidence.md new file mode 100644 index 0000000..955c29a --- /dev/null +++ b/.changeset/desktop-attachment-evidence.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Report Desktop shim configuration separately from attachment evidence. `gbot codex status` now includes `desktopShimConfigured` and no longer emits `attached-shim` merely because the wrapper is installed and selected. Preserve observed private-stdio processes and report managed attachment as unverified, including when the daemon is unreachable. diff --git a/README.md b/README.md index c62d087..101b934 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ Residual risks, stated honestly: requests without both matching thread and turn Permanent tradeoff, stated plainly: Desktop's app-tools MCP (`-c` overrides on its spawn line) is not applied to the already-running managed daemon, and no config/`mcpServer`/`reload` path imports Desktop's `-c` flags — Desktop app-tools stay degraded while pointed at the shared daemon. Fully quit and relaunch ChatGPT.app after install (or login) so it inherits `CODEX_CLI_PATH`. `status` reads the macOS GUI-domain value via `launchctl getenv` (what Desktop actually inherits) alongside the calling shell's value. LaunchAgent persistence is macOS-first; elsewhere install still writes the wrapper and bridge but leaves `CODEX_CLI_PATH` for you to export. `~/.codex/bin` holds scripts only — there is no extra revert note to clean up; revert is `gbot codex desktop-shim uninstall` plus this section. -**Status contract (`gbot codex status --json`).** `reachable` is endpoint reachability only. `socketState` is `socket`, `absent`, `permission-denied`, or `not-a-socket`; `mode` is `daemon` for a usable daemon, otherwise the failure: `socket-absent`, `permission-denied` (the file or the connect refused this user), `not-a-socket`, `connect-failed` (socket present, nothing completed the WebSocket upgrade), `handshake-failed` (upgrade or `initialize` failed), `windows-unsupported`, or `bad-response` (reachable, but `initialize` returned something off-schema — `reachable` stays `true`). `schema.compatibility` is `exact` when the daemon reports the pinned version, `unverified` when it differs (methods usually survive upgrades, but the shapes are not re-checked), or `unknown`. `cliVersionProbe` reports whether `codex --version` answered (`ok`, `missing`, `timeout` after 3 s, `error`). `desktopAttached` is `"attached-shim"` when the desktop-shim is active (installed wrapper that the Desktop-facing `CODEX_CLI_PATH` points at, so Desktop spawns bridge onto the managed daemon — this outranks a stale-looking private-stdio process list), `"private-stdio"` when a Desktop-bundled `.../ChatGPT.app/.../Resources/codex` app-server process is visible in the process list (Desktop on private stdio: unreachable, start a managed standalone daemon), otherwise `"unknown"` — whether ChatGPT Desktop owns a thread is not observable from the socket, and `"detached"` is never reported. The document is always written to stdout and includes `exitCode`; it is `0` only for a usable daemon. +**Status contract (`gbot codex status --json`).** `reachable` is endpoint reachability only. `socketState` is `socket`, `absent`, `permission-denied`, or `not-a-socket`; `mode` is `daemon` for a usable daemon, otherwise the failure: `socket-absent`, `permission-denied` (the file or the connect refused this user), `not-a-socket`, `connect-failed` (socket present, nothing completed the WebSocket upgrade), `handshake-failed` (upgrade or `initialize` failed), `windows-unsupported`, or `bad-response` (reachable, but `initialize` returned something off-schema — `reachable` stays `true`). `schema.compatibility` is `exact` when the daemon reports the pinned version, `unverified` when it differs (methods usually survive upgrades, but the shapes are not re-checked), or `unknown`. `cliVersionProbe` reports whether `codex --version` answered (`ok`, `missing`, `timeout` after 3 s, `error`). The document is always written to stdout and includes `exitCode`; it is `0` only for a usable daemon. + +`desktopShimConfigured` is true when the installed wrapper is selected by Desktop-facing `CODEX_CLI_PATH` (the GUI domain on macOS). This describes configuration for future launches; a running Desktop may not have inherited it, and the wrapper may have fallen back to stock Codex. `desktopAttached` is `"private-stdio"` when a Desktop-bundled app-server process is observed, otherwise `"unknown"`. That process observation and shim configuration can both be present. Neither a configured shim nor a reachable daemon proves Desktop is attached to that daemon. The former `"attached-shim"` value is no longer emitted; consumers checking shim setup should use `desktopShimConfigured`. Verify actual attachment with a controlled shared-thread interaction and matching thread/turn IDs. **Thread discovery.** `list-threads --limit N` (1–200) pages with the opaque `--cursor` from the previous `nextCursor`; JSON keeps the cursor verbatim, text output prints a sanitized `more: --cursor …` hint. Text fields are stripped of terminal control sequences in both outputs (single-line fields also lose line breaks; `preview` keeps its newlines; a structured `source` such as `{ "custom": … }` passes through unchanged), `status` is one of `notLoaded | idle | active | systemError | unknown`, and non-numeric `updatedAt` becomes `null`. Unknown arguments are rejected before the socket is touched; a response that does not match the pinned schema (including an entry without a string `id`) fails with `reason: "bad-response"`. diff --git a/src/core/codex-bridge.js b/src/core/codex-bridge.js index c9f4cae..9f2a38a 100644 --- a/src/core/codex-bridge.js +++ b/src/core/codex-bridge.js @@ -598,13 +598,11 @@ export function detectDesktopPrivateAppServer({ platform = process.platform, lis /** * Status contract: `reachable` is endpoint reachability only. `schema.compatibility` is * `exact` when the daemon reports the pinned version, otherwise `unverified` (methods - * usually survive upgrades) or `unknown`. `desktopAttached` is `"attached-shim"` when the - * desktop-shim is active (installed wrapper that the Desktop-facing CODEX_CLI_PATH points - * at — the GUI domain on Darwin — so Desktop spawns bridge onto the managed daemon), - * `"private-stdio"` when a Desktop-bundled app-server process is visible (see - * `detectDesktopPrivateAppServer`), otherwise `"unknown"` — whether Desktop owns any - * thread is never observable from the socket itself, and `"detached"` is never reported. - * An active shim outranks a stale-looking private-stdio process list. + * usually survive upgrades) or `unknown`. `desktopShimConfigured` records an installed + * wrapper selected by Desktop-facing CODEX_CLI_PATH (the GUI domain on Darwin). + * Configuration does not prove a running Desktop inherited it or connected. + * `desktopAttached` is `"private-stdio"` when a Desktop-bundled app-server process + * is visible, otherwise `"unknown"`. Managed attachment is not observable here. * * @param {NodeJS.ProcessEnv} [env] * @param {{ listProcesses?: string | string[], shim?: { installed?: boolean, wrapperPointsAtShim?: boolean } | null }} [opts] @@ -614,19 +612,16 @@ export function detectDesktopPrivateAppServer({ platform = process.platform, lis export async function codexStatus(env = process.env, { listProcesses, shim } = {}) { const path = codexSocketPath(env); const cli = probeLocalCodexVersion(); - // Shim state comes from the install record + the Desktop-facing CODEX_CLI_PATH - // (GUI domain on Darwin), never from process-list scraping: when the shim is - // active, Desktop's spawns bridge onto this socket, so report the live path. - let shimActive = Boolean(shim && shim.installed && shim.wrapperPointsAtShim); + let desktopShimConfigured = Boolean(shim && shim.installed && shim.wrapperPointsAtShim); if (shim === undefined) { try { const live = desktopShimStatus({ env }); - shimActive = Boolean(live.installed && live.wrapperPointsAtShim); + desktopShimConfigured = Boolean(live.installed && live.wrapperPointsAtShim); } catch { - shimActive = false; + desktopShimConfigured = false; } } - const desktopAttached = shimActive ? "attached-shim" : detectDesktopPrivateAppServer({ + const desktopAttached = detectDesktopPrivateAppServer({ platform: process.platform, listProcesses: listProcesses ?? listDesktopProcesses(), }); @@ -637,6 +632,7 @@ export async function codexStatus(env = process.env, { listProcesses, shim } = { cliVersion: cli.version, cliVersionProbe: cli.probe, desktopAttached, + desktopShimConfigured, }; let session; try { diff --git a/src/core/format.js b/src/core/format.js index c287d4f..d971f46 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -70,6 +70,7 @@ function truncateCliText(text, max = 400) { export function formatCodexStatus(s) { const lines = ["socket: " + s.socketPath + " (" + s.socketState + ")"]; + if (s.desktopShimConfigured) lines.push("desktop shim: configured (managed attachment unverified)"); if (!s.reachable) return lines.concat("reachable: no (" + s.mode + ")", s.message).join("\n"); if (s.mode !== "daemon") { return lines.concat("reachable: yes, but unusable (" + s.mode + ")", s.message).join("\n"); @@ -81,11 +82,9 @@ export function formatCodexStatus(s) { + " cli version: " + cli + " pinned schema: " + s.pinnedVersion + " (" + s.schema.compatibility + ")", ); - lines.push(s.desktopAttached === "attached-shim" - ? "desktop attached: attached-shim (Desktop shim bridges onto the managed daemon)" - : s.desktopAttached === "private-stdio" - ? "desktop attached: private-stdio (ChatGPT Desktop runs its own private stdio app-server; start a managed daemon with `codex app-server daemon start`)" - : "desktop attached: unknown (not observable from the socket)"); + lines.push(s.desktopAttached === "private-stdio" + ? "desktop attached: private-stdio (Desktop-bundled app-server process observed; managed attachment unverified)" + : "desktop attached: unknown (not observable from the socket)"); if (s.versionMismatch) { lines.push("warning: daemon and CLI versions differ; `codex app-server daemon restart` picks up the installed CLI"); } diff --git a/test/codex-bridge.test.js b/test/codex-bridge.test.js index f6eb2b5..62c13bc 100644 --- a/test/codex-bridge.test.js +++ b/test/codex-bridge.test.js @@ -1223,7 +1223,7 @@ test("codexStatus reports private-stdio with a managed-daemon message when the s assert.match(status.message, /openai\/codex\/issues\/41112/); }); -test("codexStatus reports attached-shim when the shim is active, even with a stale private-stdio process list", async () => { +test("codexStatus separates configured shim from observed Desktop processes", async () => { const home = mkdtempSync(join(tmpdir(), "gbot-codex-shim-")); const env = { ...process.env, CODEX_HOME: home, PATH: "/nonexistent" }; @@ -1231,14 +1231,26 @@ test("codexStatus reports attached-shim when the shim is active, even with a sta listProcesses: DESKTOP_MAC_PS, shim: { installed: true, wrapperPointsAtShim: true }, }); - assert.equal(active.desktopAttached, "attached-shim"); + assert.equal(active.desktopAttached, "private-stdio"); + assert.equal(active.desktopShimConfigured, true); assert.equal(active.mode, "socket-absent"); + const configured = await codexStatus(env, { + listProcesses: "", + shim: { installed: true, wrapperPointsAtShim: true }, + }); + assert.equal(configured.desktopAttached, "unknown"); + assert.equal(configured.desktopShimConfigured, true); + assert.equal(configured.reachable, false); + assert.match(formatCodexStatus(configured), /desktop shim: configured/); + assert.doesNotMatch(formatCodexStatus(configured), /attached-shim/); + const pointedAway = await codexStatus(env, { listProcesses: DESKTOP_MAC_PS, shim: { installed: true, wrapperPointsAtShim: false }, }); - assert.equal(pointedAway.desktopAttached, "private-stdio", "installed but unpointed shim defers to the process list"); + assert.equal(pointedAway.desktopAttached, "private-stdio"); + assert.equal(pointedAway.desktopShimConfigured, false); const quiet = await codexStatus(env, { listProcesses: "init\n/usr/local/bin/codex app-server daemon start", @@ -1272,7 +1284,24 @@ test("codexStatus reports attached-shim when the shim is active, even with a sta shim: { installed: liveShim.installed, wrapperPointsAtShim: liveShim.wrapperPointsAtShim }, }, ); - assert.equal(live.desktopAttached, "attached-shim"); + assert.equal(live.desktopAttached, "private-stdio"); + assert.equal(live.desktopShimConfigured, true); +}); + +test("codexStatus does not infer Desktop attachment from a healthy daemon and configured shim", async () => { + const fake = await fakeAppServer(baseHandlers); + try { + const status = await codexStatus({ ...process.env, CODEX_HOME: fake.home, PATH: "/nonexistent" }, { + listProcesses: "", + shim: { installed: true, wrapperPointsAtShim: true }, + }); + assert.equal(status.reachable, true); + assert.equal(status.mode, "daemon"); + assert.equal(status.desktopAttached, "unknown"); + assert.equal(status.desktopShimConfigured, true); + } finally { + await fake.close(); + } }); test("codexStatus keeps unknown and the generic message without Desktop evidence", async () => { @@ -1292,8 +1321,11 @@ test("formatCodexStatus names the private-stdio case and keeps the unknown line" schema: { compatibility: "exact" }, versionMismatch: false, }; assert.match(formatCodexStatus({ ...daemon, desktopAttached: "private-stdio" }), /desktop attached: private-stdio/); - assert.match(formatCodexStatus({ ...daemon, desktopAttached: "private-stdio" }), /codex app-server daemon start/); - assert.match(formatCodexStatus({ ...daemon, desktopAttached: "attached-shim" }), /desktop attached: attached-shim/); + assert.match(formatCodexStatus({ ...daemon, desktopAttached: "private-stdio" }), /process observed/); + const configured = formatCodexStatus({ ...daemon, desktopAttached: "unknown", desktopShimConfigured: true }); + assert.match(configured, /desktop shim: configured/); + assert.match(configured, /attachment unverified/); + assert.doesNotMatch(configured, /attached-shim/); assert.match(formatCodexStatus({ ...daemon, desktopAttached: "unknown" }), /desktop attached: unknown \(not observable from the socket\)/); });