Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shim-status-attached.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.

Expand Down
33 changes: 25 additions & 8 deletions src/core/codex-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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(),
});
Expand Down
9 changes: 5 additions & 4 deletions src/core/desktop-shim.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
59 changes: 57 additions & 2 deletions test/codex-bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" };
Expand All @@ -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\)/);
});

Expand Down
Loading