From b394a5eb5e8e6c68a467a01a05ddb285339a715a Mon Sep 17 00:00:00 2001 From: iml1s Date: Tue, 8 Sep 2026 17:39:03 +0800 Subject: [PATCH 01/15] fix: bound gateway requests and render nested transcripts (cherry picked from commit 379852d8037b71fe6835dfe8086b299a5b2610ed) --- .changeset/calm-gateway-readback.md | 7 + README.md | 19 ++ src/cli.js | 18 +- src/gateway.js | 132 +++++++++++-- src/transcript.js | 22 +++ test/gateway.test.js | 276 ++++++++++++++++++++++++++++ test/transcript.test.js | 35 ++++ 7 files changed, 476 insertions(+), 33 deletions(-) create mode 100644 .changeset/calm-gateway-readback.md create mode 100644 src/transcript.js create mode 100644 test/transcript.test.js diff --git a/.changeset/calm-gateway-readback.md b/.changeset/calm-gateway-readback.md new file mode 100644 index 0000000..8e7e838 --- /dev/null +++ b/.changeset/calm-gateway-readback.md @@ -0,0 +1,7 @@ +--- +"grok-bot-cli": patch +--- + +Bound gateway requests and response-body reads with a deadline, redact error +details, reject redirects, and label ambiguous sends without automatic retries. +Render nested message content in plain-text transcripts. diff --git a/README.md b/README.md index cb0df33..c8547c2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,25 @@ gbot bots delete Writer Run `gbot --help` for every command. +## Gateway failures and readback + +Gateway requests have a 15-second deadline that includes reading the response body. +Requests are not retried automatically, and redirects are rejected. Errors report +the method and status without including server response bodies or credentials. + +A send timeout, network failure, HTTP 408 or 5xx, or an invalid/empty success +response can leave delivery unknown. Do not resend automatically: read the target +thread and verify the original message in the Grok Bot app first. A new CLI send +uses a new client nonce, so invoking it again is not a deduplicated retry. + +The gateway module accepts an optional positive `timeoutMs` in the final options +argument of `ensureSandbox` and `gatewayCall`. The CLI uses the 15-second default. + +Plain-text transcript output handles both direct content and nested +`message.content` / `message.text`. Use `--json` when the full structured result is +needed. These integrations depend on the signed-in app and its internal gateway; +revalidate reads and one controlled send after app or service changes. + ## License MIT diff --git a/src/cli.js b/src/cli.js index 58fe5be..72eca08 100755 --- a/src/cli.js +++ b/src/cli.js @@ -3,6 +3,7 @@ import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS, StoreError, defaultCan import { hasGatewayAuth } from "./gateway.js"; import { openBackend } from "./commands.js"; import { inspectGrokBotGatewaySession } from "./app-session.js"; +import { entryText } from "./transcript.js"; function print(value) { if (typeof value === "string") process.stdout.write(value + "\n"); @@ -184,23 +185,6 @@ function formatRecord(rec, all) { return kind + " " + rec.name + title + "\n " + rec.id + desc + avatar + settingsLine + extra; } -function entryText(e) { - if (!e || typeof e !== "object") return ""; - const direct = e.text || e.prompt || e.message || e.preview; - if (typeof direct === "string" && direct) return direct; - const content = e.content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content.map((part) => { - if (typeof part === "string") return part; - if (part && typeof part === "object") return part.text || part.content || ""; - return ""; - }).filter(Boolean).join("\n"); - } - if (content && typeof content === "object") return content.text || JSON.stringify(content); - return ""; -} - function formatTranscript(out) { const rec = out.target; const payload = out.transcript || out.thread || {}; diff --git a/src/gateway.js b/src/gateway.js index bd0eae4..2875be5 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -4,14 +4,18 @@ import { hasGrokBotGatewaySession, loadGrokBotGatewaySession } from "./app-sessi import { AVATAR_COLORS, AVATAR_SHAPES } from "./store.js"; export class GatewayError extends Error { - constructor(message, { status, method } = {}) { + constructor(message, { status, method, code, effect } = {}) { super(message); this.name = "GatewayError"; this.status = status; this.method = method; + this.code = code; + this.effect = effect; } } +export const DEFAULT_GATEWAY_TIMEOUT_MS = 15_000; + function backendBase() { return ( process.env.SAND_BACKEND_URL || @@ -65,11 +69,109 @@ export function hasGatewayAuth() { async function readJson(res) { const text = await res.text(); - if (!text) return {}; + if (!text) return { data: {}, invalidJson: false, emptyBody: true }; try { - return JSON.parse(text); + return { data: JSON.parse(text), invalidJson: false, emptyBody: false }; } catch { - return { raw: text }; + return { data: undefined, invalidJson: true, emptyBody: false }; + } +} + +function checkedTimeoutMs(value) { + const timeoutMs = value ?? DEFAULT_GATEWAY_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + throw new GatewayError("Gateway timeout must be a positive finite number of milliseconds.", { + code: "INVALID_GATEWAY_TIMEOUT", + }); + } + return timeoutMs; +} + +function timeoutError(method, timeoutMs) { + const sendTimedOut = method === "sendPrompt"; + return new GatewayError( + sendTimedOut + ? method + " timed out after " + timeoutMs + "ms; delivery is unknown. Do not resend automatically." + : method + " timed out after " + timeoutMs + "ms.", + { + method, + code: "GATEWAY_TIMEOUT", + ...(sendTimedOut ? { effect: "unknown" } : {}), + }, + ); +} + +function unknownEffectDetails(method) { + return method === "sendPrompt" ? { effect: "unknown" } : {}; +} + +function requestFailureError(method) { + const sendFailed = method === "sendPrompt"; + return new GatewayError( + sendFailed + ? method + " request failed; delivery is unknown. Do not resend automatically." + : method + " request failed.", + { + method, + code: "GATEWAY_REQUEST_FAILED", + ...unknownEffectDetails(method), + }, + ); +} + +function invalidResponseError(method) { + const sendFailed = method === "sendPrompt"; + return new GatewayError( + sendFailed + ? method + " returned an invalid response; delivery is unknown. Do not resend automatically." + : method + " returned an invalid response.", + { + method, + code: "GATEWAY_INVALID_RESPONSE", + ...unknownEffectDetails(method), + }, + ); +} + +function httpError(method, status) { + const ambiguousSend = method === "sendPrompt" && (status === 408 || status >= 500); + return new GatewayError( + ambiguousSend + ? method + " failed with HTTP " + status + "; delivery is unknown. Do not resend automatically." + : method + " failed with HTTP " + status + ".", + { + status, + method, + ...(ambiguousSend ? { effect: "unknown" } : {}), + }, + ); +} + +async function requestJson(method, url, init, options = {}) { + const timeoutMs = checkedTimeoutMs(options.timeoutMs); + const fetchImpl = options.fetchImpl || globalThis.fetch; + const controller = new AbortController(); + const deadlineError = timeoutError(method, timeoutMs); + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(deadlineError); + }, timeoutMs); + }); + + try { + const res = await Promise.race([ + fetchImpl(url, { ...init, redirect: "error", signal: controller.signal }), + deadline, + ]); + const parsed = await Promise.race([readJson(res), deadline]); + return { res, ...parsed }; + } catch (error) { + if (error === deadlineError || controller.signal.aborted) throw deadlineError; + throw requestFailureError(method); + } finally { + clearTimeout(timer); } } @@ -81,18 +183,17 @@ function pick(obj, ...keys) { return undefined; } -export async function ensureSandbox(accessToken) { +export async function ensureSandbox(accessToken, options = {}) { const url = backendBase() + "/aiserver.v1.GrokBotService/EnsureSandBox"; - const res = await fetch(url, { + const { res, data: body, invalidJson } = await requestJson("EnsureSandBox", url, { method: "POST", headers: ensureSandboxHeaders(accessToken), body: "{}", - }); - const body = await readJson(res); + }, options); if (!res.ok) { - const detail = body.message || body.error || body.raw || res.statusText; - throw new GatewayError("EnsureSandBox failed: " + res.status + " " + detail, { status: res.status, method: "EnsureSandBox" }); + throw httpError("EnsureSandBox", res.status); } + if (invalidJson) throw invalidResponseError("EnsureSandBox"); const gatewayUrl = pick(body, "gatewayUrl", "gateway_url"); const gatewayToken = pick(body, "gatewayToken", "gateway_token"); if (!gatewayUrl || !gatewayToken) { @@ -113,18 +214,17 @@ export async function connectGateway() { return ensureSandbox(token); } -export async function gatewayCall(session, method, body = {}) { +export async function gatewayCall(session, method, body = {}, options = {}) { const url = session.gatewayUrl + "/api/" + method; - const res = await fetch(url, { + const { res, data, invalidJson, emptyBody } = await requestJson(method, url, { method: "POST", headers: requestHeaders(session), body: JSON.stringify(body), - }); - const data = await readJson(res); + }, options); if (!res.ok) { - const detail = data.message || data.error || data.raw || res.statusText; - throw new GatewayError(method + " failed: " + res.status + " " + String(detail).slice(0, 300), { status: res.status, method }); + throw httpError(method, res.status); } + if (invalidJson || (method === "sendPrompt" && emptyBody)) throw invalidResponseError(method); return data; } diff --git a/src/transcript.js b/src/transcript.js new file mode 100644 index 0000000..b0cbd1a --- /dev/null +++ b/src/transcript.js @@ -0,0 +1,22 @@ +function contentText(value, { stringifyObject = false } = {}) { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value.map((part) => contentText(part)).filter(Boolean).join("\n"); + } + if (!value || typeof value !== "object") return ""; + if (typeof value.text === "string" && value.text) return value.text; + if (value.content != null) { + const nested = contentText(value.content, { stringifyObject }); + if (nested) return nested; + } + return stringifyObject ? JSON.stringify(value) : ""; +} + +export function entryText(entry) { + if (!entry || typeof entry !== "object") return ""; + for (const direct of [entry.text, entry.prompt, entry.message, entry.preview]) { + const text = contentText(direct); + if (text) return text; + } + return contentText(entry.content, { stringifyObject: true }); +} diff --git a/test/gateway.test.js b/test/gateway.test.js index 591b293..d7a16c7 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -6,6 +6,25 @@ import { parseGatewayHeaders, requestHeaders, } from "../src/headers.js"; +import { + DEFAULT_GATEWAY_TIMEOUT_MS, + ensureSandbox, + gatewayCall, +} from "../src/gateway.js"; + +function response({ ok = true, status = 200, body = "{}", statusText = "" } = {}) { + return { + ok, + status, + statusText, + text: async () => body, + }; +} + +const session = { + gatewayUrl: "https://gateway.invalid", + gatewayToken: "test-token", +}; test("parses env JSON headers", () => { const headers = parseGatewayHeaders('{"X-Anyrun-Network-Token":"abc","empty":""}'); @@ -45,3 +64,260 @@ test("empty env JSON is a no-op", () => { assert.deepEqual(parseGatewayHeaders(""), {}); assert.deepEqual(parseGatewayHeaders(undefined), {}); }); + +test("gateway requests use a finite default deadline", () => { + assert.equal(DEFAULT_GATEWAY_TIMEOUT_MS, 15_000); +}); + +test("gateway timeout aborts one fetch without retrying", async () => { + let calls = 0; + let signal; + const fetchImpl = async (url, init) => { + calls += 1; + signal = init.signal; + return new Promise(() => {}); + }; + + await assert.rejects( + gatewayCall(session, "listAgents", {}, { timeoutMs: 10, fetchImpl }), + (error) => { + assert.equal(error.name, "GatewayError"); + assert.equal(error.code, "GATEWAY_TIMEOUT"); + assert.equal(error.method, "listAgents"); + assert.match(error.message, /^listAgents timed out after 10ms\.$/); + return true; + }, + ); + assert.equal(calls, 1); + assert.equal(signal.aborted, true); +}); + +test("gateway deadline also bounds response body reads", async () => { + let calls = 0; + let signal; + const fetchImpl = async (url, init) => { + calls += 1; + signal = init.signal; + return { + ...response(), + text: async () => new Promise(() => {}), + }; + }; + + await assert.rejects( + gatewayCall(session, "getAgentThread", {}, { timeoutMs: 10, fetchImpl }), + (error) => error.code === "GATEWAY_TIMEOUT" && error.method === "getAgentThread", + ); + assert.equal(calls, 1); + assert.equal(signal.aborted, true); +}); + +test("send timeout reports unknown effect and is never retried", async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return new Promise(() => {}); + }; + + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { timeoutMs: 10, fetchImpl }), + (error) => { + assert.equal(error.code, "GATEWAY_TIMEOUT"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt timed out after 10ms; delivery is unknown. Do not resend automatically."); + return true; + }, + ); + assert.equal(calls, 1); +}); + +test("ensureSandbox has the same controllable deadline", async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return new Promise(() => {}); + }; + + await assert.rejects( + ensureSandbox("access-token", { timeoutMs: 10, fetchImpl }), + (error) => error.code === "GATEWAY_TIMEOUT" && error.method === "EnsureSandBox", + ); + assert.equal(calls, 1); +}); + +test("invalid deadlines fail before fetch", async () => { + for (const timeoutMs of [0, -1, Infinity, NaN]) { + let calls = 0; + await assert.rejects( + gatewayCall(session, "listAgents", {}, { + timeoutMs, + fetchImpl: async () => { + calls += 1; + return response(); + }, + }), + (error) => error.code === "INVALID_GATEWAY_TIMEOUT", + ); + assert.equal(calls, 0); + } +}); + +test("HTTP errors do not expose response body or status text", async () => { + const secret = "raw-private-message token-123"; + await assert.rejects( + gatewayCall(session, "getAgentThread", {}, { + fetchImpl: async () => response({ + ok: false, + status: 403, + statusText: "Bearer status-secret", + body: JSON.stringify({ error: secret }), + }), + }), + (error) => { + assert.equal(error.message, "getAgentThread failed with HTTP 403."); + assert.equal(error.status, 403); + assert.doesNotMatch(error.message, /private|token|Bearer|status-secret/); + return true; + }, + ); +}); + +test("EnsureSandBox HTTP errors do not expose response details", async () => { + await assert.rejects( + ensureSandbox("access-token", { + fetchImpl: async () => response({ + ok: false, + status: 401, + statusText: "Bearer status-secret", + body: '{"error":"raw token=secret"}', + }), + }), + (error) => { + assert.equal(error.message, "EnsureSandBox failed with HTTP 401."); + assert.equal(error.status, 401); + assert.doesNotMatch(error.message, /raw|token|Bearer|secret/); + return true; + }, + ); +}); + +test("network errors do not expose socket details or URLs", async () => { + const secret = "connect ECONNREFUSED https://gateway.invalid/?token=secret"; + await assert.rejects( + gatewayCall(session, "listAgents", {}, { + fetchImpl: async () => { throw new Error(secret); }, + }), + (error) => { + assert.equal(error.message, "listAgents request failed."); + assert.equal(error.code, "GATEWAY_REQUEST_FAILED"); + assert.doesNotMatch(error.message, /ECONNREFUSED|gateway|token|secret/); + return true; + }, + ); +}); + +test("send network errors report unknown effect without leaking details", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => { throw new Error("socket failed with token=secret"); }, + }), + (error) => { + assert.equal(error.code, "GATEWAY_REQUEST_FAILED"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt request failed; delivery is unknown. Do not resend automatically."); + assert.doesNotMatch(error.message, /socket|token|secret/); + return true; + }, + ); +}); + +test("successful non-JSON responses fail safely without exposing body", async () => { + const raw = "private transcript and token=secret"; + await assert.rejects( + gatewayCall(session, "getAgentThread", {}, { + fetchImpl: async () => response({ body: raw }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.message, "getAgentThread returned an invalid response."); + assert.doesNotMatch(error.message, /private|token|secret/); + return true; + }, + ); +}); + +test("send non-JSON success reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "not-json" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt returned an invalid response; delivery is unknown. Do not resend automatically."); + return true; + }, + ); +}); + +test("ambiguous send HTTP failures report unknown effect without retry", async () => { + for (const status of [408, 500, 503]) { + let calls = 0; + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => { + calls += 1; + return response({ + ok: false, + status, + body: '{"error":"private token=secret"}', + }); + }, + }), + (error) => { + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt failed with HTTP " + status + "; delivery is unknown. Do not resend automatically."); + assert.doesNotMatch(error.message, /private|token|secret/); + return true; + }, + ); + assert.equal(calls, 1); + } +}); + +test("empty send success reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt returned an invalid response; delivery is unknown. Do not resend automatically."); + return true; + }, + ); +}); + +test("empty successful bodies preserve the existing empty object result", async () => { + assert.deepEqual(await gatewayCall(session, "deleteAgent", {}, { + fetchImpl: async () => response({ body: "" }), + }), {}); +}); + +test("gateway fetch rejects redirects while preserving a normal HTTPS request", async () => { + let observed; + const data = await gatewayCall(session, "listAgents", {}, { + fetchImpl: async (url, init) => { + observed = { url, redirect: init.redirect, method: init.method }; + return response({ body: '{"agents":[]}' }); + }, + }); + + assert.deepEqual(data, { agents: [] }); + assert.deepEqual(observed, { + url: "https://gateway.invalid/api/listAgents", + redirect: "error", + method: "POST", + }); +}); diff --git a/test/transcript.test.js b/test/transcript.test.js new file mode 100644 index 0000000..8721515 --- /dev/null +++ b/test/transcript.test.js @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { entryText } from "../src/transcript.js"; + +test("reads actual nested send-message and user entry content", () => { + assert.equal(entryText({ + kind: "send-message", + message: { type: "assistant", content: "nested assistant text" }, + }), "nested assistant text"); + assert.equal(entryText({ + kind: "user", + message: { type: "user", content: "nested user text" }, + }), "nested user text"); +}); + +test("reads nested message text and content parts", () => { + assert.equal(entryText({ message: { text: "nested text" } }), "nested text"); + assert.equal(entryText({ + message: { + content: ["first", { text: "second" }, { content: "third" }], + }, + }), "first\nsecond\nthird"); +}); + +test("preserves existing direct and content formats", () => { + assert.equal(entryText({ text: "direct" }), "direct"); + assert.equal(entryText({ prompt: "prompt" }), "prompt"); + assert.equal(entryText({ message: "message" }), "message"); + assert.equal(entryText({ preview: "preview" }), "preview"); + assert.equal(entryText({ content: "content" }), "content"); + assert.equal(entryText({ content: ["one", { text: "two" }] }), "one\ntwo"); + assert.equal(entryText({ content: { text: "object text" } }), "object text"); + assert.equal(entryText({ content: { type: "image" } }), '{"type":"image"}'); +}); From 1e86ad1ec5ddc9068b3e9bc9c0097908c00e5ed9 Mon Sep 17 00:00:00 2001 From: iml1s Date: Tue, 8 Sep 2026 20:07:53 +0800 Subject: [PATCH 02/15] Add stdin sends and normalized transcript contract for adapters --- README.md | 13 ++++ src/cli.js | 149 ++++++++++++++++++++++++----------- src/transcript.js | 74 ++++++++++++++++++ test/send-stdin.test.js | 169 ++++++++++++++++++++++++++++++++++++++++ test/transcript.test.js | 112 +++++++++++++++++++++++++- 5 files changed, 472 insertions(+), 45 deletions(-) create mode 100644 test/send-stdin.test.js diff --git a/README.md b/README.md index c8547c2..a16a507 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ gbot groups create --name Launch --member Researcher --member Writer --descripti gbot groups update Launch --title "Launch room" --hidden off gbot send Researcher "Summarize the launch status." gbot send Launch "Share your updates." +printf %s 'Exact UTF-8 message' | gbot send Researcher --stdin gbot thread Researcher +gbot --json thread Researcher --normalized --limit 50 gbot groups delete Launch gbot bots delete Researcher gbot bots delete Writer @@ -37,6 +39,17 @@ gbot bots delete Writer Run `gbot --help` for every command. +Use `send --stdin` when the message must not appear in the process +argument list. Standard input is preserved exactly and must be valid UTF-8, +non-empty, free of NUL bytes and surrounding whitespace, and no larger than +64 KiB. Do not combine `--stdin` with a positional message. In particular, use +`printf %s` rather than `echo` when an extra trailing newline is not intended. + +For integrations that need a stable transcript shape, combine `--normalized` +with `--json thread` or `--json chat`. It returns only the target identity and +messages with `id`, explicit `user`/`assistant`/`unknown` role, and text. Without +`--normalized`, JSON output remains the original gateway response. + ## Gateway failures and readback Gateway requests have a 15-second deadline that includes reading the response body. diff --git a/src/cli.js b/src/cli.js index 72eca08..8e918dc 100755 --- a/src/cli.js +++ b/src/cli.js @@ -3,7 +3,12 @@ import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS, StoreError, defaultCan import { hasGatewayAuth } from "./gateway.js"; import { openBackend } from "./commands.js"; import { inspectGrokBotGatewaySession } from "./app-session.js"; -import { entryText } from "./transcript.js"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { entryText, normalizeTranscript } from "./transcript.js"; + +const MAX_STDIN_MESSAGE_BYTES = 64 * 1024; function print(value) { if (typeof value === "string") process.stdout.write(value + "\n"); @@ -46,14 +51,15 @@ function usage() { " groups set --member ID [--member ...]", " groups delete ", " send ", - " thread [--limit N] [--root MESSAGE_ID]", + " send --stdin", + " thread [--limit N] [--root MESSAGE_ID] [--json --normalized]", " chat alias for thread", "", "Max group members: " + MAX_GROUP_MEMBERS, "--description / --instructions is the UI Instructions field (same key).", "Avatar shapes: " + AVATAR_SHAPES.join(" "), "Avatar colors: " + AVATAR_COLORS.join(" "), - "Flags: --gateway --files --dir DIR --json", + "Flags: --gateway --files --dir DIR --json --stdin --normalized", "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", ].join("\n"); @@ -160,8 +166,8 @@ function summarize(rec) { }; } -function done(json, rec, text) { - print(json ? summarize(rec) : text); +function done(json, rec, text, printImpl = print) { + printImpl(json ? summarize(rec) : text); } function formatRecord(rec, all) { @@ -203,24 +209,69 @@ function formatTranscript(out) { return lines.join("\n"); } -async function main(argv) { +async function readStdinMessage(stdin) { + const chunks = []; + let byteLength = 0; + for await (const chunk of stdin) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += bytes.length; + if (byteLength > MAX_STDIN_MESSAGE_BYTES) { + throw new StoreError("stdin message must be at most 64 KiB."); + } + chunks.push(bytes); + } + if (byteLength === 0) throw new StoreError("stdin message must not be empty."); + + let message; + try { + message = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); + } catch { + throw new StoreError("stdin message must be valid UTF-8."); + } + if (message.includes("\0")) throw new StoreError("stdin message must not contain a NUL byte."); + if (message.trim() !== message) { + throw new StoreError("stdin message must not have surrounding whitespace."); + } + return message; +} + +export async function main(argv, options = {}) { + const openBackendImpl = options.openBackendImpl ?? openBackend; + const stdin = options.stdin ?? process.stdin; + const printImpl = options.printImpl ?? print; const args = argv.slice(2); if (args.length === 0 || args[0] === "-h" || args[0] === "--help") { - print(usage()); + printImpl(usage()); return; } const json = hasFlag(args, "--json"); const gateway = hasFlag(args, "--gateway"); const filesMode = hasFlag(args, "--files"); + const stdinMode = hasFlag(args, "--stdin"); + const normalized = hasFlag(args, "--normalized"); const rootFlag = takeFlag(args, "--dir"); const cmd = args[0]; const sub = args[1]; const rest = args.slice(2); if (!cmd) { - print(usage()); + printImpl(usage()); return; } + if (stdinMode && cmd !== "send") throw new StoreError("--stdin is only valid with send."); + if (normalized && cmd !== "thread" && cmd !== "chat") { + throw new StoreError("--normalized is only valid with thread or chat."); + } + if (normalized && !json) throw new StoreError("--normalized requires --json."); + if (stdinMode && rest.length > 0) { + throw new StoreError("--stdin cannot be combined with a positional message."); + } + + let stdinMessage; + if (stdinMode) { + if (!sub) throw new StoreError("gbot send --stdin"); + stdinMessage = await readStdinMessage(stdin); + } if (cmd === "doctor") { const candidates = defaultCandidateRoots(); @@ -235,36 +286,36 @@ async function main(argv) { const gatewayAuthPresent = hasGatewayAuth(); const grokBotAppSession = inspectGrokBotGatewaySession(); const payload = { resolved, found, candidates, gatewayAuthPresent, grokBotAppSession, note }; - if (json) print(payload); + if (json) printImpl(payload); else { - print("resolved: " + (resolved ?? "(none)")); - print("gateway auth: " + (gatewayAuthPresent ? "present" : "no")); - if (grokBotAppSession.usable) print("Grok Bot app session: usable"); - else if (grokBotAppSession.present) print("Grok Bot app session: present but unusable: " + grokBotAppSession.error); - else print("Grok Bot app session: not found"); - print("found:"); - print(found.length ? found.map((p) => " " + p).join("\n") : " (none)"); - print("candidates:"); - for (const c of candidates) print(" " + c); - print(note); + printImpl("resolved: " + (resolved ?? "(none)")); + printImpl("gateway auth: " + (gatewayAuthPresent ? "present" : "no")); + if (grokBotAppSession.usable) printImpl("Grok Bot app session: usable"); + else if (grokBotAppSession.present) printImpl("Grok Bot app session: present but unusable: " + grokBotAppSession.error); + else printImpl("Grok Bot app session: not found"); + printImpl("found:"); + printImpl(found.length ? found.map((p) => " " + p).join("\n") : " (none)"); + printImpl("candidates:"); + for (const c of candidates) printImpl(" " + c); + printImpl(note); } return; } - const backend = await openBackend({ root: rootFlag, gateway, files: filesMode }); + const backend = await openBackendImpl({ root: rootFlag, gateway, files: filesMode }); if (cmd === "bots" && sub === "list") { const rows = (await backend.list()).filter((r) => !r.isGroup); - if (json) print(rows.map(summarize)); - else if (rows.length === 0) print("No bots."); - else print(rows.map((r) => formatRecord(r, rows)).join("\n\n")); + if (json) printImpl(rows.map(summarize)); + else if (rows.length === 0) printImpl("No bots."); + else printImpl(rows.map((r) => formatRecord(r, rows)).join("\n\n")); return; } if (cmd === "bots" && sub === "create") { const fields = takeCreateFields(rest); const rec = await backend.createAgent(fields); - done(json, rec, "Created bot " + rec.name + " (" + rec.id + ")"); + done(json, rec, "Created bot " + rec.name + " (" + rec.id + ")", printImpl); return; } @@ -272,7 +323,7 @@ async function main(argv) { const ref = rest.shift(); if (!ref || ref.startsWith("-")) throw new StoreError("gbot bots update [--name NAME] ..."); const rec = await backend.updateAgent(ref, takeUpdatePatch(rest)); - done(json, rec, "Updated " + (rec.isGroup ? "group" : "bot") + " " + rec.name + " (" + rec.id + ")"); + done(json, rec, "Updated " + (rec.isGroup ? "group" : "bot") + " " + rec.name + " (" + rec.id + ")", printImpl); return; } @@ -281,21 +332,21 @@ async function main(argv) { if (!ref) throw new StoreError("gbot bots " + sub + " "); if (sub === "get") { const rec = await backend.resolve(ref); - if (json) print(summarize(rec)); - else print(formatRecord(rec, await backend.list())); + if (json) printImpl(summarize(rec)); + else printImpl(formatRecord(rec, await backend.list())); return; } const rec = await backend.deleteAgent(ref); - done(json, rec, "Deleted " + (rec.isGroup ? "group" : "bot") + " " + rec.name + " (" + rec.id + ")"); + done(json, rec, "Deleted " + (rec.isGroup ? "group" : "bot") + " " + rec.name + " (" + rec.id + ")", printImpl); return; } if (cmd === "groups" && sub === "list") { const all = await backend.list(); const rows = all.filter((r) => r.isGroup); - if (json) print(rows.map(summarize)); - else if (rows.length === 0) print("No groups."); - else print(rows.map((r) => formatRecord(r, all)).join("\n\n")); + if (json) printImpl(rows.map(summarize)); + else if (rows.length === 0) printImpl("No groups."); + else printImpl(rows.map((r) => formatRecord(r, all)).join("\n\n")); return; } @@ -305,7 +356,7 @@ async function main(argv) { const rec = await backend.resolve(ref); if (!rec.isGroup) throw new StoreError('"' + rec.name + '" is a bot, not a group. Use bots delete.'); const deleted = await backend.deleteAgent(ref); - done(json, deleted, "Deleted group " + deleted.name + " (" + deleted.id + ")"); + done(json, deleted, "Deleted group " + deleted.name + " (" + deleted.id + ")", printImpl); return; } @@ -313,7 +364,7 @@ async function main(argv) { const fields = takeCreateFields(rest); const members = takeRepeating(rest, "--member"); const rec = await backend.createGroup({ ...fields, memberIds: members }); - done(json, rec, "Created group " + rec.name + " (" + rec.id + ") with " + rec.memberIds.length + " members"); + done(json, rec, "Created group " + rec.name + " (" + rec.id + ") with " + rec.memberIds.length + " members", printImpl); return; } @@ -323,7 +374,7 @@ async function main(argv) { const current = await backend.resolve(ref); if (!current.isGroup) throw new StoreError('"' + current.name + '" is a bot, not a group. Use bots update.'); const rec = await backend.updateAgent(ref, takeUpdatePatch(rest)); - done(json, rec, "Updated group " + rec.name + " (" + rec.id + ")"); + done(json, rec, "Updated group " + rec.name + " (" + rec.id + ")", printImpl); return; } @@ -332,8 +383,8 @@ async function main(argv) { if (!ref) throw new StoreError("gbot groups " + sub + " "); const rec = await backend.resolve(ref); if (!rec.isGroup) throw new StoreError('"' + rec.name + '" is a bot, not a group.'); - if (json) print(summarize(rec)); - else print(formatRecord(rec, await backend.list())); + if (json) printImpl(summarize(rec)); + else printImpl(formatRecord(rec, await backend.list())); return; } @@ -345,7 +396,7 @@ async function main(argv) { ? await backend.addGroupMember(group, bot) : await backend.removeGroupMember(group, bot); const verb = sub === "add" ? "Added to " : "Removed from "; - done(json, rec, verb + rec.name + ". Members: " + rec.memberIds.length); + done(json, rec, verb + rec.name + ". Members: " + rec.memberIds.length, printImpl); return; } @@ -354,17 +405,17 @@ async function main(argv) { const members = takeRepeating(rest, "--member"); if (!group) throw new StoreError("gbot groups set --member ID [--member ...]"); const rec = await backend.setGroupMembers(group, members); - done(json, rec, "Updated " + rec.name + ". Members: " + rec.memberIds.length); + done(json, rec, "Updated " + rec.name + ". Members: " + rec.memberIds.length, printImpl); return; } if (cmd === "send") { const ref = sub; - const message = rest.join(" ").trim(); + const message = stdinMode ? stdinMessage : rest.join(" ").trim(); if (!ref || !message) throw new StoreError("gbot send "); const out = await backend.send(ref, message); - if (json) print({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result }); - else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")"); + if (json) printImpl({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result }); + else printImpl("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")"); return; } @@ -375,12 +426,22 @@ async function main(argv) { const rootId = takeFlag(rest, "--root"); const limit = limitRaw ? Number(limitRaw) : 40; const out = rootId ? await backend.thread(ref, rootId) : await backend.transcript(ref, limit); - if (json) print(out); - else print(formatTranscript(out)); + if (normalized) printImpl(normalizeTranscript(out)); + else if (json) printImpl(out); + else printImpl(formatTranscript(out)); return; } throw new StoreError(usage()); } -main(process.argv).catch(fail); +function isMainModule() { + if (!process.argv[1]) return false; + try { + return realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url); + } catch { + return false; + } +} + +if (isMainModule()) main(process.argv).catch(fail); diff --git a/src/transcript.js b/src/transcript.js index b0cbd1a..2cccdac 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -20,3 +20,77 @@ export function entryText(entry) { } return contentText(entry.content, { stringifyObject: true }); } + +function transcriptEntries(payload) { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== "object") { + throw new Error("Invalid transcript container."); + } + for (const key of ["entries", "messages", "items"]) { + if (!(key in payload)) continue; + if (!Array.isArray(payload[key])) throw new Error("Invalid transcript container."); + return payload[key]; + } + throw new Error("Invalid transcript container."); +} + +function explicitRole(entry) { + const nested = entry.message && typeof entry.message === "object" && !Array.isArray(entry.message) + ? entry.message + : null; + const candidates = [nested?.type, nested?.role, entry.role, entry.kind, entry.type]; + const roles = new Set(); + for (const candidate of candidates) { + if (candidate === "user" || candidate === "assistant") roles.add(candidate); + } + return roles.size === 1 ? roles.values().next().value : "unknown"; +} + +function isNonblankString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +export function normalizeTranscript(out) { + if (!out || typeof out !== "object" || Array.isArray(out)) { + throw new Error("Invalid transcript response."); + } + const target = out.target; + if ( + !target + || typeof target !== "object" + || Array.isArray(target) + || !isNonblankString(target.id) + || !isNonblankString(target.name) + || typeof target.isGroup !== "boolean" + ) { + throw new Error("Invalid transcript target."); + } + + const hasTranscript = Object.prototype.hasOwnProperty.call(out, "transcript"); + const hasThread = Object.prototype.hasOwnProperty.call(out, "thread"); + const payload = hasTranscript ? out.transcript : hasThread ? out.thread : undefined; + const entries = transcriptEntries(payload); + const messages = entries.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error("Invalid transcript entry."); + } + const rawId = entry.id ?? entry.messageId ?? null; + if (rawId !== null && !isNonblankString(rawId)) { + throw new Error("Invalid transcript message id."); + } + return { + id: rawId, + role: explicitRole(entry), + text: entryText(entry), + }; + }); + + return { + target: { + id: target.id, + name: target.name, + kind: target.isGroup ? "group" : "bot", + }, + messages, + }; +} diff --git a/test/send-stdin.test.js b/test/send-stdin.test.js new file mode 100644 index 0000000..d94c7b0 --- /dev/null +++ b/test/send-stdin.test.js @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import test from "node:test"; + +import { main } from "../src/cli.js"; + +function stdinFrom(...chunks) { + return Readable.from(chunks); +} + +function harness(chunks = []) { + const sent = []; + let backendOpens = 0; + const output = []; + const backend = { + send: async (ref, message) => { + sent.push({ ref, message }); + return { + target: { id: "bot-1", name: ref, isGroup: false }, + result: { ok: true }, + }; + }, + transcript: async (ref) => ({ + target: { id: "bot-1", name: ref, isGroup: false }, + transcript: { entries: [{ id: "m1", message: { type: "assistant", content: "完成" } }] }, + extraRawField: true, + }), + }; + + return { + sent, + output, + get backendOpens() { return backendOpens; }, + options: { + stdin: stdinFrom(...chunks), + openBackendImpl: async () => { + backendOpens += 1; + return backend; + }, + printImpl: (value) => output.push(value), + }, + }; +} + +test("send --stdin preserves exact UTF-8 text and treats flag-looking lines as text", async () => { + const h = harness([Buffer.from("第一行\n--json\n--files\n最後一行", "utf8")]); + + await main(["node", "gbot", "send", "Researcher", "--stdin"], h.options); + + assert.equal(h.backendOpens, 1); + assert.deepEqual(h.sent, [{ + ref: "Researcher", + message: "第一行\n--json\n--files\n最後一行", + }]); +}); + +test("send --stdin supports the 64 KiB byte boundary", async () => { + const h = harness([Buffer.alloc(64 * 1024, 0x61)]); + + await main(["node", "gbot", "send", "Researcher", "--stdin"], h.options); + + assert.equal(h.sent[0].message.length, 64 * 1024); +}); + +test("positional send behavior remains unchanged", async () => { + const h = harness(); + + await main( + ["node", "gbot", "send", "Researcher", "existing", "positional", "message"], + h.options, + ); + + assert.deepEqual(h.sent, [{ ref: "Researcher", message: "existing positional message" }]); +}); + +for (const [name, chunks, pattern] of [ + ["empty input", [], /stdin message must not be empty/i], + ["invalid UTF-8", [Buffer.from([0xc3, 0x28])], /valid UTF-8/i], + ["NUL byte", [Buffer.from("hello\0world")], /NUL/i], + ["leading whitespace", [Buffer.from(" message")], /surrounding whitespace/i], + ["trailing whitespace", [Buffer.from("message\n")], /surrounding whitespace/i], + ["oversized input", [Buffer.alloc(64 * 1024 + 1, 0x61)], /64 KiB/i], +]) { + test(`send --stdin rejects ${name} before opening a backend`, async () => { + const h = harness(chunks); + + await assert.rejects( + main(["node", "gbot", "send", "Researcher", "--stdin"], h.options), + pattern, + ); + + assert.equal(h.backendOpens, 0); + assert.deepEqual(h.sent, []); + }); +} + +test("send --stdin rejects a positional message before opening a backend", async () => { + const h = harness([Buffer.from("stdin message")]); + + await assert.rejects( + main(["node", "gbot", "send", "Researcher", "positional", "--stdin"], h.options), + /cannot be combined/i, + ); + + assert.equal(h.backendOpens, 0); +}); + +test("--stdin is rejected for non-send commands before opening a backend", async () => { + const h = harness([Buffer.from("ignored")]); + + await assert.rejects( + main(["node", "gbot", "bots", "list", "--stdin"], h.options), + /only valid with send/i, + ); + + assert.equal(h.backendOpens, 0); +}); + +test("thread --normalized requires --json before opening a backend", async () => { + const h = harness(); + await assert.rejects( + main(["node", "gbot", "thread", "Researcher", "--normalized"], h.options), + /requires --json/i, + ); + assert.equal(h.backendOpens, 0); +}); + +test("--normalized is rejected for non-thread commands before opening a backend", async () => { + const h = harness(); + await assert.rejects( + main(["node", "gbot", "--json", "bots", "list", "--normalized"], h.options), + /only valid with thread or chat/i, + ); + assert.equal(h.backendOpens, 0); +}); + +test("thread --json --normalized emits the stable normalized schema", async () => { + const h = harness(); + await main( + ["node", "gbot", "--json", "thread", "Researcher", "--normalized"], + h.options, + ); + assert.deepEqual(h.output, [{ + target: { id: "bot-1", name: "Researcher", kind: "bot" }, + messages: [{ id: "m1", role: "assistant", text: "完成" }], + }]); +}); + +test("thread raw JSON remains unchanged without --normalized", async () => { + const h = harness(); + await main(["node", "gbot", "--json", "thread", "Researcher"], h.options); + assert.equal(h.output[0].extraRawField, true); + assert.ok(h.output[0].transcript); +}); + +test("malformed normalized transcript emits no partial stdout", async () => { + const h = harness(); + h.options.openBackendImpl = async () => ({ + transcript: async () => ({ + target: { id: "bot-1", name: "Researcher", isGroup: false }, + transcript: { entries: "bad" }, + }), + }); + await assert.rejects( + main(["node", "gbot", "--json", "chat", "Researcher", "--normalized"], h.options), + /invalid transcript container/i, + ); + assert.deepEqual(h.output, []); +}); diff --git a/test/transcript.test.js b/test/transcript.test.js index 8721515..e8c67f0 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { entryText } from "../src/transcript.js"; +import { entryText, normalizeTranscript } from "../src/transcript.js"; test("reads actual nested send-message and user entry content", () => { assert.equal(entryText({ @@ -33,3 +33,113 @@ test("preserves existing direct and content formats", () => { assert.equal(entryText({ content: { text: "object text" } }), "object text"); assert.equal(entryText({ content: { type: "image" } }), '{"type":"image"}'); }); + +test("normalizes actual nested transcript schema with exact text", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "研究員", isGroup: false }, + transcript: { + entries: [ + { + id: "message-1", + kind: "send-message", + message: { type: "assistant", content: "第一行\n第二行" }, + }, + { + messageId: "message-2", + role: "user", + message: { content: "請繼續" }, + }, + ], + }, + }); + + assert.deepEqual(normalized, { + target: { id: "bot-1", name: "研究員", kind: "bot" }, + messages: [ + { id: "message-1", role: "assistant", text: "第一行\n第二行" }, + { id: "message-2", role: "user", text: "請繼續" }, + ], + }); +}); + +test("normalizer only accepts explicit user and assistant roles", () => { + const normalized = normalizeTranscript({ + target: { id: "group-1", name: "Launch", isGroup: true }, + thread: { + messages: [ + { id: "1", kind: "send-message", text: "looks sent by user" }, + { id: "2", type: "assistant", text: "answer" }, + { id: "3", message: { role: "user", text: "question" } }, + { id: "4", role: "system", text: "system text" }, + ], + }, + }); + + assert.deepEqual(normalized, { + target: { id: "group-1", name: "Launch", kind: "group" }, + messages: [ + { id: "1", role: "unknown", text: "looks sent by user" }, + { id: "2", role: "assistant", text: "answer" }, + { id: "3", role: "user", text: "question" }, + { id: "4", role: "unknown", text: "system text" }, + ], + }); +}); + +test("normalizer marks conflicting explicit roles unknown", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ + id: "1", + role: "assistant", + message: { type: "user", content: "conflicting echo" }, + }], + }, + }); + + assert.equal(normalized.messages[0].role, "unknown"); +}); + +test("normalizer supports items and direct array containers", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.equal(normalizeTranscript({ target, transcript: { items: [] } }).messages.length, 0); + assert.equal(normalizeTranscript({ target, transcript: [] }).messages.length, 0); +}); + +test("normalizer fails closed on malformed containers and entries", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: "not-an-array" } }), + /invalid transcript container/i, + ); + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: [null] } }), + /invalid transcript entry/i, + ); +}); + +test("normalizer fails closed on malformed target and message identities", () => { + const transcript = { entries: [] }; + for (const target of [ + { id: "", name: "Bot", isGroup: false }, + { id: "bot-1", name: " ", isGroup: false }, + { id: 7, name: "Bot", isGroup: false }, + { id: "bot-1", name: {}, isGroup: false }, + { id: "bot-1", name: "Bot", isGroup: "false" }, + ]) { + assert.throws(() => normalizeTranscript({ target, transcript }), /invalid transcript target/i); + } + + const target = { id: "bot-1", name: "Bot", isGroup: false }; + for (const id of ["", " ", 42, {}]) { + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: [{ id, text: "message" }] } }), + /invalid transcript message id/i, + ); + } + assert.deepEqual( + normalizeTranscript({ target, transcript: { entries: [{ text: "no id" }] } }).messages[0], + { id: null, role: "unknown", text: "no id" }, + ); +}); From 825f97cd4e5c65ca0756508d3a89a35bc142b650 Mon Sep 17 00:00:00 2001 From: iml1s Date: Tue, 8 Sep 2026 20:15:35 +0800 Subject: [PATCH 03/15] Reject conflicting normalized transcript evidence (cherry picked from commit 50c5deb6a03351393c67bf9135455dc95f63b73b) --- src/transcript.js | 48 ++++++++++++++++++++++---- test/transcript.test.js | 75 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/src/transcript.js b/src/transcript.js index 2cccdac..60f0ed7 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -50,6 +50,46 @@ function isNonblankString(value) { return typeof value === "string" && value.trim().length > 0; } +const TEXT_CARRIERS = ["text", "prompt", "message", "preview", "content"]; + +function evidenceText(value) { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value.map((part) => evidenceText(part)).filter(Boolean).join("\n"); + } + if (!value || typeof value !== "object") return ""; + + const candidates = TEXT_CARRIERS + .filter((key) => Object.prototype.hasOwnProperty.call(value, key)) + .map((key) => evidenceText(value[key])) + .filter(Boolean); + if (new Set(candidates).size > 1) { + throw new Error("Conflicting transcript text evidence."); + } + return candidates[0] ?? ""; +} + +function normalizedEntryText(entry) { + evidenceText(entry); + return entryText(entry); +} + +function normalizedMessageId(entry) { + const candidates = []; + for (const key of ["id", "messageId"]) { + if (!Object.prototype.hasOwnProperty.call(entry, key)) continue; + const value = entry[key]; + if (value !== null && !isNonblankString(value)) { + throw new Error("Invalid transcript message id."); + } + if (value !== null) candidates.push(value); + } + if (new Set(candidates).size > 1) { + throw new Error("Invalid transcript message id."); + } + return candidates[0] ?? null; +} + export function normalizeTranscript(out) { if (!out || typeof out !== "object" || Array.isArray(out)) { throw new Error("Invalid transcript response."); @@ -74,14 +114,10 @@ export function normalizeTranscript(out) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { throw new Error("Invalid transcript entry."); } - const rawId = entry.id ?? entry.messageId ?? null; - if (rawId !== null && !isNonblankString(rawId)) { - throw new Error("Invalid transcript message id."); - } return { - id: rawId, + id: normalizedMessageId(entry), role: explicitRole(entry), - text: entryText(entry), + text: normalizedEntryText(entry), }; }); diff --git a/test/transcript.test.js b/test/transcript.test.js index e8c67f0..90ca979 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -101,6 +101,55 @@ test("normalizer marks conflicting explicit roles unknown", () => { assert.equal(normalized.messages[0].role, "unknown"); }); +test("normalizer rejects conflicting text evidence without changing display formatting", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + const conflicting = { id: "1", text: "first", preview: "second" }; + + assert.equal(entryText(conflicting), "first"); + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: [conflicting] } }), + /conflicting transcript text/i, + ); +}); + +test("normalizer rejects conflicting nested message text and content", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.throws( + () => normalizeTranscript({ + target, + transcript: { + entries: [{ id: "1", message: { type: "text", text: "first", content: "second" } }], + }, + }), + /conflicting transcript text/i, + ); +}); + +test("matching text carriers normalize once and content arrays remain intact", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + const normalized = normalizeTranscript({ + target, + transcript: { + entries: [ + { id: "1", text: "same", preview: "same" }, + { id: "2", message: { content: ["first", { text: "second" }] } }, + ], + }, + }); + assert.equal(normalized.messages[0].text, "same"); + assert.equal(normalized.messages[1].text, "first\nsecond"); +}); + +test("send-message with nested type text stays unknown", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", kind: "send-message", message: { type: "text", content: "hello" } }], + }, + }); + assert.equal(normalized.messages[0].role, "unknown"); +}); + test("normalizer supports items and direct array containers", () => { const target = { id: "bot-1", name: "Bot", isGroup: false }; assert.equal(normalizeTranscript({ target, transcript: { items: [] } }).messages.length, 0); @@ -143,3 +192,29 @@ test("normalizer fails closed on malformed target and message identities", () => { id: null, role: "unknown", text: "no id" }, ); }); + +test("normalizer rejects conflicting or malformed ID carriers", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + for (const entry of [ + { id: "first", messageId: "second", text: "message" }, + { id: "first", messageId: {}, text: "message" }, + { id: {}, messageId: "second", text: "message" }, + ]) { + assert.throws( + () => normalizeTranscript({ target, transcript: { entries: [entry] } }), + /invalid transcript message id/i, + ); + } + + const messages = normalizeTranscript({ + target, + transcript: { + entries: [ + { id: "same", messageId: "same", text: "one" }, + { id: null, messageId: "fallback", text: "two" }, + ], + }, + }).messages; + assert.equal(messages[0].id, "same"); + assert.equal(messages[1].id, "fallback"); +}); From 755c55c247bad6de9ad969ab1ac78f8036f0d8c2 Mon Sep 17 00:00:00 2001 From: iml1s Date: Tue, 8 Sep 2026 20:16:46 +0800 Subject: [PATCH 04/15] Keep normalized text separate from display stringification (cherry picked from commit 60f571416aa9659b1a1b610001ac7384ab722b78) --- src/transcript.js | 3 +-- test/transcript.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/transcript.js b/src/transcript.js index 60f0ed7..e3f00a3 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -70,8 +70,7 @@ function evidenceText(value) { } function normalizedEntryText(entry) { - evidenceText(entry); - return entryText(entry); + return evidenceText(entry); } function normalizedMessageId(entry) { diff --git a/test/transcript.test.js b/test/transcript.test.js index 90ca979..43681fd 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -150,6 +150,15 @@ test("send-message with nested type text stays unknown", () => { assert.equal(normalized.messages[0].role, "unknown"); }); +test("normalized evidence never synthesizes JSON from non-text content", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { entries: [{ id: "new", role: "user", content: { type: "image" } }] }, + }); + assert.equal(normalized.messages[0].text, ""); + assert.equal(entryText({ content: { type: "image" } }), '{"type":"image"}'); +}); + test("normalizer supports items and direct array containers", () => { const target = { id: "bot-1", name: "Bot", isGroup: false }; assert.equal(normalizeTranscript({ target, transcript: { items: [] } }).messages.length, 0); From e4fbcb19a0f4a97daa660a42eb005b7793a7b353 Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 10:45:41 +0800 Subject: [PATCH 05/15] docs: add changeset for stdin sends and normalized transcripts --- .changeset/stdin-normalized-transcripts.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/stdin-normalized-transcripts.md diff --git a/.changeset/stdin-normalized-transcripts.md b/.changeset/stdin-normalized-transcripts.md new file mode 100644 index 0000000..4d11901 --- /dev/null +++ b/.changeset/stdin-normalized-transcripts.md @@ -0,0 +1,11 @@ +--- +"grok-bot-cli": minor +--- + +Add `send --stdin` so a message can be passed on standard input instead +of the process argument list, with strict UTF-8, non-empty, no-NUL, no +surrounding whitespace and 64 KiB validation. Add a `--normalized` transcript +contract for `thread`/`chat` JSON output that returns only the target identity +and messages with `id`, an explicit `user`/`assistant`/`unknown` role, and text. +The normalizer fails closed on malformed or conflicting evidence rather than +guessing, and keeps normalized text separate from display stringification. From edd6a36b9c6b5c1da2e17bde1f36d3553a60738d Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 11:06:47 +0800 Subject: [PATCH 06/15] fix: return HTTP errors before reading discarded bodies When the gateway returns a non-OK status but its body stream stalls, requestJson previously raced readJson against the deadline, causing the known HTTP error to be misclassified as GATEWAY_TIMEOUT with unknown delivery effect. Now requestJson checks res.ok immediately after the fetch resolves and returns before attempting to read the body. This ensures client errors (4xx) and server errors (5xx) are reported with their actual status codes even when the response body never completes. Addresses Codex review feedback on PR #20. --- src/gateway.js | 1 + test/gateway.test.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/gateway.js b/src/gateway.js index 2875be5..7702f08 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -165,6 +165,7 @@ async function requestJson(method, url, init, options = {}) { fetchImpl(url, { ...init, redirect: "error", signal: controller.signal }), deadline, ]); + if (!res.ok) return { res, data: undefined, invalidJson: false, emptyBody: true }; const parsed = await Promise.race([readJson(res), deadline]); return { res, ...parsed }; } catch (error) { diff --git a/test/gateway.test.js b/test/gateway.test.js index d7a16c7..e601ab8 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -321,3 +321,46 @@ test("gateway fetch rejects redirects while preserving a normal HTTPS request", method: "POST", }); }); + +test("HTTP error with stalled body reports HTTP status, not timeout", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + timeoutMs: 50, + fetchImpl: async () => ({ + ok: false, + status: 400, + statusText: "", + text: async () => new Promise(() => {}), + }), + }), + (error) => { + assert.equal(error.message, "sendPrompt failed with HTTP 400."); + assert.equal(error.status, 400); + assert.equal(error.effect, undefined); + assert.notEqual(error.code, "GATEWAY_TIMEOUT"); + return true; + }, + ); +}); + +test("5xx error with stalled body reports HTTP status with unknown effect, not timeout", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + timeoutMs: 50, + fetchImpl: async () => ({ + ok: false, + status: 503, + statusText: "", + text: async () => new Promise(() => {}), + }), + }), + (error) => { + assert.equal(error.message, "sendPrompt failed with HTTP 503; delivery is unknown. Do not resend automatically."); + assert.equal(error.status, 503); + assert.equal(error.effect, "unknown"); + assert.notEqual(error.code, "GATEWAY_TIMEOUT"); + return true; + }, + ); +}); + From 55302127a60ddb872f3a20c24b43613016cb0501 Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 11:45:13 +0800 Subject: [PATCH 07/15] fix: address remaining Codex review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent fixes addressing all remaining P2 suggestions from the Codex automated review on PR #20: 1. Treat JSON null sendPrompt responses as unknown delivery — null provides no confirmation the prompt was accepted, so it follows the same path as empty or non-JSON success responses. 2. Preserve leading UTF-8 BOM during stdin decoding (ignoreBOM: true) so the whitespace check rejects it rather than silently stripping the first character of the message. 3. Include entry.sender in the normalizer's role candidate set so transcripts with an explicit sender field produce the correct user/assistant role instead of unknown. --- src/cli.js | 2 +- src/gateway.js | 2 +- src/transcript.js | 2 +- test/gateway.test.js | 20 ++++++++++++++++++++ test/send-stdin.test.js | 1 + test/transcript.test.js | 24 ++++++++++++++++++++++++ 6 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/cli.js b/src/cli.js index 8e918dc..445ed16 100755 --- a/src/cli.js +++ b/src/cli.js @@ -224,7 +224,7 @@ async function readStdinMessage(stdin) { let message; try { - message = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); + message = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); } catch { throw new StoreError("stdin message must be valid UTF-8."); } diff --git a/src/gateway.js b/src/gateway.js index 7702f08..aad0a1c 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -225,7 +225,7 @@ export async function gatewayCall(session, method, body = {}, options = {}) { if (!res.ok) { throw httpError(method, res.status); } - if (invalidJson || (method === "sendPrompt" && emptyBody)) throw invalidResponseError(method); + if (invalidJson || (method === "sendPrompt" && (emptyBody || data === null))) throw invalidResponseError(method); return data; } diff --git a/src/transcript.js b/src/transcript.js index e3f00a3..b314cc4 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -38,7 +38,7 @@ function explicitRole(entry) { const nested = entry.message && typeof entry.message === "object" && !Array.isArray(entry.message) ? entry.message : null; - const candidates = [nested?.type, nested?.role, entry.role, entry.kind, entry.type]; + const candidates = [nested?.type, nested?.role, entry.role, entry.sender, entry.kind, entry.type]; const roles = new Set(); for (const candidate of candidates) { if (candidate === "user" || candidate === "assistant") roles.add(candidate); diff --git a/test/gateway.test.js b/test/gateway.test.js index e601ab8..ab0a7a4 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -364,3 +364,23 @@ test("5xx error with stalled body reports HTTP status with unknown effect, not t ); }); +test("null JSON send response reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "null" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + assert.equal(error.message, "sendPrompt returned an invalid response; delivery is unknown. Do not resend automatically."); + return true; + }, + ); +}); + +test("null JSON for non-send methods returns null data as-is", async () => { + const result = await gatewayCall(session, "deleteAgent", {}, { + fetchImpl: async () => response({ body: "null" }), + }); + assert.equal(result, null); +}); diff --git a/test/send-stdin.test.js b/test/send-stdin.test.js index d94c7b0..959a5a4 100644 --- a/test/send-stdin.test.js +++ b/test/send-stdin.test.js @@ -77,6 +77,7 @@ for (const [name, chunks, pattern] of [ ["empty input", [], /stdin message must not be empty/i], ["invalid UTF-8", [Buffer.from([0xc3, 0x28])], /valid UTF-8/i], ["NUL byte", [Buffer.from("hello\0world")], /NUL/i], + ["leading UTF-8 BOM", [Buffer.from([0xef, 0xbb, 0xbf, ...Buffer.from("hello")])], /surrounding whitespace/i], ["leading whitespace", [Buffer.from(" message")], /surrounding whitespace/i], ["trailing whitespace", [Buffer.from("message\n")], /surrounding whitespace/i], ["oversized input", [Buffer.alloc(64 * 1024 + 1, 0x61)], /64 KiB/i], diff --git a/test/transcript.test.js b/test/transcript.test.js index 43681fd..ad0c55b 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -101,6 +101,30 @@ test("normalizer marks conflicting explicit roles unknown", () => { assert.equal(normalized.messages[0].role, "unknown"); }); +test("normalizer recognizes explicit sender role", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [ + { id: "1", sender: "user", text: "hello from sender" }, + { id: "2", sender: "assistant", text: "reply from sender" }, + ], + }, + }); + assert.equal(normalized.messages[0].role, "user"); + assert.equal(normalized.messages[1].role, "assistant"); +}); + +test("normalizer marks conflicting sender and role as unknown", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", role: "assistant", sender: "user", text: "conflict" }], + }, + }); + assert.equal(normalized.messages[0].role, "unknown"); +}); + test("normalizer rejects conflicting text evidence without changing display formatting", () => { const target = { id: "bot-1", name: "Bot", isGroup: false }; const conflicting = { id: "1", text: "first", preview: "second" }; From a041a86e6f87a0e2f579df5cec44e29508e1fb65 Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 11:53:57 +0800 Subject: [PATCH 08/15] fix: unsupported explicit roles veto role inference (fail-closed) When a transcript entry carries an explicit but unsupported role string (e.g. role: 'system' or sender: 'system') alongside a user/assistant carrier from a nested field, the normalizer now returns 'unknown' instead of letting the nested carrier win. This only applies to strict role fields (role, sender, nested.role, nested.type). Loose category carriers like entry.kind ('send-message') and entry.type ('text') continue to contribute user/assistant roles without vetoing, since they are multi-purpose identifiers. Addresses Codex review round 3 on PR #20. --- src/transcript.js | 16 ++++++++++++++-- test/transcript.test.js | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/transcript.js b/src/transcript.js index b314cc4..6737d71 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -38,11 +38,23 @@ function explicitRole(entry) { const nested = entry.message && typeof entry.message === "object" && !Array.isArray(entry.message) ? entry.message : null; - const candidates = [nested?.type, nested?.role, entry.role, entry.sender, entry.kind, entry.type]; + // Strict role carriers: unsupported values veto role inference. + const strictCandidates = [nested?.type, nested?.role, entry.role, entry.sender]; + // Loose carriers: only contribute user/assistant, never veto. + const looseCandidates = [entry.kind, entry.type]; const roles = new Set(); - for (const candidate of candidates) { + let hasUnsupportedRole = false; + for (const candidate of strictCandidates) { + if (candidate === "user" || candidate === "assistant") { + roles.add(candidate); + } else if (typeof candidate === "string" && candidate.length > 0) { + hasUnsupportedRole = true; + } + } + for (const candidate of looseCandidates) { if (candidate === "user" || candidate === "assistant") roles.add(candidate); } + if (hasUnsupportedRole && roles.size > 0) return "unknown"; return roles.size === 1 ? roles.values().next().value : "unknown"; } diff --git a/test/transcript.test.js b/test/transcript.test.js index ad0c55b..6b9ea56 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -125,6 +125,26 @@ test("normalizer marks conflicting sender and role as unknown", () => { assert.equal(normalized.messages[0].role, "unknown"); }); +test("unsupported explicit role vetoes inference from nested carrier", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", role: "system", message: { type: "assistant", content: "system says hi" } }], + }, + }); + assert.equal(normalized.messages[0].role, "unknown"); +}); + +test("loose carrier kind does not veto nested role inference", () => { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", kind: "send-message", message: { type: "assistant", content: "reply" } }], + }, + }); + assert.equal(normalized.messages[0].role, "assistant"); +}); + test("normalizer rejects conflicting text evidence without changing display formatting", () => { const target = { id: "bot-1", name: "Bot", isGroup: false }; const conflicting = { id: "1", text: "first", preview: "second" }; From 1ba2e655c0a03ff3d3b66f1eb232e993325bb5ca Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 12:01:54 +0800 Subject: [PATCH 09/15] fix: abort controller on HTTP errors and reject false send responses Two fixes from Codex review round 4: 1. Abort the controller when returning early on non-OK HTTP status. Without this, the response body stream and underlying socket remain active indefinitely, potentially exhausting the connection pool on repeated failures. 2. Treat JSON false sendPrompt responses as unknown delivery. A boolean cannot confirm prompt acceptance, so it follows the same unknown-delivery path as null and empty body responses. Non-send methods continue to pass false through unchanged. --- src/gateway.js | 7 +++++-- test/gateway.test.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/gateway.js b/src/gateway.js index aad0a1c..37c4fd2 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -165,7 +165,10 @@ async function requestJson(method, url, init, options = {}) { fetchImpl(url, { ...init, redirect: "error", signal: controller.signal }), deadline, ]); - if (!res.ok) return { res, data: undefined, invalidJson: false, emptyBody: true }; + if (!res.ok) { + controller.abort(); + return { res, data: undefined, invalidJson: false, emptyBody: true }; + } const parsed = await Promise.race([readJson(res), deadline]); return { res, ...parsed }; } catch (error) { @@ -225,7 +228,7 @@ export async function gatewayCall(session, method, body = {}, options = {}) { if (!res.ok) { throw httpError(method, res.status); } - if (invalidJson || (method === "sendPrompt" && (emptyBody || data === null))) throw invalidResponseError(method); + if (invalidJson || (method === "sendPrompt" && (emptyBody || data === null || data === false))) throw invalidResponseError(method); return data; } diff --git a/test/gateway.test.js b/test/gateway.test.js index ab0a7a4..2a5fe08 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -384,3 +384,37 @@ test("null JSON for non-send methods returns null data as-is", async () => { }); assert.equal(result, null); }); + +test("false JSON send response reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "false" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); +}); + +test("false JSON for non-send methods returns false data as-is", async () => { + const result = await gatewayCall(session, "deleteAgent", {}, { + fetchImpl: async () => response({ body: "false" }), + }); + assert.equal(result, false); +}); + +test("HTTP error aborts controller to release the socket", async () => { + let signal; + await assert.rejects( + gatewayCall(session, "listAgents", {}, { + fetchImpl: async (url, init) => { + signal = init.signal; + return response({ ok: false, status: 500 }); + }, + }), + (error) => error.status === 500, + ); + assert.equal(signal.aborted, true); +}); From 8f6533c79bc240f884ecf5fa5ce1a9c3f155a6a0 Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 12:10:13 +0800 Subject: [PATCH 10/15] fix: require object send acknowledgement and veto malformed role carriers Two fixes from Codex review round 5: 1. Require sendPrompt data to be a non-null object for a successful acknowledgement. Any non-object JSON value (null, false, 0, '') is treated as unknown delivery. This replaces the growing list of individual falsy-value checks with a positive shape assertion. 2. Malformed non-string strict role carriers (e.g. role: 42, role: true) now veto role inference the same way unsupported string roles do. Empty strings are treated as absent (no veto). --- src/gateway.js | 2 +- src/transcript.js | 2 +- test/gateway.test.js | 24 ++++++++++++++++++++++++ test/transcript.test.js | 13 +++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/gateway.js b/src/gateway.js index 37c4fd2..fbbfdf0 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -228,7 +228,7 @@ export async function gatewayCall(session, method, body = {}, options = {}) { if (!res.ok) { throw httpError(method, res.status); } - if (invalidJson || (method === "sendPrompt" && (emptyBody || data === null || data === false))) throw invalidResponseError(method); + if (invalidJson || (method === "sendPrompt" && (emptyBody || !data || typeof data !== "object"))) throw invalidResponseError(method); return data; } diff --git a/src/transcript.js b/src/transcript.js index 6737d71..ec965b8 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -47,7 +47,7 @@ function explicitRole(entry) { for (const candidate of strictCandidates) { if (candidate === "user" || candidate === "assistant") { roles.add(candidate); - } else if (typeof candidate === "string" && candidate.length > 0) { + } else if (candidate != null && candidate !== "") { hasUnsupportedRole = true; } } diff --git a/test/gateway.test.js b/test/gateway.test.js index 2a5fe08..593402d 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -418,3 +418,27 @@ test("HTTP error aborts controller to release the socket", async () => { ); assert.equal(signal.aborted, true); }); + +test("non-object send responses (0, empty string) report unknown effect", async () => { + for (const body of ["0", '""']) { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); + } +}); + +test("non-object values for non-send methods pass through as-is", async () => { + for (const [body, expected] of [["0", 0], ['""', ""]]) { + const result = await gatewayCall(session, "deleteAgent", {}, { + fetchImpl: async () => response({ body }), + }); + assert.equal(result, expected); + } +}); diff --git a/test/transcript.test.js b/test/transcript.test.js index 6b9ea56..466e8d6 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -145,6 +145,19 @@ test("loose carrier kind does not veto nested role inference", () => { assert.equal(normalized.messages[0].role, "assistant"); }); +test("malformed non-string strict role carrier vetoes inference", () => { + for (const role of [42, true, {}]) { + const normalized = normalizeTranscript({ + target: { id: "bot-1", name: "Bot", isGroup: false }, + transcript: { + entries: [{ id: "1", role, message: { type: "assistant", content: "hello" } }], + }, + }); + assert.equal(normalized.messages[0].role, "unknown", + `role: ${JSON.stringify(role)} should veto assistant inference`); + } +}); + test("normalizer rejects conflicting text evidence without changing display formatting", () => { const target = { id: "bot-1", name: "Bot", isGroup: false }; const conflicting = { id: "1", text: "first", preview: "second" }; From cecb43b3e1d1d6fc2ad494813669e37e0674f5cc Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 12:17:13 +0800 Subject: [PATCH 11/15] fix: reject array send acknowledgements and malformed text carriers Two fixes from Codex review round 6: 1. Exclude arrays from valid sendPrompt acknowledgements. Since typeof [] === 'object', the previous positive shape check still accepted empty arrays. Added Array.isArray guard. 2. Reject non-string, non-object text carrier values (e.g. text: 42) as malformed evidence instead of silently filtering them out and falling back to secondary carriers. This preserves the fail-closed normalizer contract. --- src/gateway.js | 2 +- src/transcript.js | 11 +++++++++-- test/gateway.test.js | 13 +++++++++++++ test/transcript.test.js | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/gateway.js b/src/gateway.js index fbbfdf0..0b014c5 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -228,7 +228,7 @@ export async function gatewayCall(session, method, body = {}, options = {}) { if (!res.ok) { throw httpError(method, res.status); } - if (invalidJson || (method === "sendPrompt" && (emptyBody || !data || typeof data !== "object"))) throw invalidResponseError(method); + if (invalidJson || (method === "sendPrompt" && (emptyBody || !data || typeof data !== "object" || Array.isArray(data)))) throw invalidResponseError(method); return data; } diff --git a/src/transcript.js b/src/transcript.js index ec965b8..6ae20b5 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -71,8 +71,15 @@ function evidenceText(value) { } if (!value || typeof value !== "object") return ""; - const candidates = TEXT_CARRIERS - .filter((key) => Object.prototype.hasOwnProperty.call(value, key)) + const presentKeys = TEXT_CARRIERS + .filter((key) => Object.prototype.hasOwnProperty.call(value, key)); + for (const key of presentKeys) { + const v = value[key]; + if (v != null && typeof v !== "string" && typeof v !== "object") { + throw new Error("Malformed transcript text evidence."); + } + } + const candidates = presentKeys .map((key) => evidenceText(value[key])) .filter(Boolean); if (new Set(candidates).size > 1) { diff --git a/test/gateway.test.js b/test/gateway.test.js index 593402d..34d2b9c 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -442,3 +442,16 @@ test("non-object values for non-send methods pass through as-is", async () => { assert.equal(result, expected); } }); + +test("array send response reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "[]" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); +}); diff --git a/test/transcript.test.js b/test/transcript.test.js index 466e8d6..d2dea35 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -182,6 +182,20 @@ test("normalizer rejects conflicting nested message text and content", () => { ); }); +test("normalizer rejects malformed non-string text carriers", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + for (const text of [42, true]) { + assert.throws( + () => normalizeTranscript({ + target, + transcript: { entries: [{ id: "1", text, content: "fallback" }] }, + }), + /malformed transcript text/i, + `text: ${JSON.stringify(text)} should be rejected`, + ); + } +}); + test("matching text carriers normalize once and content arrays remain intact", () => { const target = { id: "bot-1", name: "Bot", isGroup: false }; const normalized = normalizeTranscript({ From b906bb8dd68079f5ff0581859da40104fce9cfec Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 12:28:17 +0800 Subject: [PATCH 12/15] fix: reject malformed primitives inside text evidence arrays When content arrays contain unsupported primitives (e.g. ["hello", 42]), the array branch silently filters them out. Validate each element and reject non-string, non-object primitives to preserve fail-closed. --- src/transcript.js | 5 +++++ test/transcript.test.js | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/transcript.js b/src/transcript.js index 6ae20b5..7a4d866 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -67,6 +67,11 @@ const TEXT_CARRIERS = ["text", "prompt", "message", "preview", "content"]; function evidenceText(value) { if (typeof value === "string") return value; if (Array.isArray(value)) { + for (const part of value) { + if (part != null && typeof part !== "string" && typeof part !== "object") { + throw new Error("Malformed transcript text evidence."); + } + } return value.map((part) => evidenceText(part)).filter(Boolean).join("\n"); } if (!value || typeof value !== "object") return ""; diff --git a/test/transcript.test.js b/test/transcript.test.js index d2dea35..f6f4351 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -196,6 +196,17 @@ test("normalizer rejects malformed non-string text carriers", () => { } }); +test("normalizer rejects malformed primitives inside text arrays", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.throws( + () => normalizeTranscript({ + target, + transcript: { entries: [{ id: "1", content: ["hello", 42] }] }, + }), + /malformed transcript text/i, + ); +}); + test("matching text carriers normalize once and content arrays remain intact", () => { const target = { id: "bot-1", name: "Bot", isGroup: false }; const normalized = normalizeTranscript({ From 31b03adbdacf8c7dd97aac0d42f72969a5d4049a Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 12:37:05 +0800 Subject: [PATCH 13/15] fix: reject empty-object send ack and opaque text carrier objects - sendPrompt now requires response to have at least one key - Strict text carriers (text, prompt, preview) with opaque objects (no TEXT_CARRIERS subkeys) are rejected as malformed evidence - content/message carriers continue to accept rich objects like { type: 'image' } --- src/gateway.js | 2 +- src/transcript.js | 5 +++++ test/gateway.test.js | 13 +++++++++++++ test/transcript.test.js | 11 +++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/gateway.js b/src/gateway.js index 0b014c5..55cb52d 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -228,7 +228,7 @@ export async function gatewayCall(session, method, body = {}, options = {}) { if (!res.ok) { throw httpError(method, res.status); } - if (invalidJson || (method === "sendPrompt" && (emptyBody || !data || typeof data !== "object" || Array.isArray(data)))) throw invalidResponseError(method); + if (invalidJson || (method === "sendPrompt" && (emptyBody || !data || typeof data !== "object" || Array.isArray(data) || Object.keys(data).length === 0))) throw invalidResponseError(method); return data; } diff --git a/src/transcript.js b/src/transcript.js index 7a4d866..4f45a3f 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -78,11 +78,16 @@ function evidenceText(value) { const presentKeys = TEXT_CARRIERS .filter((key) => Object.prototype.hasOwnProperty.call(value, key)); + const STRICT_TEXT_KEYS = ["text", "prompt", "preview"]; for (const key of presentKeys) { const v = value[key]; if (v != null && typeof v !== "string" && typeof v !== "object") { throw new Error("Malformed transcript text evidence."); } + if (v != null && typeof v === "object" && !Array.isArray(v) && STRICT_TEXT_KEYS.includes(key)) { + const hasTextKeys = TEXT_CARRIERS.some((k) => Object.prototype.hasOwnProperty.call(v, k)); + if (!hasTextKeys) throw new Error("Malformed transcript text evidence."); + } } const candidates = presentKeys .map((key) => evidenceText(value[key])) diff --git a/test/gateway.test.js b/test/gateway.test.js index 34d2b9c..9d07769 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -455,3 +455,16 @@ test("array send response reports unknown effect", async () => { }, ); }); + +test("empty-object send response reports unknown effect", async () => { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: "{}" }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); +}); diff --git a/test/transcript.test.js b/test/transcript.test.js index f6f4351..86beb8f 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -207,6 +207,17 @@ test("normalizer rejects malformed primitives inside text arrays", () => { ); }); +test("normalizer rejects opaque object in strict text carrier", () => { + const target = { id: "bot-1", name: "Bot", isGroup: false }; + assert.throws( + () => normalizeTranscript({ + target, + transcript: { entries: [{ id: "1", text: { unexpected: true }, content: "fallback" }] }, + }), + /malformed transcript text/i, + ); +}); + test("matching text carriers normalize once and content arrays remain intact", () => { const target = { id: "bot-1", name: "Bot", isGroup: false }; const normalized = normalizeTranscript({ From 26c54c5b33cabd898c79f23135075ddf3b317164 Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 12:43:32 +0800 Subject: [PATCH 14/15] fix: reject negative send acknowledgements (ok:false, error field) Extract sendPrompt validation into isAffirmativeSendAck helper. Reject responses with ok:false, success:false, or an error field since they explicitly indicate the gateway rejected the prompt despite returning a 2xx status code. --- src/gateway.js | 11 ++++++++++- test/gateway.test.js | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/gateway.js b/src/gateway.js index 55cb52d..8c0b129 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -228,10 +228,19 @@ export async function gatewayCall(session, method, body = {}, options = {}) { if (!res.ok) { throw httpError(method, res.status); } - if (invalidJson || (method === "sendPrompt" && (emptyBody || !data || typeof data !== "object" || Array.isArray(data) || Object.keys(data).length === 0))) throw invalidResponseError(method); + if (invalidJson || (method === "sendPrompt" && !isAffirmativeSendAck(data, emptyBody))) throw invalidResponseError(method); return data; } +function isAffirmativeSendAck(data, emptyBody) { + if (emptyBody || !data || typeof data !== "object" || Array.isArray(data)) return false; + if (Object.keys(data).length === 0) return false; + if ("ok" in data && !data.ok) return false; + if ("success" in data && !data.success) return false; + if ("error" in data) return false; + return true; +} + function asRecord(agent) { if (!agent) return null; const id = agent.id || agent.agentId; diff --git a/test/gateway.test.js b/test/gateway.test.js index 9d07769..dbe0ada 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -468,3 +468,25 @@ test("empty-object send response reports unknown effect", async () => { }, ); }); + +test("negative send acknowledgements (ok:false, error field) report unknown effect", async () => { + for (const body of ['{"ok":false}', '{"success":false}', '{"error":"rejected"}']) { + await assert.rejects( + gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body }), + }), + (error) => { + assert.equal(error.code, "GATEWAY_INVALID_RESPONSE"); + assert.equal(error.effect, "unknown"); + return true; + }, + ); + } +}); + +test("affirmative send acknowledgement with ok:true is accepted", async () => { + const result = await gatewayCall(session, "sendPrompt", { prompt: "hello" }, { + fetchImpl: async () => response({ body: '{"ok":true,"id":"msg-1"}' }), + }); + assert.deepEqual(result, { ok: true, id: "msg-1" }); +}); From f7990a4b31df6c495ffb43f99384ce0565b01a22 Mon Sep 17 00:00:00 2001 From: iml1s Date: Wed, 9 Sep 2026 12:52:09 +0800 Subject: [PATCH 15/15] fix: require strict boolean true for ok/success ack fields When ok or success fields are present but not exactly boolean true (e.g. ok:'false', success:[], ok:0), reject the response as unknown delivery instead of trusting truthy coercion. --- src/gateway.js | 4 ++-- test/gateway.test.js | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/gateway.js b/src/gateway.js index 8c0b129..02b9fb6 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -235,8 +235,8 @@ export async function gatewayCall(session, method, body = {}, options = {}) { function isAffirmativeSendAck(data, emptyBody) { if (emptyBody || !data || typeof data !== "object" || Array.isArray(data)) return false; if (Object.keys(data).length === 0) return false; - if ("ok" in data && !data.ok) return false; - if ("success" in data && !data.success) return false; + if ("ok" in data && data.ok !== true) return false; + if ("success" in data && data.success !== true) return false; if ("error" in data) return false; return true; } diff --git a/test/gateway.test.js b/test/gateway.test.js index dbe0ada..4ca0d7c 100644 --- a/test/gateway.test.js +++ b/test/gateway.test.js @@ -469,8 +469,11 @@ test("empty-object send response reports unknown effect", async () => { ); }); -test("negative send acknowledgements (ok:false, error field) report unknown effect", async () => { - for (const body of ['{"ok":false}', '{"success":false}', '{"error":"rejected"}']) { +test("negative send acknowledgements (ok:false, error field, wrong types) report unknown effect", async () => { + for (const body of [ + '{"ok":false}', '{"success":false}', '{"error":"rejected"}', + '{"ok":"false"}', '{"success":[]}', '{"ok":0}', + ]) { await assert.rejects( gatewayCall(session, "sendPrompt", { prompt: "hello" }, { fetchImpl: async () => response({ body }),