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/desktop-attachment-evidence.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.

Expand Down
24 changes: 10 additions & 14 deletions src/core/codex-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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(),
});
Expand All @@ -637,6 +632,7 @@ export async function codexStatus(env = process.env, { listProcesses, shim } = {
cliVersion: cli.version,
cliVersionProbe: cli.probe,
desktopAttached,
desktopShimConfigured,
};
let session;
try {
Expand Down
9 changes: 4 additions & 5 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
}
Expand Down
44 changes: 38 additions & 6 deletions test/codex-bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1223,22 +1223,34 @@ 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" };

const active = await codexStatus(env, {
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",
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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\)/);
});

Expand Down