diff --git a/.changeset/codex-cli-hardening.md b/.changeset/codex-cli-hardening.md new file mode 100644 index 0000000..1d479e4 --- /dev/null +++ b/.changeset/codex-cli-hardening.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Parse global `gbot` flags only before the command so `gbot codex send` keeps `--json` / `--dir` inside the message; refuse native Windows for `gbot codex` with a clear error; strip terminal controls from thread listings; run unit tests through `scripts/run-unit-tests.mjs` so Windows and Node 18 work without shell globs. diff --git a/.changeset/thread-send-message-text.md b/.changeset/thread-send-message-text.md new file mode 100644 index 0000000..f89ab06 --- /dev/null +++ b/.changeset/thread-send-message-text.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Fix `gbot thread` so bot replies (`send-message` entries) show their text instead of empty lines, by sharing transcript parsing with the grok-bot plugin. diff --git a/README.md b/README.md index 91c03da..cf79c7c 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ 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. +**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. Native Windows is not supported yet (AF_UNIX control socket); use WSL, Linux, or macOS. **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. diff --git a/package.json b/package.json index 47a96b0..74b3783 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "changeset": "changeset", "gbot": "node src/cli.js", "release": "changeset publish", - "test": "node --test test/*.test.js" + "test": "node scripts/run-unit-tests.mjs" }, "repository": { "type": "git", diff --git a/plugin/src/gbot.ts b/plugin/src/gbot.ts index 493b2a3..9a66195 100644 --- a/plugin/src/gbot.ts +++ b/plugin/src/gbot.ts @@ -37,6 +37,14 @@ export const entrySchema = z.object({ }); type Entry = z.infer; +/** Match `gbot thread` CLI preview width so MCP hosts are not flooded. */ +export const ENTRY_TEXT_MAX = 400; + +export const truncateEntryText = (text: string, max = ENTRY_TEXT_MAX): string => { + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 1))}…`; +}; + const entryFields = z.object({ id: z.string().default(''), kind: z.string().default('message'), @@ -46,13 +54,15 @@ const entryFields = z.object({ const threadEntry = (raw: unknown): Entry => { const fields = entryFields.safeParse(raw); - if (!fields.success) return { id: '', kind: 'unknown', text: JSON.stringify(raw) }; + if (!fields.success) { + return { id: '', kind: 'unknown', text: truncateEntryText(JSON.stringify(raw)) }; + } const { id, kind, role, timestampMs } = fields.data; return { id, kind, ...(role === undefined ? {} : { role }), - text: entryText(raw), + text: truncateEntryText(entryText(raw)), ...(timestampMs === undefined ? {} : { timestampMs }), }; }; diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index d0e6747..3df7f43 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -20,7 +20,11 @@ export default defineTool( inputJsonSchema: { additionalProperties: false, properties: { - limit: { default: 40, description: 'How many trailing entries to return (1-200).', type: 'number' }, + limit: { + default: 40, + description: 'How many trailing entries to return (1-200). Each entry text is capped at 400 characters.', + type: 'number', + }, target: { description: 'Bot or group name or id, for example "General".', type: 'string' }, }, required: ['target'], diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index 105f4c7..8d4ccaa 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -31,6 +31,7 @@ const transcripts: Record = { { content: 'ignored when text is set', id: 'l1', text: 'direct text' }, { content: [{ text: 'part one' }, 'part two', { content: 'part three' }], id: 'l2', kind: 'note' }, { id: 'l3', message: 'plain message' }, + { id: 'l4', kind: 'message', text: `${'x'.repeat(450)}` }, ], }, 'grp-1': { @@ -151,8 +152,11 @@ describe('grok-bot MCP server', () => { { id: 'l1', kind: 'message', text: 'direct text' }, { id: 'l2', kind: 'note', text: 'part one\npart two\npart three' }, { id: 'l3', kind: 'message', text: 'plain message' }, + { id: 'l4', kind: 'message', text: `${'x'.repeat(399)}…` }, ], }); + expect(contentText(legacy.content)).toContain('…'); + expect(contentText(legacy.content)).not.toContain('x'.repeat(450)); }); it('redacts a bearer token echoed by the gateway before the error reaches the host', async () => { diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs new file mode 100644 index 0000000..f02637b --- /dev/null +++ b/scripts/run-unit-tests.mjs @@ -0,0 +1,18 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Enumerate test/*.test.js in JS so Windows cmd and Node 18/24 all work +// (shell globs do not expand on Windows; `node --test test` is not a directory walk). +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const dir = join(root, "test"); +const files = readdirSync(dir) + .filter((name) => name.endsWith(".test.js")) + .sort() + .map((name) => join("test", name)); +const result = spawnSync(process.execPath, ["--test", ...files], { + cwd: root, + stdio: "inherit", +}); +process.exit(result.status === null ? 1 : result.status); diff --git a/src/cli.js b/src/cli.js index 79296b0..bfe084c 100755 --- a/src/cli.js +++ b/src/cli.js @@ -105,11 +105,51 @@ function takeRepeating(args, name) { return out; } -function hasFlag(args, name) { - const i = args.indexOf(name); - if (i === -1) return false; - args.splice(i, 1); - return true; +/** Peel global CLI options only from the leading argv (before the command). */ +function takeLeadingGlobals(args) { + let json = false; + let gateway = false; + let files = false; + let dir; + while (args.length) { + const a = args[0]; + if (a === "--") { + args.shift(); + break; + } + if (a === "--json") { + json = true; + args.shift(); + continue; + } + if (a === "--gateway") { + gateway = true; + args.shift(); + continue; + } + if (a === "--files") { + files = true; + args.shift(); + continue; + } + if (a === "--dir") { + args.shift(); + const value = args.shift(); + if (value == null || value.startsWith("-")) throw new StoreError("--dir needs a value"); + dir = value; + continue; + } + break; + } + return { json, gateway, files, dir }; +} + +/** Strip CSI/OSC so thread names/previews cannot drive the terminal. */ +function stripTerminalControls(text) { + return String(text) + .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "") + .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "") + .replace(/\u001b./g, ""); } function parseOnOff(value, flag) { @@ -219,9 +259,11 @@ function formatCodexStatus(s) { } 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; + const title = t.name ? " - " + stripTerminalControls(t.name) : ""; + const preview = t.preview + ? "\n " + stripTerminalControls(String(t.preview).replace(/\s+/g, " ")).slice(0, 200) + : ""; + return stripTerminalControls(t.id) + " " + stripTerminalControls(t.status) + title + "\n " + stripTerminalControls(t.cwd ?? "") + preview; } async function runCodex(sub, rest, json) { @@ -243,6 +285,7 @@ async function runCodex(sub, rest, json) { } if (sub === "send") { const threadId = rest.shift(); + if (rest[0] === "--") rest.shift(); const message = rest.join(" ").trim(); if (!threadId || threadId.startsWith("-") || !message) throw new StoreError("gbot codex send "); const out = await sendToCodexThread(threadId, message); @@ -260,10 +303,7 @@ async function main(argv) { return; } - const json = hasFlag(args, "--json"); - const gateway = hasFlag(args, "--gateway"); - const filesMode = hasFlag(args, "--files"); - const rootFlag = takeFlag(args, "--dir"); + const { json, gateway, files: filesMode, dir: rootFlag } = takeLeadingGlobals(args); const cmd = args[0]; const sub = args[1]; const rest = args.slice(2); diff --git a/src/codex-bridge.js b/src/codex-bridge.js index f8ba992..85f3740 100644 --- a/src/codex-bridge.js +++ b/src/codex-bridge.js @@ -41,6 +41,14 @@ export function unreachableMessage(path) { ].join("\n"); } +export function windowsUnsupportedMessage() { + return [ + "gbot codex does not support native Windows yet.", + "Codex's control socket is AF_UNIX; this CLI's Node client only dials Unix domain sockets.", + "Use WSL, Linux, or macOS (or a future stdio proxy path).", + ].join("\n"); +} + export function encodeFrame(opcode, payload, mask) { const len = payload.length; const head = Buffer.alloc(len < 126 ? 2 : len < 65536 ? 4 : 10); @@ -222,6 +230,7 @@ function appServerVersion(initResult) { } async function openSession(env = process.env) { + if (process.platform === "win32") throw new Error(windowsUnsupportedMessage()); const path = codexSocketPath(env); if (!socketPresent(path)) throw new Error(unreachableMessage(path)); const client = await connectCodexAppServer(path); @@ -239,6 +248,9 @@ export function localCodexVersion() { export async function codexStatus(env = process.env) { const path = codexSocketPath(env); const base = { socketPath: path, pinnedVersion: PINNED_CODEX_VERSION, cliVersion: localCodexVersion() }; + if (process.platform === "win32") { + return { ...base, reachable: false, mode: "windows-unsupported", message: windowsUnsupportedMessage() }; + } if (!socketPresent(path)) { return { ...base, reachable: false, mode: "socket-absent", message: unreachableMessage(path) }; } diff --git a/test/codex-bridge.test.js b/test/codex-bridge.test.js index fcdecc3..9ab2405 100644 --- a/test/codex-bridge.test.js +++ b/test/codex-bridge.test.js @@ -256,12 +256,56 @@ test("codex send fails fast when the server sends a Close frame mid-request", as } }); -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" }) }); +test("codex send keeps --json / --dir tokens that appear after the thread id", async () => { + const fake = await fakeAppServer(baseHandlers); 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"); + const { code, out } = await gbot( + fake.home, + "codex", + "send", + "t-1", + "explain", + "--json", + "output", + "and", + "--dir", + "src", + ); + assert.equal(code, 0); + assert.match(out, /Started turn/); + const start = fake.received.find((m) => m.method === "turn/start"); + assert.equal(start.params.input[0].text, "explain --json output and --dir src"); + } finally { + await fake.close(); + } +}); + +test("codex list-threads strips terminal controls from names and previews", async () => { + const handlers = { + ...baseHandlers, + "thread/list": (params, ok) => + ok({ + data: [ + { + id: "t-evil", + status: { type: "idle" }, + name: "Build\u001b[31mRED\u001b[0m", + preview: "hi\u001b]0;owned\u0007 there", + cwd: "/repo", + source: "cli", + updatedAt: 1, + }, + ], + nextCursor: null, + }), + }; + const fake = await fakeAppServer(handlers); + try { + const { code, out } = await gbot(fake.home, "codex", "list-threads"); + assert.equal(code, 0); + assert.match(out, /BuildRED/); + assert.doesNotMatch(out, /\u001b/); + assert.doesNotMatch(out, /\]0;owned/); } finally { await fake.close(); }