diff --git a/.changeset/shim-status-attached.md b/.changeset/shim-status-attached.md new file mode 100644 index 0000000..399e3e2 --- /dev/null +++ b/.changeset/shim-status-attached.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Report `attached-shim` from `gbot codex status` when the desktop-shim is active (installed wrapper that the Desktop-facing `CODEX_CLI_PATH` points at), outranking a stale-looking private-stdio process list. diff --git a/README.md b/README.md index 47f1410..c62d087 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ 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 `"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`). `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. **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 c98d569..c9f4cae 100644 --- a/src/core/codex-bridge.js +++ b/src/core/codex-bridge.js @@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { outcomeFromError, outcomeFromReceipt, withStatusExitCode } from "./codex/contract.js"; +import { desktopShimStatus } from "./desktop-shim.js"; // Method and param names below come from `codex app-server generate-json-schema` // of this Codex release. Newer daemons usually keep them; `gbot codex status` @@ -597,19 +598,35 @@ 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 `"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. + * 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. * * @param {NodeJS.ProcessEnv} [env] - * @param {{ listProcesses?: string | string[] }} [opts] injected process list for tests; - * defaults to a live `ps` snapshot. Never reads pipes or connects to Desktop. + * @param {{ listProcesses?: string | string[], shim?: { installed?: boolean, wrapperPointsAtShim?: boolean } | null }} [opts] + * injected process list and shim state for tests; defaults to a live `ps` snapshot and + * `desktopShimStatus`. Never reads pipes or connects to Desktop. */ -export async function codexStatus(env = process.env, { listProcesses } = {}) { +export async function codexStatus(env = process.env, { listProcesses, shim } = {}) { const path = codexSocketPath(env); const cli = probeLocalCodexVersion(); - const desktopAttached = detectDesktopPrivateAppServer({ + // 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); + if (shim === undefined) { + try { + const live = desktopShimStatus({ env }); + shimActive = Boolean(live.installed && live.wrapperPointsAtShim); + } catch { + shimActive = false; + } + } + const desktopAttached = shimActive ? "attached-shim" : detectDesktopPrivateAppServer({ platform: process.platform, listProcesses: listProcesses ?? listDesktopProcesses(), }); diff --git a/src/core/desktop-shim.js b/src/core/desktop-shim.js index 7f79ea5..64ab0c2 100644 --- a/src/core/desktop-shim.js +++ b/src/core/desktop-shim.js @@ -4,7 +4,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { BRIDGE_SOURCE } from "./desktop-shim-bridge.js"; -import { codexSocketPath } from "./codex-bridge.js"; /** * ChatGPT Desktop -> managed Codex daemon shim (CODEX_CLI_PATH bridge). @@ -432,9 +431,11 @@ export function desktopShimStatus({ // Desktop-facing value: the GUI domain on macOS, the caller env elsewhere. const desktopCliPath = platform === "darwin" ? guiCliPath : cliPath; const installed = wrapperPresent && isExecutable(paths.wrapperPath) && bridgePresent; - // Effective socket, resolved exactly like gbot's own client so status and - // send/queue/status never disagree: CODEX_APP_SERVER_SOCK wins, else CODEX_HOME. - const socketPath = codexSocketPath({ ...env, CODEX_HOME: paths.codexHome }); + // Effective socket, resolved exactly like gbot's own client (CODEX_APP_SERVER_SOCK + // wins, else the install-time CODEX_HOME) so status and send/queue/status never + // disagree. Inlined here so codex-bridge can reuse desktopShimStatus without a cycle. + const socketPath = env.CODEX_APP_SERVER_SOCK + || join(paths.codexHome, "app-server-control", "app-server-control.sock"); return { action: "status", bridgePath: paths.bridgePath, diff --git a/src/core/format.js b/src/core/format.js index d46a6c1..c287d4f 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -81,9 +81,11 @@ export function formatCodexStatus(s) { + " cli version: " + cli + " pinned schema: " + s.pinnedVersion + " (" + s.schema.compatibility + ")", ); - lines.push(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 === "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)"); 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 f0cfe46..f6eb2b5 100644 --- a/test/codex-bridge.test.js +++ b/test/codex-bridge.test.js @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; import { decodeFrame, encodeFrame, websocketAccept, connectCodexAppServer, sendToCodexThread, codexSocketPath, codexStatus, detectDesktopPrivateAppServer, unreachableMessage } from "../src/core/codex-bridge.js"; +import { desktopShimStatus } from "../src/core/desktop-shim.js"; import { formatCodexStatus } from "../src/core/format.js"; import { createServer as createTcpServer } from "node:net"; @@ -1065,13 +1066,14 @@ test("every codex send rejection carries the envelope, and unknown statuses are "thread/resume": (params, ok) => ok({ thread: { id: params.threadId, status: { type: "hibernating" } }, model: "m", cwd: "/", approvalPolicy: "never" }), }); const run = (env, ...args) => new Promise((resolve) => { - execFile(process.execPath, [CLI, ...args], { encoding: "utf8", env: { ...process.env, CODEX_HOME: fake.home, PATH: "/nonexistent", ...env } }, (error, out, err) => resolve({ code: error ? error.code : 0, out, err })); + // Clear GROK_BOT_CODEX_EXPERIMENTAL in the base env so a host export cannot leak into the "experimental off" case. + execFile(process.execPath, [CLI, ...args], { encoding: "utf8", env: { ...process.env, CODEX_HOME: fake.home, PATH: "/nonexistent", GROK_BOT_CODEX_EXPERIMENTAL: "", ...env } }, (error, out, err) => resolve({ code: error ? error.code : 0, out, err })); }); try { for (const [label, env, args, reason] of [ ["allowlist", { GROK_BOT_CODEX_THREADS: "other" }, ["t-1", "x"], "route-not-allowed"], ["unknown status", {}, ["t-1", "x"], "unknown-status"], - ["experimental off", {}, ["--when-busy", "queue", "t-1", "x"], "experimental-disabled"], + ["experimental off", { GROK_BOT_CODEX_EXPERIMENTAL: "0" }, ["--when-busy", "queue", "t-1", "x"], "experimental-disabled"], ]) { const { code, out } = await run(env, "codex", "send", "--correlation-id", "corr-9", ...args, "--json"); assert.equal(code, 1, label); @@ -1221,6 +1223,58 @@ 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 () => { + const home = mkdtempSync(join(tmpdir(), "gbot-codex-shim-")); + const env = { ...process.env, CODEX_HOME: home, PATH: "/nonexistent" }; + + const active = await codexStatus(env, { + listProcesses: DESKTOP_MAC_PS, + shim: { installed: true, wrapperPointsAtShim: true }, + }); + assert.equal(active.desktopAttached, "attached-shim"); + assert.equal(active.mode, "socket-absent"); + + 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"); + + const quiet = await codexStatus(env, { + listProcesses: "init\n/usr/local/bin/codex app-server daemon start", + shim: { installed: false, wrapperPointsAtShim: false }, + }); + assert.equal(quiet.desktopAttached, "unknown"); + + // Live wiring: real wrapper+bridge files. On Darwin, Desktop-facing CODEX_CLI_PATH + // is launchctl's GUI domain (not shell env), so mock launchctl getenv for the status probe. + const bin = join(home, "bin"); + mkdirSync(bin, { recursive: true }); + const wrapper = join(bin, "codex-desktop-to-daemon"); + writeFileSync(wrapper, "#!/bin/bash\n"); + chmodSync(wrapper, 0o755); + writeFileSync(join(bin, "codex-stdio-to-daemon-ws.py"), "# bridge\n"); + const liveShim = desktopShimStatus({ + env: { ...env, CODEX_CLI_PATH: wrapper }, + runner: (file, args) => { + if (file === "launchctl" && args[0] === "getenv" && args[1] === "CODEX_CLI_PATH") { + return { status: 0, stdout: wrapper + "\n" }; + } + return { status: 0, stdout: "" }; + }, + }); + assert.equal(liveShim.installed, true); + assert.equal(liveShim.wrapperPointsAtShim, true); + const live = await codexStatus( + { ...env, CODEX_CLI_PATH: wrapper }, + { + listProcesses: DESKTOP_MAC_PS, + shim: { installed: liveShim.installed, wrapperPointsAtShim: liveShim.wrapperPointsAtShim }, + }, + ); + assert.equal(live.desktopAttached, "attached-shim"); +}); + test("codexStatus keeps unknown and the generic message without Desktop evidence", async () => { const home = mkdtempSync(join(tmpdir(), "gbot-codex-nodesktop-")); const env = { ...process.env, CODEX_HOME: home, PATH: "/nonexistent" }; @@ -1239,6 +1293,7 @@ test("formatCodexStatus names the private-stdio case and keeps the unknown line" }; 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: "unknown" }), /desktop attached: unknown \(not observable from the socket\)/); });