diff --git a/.changeset/potato-p1-hold-fixes.md b/.changeset/potato-p1-hold-fixes.md new file mode 100644 index 0000000..27595ab --- /dev/null +++ b/.changeset/potato-p1-hold-fixes.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +HOLD-fix follow-up for the P1 bridge work: gateway reads count true bytes through a streaming reader that cancels on overflow; Codex transport uses an absolute handshake deadline, caps terminated headers and complete frames before decoding, guarantees socket destruction on close, and routes malformed RPC through failure handling; CLI `--json` failures emit structured errors with delivery and IDs; sends stay `unknown` without a confirmed receipt; MCP full reads add aggregate budgets with truncation metadata and a CLI continuation path. diff --git a/plugin/src/gbot.ts b/plugin/src/gbot.ts index a0f6659..ac4be59 100644 --- a/plugin/src/gbot.ts +++ b/plugin/src/gbot.ts @@ -50,7 +50,12 @@ type Entry = z.infer; /** Match `gbot thread` CLI preview width so MCP hosts are not flooded. */ export const ENTRY_TEXT_MAX = 400; // ponytail: fixed preview/full budgets; upgrade path is a paged thread resource instead of wider caps. +// When an entry is cut by these budgets it still reports truncated/fullLength, and the +// remainder is retrievable with `gbot thread --full` / `--json` on the machine. export const ENTRY_FULL_MAX = 20000; +export const TRANSCRIPT_TOTAL_MAX = 200000; +// Metadata fields are capped too: an uncapped id/kind/role would bypass the total budget. +export const ENTRY_META_MAX = 200; export const truncateEntryText = (text: string, max = ENTRY_TEXT_MAX): string => { if (text.length <= max) return text; @@ -64,20 +69,21 @@ const entryFields = z.object({ timestampMs: z.number().optional(), }); -const threadEntry = (raw: unknown, opts: { full?: boolean } = {}): Entry => { +const capMeta = (value: string): string => (value.length > ENTRY_META_MAX ? `${value.slice(0, ENTRY_META_MAX)}…` : value); + +const threadEntry = (raw: unknown, max: number, ellipsis: boolean): Entry => { const fields = entryFields.safeParse(raw); const full = entryText(raw); - const max = opts.full ? ENTRY_FULL_MAX : ENTRY_TEXT_MAX; const truncated = full.length > max; - const text = !truncated ? full : opts.full ? full.slice(0, max) : truncateEntryText(full); + const text = !truncated ? full : ellipsis ? truncateEntryText(full, max) : full.slice(0, max); if (!fields.success) { return { id: '', kind: 'unknown', text, truncated, fullLength: full.length }; } const { id, kind, role, timestampMs } = fields.data; return { - id, - kind, - ...(role === undefined ? {} : { role }), + id: capMeta(id), + kind: capMeta(kind), + ...(role === undefined ? {} : { role: capMeta(role) }), text, truncated, fullLength: full.length, @@ -85,5 +91,23 @@ const threadEntry = (raw: unknown, opts: { full?: boolean } = {}): Entry => { }; }; -export const transcriptEntries = (transcript: unknown, opts: { full?: boolean } = {}): Entry[] => - unwrapEntries(transcript).map((raw) => threadEntry(raw, opts)); +const metaLength = (entry: Entry): number => entry.id.length + entry.kind.length + (entry.role?.length ?? 0); + +export const transcriptEntries = (transcript: unknown, opts: { full?: boolean; limit?: number } = {}): Entry[] => { + const rows = unwrapEntries(transcript); + // Enforce the requested count locally: a gateway ignoring `limit` cannot inflate output. + const wanted = + typeof opts.limit === 'number' && Number.isInteger(opts.limit) && opts.limit > 0 ? Math.min(opts.limit, 200) : rows.length; + const perEntry = opts.full ? ENTRY_FULL_MAX : ENTRY_TEXT_MAX; + let remaining = TRANSCRIPT_TOTAL_MAX; + return rows.slice(0, wanted).map((raw) => { + const entry = threadEntry(raw, perEntry, !opts.full); + const allowText = Math.max(0, Math.min(entry.text.length, remaining - metaLength(entry))); + if (allowText < entry.text.length) { + entry.text = allowText <= 0 ? '' : !opts.full ? truncateEntryText(entry.text, allowText) : entry.text.slice(0, allowText); + entry.truncated = entry.fullLength > entry.text.length; + } + remaining = Math.max(0, remaining - metaLength(entry) - entry.text.length); + return entry; + }); +}; diff --git a/plugin/src/mcp/grok-bot/tools/gbot_send.tsx b/plugin/src/mcp/grok-bot/tools/gbot_send.tsx index e421a0d..b035350 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_send.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_send.tsx @@ -21,18 +21,20 @@ export default defineTool( resultSchema: z.object({ result: z.record(z.string(), z.json()), target: targetSchema, - delivery: z.enum(['accepted']), + delivery: z.enum(['accepted', 'unknown']), messageId: z.string().optional(), }), title: 'Send a message to Grok Bot', }, async ({ message, target }) => { const sent = await withRedactedErrors(async () => sendPrompt(await connectGateway(), target, message)); - const messageId = typeof sent.messageId === 'string' ? (sent.messageId as string) : undefined; + // Never claim acceptance the gateway didn't confirm; no-receipt sends stay unknown. + const delivery = sent.delivery === 'accepted' ? ('accepted' as const) : ('unknown' as const); + const messageId = delivery === 'accepted' && typeof sent.messageId === 'string' ? (sent.messageId as string) : undefined; const value = { result: sent.result, target: summarizeTarget(sent.target), - delivery: 'accepted' as const, + delivery, ...(messageId === undefined ? {} : { messageId }), }; return ( diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index 6422547..a242ce2 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -28,7 +28,7 @@ export default defineTool( full: { default: false, description: - 'Return complete entry text up to a bounded full-read budget instead of the 400-character preview. Every entry still reports truncation metadata.', + 'Return complete entry text up to bounded budgets (20k chars per entry, 200k total) instead of the 400-character preview. Every entry still reports truncated/fullLength; read the remainder with `gbot thread --full` / `--json` on the machine.', type: 'boolean', }, target: { description: 'Bot or group name or id, for example "General".', type: 'string' }, @@ -37,6 +37,8 @@ export default defineTool( type: 'object', }, inputSchema: z.object({ + // ponytail: the route inputJsonSchema type cannot express minimum/maximum, so the + // 1-200 bound lives here in zod (and in the CLI/gateway); widen the route type to align them. limit: z.number().int().min(1).max(200).default(40), full: z.boolean().default(false), target: z.string().min(1), @@ -46,7 +48,7 @@ export default defineTool( }, async ({ limit, target, full }) => { const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit)); - const value = { entries: transcriptEntries(tail.transcript, { full }), target: summarizeTarget(tail.target) }; + const value = { entries: transcriptEntries(tail.transcript, { full, limit }), target: summarizeTarget(tail.target) }; return ( {`${value.target.kind} ${value.target.name}: ${value.entries.length} entries.`} diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index a633222..fbba0c5 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -23,6 +23,9 @@ const roster = { { id: 'bot-2', isGroup: false, name: 'Legacy' }, { id: 'bot-3', isGroup: false, name: 'Proxy' }, { id: 'bot-4', isGroup: false, name: 'Odd' }, + { id: 'bot-5', isGroup: false, name: 'Noreceipt' }, + { id: 'bot-6', isGroup: false, name: 'Big' }, + { id: 'bot-7', isGroup: false, name: 'Meta' }, ], }; const transcripts: Record = { @@ -50,6 +53,12 @@ const transcripts: Record = { { id: 'o3', kind: 'note', text: `a${String.fromCharCode(0xd800)}b` }, ], }, + 'bot-6': { + entries: Array.from({ length: 11 }, (_, index) => ({ id: `b${index}`, kind: 'note', text: 'q'.repeat(20000) })), + }, + 'bot-7': { + entries: [{ id: 'i'.repeat(300), kind: 'k'.repeat(300), role: 'R'.repeat(5000), text: 'hi' }], + }, }; const responses: Record) => [number, unknown]> = { getAgentTranscriptTail: (body) => [200, transcripts[String(body.id)]], @@ -57,7 +66,9 @@ const responses: Record) => [number, unkn sendPrompt: (body) => body.agentId === 'bot-3' ? [401, { message: 'upstream rejected authorization: Bearer test-token' }] - : [200, { messageId: 'm-1' }], + : body.agentId === 'bot-5' + ? [200, { ok: true }] + : [200, { messageId: 'm-1' }], }; const calls: GatewayCall[] = []; @@ -191,6 +202,62 @@ describe('grok-bot MCP server', () => { }); }); + it('gbot_send stays unknown when the gateway confirms no receipt', async () => { + const result = await invokeMcpTool('gbot_send', { + input: { message: 'ping', target: 'Noreceipt' }, + server: 'grok-bot', + }); + expect(result.isError).toBe(false); + expect(result.structuredContent).toEqual({ + delivery: 'unknown', + result: { ok: true }, + target: { id: 'bot-5', kind: 'bot', name: 'Noreceipt' }, + }); + expect(contentText(result.content)).toContain('no receipt'); + expect(contentText(result.content)).not.toContain(' as '); + }); + + it('gbot_thread caps aggregate output and keeps the remainder visible in metadata', async () => { + const full = await invokeMcpTool('gbot_thread', { + input: { full: true, target: 'Big' }, + server: 'grok-bot', + }); + expect(full.isError).toBe(false); + const entries = (full.structuredContent as { entries: { id: string; text: string; truncated: boolean; fullLength: number }[] }).entries; + expect(entries.length).toBe(11); + expect(entries[9]?.text.length).toBe(19940); + expect(entries[9]).toMatchObject({ id: 'b9', truncated: true, fullLength: 20000 }); + expect(entries[10]).toEqual({ id: 'b10', kind: 'note', text: '', truncated: true, fullLength: 20000 }); + }); + + it('gbot_thread enforces the requested count even when the gateway ignores the limit', async () => { + const capped = await invokeMcpTool('gbot_thread', { + input: { limit: 3, target: 'Big' }, + server: 'grok-bot', + }); + expect(capped.isError).toBe(false); + const entries = (capped.structuredContent as { entries: unknown[] }).entries; + expect(entries.length).toBe(3); + }); + + it('gbot_thread bounds id/kind/role metadata that would bypass the text budget', async () => { + const odd = await invokeMcpTool('gbot_thread', { input: { target: 'Meta' }, server: 'grok-bot' }); + expect(odd.isError).toBe(false); + expect(odd.structuredContent).toEqual({ + entries: [ + { + id: `${'i'.repeat(200)}…`, + kind: `${'k'.repeat(200)}…`, + role: `${'R'.repeat(200)}…`, + text: 'hi', + truncated: false, + fullLength: 2, + }, + ], + target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, + }); + }); + it('redacts a bearer token echoed by the gateway before the error reaches the host', async () => { const result = await invokeMcpTool('gbot_send', { input: { message: 'x', target: 'Proxy' }, diff --git a/src/cli.js b/src/cli.js index ea23b14..d2eb07d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -16,7 +16,16 @@ function print(value) { function fail(err) { let message = err instanceof Error ? err.message : String(err); message = redactSecrets(message); - process.stderr.write(message + "\n"); + if (jsonErrors && err instanceof Error) { + const out = { error: message }; + if (err.delivery !== undefined) out.delivery = err.delivery; + if (err.threadId !== undefined) out.threadId = err.threadId; + if (err.turnId !== undefined) out.turnId = err.turnId; + if (err.targetId !== undefined) out.targetId = err.targetId; + process.stderr.write(JSON.stringify(out) + "\n"); + } else { + process.stderr.write(message + "\n"); + } process.exit(1); } @@ -118,7 +127,19 @@ function hasFlag(args, name) { return true; } +/** Peel a trailing flag that `--` does not protect; free-text commands keep mid-text tokens. */ +function takeTrailingFlag(args, name) { + const stop = args.indexOf("--"); + const end = stop === -1 ? args.length : stop; + if (end > 0 && args[end - 1] === name) { + args.splice(end - 1, 1); + return true; + } + return false; +} + /** Peel global CLI options only from the leading argv (before the command). */ +let jsonErrors = false; function takeLeadingGlobals(args) { let json = false; let gateway = false; @@ -134,6 +155,7 @@ function takeLeadingGlobals(args) { } if (a === "--json") { json = true; + jsonErrors = true; args.shift(); continue; } @@ -301,6 +323,11 @@ function formatCodexThread(t) { } async function runCodex(sub, rest, json) { + // Structured subcommands take no free text, so --json peels anywhere. Send + // peels a trailing --json only; mid-message tokens stay message content. + if (sub === "status" || sub === "list-threads") { + if (hasFlag(rest, "--json")) { json = true; jsonErrors = true; } + } if (sub === "status") { const status = await codexStatus(); print(json ? status : formatCodexStatus(status)); @@ -319,6 +346,7 @@ async function runCodex(sub, rest, json) { } if (sub === "send") { const threadId = rest.shift(); + if (takeTrailingFlag(rest, "--json")) { json = true; jsonErrors = true; } if (rest[0] === "--") rest.shift(); const message = rest.join(" ").trim(); if (!threadId || threadId.startsWith("-") || !message) throw new StoreError("gbot codex send "); @@ -379,7 +407,7 @@ async function main(argv) { if (cmd === "history") { const options = args.slice(1); // Command-local flags (globals only peel from argv before the command). - if (hasFlag(options, "--json")) json = true; + if (hasFlag(options, "--json")) { json = true; jsonErrors = true; } const showPath = hasFlag(options, "--path"); const search = takeFlag(options, "--search"); const limitRaw = takeFlag(options, "--limit"); @@ -405,6 +433,12 @@ async function main(argv) { return; } + // Peel command-local --json before touching the backend so auth/gateway + // failures honor it. Send peels a trailing --json only (mid-text tokens stay + // message content); `--` protects everything after it. + if (cmd === "send" && takeTrailingFlag(rest, "--json")) { json = true; jsonErrors = true; } + if ((cmd === "thread" || cmd === "chat") && hasFlag(rest, "--json")) { json = true; jsonErrors = true; } + const backend = await openBackend({ root: rootFlag, gateway, files: filesMode }); if (cmd === "bots" && sub === "list") { @@ -514,7 +548,7 @@ async function main(argv) { if (cmd === "send") { const ref = sub; - if (hasFlag(rest, "--json")) json = true; + if (hasFlag(rest, "--json")) { json = true; jsonErrors = true; } const message = rest.join(" ").trim(); if (!ref || !message) throw new StoreError("gbot send "); const out = await backend.send(ref, message); @@ -528,9 +562,7 @@ async function main(argv) { if (cmd === "thread" || cmd === "chat") { const ref = sub; if (!ref) throw new StoreError("gbot thread [--limit N] [--root MESSAGE_ID] [--full]"); - if (hasFlag(rest, "--json")) json = true; - const full = rest.includes("--full"); - if (full) rest.splice(rest.indexOf("--full"), 1); + const full = hasFlag(rest, "--full"); const limitRaw = takeFlag(rest, "--limit"); const rootId = takeFlag(rest, "--root"); const limit = limitRaw ? Number(limitRaw) : 40; diff --git a/src/codex-bridge.js b/src/codex-bridge.js index a46dd5f..084f246 100644 --- a/src/codex-bridge.js +++ b/src/codex-bridge.js @@ -173,10 +173,15 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { let fragOpcode = null; let fragParts = []; let fragBytes = 0; + // Absolute handshake deadline: socket timeouts reset on any bytes, so trickled + // headers must not extend this. Per-request timers stay absolute after upgrade. + const handshakeTimer = setTimeout(() => failAll(new Error("Timed out connecting to Codex app-server at " + path)), timeoutMs); + if (typeof handshakeTimer.unref === "function") handshakeTimer.unref(); const failAll = (err) => { if (closed) return; closed = true; + clearTimeout(handshakeTimer); if (err && err.delivery == null) err.delivery = pending.size ? "unknown" : "rejected"; for (const { reject: rej } of pending.values()) rej(err); pending.clear(); @@ -212,15 +217,20 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { close() { if (closed) return; closed = true; + clearTimeout(handshakeTimer); const err = new Error("Codex client closed"); err.delivery = pending.size ? "unknown" : "rejected"; for (const { reject: rej } of pending.values()) rej(err); pending.clear(); - if (!socket.destroyed) { - if (upgraded) write(0x8, Buffer.from([0x03, 0xe8])); - socket.end(); - socket.unref(); - } + // Best-effort close frame, then guaranteed destruction so no path leaks the socket. + if (!socket.destroyed && upgraded) write(0x8, Buffer.from([0x03, 0xe8])); + if (socket.destroyed) return; + const forceDestroy = setTimeout(() => { try { socket.destroy(); } catch { /* already gone */ } }, 1000); + if (typeof forceDestroy.unref === "function") forceDestroy.unref(); + socket.end(() => { + clearTimeout(forceDestroy); + try { socket.destroy(); } catch { /* already gone */ } + }); }, }; @@ -256,10 +266,13 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { failAll(new Error("Codex app-server sent an unreadable message: " + err.message)); return; } + if (!msg || typeof msg !== "object" || Array.isArray(msg)) { + failAll(new Error("Codex app-server sent a malformed message")); + return; + } onMessage(msg); }; - 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))); @@ -271,11 +284,13 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { if (buf.length > WS_MAX_HEADER_BYTES) failAll(new Error("Codex app-server handshake headers exceed " + WS_MAX_HEADER_BYTES + " bytes")); return; } + // Terminated headers hit the cap too; size is checked before any decoding. + if (end > WS_MAX_HEADER_BYTES) return failAll(new Error("Codex app-server handshake headers exceed " + WS_MAX_HEADER_BYTES + " bytes")); const head = buf.subarray(0, end).toString(); buf = buf.subarray(end + 4); if (!validateUpgradeHead(head, key)) return failAll(new Error("Codex app-server refused the WebSocket upgrade: " + head.split("\r\n")[0])); upgraded = true; - socket.setTimeout(0); + clearTimeout(handshakeTimer); resolve(client); } for (;;) { @@ -290,6 +305,7 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { return; } buf = frame.rest; + if (frame.payload.length > WS_MAX_MESSAGE_BYTES) return failAll(new Error("Codex app-server frame exceeds " + WS_MAX_MESSAGE_BYTES + " bytes")); if (frame.masked) return failProtocol("server frames must not be masked"); if (frame.opcode >= 0x8) { if (!frame.fin || frame.payload.length > 125) return failProtocol("bad control frame"); diff --git a/src/gateway.js b/src/gateway.js index 5e89869..d0da4a3 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -82,11 +82,32 @@ export function hasGatewayAuth() { return Boolean(gatewayOverride() || accessTokenFromEnv() || hasGrokBotGatewaySession()); } -async function readJson(res) { - const text = await res.text(); - if (text.length > GATEWAY_MAX_RESPONSE_BYTES) { - throw new GatewayError("Gateway response too large (" + text.length + " bytes, limit " + GATEWAY_MAX_RESPONSE_BYTES + ")"); +async function readTextCapped(res, maxBytes) { + if (!res.body || typeof res.body.getReader !== "function") { + const text = await res.text(); + if (Buffer.byteLength(text, "utf8") > maxBytes) { + throw new GatewayError("Gateway response too large (over " + maxBytes + " bytes)"); + } + return text; + } + const reader = res.body.getReader(); + const chunks = []; + let bytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength ?? value.length; + if (bytes > maxBytes) { + try { await reader.cancel(); } catch { /* already closed */ } + throw new GatewayError("Gateway response too large (over " + maxBytes + " bytes)"); + } + chunks.push(value); } + return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer ?? c, c.byteOffset ?? 0, c.byteLength ?? c.length))).toString("utf8"); +} + +async function readJson(res) { + const text = await readTextCapped(res, GATEWAY_MAX_RESPONSE_BYTES); if (!text) return {}; try { return JSON.parse(text); @@ -343,8 +364,10 @@ export async function sendPrompt(session, ref, prompt, extra = {}) { } throw err; } - const messageId = data && typeof data.messageId === "string" ? data.messageId : null; - return { target: rec, result: data, delivery: "accepted", ...(messageId ? { messageId } : {}) }; + const messageId = data && typeof data === "object" && typeof data.messageId === "string" ? data.messageId : null; + // Only a confirmed receipt counts as accepted; anything else is unknown, never a silent accept. + // ponytail: full send/execution correlation envelope stays in #37. + return { target: rec, result: data && typeof data === "object" ? data : {}, delivery: messageId ? "accepted" : "unknown", ...(messageId ? { messageId } : {}) }; } export async function getTranscriptTail(session, ref, limit = 40) { diff --git a/test/codex-bridge.test.js b/test/codex-bridge.test.js index 70c6a11..980c361 100644 --- a/test/codex-bridge.test.js +++ b/test/codex-bridge.test.js @@ -351,8 +351,7 @@ test("codex send reassembles a fragmented turn/start reply split across TCP chun } }); -test("codex status fails on a wrong handshake and closes the socket instead of leaking it", async () => { - const home = mkdtempSync(join(tmpdir(), "gbot-codex-badhs-")); +test("codex status fails on a wrong handshake and closes the socket instead of leaking it", async () => { const home = mkdtempSync(join(tmpdir(), "gbot-codex-badhs-")); mkdirSync(join(home, "app-server-control")); const socketPath = join(home, "app-server-control", "app-server-control.sock"); let serverSocket = null; @@ -461,3 +460,173 @@ test("codex send preserves turn and thread ids with accepted delivery on refusal await fake.close(); } }); + +test("a JSON null message fails as malformed instead of crashing on msg.id", async () => { + const fake = await fakeAppServer({ + ...baseHandlers, + initialize: (params, ok, err, send) => { + void params; void ok; void err; + send(null); + }, + }); + try { + const started = Date.now(); + const { code, err } = await gbot(fake.home, "codex", "status"); + assert.equal(code, 1); + assert.match(err, /malformed message/); + assert.ok(Date.now() - started < 5000, "did not hang on the bad message"); + } finally { + await fake.close(); + } +}); + +test("handshake timeout is absolute; trickled bytes do not extend it", async () => { + const home = mkdtempSync(join(tmpdir(), "gbot-codex-trickle-")); + mkdirSync(join(home, "app-server-control")); + const socketPath = join(home, "app-server-control", "app-server-control.sock"); + const server = createTcpServer((sock) => { + sock.on("data", () => { + const t = setInterval(() => sock.write("X"), 50); + sock.on("close", () => clearInterval(t)); + }); + sock.on("error", () => {}); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const started = Date.now(); + await assert.rejects(connectCodexAppServer(socketPath, { timeoutMs: 300 }), /Timed out connecting/); + assert.ok(Date.now() - started < 5000, "absolute deadline fired instead of waiting on the trickle"); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test("codex status rejects terminated oversized handshake headers", async () => { + const home = mkdtempSync(join(tmpdir(), "gbot-codex-bighdr-")); + mkdirSync(join(home, "app-server-control")); + const socketPath = join(home, "app-server-control", "app-server-control.sock"); + const server = createTcpServer((sock) => { + sock.on("data", () => sock.write("HTTP/1.1 101 Switching Protocols\r\nX-Pad: " + "y".repeat(20000) + "\r\n\r\n")); + sock.on("error", () => {}); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const started = Date.now(); + const { code, err } = await gbot(home, "codex", "status"); + assert.equal(code, 1); + assert.match(err, /exceed/); + assert.ok(Date.now() - started < 5000, "failed fast instead of decoding the headers"); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test("codex status rejects a complete oversized frame without buffering it", async () => { + const fake = await fakeAppServer({ + ...baseHandlers, + initialize: (params, ok, err, send, socket) => { + void params; void ok; void err; void send; + socket.write(encodeFrame(0x1, Buffer.alloc(5 * 1024 * 1024))); + }, + }); + try { + const started = Date.now(); + const { code, err } = await gbot(fake.home, "codex", "status"); + assert.equal(code, 1); + assert.match(err, /exceed/); + assert.ok(Date.now() - started < 5000, "failed fast instead of buffering the frame"); + } finally { + await fake.close(); + } +}); + +test("codex send emits structured JSON errors with delivery and ids", async () => { const fake = await fakeAppServer(baseHandlers); + try { + const unknown = await gbot(fake.home, "--json", "codex", "send", "nope", "hi"); + assert.equal(unknown.code, 1); + assert.deepEqual(JSON.parse(unknown.err), { + error: "Unknown Codex thread nope. Run `gbot codex list-threads` to see reachable threads.", + delivery: "rejected", + threadId: "nope", + }); + assert.equal(unknown.out, ""); + } finally { + await fake.close(); + } + const refusing = await fakeAppServer({ + ...baseHandlers, + "turn/start": (params, ok, err, send) => { + send({ jsonrpc: "2.0", id: "srv-1", method: "item/commandExecution/requestApproval", params: { threadId: params.threadId } }); + setTimeout(() => ok({ turn: { id: "turn-10", status: "inProgress", items: [] } }), 20); + }, + }); + try { + const { code, err } = await gbot(refusing.home, "--json", "codex", "send", "t-1", "do it"); + assert.equal(code, 1); + const parsed = JSON.parse(err); + assert.equal(parsed.delivery, "accepted"); + assert.equal(parsed.threadId, "t-1"); + assert.equal(parsed.turnId, "turn-10"); + assert.match(parsed.error, /^Turn turn-10 started on thread t-1/); + } finally { + await refusing.close(); + } +}); + +test("codex send parses a trailing --json as a flag in both placements", async () => { + const fake = await fakeAppServer(baseHandlers); + try { + const trailing = await gbot(fake.home, "codex", "send", "t-1", "hi", "--json"); + assert.equal(trailing.code, 0, trailing.err); + assert.equal(JSON.parse(trailing.out).turnId, "turn-9"); + assert.equal(fake.received.at(-1).params.input[0].text, "hi"); + + const leading = await gbot(fake.home, "--json", "codex", "send", "t-1", "hi"); + assert.equal(leading.code, 0, leading.err); + assert.equal(JSON.parse(leading.out).turnId, "turn-9"); + assert.equal(fake.received.at(-1).params.input[0].text, "hi"); + } finally { + await fake.close(); + } +}); + +test("codex send keeps a --json protected by -- as message content", async () => { + const fake = await fakeAppServer(baseHandlers); + try { + const { code, out } = await gbot(fake.home, "codex", "send", "t-1", "--", "--json"); + assert.equal(code, 0, out); + assert.match(out, /^Started turn turn-9/); + assert.equal(fake.received.at(-1).params.input[0].text, "--json"); + } finally { + await fake.close(); + } +}); + +test("codex send failures honor a trailing --json with structured errors", async () => { + const fake = await fakeAppServer(baseHandlers); + try { + const { code, err, out } = await gbot(fake.home, "codex", "send", "nope", "hi", "--json"); + assert.equal(code, 1); + assert.equal(out, ""); + assert.deepEqual(JSON.parse(err), { + error: "Unknown Codex thread nope. Run `gbot codex list-threads` to see reachable threads.", + delivery: "rejected", + threadId: "nope", + }); + } finally { + await fake.close(); + } +}); + +test("send failures honor a trailing --json before backend auth runs", async () => { + const env = { ...process.env, CODEX_HOME: "/nonexistent", PATH: "/nonexistent" }; + const run = (...args) => new Promise((resolve) => { + execFile(process.execPath, [CLI, ...args], { encoding: "utf8", env }, (error, out, err) => { + resolve({ code: error ? error.code : 0, out, err }); + }); + }); + const { code, err } = await run("--dir", "/nonexistent-gbot-dir", "send", "Nobody", "hi", "--json"); + assert.equal(code, 1); + const parsed = JSON.parse(err); + assert.equal(typeof parsed.error, "string"); +}); diff --git a/test/gateway-send.test.js b/test/gateway-send.test.js new file mode 100644 index 0000000..9005ce5 --- /dev/null +++ b/test/gateway-send.test.js @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { GATEWAY_MAX_RESPONSE_BYTES, getTranscriptTail, sendPrompt } from "../src/gateway.js"; + +const session = { gatewayUrl: "https://box.cursor.sh", gatewayToken: "t" }; +const roster = { agents: [{ id: "bot-1", name: "General" }] }; + +function mockGateway(t, send) { + t.mock.method(globalThis, "fetch", async (url) => { + if (String(url).endsWith("/api/listAgents")) return new Response(JSON.stringify(roster), { status: 200 }); + return send(); + }); +} + +test("sendPrompt accepts only a confirmed messageId receipt", async (t) => { + mockGateway(t, () => new Response(JSON.stringify({ messageId: "m-1" }), { status: 200 })); + const out = await sendPrompt(session, "General", "hi"); + assert.equal(out.delivery, "accepted"); + assert.equal(out.messageId, "m-1"); +}); + +test("sendPrompt without a receipt is unknown, not accepted", async (t) => { + mockGateway(t, () => new Response(JSON.stringify({ ok: true }), { status: 200 })); + const out = await sendPrompt(session, "General", "hi"); + assert.equal(out.delivery, "unknown"); + assert.equal(out.messageId, undefined); +}); + +test("sendPrompt rejects on a 4xx and preserves the target id", async (t) => { + mockGateway(t, () => new Response(JSON.stringify({ message: "nope" }), { status: 400 })); + await assert.rejects(sendPrompt(session, "General", "hi"), (e) => { + assert.equal(e.delivery, "rejected"); + assert.equal(e.targetId, "bot-1"); + return true; + }); +}); + +test("sendPrompt marks transport loss unknown with a no-retry hint", async (t) => { + t.mock.method(globalThis, "fetch", async (url) => { + if (String(url).endsWith("/api/listAgents")) return new Response(JSON.stringify(roster), { status: 200 }); + throw new TypeError("fetch failed"); + }); + await assert.rejects(sendPrompt(session, "General", "hi"), (e) => { + assert.equal(e.delivery, "unknown"); + assert.match(e.message, /delivery unknown; check the thread before resending/); + return true; + }); +}); + +test("oversized gateway responses abort mid-stream instead of buffering", async (t) => { + mockGateway(t, () => new Response("x".repeat(GATEWAY_MAX_RESPONSE_BYTES + 8), { status: 200 })); + await assert.rejects(sendPrompt(session, "General", "hi"), /too large/); +}); + +test("getTranscriptTail clamps the limit to 1-200 like the CLI and tool", async (t) => { + const seen = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + if (String(url).endsWith("/api/listAgents")) return new Response(JSON.stringify(roster), { status: 200 }); + seen.push(JSON.parse(options.body)); + return new Response(JSON.stringify({ entries: [] }), { status: 200 }); + }); + await getTranscriptTail(session, "General", 99999); + assert.equal(seen[0].limit, 200); + await getTranscriptTail(session, "General", -3); + assert.equal(seen[1].limit, 1); +});