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..6e6672d 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 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 MIT 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..f8ba992 --- /dev/null +++ b/src/codex-bridge.js @@ -0,0 +1,322 @@ +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); + } + // 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) { + 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) { + write(0x8, frame.payload); + socket.end(); + failAll(new Error("Codex app-server closed the connection")); + } + } + }); + }); +} + +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 { + // 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(); + } +} + +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(); + } +} diff --git a/test/codex-bridge.test.js b/test/codex-bridge.test.js new file mode 100644 index 0000000..fcdecc3 --- /dev/null +++ b/test/codex-bridge.test.js @@ -0,0 +1,268 @@ +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) { 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, socket); + } + } + }); + 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("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); + 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, useStateDbOnly: true }); + + 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, useStateDbOnly: true }); + } 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 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 { + 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(); + } +});