From 047d237cc630185c5fa1e230f60a2d34cdf64e46 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 00:50:39 +0000 Subject: [PATCH 1/6] feat(codex): add gbot codex status, list-threads, and send over the app-server socket --- src/cli.js | 54 ++++++++ src/codex-bridge.js | 310 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/codex-bridge.js diff --git a/src/cli.js b/src/cli.js index b396bb0..696a983 100755 --- a/src/cli.js +++ b/src/cli.js @@ -4,6 +4,7 @@ import { hasGatewayAuth } from "./gateway.js"; import { openBackend } from "./commands.js"; import { inspectGrokBotGatewaySession } from "./app-session.js"; import { redactSecrets } from "./url-policy.js"; +import { codexStatus, listCodexThreads, sendToCodexThread } from "./codex-bridge.js"; function print(value) { if (typeof value === "string") process.stdout.write(value + "\n"); @@ -48,6 +49,9 @@ function usage() { " send ", " thread [--limit N] [--root MESSAGE_ID]", " chat alias for thread", + " codex status", + " codex list-threads [--limit N]", + " codex send ", "", "Max group members: " + MAX_GROUP_MEMBERS, "--description / --instructions is the UI Instructions field (same key).", @@ -56,6 +60,7 @@ function usage() { "Flags: --gateway --files --dir DIR --json", "Auth: GROK_BOT_GATEWAY_URL + GROK_BOT_GATEWAY_TOKEN, or the Grok Bot app session, or CURSOR_ACCESS_TOKEN", "File fallback: GROK_BOT_AGENTS_DIR", + "Codex: talks to the local app-server daemon socket under CODEX_HOME (default ~/.codex)", ].join("\n"); } @@ -220,6 +225,50 @@ function formatTranscript(out) { return lines.join("\n"); } +function formatCodexStatus(s) { + const lines = ["socket: " + s.socketPath]; + if (!s.reachable) return lines.concat("reachable: no", s.message).join("\n"); + lines.push("reachable: yes (daemon)"); + lines.push("daemon version: " + (s.daemonVersion ?? "unknown") + " cli version: " + (s.cliVersion ?? "unknown") + " pinned schema: " + s.pinnedVersion); + if (s.versionMismatch) lines.push("warning: daemon and CLI versions differ; `codex app-server daemon restart` picks up the installed CLI"); + return lines.join("\n"); +} + +function formatCodexThread(t) { + const title = t.name ? " - " + t.name : ""; + const preview = t.preview ? "\n " + String(t.preview).replace(/\s+/g, " ").slice(0, 200) : ""; + return t.id + " " + t.status + title + "\n " + (t.cwd ?? "") + preview; +} + +async function runCodex(sub, rest, json) { + if (sub === "status") { + const status = await codexStatus(); + print(json ? status : formatCodexStatus(status)); + if (!status.reachable) process.exitCode = 1; + return; + } + if (sub === "list-threads") { + const limitRaw = takeFlag(rest, "--limit"); + const limit = limitRaw ? Number(limitRaw) : 20; + if (!Number.isInteger(limit) || limit < 1) throw new StoreError("--limit must be a positive integer"); + const out = await listCodexThreads({ limit }); + if (json) print(out); + else if (out.threads.length === 0) print("No Codex threads."); + else print(out.threads.map(formatCodexThread).join("\n\n")); + return; + } + if (sub === "send") { + const threadId = rest.shift(); + const message = rest.join(" ").trim(); + if (!threadId || threadId.startsWith("-") || !message) throw new StoreError("gbot codex send "); + const out = await sendToCodexThread(threadId, message); + if (json) print(out); + else print("Started turn " + out.turnId + " (" + out.turnStatus + ") on Codex thread " + out.threadId); + return; + } + throw new StoreError("gbot codex status | list-threads [--limit N] | send "); +} + async function main(argv) { const args = argv.slice(2); if (args.length === 0 || args[0] === "-h" || args[0] === "--help") { @@ -268,6 +317,11 @@ async function main(argv) { return; } + if (cmd === "codex") { + await runCodex(sub, rest, json); + return; + } + const backend = await openBackend({ root: rootFlag, gateway, files: filesMode }); if (cmd === "bots" && sub === "list") { diff --git a/src/codex-bridge.js b/src/codex-bridge.js new file mode 100644 index 0000000..c6adce9 --- /dev/null +++ b/src/codex-bridge.js @@ -0,0 +1,310 @@ +import { createHash, randomBytes } from "node:crypto"; +import { statSync } from "node:fs"; +import { createConnection } from "node:net"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; + +// 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` +// reports the running daemon's version next to this one. +export const PINNED_CODEX_VERSION = "0.154.0"; +export const UPSTREAM_DESKTOP_ISSUES = [ + "https://github.com/openai/codex/issues/41014", + "https://github.com/openai/codex/issues/41112", +]; + +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const pkg = createRequire(import.meta.url)("../package.json"); + +export function codexSocketPath(env = process.env) { + const home = env.CODEX_HOME || join(homedir(), ".codex"); + return join(home, "app-server-control", "app-server-control.sock"); +} + +export function socketPresent(path) { + try { + return statSync(path).isSocket(); + } catch { + return false; + } +} + +export function unreachableMessage(path) { + return [ + "No Codex app-server control socket at " + path + ".", + "Either no daemon is running (start one with `codex app-server daemon start`),", + "or ChatGPT Desktop is running a private stdio app-server that external clients cannot reach", + "(" + UPSTREAM_DESKTOP_ISSUES.join(", ") + ").", + "gbot codex targets daemon-managed threads only.", + ].join("\n"); +} + +export function encodeFrame(opcode, payload, mask) { + const len = payload.length; + const head = Buffer.alloc(len < 126 ? 2 : len < 65536 ? 4 : 10); + head[0] = 0x80 | opcode; + if (len < 126) head[1] = len; + else if (len < 65536) { + head[1] = 126; + head.writeUInt16BE(len, 2); + } else { + head[1] = 127; + head.writeBigUInt64BE(BigInt(len), 2); + } + if (!mask) return Buffer.concat([head, payload]); + head[1] |= 0x80; + const body = Buffer.from(payload); + for (let i = 0; i < body.length; i++) body[i] ^= mask[i & 3]; + return Buffer.concat([head, mask, body]); +} + +export function decodeFrame(buf) { + if (buf.length < 2) return null; + const fin = (buf[0] & 0x80) !== 0; + const opcode = buf[0] & 0x0f; + const masked = (buf[1] & 0x80) !== 0; + let len = buf[1] & 0x7f; + let offset = 2; + if (len === 126) { + if (buf.length < 4) return null; + len = buf.readUInt16BE(2); + offset = 4; + } else if (len === 127) { + if (buf.length < 10) return null; + len = Number(buf.readBigUInt64BE(2)); + offset = 10; + } + const mask = masked ? buf.subarray(offset, offset + 4) : null; + if (masked) offset += 4; + if (buf.length < offset + len) return null; + const payload = Buffer.from(buf.subarray(offset, offset + len)); + if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]; + return { fin, opcode, payload, rest: buf.subarray(offset + len) }; +} + +function upgradeRequest(key) { + return "GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Key: " + key + "\r\nSec-WebSocket-Version: 13\r\n\r\n"; +} + +export function websocketAccept(key) { + return createHash("sha1").update(key + WS_GUID).digest("base64"); +} + +export class CodexRpcError extends Error { + constructor(method, error) { + super("Codex app-server rejected " + method + ": " + (error && error.message ? error.message : JSON.stringify(error))); + this.name = "CodexRpcError"; + this.method = method; + this.rpc = error; + } +} + +/** + * Open a JSON-RPC session to the app-server over its Unix socket (WebSocket framing). + * Server-initiated requests (approvals, user input) are refused with a JSON-RPC error + * and recorded in `refused`; gbot never approves on the user's behalf. + */ +export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { + return new Promise((resolve, reject) => { + const socket = createConnection({ path }); + const key = randomBytes(16).toString("base64"); + const pending = new Map(); + const refused = []; + let nextId = 1; + let buf = Buffer.alloc(0); + let upgraded = false; + let closed = false; + + const failAll = (err) => { + if (closed) return; + closed = true; + for (const { reject: rej } of pending.values()) rej(err); + pending.clear(); + reject(err); + }; + const write = (opcode, payload) => { + if (!socket.destroyed) socket.write(encodeFrame(opcode, payload, randomBytes(4))); + }; + const sendJson = (obj) => write(0x1, Buffer.from(JSON.stringify(obj))); + + const client = { + refused, + request(method, params) { + const id = nextId++; + return new Promise((res, rej) => { + const timer = setTimeout(() => { + pending.delete(id); + rej(new Error("Codex app-server did not answer " + method + " within " + timeoutMs + "ms")); + }, timeoutMs); + pending.set(id, { + method, + resolve: (v) => { clearTimeout(timer); res(v); }, + reject: (e) => { clearTimeout(timer); rej(e); }, + }); + sendJson({ jsonrpc: "2.0", id, method, params }); + }); + }, + notify(method, params) { + sendJson({ jsonrpc: "2.0", method, params }); + }, + close() { + closed = true; + write(0x8, Buffer.from([0x03, 0xe8])); + socket.end(); + socket.unref(); + }, + }; + + const onMessage = (msg) => { + if (msg.id != null && msg.method) { + refused.push({ id: msg.id, method: msg.method, params: msg.params }); + sendJson({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32601, message: "gbot codex does not answer " + msg.method + "; configure approval_policy on the daemon" }, + }); + return; + } + if (msg.id == null || !pending.has(msg.id)) return; + const entry = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) entry.reject(new CodexRpcError(entry.method, msg.error)); + else entry.resolve(msg.result); + }; + + socket.setTimeout(timeoutMs, () => failAll(new Error("Timed out connecting to Codex app-server at " + path))); + socket.once("error", (err) => failAll(new Error("Could not connect to Codex app-server at " + path + ": " + err.message))); + socket.once("close", () => failAll(new Error("Codex app-server closed the connection"))); + socket.once("connect", () => socket.write(upgradeRequest(key))); + socket.on("data", (chunk) => { + buf = Buffer.concat([buf, chunk]); + if (!upgraded) { + const end = buf.indexOf("\r\n\r\n"); + if (end === -1) return; + const head = buf.subarray(0, end).toString(); + buf = buf.subarray(end + 4); + const ok = /^HTTP\/1\.1 101/.test(head) + && head.toLowerCase().includes("sec-websocket-accept: " + websocketAccept(key).toLowerCase()); + if (!ok) return failAll(new Error("Codex app-server refused the WebSocket upgrade: " + head.split("\r\n")[0])); + upgraded = true; + socket.setTimeout(0); + resolve(client); + } + for (;;) { + const frame = decodeFrame(buf); + if (!frame) return; + buf = frame.rest; + if (frame.opcode === 0x1) onMessage(JSON.parse(frame.payload.toString())); + else if (frame.opcode === 0x9) write(0xa, frame.payload); + else if (frame.opcode === 0x8) socket.end(); + } + }); + }); +} + +function appServerVersion(initResult) { + const ua = initResult && typeof initResult.userAgent === "string" ? initResult.userAgent : ""; + const m = /^[^/\s]+\/(\S+)/.exec(ua); + return m ? m[1] : null; +} + +async function openSession(env = process.env) { + const path = codexSocketPath(env); + if (!socketPresent(path)) throw new Error(unreachableMessage(path)); + const client = await connectCodexAppServer(path); + const init = await client.request("initialize", { clientInfo: { name: "gbot", version: pkg.version } }); + client.notify("initialized"); + return { client, path, init }; +} + +export function localCodexVersion() { + const out = spawnSync("codex", ["--version"], { encoding: "utf8" }); + const m = out.status === 0 ? /(\d+\.\d+\.\d+\S*)/.exec(out.stdout) : null; + return m ? m[1] : null; +} + +export async function codexStatus(env = process.env) { + const path = codexSocketPath(env); + const base = { socketPath: path, pinnedVersion: PINNED_CODEX_VERSION, cliVersion: localCodexVersion() }; + if (!socketPresent(path)) { + return { ...base, reachable: false, mode: "socket-absent", message: unreachableMessage(path) }; + } + const { client, init } = await openSession(env); + client.close(); + const daemonVersion = appServerVersion(init); + return { + ...base, + reachable: true, + mode: "daemon", + daemonVersion, + codexHome: init.codexHome ?? null, + versionMismatch: Boolean(base.cliVersion && daemonVersion && base.cliVersion !== daemonVersion), + }; +} + +export function summarizeThread(t) { + return { + id: t.id, + status: t.status && t.status.type ? t.status.type : "unknown", + name: t.name ?? null, + preview: t.preview ?? "", + cwd: t.cwd ?? null, + source: t.source ?? null, + updatedAt: t.updatedAt ?? null, + }; +} + +export async function listCodexThreads({ limit = 20, env = process.env } = {}) { + const { client } = await openSession(env); + try { + const out = await client.request("thread/list", { limit }); + return { threads: out.data.map(summarizeThread), nextCursor: out.nextCursor ?? null }; + } finally { + client.close(); + } +} + +function explainSendError(err, threadId) { + if (!(err instanceof CodexRpcError)) return err; + const msg = String(err.rpc && err.rpc.message || ""); + if (/no rollout found|thread not found/i.test(msg)) { + return new Error("Unknown Codex thread " + threadId + ". Run `gbot codex list-threads` to see reachable threads."); + } + if (/active writer/i.test(msg)) { + return new Error("Codex thread " + threadId + " is open in another client (VS Code, TUI, or Desktop), which owns its turns. Close it there first."); + } + return err; +} + +export async function sendToCodexThread(threadId, text, env = process.env) { + const { client } = await openSession(env); + try { + let resumed; + try { + resumed = await client.request("thread/resume", { threadId, excludeTurns: true }); + } catch (err) { + throw explainSendError(err, threadId); + } + const turn = await client.request("turn/start", { threadId, input: [{ type: "text", text }] }); + if (client.refused.length) { + const methods = client.refused.map((r) => r.method).join(", "); + throw new Error( + "Turn " + turn.turn.id + " started on thread " + threadId + " but Codex asked for " + methods + ", which gbot refused. " + + "Answer it in a Codex client, or set `approval_policy = \"never\"` in the daemon's config.toml for unattended sends.", + ); + } + return { + threadId: resumed.thread.id, + turnId: turn.turn.id, + turnStatus: turn.turn.status, + model: resumed.model, + cwd: resumed.cwd, + approvalPolicy: resumed.approvalPolicy, + }; + } finally { + client.close(); + } +} From dfc0cd1bc056d00a58d20a09f80dc887910ecd86 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 00:54:07 +0000 Subject: [PATCH 2/6] test(codex): drive gbot codex against a fake app-server on a Unix socket --- test/codex-bridge.test.js | 227 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 test/codex-bridge.test.js diff --git a/test/codex-bridge.test.js b/test/codex-bridge.test.js new file mode 100644 index 0000000..e2f0612 --- /dev/null +++ b/test/codex-bridge.test.js @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { decodeFrame, encodeFrame, websocketAccept } from "../src/codex-bridge.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + +const THREADS = [ + { id: "t-1", status: { type: "idle" }, name: "Fix the build", preview: "please fix the build", cwd: "/repo/a", source: "vscode", updatedAt: 1700000001 }, + { id: "t-2", status: { type: "notLoaded" }, name: null, preview: "second thread\npreview", cwd: "/repo/b", source: "cli", updatedAt: 1700000000 }, +]; + +/** + * Fake Codex app-server: WebSocket over a Unix socket under a scratch CODEX_HOME. + * `handlers[method](params, reply)` answers each request; `received` keeps every inbound message. + */ +async function fakeAppServer(handlers) { + const home = mkdtempSync(join(tmpdir(), "gbot-codex-")); + mkdirSync(join(home, "app-server-control")); + const socketPath = join(home, "app-server-control", "app-server-control.sock"); + const received = []; + const server = createServer(); + server.on("upgrade", (req, socket) => { + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + websocketAccept(req.headers["sec-websocket-key"]) + "\r\n\r\n", + ); + const send = (obj) => socket.write(encodeFrame(0x1, Buffer.from(JSON.stringify(obj)))); + let buf = Buffer.alloc(0); + socket.on("data", (chunk) => { + buf = Buffer.concat([buf, chunk]); + for (;;) { + const frame = decodeFrame(buf); + if (!frame) return; + buf = frame.rest; + if (frame.opcode === 0x8) return socket.end(); + if (frame.opcode !== 0x1) continue; + const msg = JSON.parse(frame.payload.toString()); + received.push(msg); + if (msg.method && msg.id != null) { + const handler = handlers[msg.method]; + if (!handler) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "unknown method " + msg.method } }); + else handler(msg.params, (result) => send({ jsonrpc: "2.0", id: msg.id, result }), (error) => send({ jsonrpc: "2.0", id: msg.id, error }), send); + } + } + }); + socket.on("error", () => {}); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + return { home, received, close: () => new Promise((resolve) => server.close(resolve)) }; +} + +const baseHandlers = { + initialize: (params, ok) => ok({ userAgent: "gbot/0.154.0 (Ubuntu 24.4.0; x86_64) dumb (" + params.clientInfo.name + ")", codexHome: "/fake" }), + "thread/list": (params, ok) => ok({ data: THREADS.slice(0, params.limit), nextCursor: params.limit < THREADS.length ? "cursor-2" : null }), + "thread/resume": (params, ok, err) => { + const t = THREADS.find((x) => x.id === params.threadId); + if (!t) return err({ code: -32600, message: "no rollout found for thread id " + params.threadId }); + if (t.id === "t-2") return err({ code: -32600, message: "thread " + t.id + " already has an active writer" }); + ok({ thread: t, model: "gpt-6", cwd: t.cwd, approvalPolicy: "never", approvalsReviewer: "user", sandbox: "read-only", modelProvider: "openai" }); + }, + "turn/start": (params, ok) => ok({ turn: { id: "turn-9", status: "inProgress", items: [] } }), +}; + +// The fake server runs in this process, so the CLI must be spawned asynchronously. +function gbot(home, ...args) { + const env = { ...process.env, CODEX_HOME: home, PATH: "/nonexistent" }; + return new Promise((resolve) => { + execFile(process.execPath, [CLI, ...args], { encoding: "utf8", env }, (error, out, err) => { + resolve({ code: error ? error.code : 0, out, err }); + }); + }); +} + +test("encodeFrame masks a client text frame per RFC 6455", () => { + const frame = encodeFrame(0x1, Buffer.from("Hello"), Buffer.from([0x37, 0xfa, 0x21, 0x3d])); + assert.equal(frame.toString("hex"), "818537fa213d7f9f4d5158"); + const back = decodeFrame(frame); + assert.equal(back.payload.toString(), "Hello"); + assert.equal(back.rest.length, 0); +}); + +test("decodeFrame waits for a complete frame and returns the remainder", () => { + const two = Buffer.concat([encodeFrame(0x1, Buffer.from("a")), encodeFrame(0x1, Buffer.from("bc"))]); + assert.equal(decodeFrame(two.subarray(0, 2)), null); + const first = decodeFrame(two); + assert.equal(first.payload.toString(), "a"); + assert.equal(decodeFrame(first.rest).payload.toString(), "bc"); +}); + +test("codex status explains an absent socket and exits 1", async () => { + const home = mkdtempSync(join(tmpdir(), "gbot-codex-empty-")); + const { code, out } = await gbot(home, "--json", "codex", "status"); + assert.equal(code, 1); + const status = JSON.parse(out); + assert.equal(status.reachable, false); + assert.equal(status.mode, "socket-absent"); + assert.equal(status.socketPath, join(home, "app-server-control", "app-server-control.sock")); + assert.match(status.message, /codex app-server daemon start/); + assert.match(status.message, /ChatGPT Desktop/); + assert.match(status.message, /openai\/codex\/issues\/41014/); +}); + +test("codex status reports the daemon version over the socket", async () => { + const fake = await fakeAppServer(baseHandlers); + try { + const { code, out } = await gbot(fake.home, "--json", "codex", "status"); + assert.equal(code, 0, out); + const status = JSON.parse(out); + assert.equal(status.reachable, true); + assert.equal(status.mode, "daemon"); + assert.equal(status.daemonVersion, "0.154.0"); + assert.equal(status.codexHome, "/fake"); + assert.equal(status.cliVersion, null); + assert.equal(status.versionMismatch, false); + assert.deepEqual(fake.received.map((m) => m.method), ["initialize", "initialized"]); + } finally { + await fake.close(); + } +}); + +test("codex list-threads passes --limit and prints threads", async () => { + const fake = await fakeAppServer(baseHandlers); + try { + const text = await gbot(fake.home, "codex", "list-threads", "--limit", "1"); + assert.equal(text.code, 0, text.err); + assert.equal(text.out, "t-1 idle - Fix the build\n /repo/a\n please fix the build\n"); + assert.deepEqual(fake.received.find((m) => m.method === "thread/list").params, { limit: 1 }); + + const json = await gbot(fake.home, "--json", "codex", "list-threads"); + assert.equal(json.code, 0, json.err); + assert.deepEqual(JSON.parse(json.out), { + threads: [ + { id: "t-1", status: "idle", name: "Fix the build", preview: "please fix the build", cwd: "/repo/a", source: "vscode", updatedAt: 1700000001 }, + { id: "t-2", status: "notLoaded", name: null, preview: "second thread\npreview", cwd: "/repo/b", source: "cli", updatedAt: 1700000000 }, + ], + nextCursor: null, + }); + assert.deepEqual(fake.received.at(-1).params, { limit: 20 }); + } finally { + await fake.close(); + } +}); + +test("codex list-threads rejects a bad --limit", async () => { + const { code, err } = await gbot("/nonexistent", "codex", "list-threads", "--limit", "0"); + assert.equal(code, 1); + assert.equal(err, "--limit must be a positive integer\n"); +}); + +test("codex send resumes the thread, starts a turn, and prints the ids", async () => { + const fake = await fakeAppServer(baseHandlers); + try { + const { code, out } = await gbot(fake.home, "--json", "codex", "send", "t-1", "hello", "from", "gbot"); + assert.equal(code, 0, out); + assert.deepEqual(JSON.parse(out), { + threadId: "t-1", + turnId: "turn-9", + turnStatus: "inProgress", + model: "gpt-6", + cwd: "/repo/a", + approvalPolicy: "never", + }); + assert.deepEqual(fake.received.map((m) => m.method), ["initialize", "initialized", "thread/resume", "turn/start"]); + assert.deepEqual(fake.received[2].params, { threadId: "t-1", excludeTurns: true }); + assert.deepEqual(fake.received[3].params, { threadId: "t-1", input: [{ type: "text", text: "hello from gbot" }] }); + + const text = await gbot(fake.home, "codex", "send", "t-1", "again"); + assert.equal(text.out, "Started turn turn-9 (inProgress) on Codex thread t-1\n"); + } finally { + await fake.close(); + } +}); + +test("codex send explains unknown threads and threads owned by another client", async () => { + const fake = await fakeAppServer(baseHandlers); + try { + const unknown = await gbot(fake.home, "codex", "send", "nope", "hi"); + assert.equal(unknown.code, 1); + assert.equal(unknown.err, "Unknown Codex thread nope. Run `gbot codex list-threads` to see reachable threads.\n"); + + const busy = await gbot(fake.home, "codex", "send", "t-2", "hi"); + assert.equal(busy.code, 1); + assert.match(busy.err, /^Codex thread t-2 is open in another client/); + assert.equal(fake.received.filter((m) => m.method === "turn/start").length, 0); + } finally { + await fake.close(); + } +}); + +test("codex send refuses server approval requests and fails with guidance", async () => { + const fake = await fakeAppServer({ + ...baseHandlers, + "turn/start": (params, ok, err, send) => { + send({ jsonrpc: "2.0", id: "srv-1", method: "item/commandExecution/requestApproval", params: { threadId: params.threadId, command: "rm -rf /" } }); + setTimeout(() => ok({ turn: { id: "turn-10", status: "inProgress", items: [] } }), 50); + }, + }); + try { + const { code, err } = await gbot(fake.home, "codex", "send", "t-1", "do it"); + assert.equal(code, 1); + assert.match(err, /^Turn turn-10 started on thread t-1 but Codex asked for item\/commandExecution\/requestApproval, which gbot refused/); + assert.match(err, /approval_policy = "never"/); + const refusal = fake.received.find((m) => m.id === "srv-1"); + assert.equal(refusal.error.code, -32601); + assert.equal(refusal.result, undefined); + } finally { + await fake.close(); + } +}); + +test("codex send surfaces other JSON-RPC errors verbatim", async () => { + const fake = await fakeAppServer({ ...baseHandlers, "turn/start": (params, ok, err) => err({ code: -32600, message: "model unavailable" }) }); + try { + const { code, err } = await gbot(fake.home, "codex", "send", "t-1", "hi"); + assert.equal(code, 1); + assert.equal(err, "Codex app-server rejected turn/start: model unavailable\n"); + } finally { + await fake.close(); + } +}); From 8c1264bcb81a25ec6d25dcfe74e2a5fd3d18b13d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 00:54:44 +0000 Subject: [PATCH 3/6] docs(codex): document gbot codex, the Desktop limitation, and add a changeset --- .changeset/codex-bridge.md | 5 +++++ README.md | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 .changeset/codex-bridge.md diff --git a/.changeset/codex-bridge.md b/.changeset/codex-bridge.md new file mode 100644 index 0000000..b847a74 --- /dev/null +++ b/.changeset/codex-bridge.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": minor +--- + +Add `gbot codex status`, `gbot codex list-threads [--limit N]`, and `gbot codex send `: attach to the local Codex app-server daemon socket (`$CODEX_HOME/app-server-control/app-server-control.sock`) with a built-in WebSocket client, list threads, and start a turn with documented JSON-RPC (`thread/resume` + `turn/start`). Reports an absent socket (no daemon or ChatGPT Desktop private mode), unknown threads, threads owned by another client, and refuses server approval requests instead of approving them. Method names are pinned to Codex 0.154.0. diff --git a/README.md b/README.md index dca3a0e..2473df3 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,30 @@ By default `gbot` only sends credentials to expected hosts: All gateway / `EnsureSandBox` fetches use `redirect: "error"` so credentials are not followed across redirects. +## Messaging Codex threads from Grok Bot + +`gbot codex` attaches to a local [Codex app-server](https://learn.chatgpt.com/docs/app-server) daemon and injects messages into its threads with the documented JSON-RPC methods (`initialize`, `thread/list`, `thread/resume`, `turn/start`). + +```sh +codex app-server daemon start # once per machine session +gbot codex status # socket, daemon version, reachability +gbot codex list-threads --limit 10 # id, status, cwd, preview +gbot codex send "Grok here: the build is green, please continue." +``` + +`send` resumes the thread, starts a turn with your text, prints the turn id, and returns; Codex keeps working after `gbot` disconnects. Every command accepts `--json`. + +**Which Codex you reach.** `gbot` connects to `$CODEX_HOME/app-server-control/app-server-control.sock` (default `~/.codex/...`) with a built-in WebSocket client. The daemon must be started by `codex app-server daemon start`. `list-threads` shows the threads recorded under `CODEX_HOME` (CLI, TUI, VS Code); `send` works on any of them that no other client currently holds open. Method and parameter names are pinned to the Codex release recorded in `src/codex-bridge.js` (`codex app-server generate-json-schema`); `status` prints the daemon and CLI versions so a stale daemon is visible, and `codex app-server daemon restart` picks up the installed CLI. + +**ChatGPT Desktop limitation.** Desktop runs its own private stdio app-server and does not publish the shared control socket, so external clients cannot reach live Desktop tasks. When the socket is absent, `gbot codex status` exits 1 and says so, naming the upstream issues: [openai/codex#41014](https://github.com/openai/codex/issues/41014) and [openai/codex#41112](https://github.com/openai/codex/issues/41112). `gbot` never reads Desktop's temporary `CODEX_APP_TOOLS_PIPE_PATH` sockets under `/tmp/codex-browser-use/`; that channel is private to Desktop. + +**Failure modes.** + +- Socket absent: no daemon, or Desktop-private mode. Start the daemon or wait for the upstream fixes. +- Unknown thread: `send` fails with "Unknown Codex thread"; use `list-threads`. +- Thread open elsewhere: a thread with an active writer (VS Code, TUI) fails with "open in another client"; close it there first. +- Approvals: `gbot` never approves commands or file changes on your behalf. If Codex asks while `gbot` is connected, `send` refuses the request, exits 1, and tells you the turn id. For unattended sends set `approval_policy = "never"` in the daemon's `config.toml`. + ## License MIT From 78d29e8c8cf460a87650599e54b193f6ab7897f1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 00:57:22 +0000 Subject: [PATCH 4/6] fix(codex): list threads from the state DB instead of rescanning rollouts --- src/codex-bridge.js | 4 +++- test/codex-bridge.test.js | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/codex-bridge.js b/src/codex-bridge.js index c6adce9..354dc68 100644 --- a/src/codex-bridge.js +++ b/src/codex-bridge.js @@ -260,7 +260,9 @@ export function summarizeThread(t) { export async function listCodexThreads({ limit = 20, env = process.env } = {}) { const { client } = await openSession(env); try { - const out = await client.request("thread/list", { limit }); + // The default listing rescans every rollout file to repair metadata (26 s on a busy machine); + // the state DB already holds what we print. + const out = await client.request("thread/list", { limit, useStateDbOnly: true }); return { threads: out.data.map(summarizeThread), nextCursor: out.nextCursor ?? null }; } finally { client.close(); diff --git a/test/codex-bridge.test.js b/test/codex-bridge.test.js index e2f0612..0001dd0 100644 --- a/test/codex-bridge.test.js +++ b/test/codex-bridge.test.js @@ -131,7 +131,7 @@ test("codex list-threads passes --limit and prints threads", async () => { const text = await gbot(fake.home, "codex", "list-threads", "--limit", "1"); assert.equal(text.code, 0, text.err); assert.equal(text.out, "t-1 idle - Fix the build\n /repo/a\n please fix the build\n"); - assert.deepEqual(fake.received.find((m) => m.method === "thread/list").params, { limit: 1 }); + assert.deepEqual(fake.received.find((m) => m.method === "thread/list").params, { limit: 1, useStateDbOnly: true }); const json = await gbot(fake.home, "--json", "codex", "list-threads"); assert.equal(json.code, 0, json.err); @@ -142,7 +142,7 @@ test("codex list-threads passes --limit and prints threads", async () => { ], nextCursor: null, }); - assert.deepEqual(fake.received.at(-1).params, { limit: 20 }); + assert.deepEqual(fake.received.at(-1).params, { limit: 20, useStateDbOnly: true }); } finally { await fake.close(); } From a526c1afffd55130e1890ca059bf20725b34e3b1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 00:58:12 +0000 Subject: [PATCH 5/6] fix(codex): fail the session on an unreadable server frame --- src/codex-bridge.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/codex-bridge.js b/src/codex-bridge.js index 354dc68..dcf9357 100644 --- a/src/codex-bridge.js +++ b/src/codex-bridge.js @@ -193,12 +193,18 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { socket.setTimeout(0); resolve(client); } + // ponytail: unfragmented frames only; the app-server sends each JSON-RPC message as one text frame. for (;;) { const frame = decodeFrame(buf); if (!frame) return; buf = frame.rest; - if (frame.opcode === 0x1) onMessage(JSON.parse(frame.payload.toString())); - else if (frame.opcode === 0x9) write(0xa, frame.payload); + if (frame.opcode === 0x1) { + try { + onMessage(JSON.parse(frame.payload.toString())); + } catch (err) { + failAll(new Error("Codex app-server sent an unreadable message: " + err.message)); + } + } else if (frame.opcode === 0x9) write(0xa, frame.payload); else if (frame.opcode === 0x8) socket.end(); } }); From fd7f07b4c8f0634addd0889aae298ea435f59645 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 01:06:47 +0000 Subject: [PATCH 6/6] fix(codex): echo server Close frames and settle pending requests; pin RFC 6455 vectors --- README.md | 2 +- src/codex-bridge.js | 6 +++++- test/codex-bridge.test.js | 45 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2473df3..6e6672d 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ gbot codex send "Grok here: the build is green, please continue." - Socket absent: no daemon, or Desktop-private mode. Start the daemon or wait for the upstream fixes. - Unknown thread: `send` fails with "Unknown Codex thread"; use `list-threads`. - Thread open elsewhere: a thread with an active writer (VS Code, TUI) fails with "open in another client"; close it there first. -- Approvals: `gbot` never approves commands or file changes on your behalf. If Codex asks while `gbot` is connected, `send` refuses the request, exits 1, and tells you the turn id. For unattended sends set `approval_policy = "never"` in the daemon's `config.toml`. +- Approvals: `gbot` never approves commands or file changes on your behalf. If Codex asks while `gbot` is still connected, `send` refuses the request, exits 1, and tells you the turn id. `send` disconnects as soon as the turn starts, so later approval requests stay with the daemon for a Codex client to answer; for unattended sends set `approval_policy = "never"` in the daemon's `config.toml`. ## License diff --git a/src/codex-bridge.js b/src/codex-bridge.js index dcf9357..f8ba992 100644 --- a/src/codex-bridge.js +++ b/src/codex-bridge.js @@ -205,7 +205,11 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { failAll(new Error("Codex app-server sent an unreadable message: " + err.message)); } } else if (frame.opcode === 0x9) write(0xa, frame.payload); - else if (frame.opcode === 0x8) socket.end(); + else if (frame.opcode === 0x8) { + write(0x8, frame.payload); + socket.end(); + failAll(new Error("Codex app-server closed the connection")); + } } }); }); diff --git a/test/codex-bridge.test.js b/test/codex-bridge.test.js index 0001dd0..fcdecc3 100644 --- a/test/codex-bridge.test.js +++ b/test/codex-bridge.test.js @@ -39,14 +39,14 @@ async function fakeAppServer(handlers) { const frame = decodeFrame(buf); if (!frame) return; buf = frame.rest; - if (frame.opcode === 0x8) return socket.end(); + if (frame.opcode === 0x8) { socket.end(); return; } if (frame.opcode !== 0x1) continue; const msg = JSON.parse(frame.payload.toString()); received.push(msg); if (msg.method && msg.id != null) { const handler = handlers[msg.method]; if (!handler) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "unknown method " + msg.method } }); - else handler(msg.params, (result) => send({ jsonrpc: "2.0", id: msg.id, result }), (error) => send({ jsonrpc: "2.0", id: msg.id, error }), send); + else handler(msg.params, (result) => send({ jsonrpc: "2.0", id: msg.id, result }), (error) => send({ jsonrpc: "2.0", id: msg.id, error }), send, socket); } } }); @@ -86,6 +86,14 @@ test("encodeFrame masks a client text frame per RFC 6455", () => { assert.equal(back.rest.length, 0); }); +test("websocketAccept and frame headers match the RFC 6455 vectors", () => { + assert.equal(websocketAccept("dGhlIHNhbXBsZSBub25jZQ=="), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + assert.equal(encodeFrame(0x1, Buffer.alloc(125)).subarray(0, 2).toString("hex"), "817d"); + assert.equal(encodeFrame(0x1, Buffer.alloc(126)).subarray(0, 4).toString("hex"), "817e007e"); + assert.equal(encodeFrame(0x1, Buffer.alloc(65536)).subarray(0, 10).toString("hex"), "817f0000000000010000"); + assert.equal(decodeFrame(encodeFrame(0x1, Buffer.alloc(65536))).payload.length, 65536); +}); + test("decodeFrame waits for a complete frame and returns the remainder", () => { const two = Buffer.concat([encodeFrame(0x1, Buffer.from("a")), encodeFrame(0x1, Buffer.from("bc"))]); assert.equal(decodeFrame(two.subarray(0, 2)), null); @@ -215,6 +223,39 @@ test("codex send refuses server approval requests and fails with guidance", asyn } }); +test("codex send returns once the turn starts; later approval requests are the daemon's to route", async () => { + const fake = await fakeAppServer({ + ...baseHandlers, + "turn/start": (params, ok, err, send) => { + ok({ turn: { id: "turn-11", status: "inProgress", items: [] } }); + setTimeout(() => send({ jsonrpc: "2.0", id: "srv-2", method: "item/commandExecution/requestApproval", params: {} }), 20); + }, + }); + try { + const { code, out } = await gbot(fake.home, "codex", "send", "t-1", "go"); + assert.equal(code, 0); + assert.equal(out, "Started turn turn-11 (inProgress) on Codex thread t-1\n"); + } finally { + await fake.close(); + } +}); + +test("codex send fails fast when the server sends a Close frame mid-request", async () => { + const fake = await fakeAppServer({ + ...baseHandlers, + "turn/start": (params, ok, err, send, socket) => socket.write(encodeFrame(0x8, Buffer.from([0x03, 0xe8]))), + }); + try { + const started = Date.now(); + const { code, err } = await gbot(fake.home, "codex", "send", "t-1", "go"); + assert.equal(code, 1); + assert.equal(err, "Codex app-server closed the connection\n"); + assert.ok(Date.now() - started < 5000, "did not wait for the request timeout"); + } finally { + await fake.close(); + } +}); + test("codex send surfaces other JSON-RPC errors verbatim", async () => { const fake = await fakeAppServer({ ...baseHandlers, "turn/start": (params, ok, err) => err({ code: -32600, message: "model unavailable" }) }); try {