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/codex-cli-hardening.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/thread-send-message-text.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ gbot codex send <threadId> "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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions plugin/src/gbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export const entrySchema = z.object({
});
type Entry = z.infer<typeof entrySchema>;

/** 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'),
Expand All @@ -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 }),
};
};
Expand Down
6 changes: 5 additions & 1 deletion plugin/src/mcp/grok-bot/tools/gbot_thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
4 changes: 4 additions & 0 deletions plugin/tests/route-unit/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const transcripts: Record<string, unknown> = {
{ 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': {
Expand Down Expand Up @@ -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 () => {
Expand Down
18 changes: 18 additions & 0 deletions scripts/run-unit-tests.mjs
Original file line number Diff line number Diff line change
@@ -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);
64 changes: 52 additions & 12 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 <threadId> <message...>");
const out = await sendToCodexThread(threadId, message);
Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/codex-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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) };
}
Expand Down
54 changes: 49 additions & 5 deletions test/codex-bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down