From 9398cb8026c441623b8b1c6e98d7251f8e94af0d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:09:16 -0700 Subject: [PATCH 01/18] docs: define persistent Codex session implementation --- .../plans/2026-09-15-codex-session.md | 56 +++++++++++++++++++ .../specs/2026-09-15-codex-session-design.md | 24 ++++++++ 2 files changed, 80 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-15-codex-session.md create mode 100644 docs/superpowers/specs/2026-09-15-codex-session-design.md diff --git a/docs/superpowers/plans/2026-09-15-codex-session.md b/docs/superpowers/plans/2026-09-15-codex-session.md new file mode 100644 index 0000000..90c629b --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-codex-session.md @@ -0,0 +1,56 @@ +# Persistent Codex Session Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose a bounded, persistent app-server connection that can deliver events and serve as the foundation for completion waiting and a recoverable Grok binding. + +**Architecture:** Extend the existing Unix WebSocket implementation. Preserve one-shot send behavior and expose its existing initialization path. Keep conversation scheduling and durable delivery state outside the transport. + +**Tech Stack:** JavaScript/JSDoc, Node.js >=22.19.0, node:test, existing Agent Bundle build; app-server schema 0.154.0. + +**Spec:** docs/superpowers/specs/2026-09-15-codex-session-design.md + +## Global Constraints + +- No new dependencies or Desktop/private-pipe APIs. +- Preserve current envelope, route, receipt, busy and approval ownership contracts. +- One writer owns `src/core/codex-bridge.js` during this task. +- Use scratch Unix sockets and explicit test env; no live gateway messages in unit tests. +- Failures after a submission cannot be mislabeled as rejected deliveries. + +### Task 1: Event-capable persistent transport + +**Files:** Modify `src/core/codex-bridge.js`; create `test/codex-session.test.js`. A focused `src/core/codex/transport.js` extraction is permitted only if needed to keep the transport understandable, with compatibility re-exports from `codex-bridge.js`. Do not modify CLI routes, existing test files, gateway modules or package metadata. + +**Interfaces:** Keep `connectCodexAppServer(path, options)` and add the listener/response APIs defined in the spec. Export `openCodexSession(env, options)` as the canonical initialized persistent connection. Existing send/list/status callers keep their behavior. + +- [ ] Write failing tests using a scratch Unix HTTP Upgrade server. The minimum observable cases are: + +```js +const seen = []; +const off = client.onNotification(message => seen.push(message)); +// Server sends two notification frames in one write. +assert.deepEqual(seen.map(x => x.method), ['turn/started', 'turn/completed']); +off(); +// Later notifications must not reach this listener. + +client.onServerRequest(request => client.respond(request.id, {decision: 'decline'})); +// The peer receives exactly one matching response; a duplicate local respond throws. +assert.throws(() => client.respond('already-resolved', {})); + +const closed = new Promise(resolve => client.onClose(resolve)); +// Destroy the idle peer without a pending request. +assert.match((await closed).message, /closed|disconnect|socket/i); +await assert.rejects(client.request('thread/list', {})); +``` + +Also test AbortSignal cleanup, observer silence on foreign approvals, post-resolution response refusal, absolute limits on outgoing writes and remembered requests, a throwing listener, and notification listeners passed at connection construction. Tests must distinguish a healthy idle connection from a dead one and use short injected test deadlines. + +- [ ] Run `TMPDIR=/tmp GROK_BOT_TEST=1 node --test test/codex-session.test.js` and record the expected failures. +- [ ] Implement event dispatch in `onMessage`, close notification in all cleanup paths, bounded registration/request state, safe response methods and the exported initialization path. Validate numeric limits and timeouts. Attach initial hooks before any bytes can be handled. Maintain existing refusal semantics for legacy one-shot callers. +- [ ] Run the new test file, then `npm run build` and `TMPDIR=/tmp GROK_BOT_TEST=1 node --test test/codex-bridge.test.js test/codex-session.test.js`. +- [ ] Self-review for resource leaks, unbounded state and changed legacy delivery semantics. Commit only the task-owned implementation/test files and write the report with test commands and results. + +## Continuation roadmap + +The controller continues without another user checkpoint: completion waiting and watch commands; atomic delivery ledger and one foreground binding; explicit steering/operator interaction and optional supervision; live loop/recovery proofs; final review, merge and release verification. Each continuation gets its own task brief after the preceding interfaces are verified. diff --git a/docs/superpowers/specs/2026-09-15-codex-session-design.md b/docs/superpowers/specs/2026-09-15-codex-session-design.md new file mode 100644 index 0000000..78a3d72 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-codex-session-design.md @@ -0,0 +1,24 @@ +# Persistent Codex session design + +This implements the first part of the Grok↔Codex design approved by Zack's request to do all next steps. Keep the existing managed daemon and gateway. The later binding will consume this session layer; it will not run another Codex engine. + +## Contract + +Extend the existing WebSocket-over-Unix-socket client with synchronous registration methods `onNotification(listener)`, `onServerRequest(listener)`, and `onClose(listener)`, each returning an unsubscribe function. Notifications carry the original `{method, params}` object. Server requests carry original `{id, method, params}`. Close listeners receive an Error explaining closure, once. Registration after closure must still expose closed state (through `closed` and/or immediate close callback). Constructor options may install the three listeners before connection/initialization messages arrive. + +Keep request/notify/close and the existing one-shot refusal/ownership behavior working. New passive listeners must never reject another client's approval. Add `respond(id, result)` and `rejectRequest(id, error)` for pending server requests; reject duplicate or resolved IDs locally. Clear pending request ownership on `serverRequest/resolved` and connection close. The high-level session will enforce thread/turn ownership and method-specific response validation before invoking these low-level methods. + +Expose the existing initialization path as `openCodexSession(env, options)` returning `{client, path, init}`. It must use the same socket selection, route checks, initialize validation, version identity and cleanup as today's `openSession`. Support `signal`, `timeoutMs`, `experimental`, and initial event listeners. Keep existing internal callers compatible. + +## Resource and lifecycle limits + +- Retain 16 KiB upgrade header, 4 MiB message and 8 MiB aggregate receive bounds and absolute handshake/RPC deadlines. +- Outbound encoded frames and queued socket bytes must fit an 8 MiB budget; a nonreading peer must cause a visible bounded failure. Await/drain or fail rather than silently accumulating writes. +- Bound outstanding client requests and remembered server requests/refusals/deferred requests to 128 each. Exceeding a bound closes the connection with an explicit error and rejects pending operations. +- Close on protocol errors; complete cleanup on abort and local/remote close. No new requests after closure. Dispose listeners and pending timers; no unhandled rejection or process-level exception from a throwing listener. +- Idle healthy connections survive the RPC timeout. Disconnection must notify observers even when no RPC is pending. Local close must not stop the daemon or other clients. +- No new dependency, no raw private Desktop pipes, no Desktop patch, and no daemon/global permission change. + +## Validation + +Use real Unix socket fake servers for framing and lifecycle tests. Verify notification ordering, listener disposal, pending server request response exactly once, resolution invalidation, listener failure, abort, idle disconnect, request-after-close, bounded outgoing pressure and server-request floods. Retain the existing codex bridge suite, including its one-shot ownership tests. A live metadata-only probe may connect two clients to the existing daemon; no model prompt is needed for this layer. From f7d9c966871e23d34d46fa46b0ffa2386b6f2d4e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:16:30 -0700 Subject: [PATCH 02/18] feat(codex): add persistent event-capable app-server sessions --- src/core/codex-bridge.js | 192 ++++++++++++++++++++++------ test/codex-session.test.js | 248 +++++++++++++++++++++++++++++++++++++ 2 files changed, 400 insertions(+), 40 deletions(-) create mode 100644 test/codex-session.test.js diff --git a/src/core/codex-bridge.js b/src/core/codex-bridge.js index 9f2a38a..ed7eeb3 100644 --- a/src/core/codex-bridge.js +++ b/src/core/codex-bridge.js @@ -24,6 +24,9 @@ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; export const WS_MAX_HEADER_BYTES = 16 * 1024; export const WS_MAX_MESSAGE_BYTES = 4 * 1024 * 1024; export const WS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; +export const WS_MAX_WRITE_BYTES = 8 * 1024 * 1024; +export const CODEX_MAX_REQUESTS = 128; +export const CODEX_MAX_LISTENERS = 128; const textDecoder = new TextDecoder("utf-8", { fatal: true }); const pkg = createRequire(import.meta.url)("../../package.json"); @@ -243,15 +246,31 @@ function transportError(err) { /** * Open a JSON-RPC session to the app-server over its Unix socket (WebSocket framing). - * Server-initiated requests (approvals, user input) are refused with a JSON-RPC error - * and recorded in `refused`; gbot never approves on the user's behalf. + * Server requests are observed without answering by default. Legacy one-shot + * callers arm ownership-scoped refusals; persistent sessions respond explicitly. */ -export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { +export function connectCodexAppServer(path, { timeoutMs = 15000, signal, onNotification, onServerRequest, onClose } = {}) { return new Promise((resolve, reject) => { + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) { + throw new RangeError("timeoutMs must be an integer 1-2147483647"); + } + for (const listener of [onNotification, onServerRequest, onClose]) { + if (listener !== undefined && typeof listener !== "function") throw new TypeError("Event listener must be a function"); + } + if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("signal must be an AbortSignal"); + if (signal?.aborted) throw new Error("Codex connection aborted", { cause: signal.reason }); const socket = createConnection({ path }); const key = randomBytes(16).toString("base64"); const pending = new Map(); const refused = []; + const serverRequests = new Map(); + const listeners = { + notification: new Set(onNotification ? [onNotification] : []), + serverRequest: new Set(onServerRequest ? [onServerRequest] : []), + close: new Set(onClose ? [onClose] : []), + }; + let closeError; + let writeTimer; let nextId = 1; let buf = Buffer.alloc(0); let upgraded = false; @@ -267,21 +286,95 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { const failAll = (err) => { if (closed) return; closed = true; + closeError = err; clearTimeout(handshakeTimer); + clearTimeout(writeTimer); + signal?.removeEventListener("abort", abort); if (err && err.delivery == null) err.delivery = pending.size ? "unknown" : "rejected"; for (const { reject: rej } of pending.values()) rej(err); pending.clear(); + serverRequests.clear(); + client.deferred.length = 0; + buf = Buffer.alloc(0); + fragParts = []; + fragBytes = 0; + const closeListeners = [...listeners.close]; + for (const group of Object.values(listeners)) group.clear(); try { socket.destroy(); } catch { /* already gone */ } reject(err); + for (const listener of closeListeners) invoke(listener, err, true); + }; + // Observers execute synchronously to preserve order, but failures never escape + // the socket callback. Rejected async callbacks use the same cleanup path. + const invoke = (listener, message, closing = false) => { + const failed = (cause) => { + if (!closing) failAll(new Error("Codex event listener failed: " + String(cause?.message ?? cause), { cause })); + }; + try { + const result = listener(message); + if (result && typeof result.then === "function") Promise.resolve(result).catch(failed); + } catch (err) { failed(err); } + }; + const dispatch = (kind, message) => { + for (const listener of [...listeners[kind]]) { + if (closed) break; + if (listeners[kind].has(listener)) invoke(listener, message); + } }; + const subscribe = (kind, listener) => { + if (typeof listener !== "function") throw new TypeError("Event listener must be a function"); + if (closed) { + if (kind === "close") invoke(listener, closeError, true); + return () => {}; + } + const group = listeners[kind]; + if (!group.has(listener) && group.size >= CODEX_MAX_LISTENERS) { + const err = new Error("Codex listener limit exceeds " + CODEX_MAX_LISTENERS); + failAll(err); + throw err; + } + group.add(listener); + return () => group.delete(listener); + }; + const abort = () => failAll(new Error("Codex connection aborted", { cause: signal.reason })); const failProtocol = (detail) => failAll(new Error("Codex app-server violated the WebSocket protocol: " + detail)); const write = (opcode, payload) => { - if (!socket.destroyed) socket.write(encodeFrame(opcode, payload, randomBytes(4))); + if (closed) throw closeError; + const encodedBytes = payload.length + (payload.length < 126 ? 6 : payload.length < 65536 ? 8 : 14); + if (socket.destroyed || encodedBytes + socket.writableLength > WS_MAX_WRITE_BYTES) { + const err = new Error(socket.destroyed ? "Codex socket closed" : "Codex outbound write budget exceeds " + WS_MAX_WRITE_BYTES + " bytes"); + failAll(err); + throw err; + } + try { + if (!socket.write(encodeFrame(opcode, payload, randomBytes(4))) && !writeTimer) { + // One absolute deadline for the whole backpressured interval: more + // writes must not keep an unread socket alive indefinitely. + writeTimer = setTimeout(() => failAll(new Error("Codex outbound write did not drain within " + timeoutMs + "ms")), timeoutMs); + writeTimer.unref(); + } + } catch (err) { failAll(err); throw err; } }; const sendJson = (obj) => write(0x1, Buffer.from(JSON.stringify(obj))); const client = { refused, + get closed() { return closed; }, + onNotification: (listener) => subscribe("notification", listener), + onServerRequest: (listener) => subscribe("serverRequest", listener), + onClose: (listener) => subscribe("close", listener), + respond(id, result) { + if (closed) throw closeError; + if (!serverRequests.has(id)) throw new Error("Unknown or resolved server request: " + id); + sendJson({ jsonrpc: "2.0", id, result }); + serverRequests.delete(id); + }, + rejectRequest(id, error) { + if (closed) throw closeError; + if (!serverRequests.has(id)) throw new Error("Unknown or resolved server request: " + id); + sendJson({ jsonrpc: "2.0", id, error }); + serverRequests.delete(id); + }, // Server-initiated requests are only *answered* once our own turn/start // is in flight, and then only when they name our own thread/turn: // anything earlier — or naming another thread or Desktop turn — belongs @@ -310,19 +403,21 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { const namedTurnId = params ? (params.turnId ?? params.turn_id ?? (params.turn && params.turn.id)) : null; - if (threadId === client.expectedThreadId && namedTurnId === turnId) { + if (threadId === client.expectedThreadId && namedTurnId === turnId && serverRequests.has(entry.id)) { + client.rejectRequest(entry.id, { code: -32601, message: "gbot codex does not answer " + entry.method + "; configure approval_policy on the daemon" }); entry.answered = true; - sendJson({ - jsonrpc: "2.0", - id: entry.id, - error: { code: -32601, message: "gbot codex does not answer " + entry.method + "; configure approval_policy on the daemon" }, - }); } } }, request(method, params) { - const id = nextId++; return new Promise((res, rej) => { + if (closed) return rej(closeError); + if (pending.size >= CODEX_MAX_REQUESTS) { + const err = new Error("Codex pending request limit exceeds " + CODEX_MAX_REQUESTS); + failAll(err); + return rej(err); + } + const id = nextId++; const timer = setTimeout(() => { pending.delete(id); rej(new Error("Codex app-server did not answer " + method + " within " + timeoutMs + "ms")); @@ -332,7 +427,12 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { resolve: (v) => { clearTimeout(timer); res(v); }, reject: (e) => { clearTimeout(timer); rej(e); }, }); - sendJson({ jsonrpc: "2.0", id, method, params }); + try { sendJson({ jsonrpc: "2.0", id, method, params }); } + catch (err) { + const entry = pending.get(id); + pending.delete(id); + entry?.reject(err); + } }); }, notify(method, params) { @@ -340,26 +440,22 @@ 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(); - // 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 */ } - }); + // Best effort protocol close; cleanup never depends on the peer reading it. + try { if (upgraded) write(0x8, Buffer.from([0x03, 0xe8])); } catch { /* write already closed the session */ } + failAll(new Error("Codex client closed")); }, }; + signal?.addEventListener("abort", abort, { once: true }); const onMessage = (msg) => { if (msg.id != null && msg.method) { + if (serverRequests.has(msg.id)) return failProtocol("duplicate pending server request id"); + if (serverRequests.size >= CODEX_MAX_REQUESTS || refused.length >= CODEX_MAX_REQUESTS || client.deferred.length >= CODEX_MAX_REQUESTS) { + return failAll(new Error("Codex remembered server request limit exceeds " + CODEX_MAX_REQUESTS)); + } + serverRequests.set(msg.id, msg); + dispatch("serverRequest", msg); + if (closed) return; let answered = client.answerServerRequests; let defer = false; // Approval ownership: only answer requests for the turn gbot itself @@ -377,6 +473,8 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { else if (turnId !== client.expectedTurnId) answered = false; } else answered = false; } + answered = answered && serverRequests.has(msg.id); + defer = defer && serverRequests.has(msg.id); const entry = { id: msg.id, method: msg.method, params: msg.params, answered }; refused.push(entry); if (defer) { @@ -384,11 +482,16 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { return; } if (!answered) return; - sendJson({ - jsonrpc: "2.0", - id: msg.id, - error: { code: -32601, message: "gbot codex does not answer " + msg.method + "; configure approval_policy on the daemon" }, - }); + client.rejectRequest(msg.id, { code: -32601, message: "gbot codex does not answer " + msg.method + "; configure approval_policy on the daemon" }); + return; + } + if (msg.id == null && msg.method) { + if (msg.method === "serverRequest/resolved") { + const id = msg.params?.requestId; + serverRequests.delete(id); + client.deferred = client.deferred.filter(entry => entry.id !== id); + } + dispatch("notification", msg); return; } if (msg.id == null || !pending.has(msg.id)) return; @@ -417,13 +520,18 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { failAll(new Error("Codex app-server sent a malformed message")); return; } - onMessage(msg); + try { onMessage(msg); } catch (err) { failAll(err); } }; + socket.on("drain", () => { + clearTimeout(writeTimer); + writeTimer = undefined; + }); socket.once("error", (err) => failAll(new Error("Could not connect to Codex app-server at " + path + ": " + err.message))); socket.once("close", () => failAll(new Error("Codex app-server closed the connection"))); socket.once("connect", () => socket.write(upgradeRequest(key))); socket.on("data", (chunk) => { + if (closed) return; buf = Buffer.concat([buf, chunk]); if (!upgraded) { const end = buf.indexOf("\r\n\r\n"); @@ -440,7 +548,7 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { clearTimeout(handshakeTimer); resolve(client); } - for (;;) { + while (!closed) { const frame = decodeFrame(buf); if (!frame) { const claimed = peekFrameLength(buf); @@ -456,10 +564,11 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) { 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"); - if (frame.opcode === 0x9) write(0xa, frame.payload); + if (frame.opcode === 0x9) { + try { write(0xa, frame.payload); } catch { return; } + } else if (frame.opcode === 0x8) { - write(0x8, frame.payload); - socket.end(); + try { write(0x8, frame.payload); } catch { return; } failAll(new Error("Codex app-server closed the connection")); } continue; // pong and other control frames carry nothing for us @@ -518,12 +627,12 @@ export function assertRoute(path) { throw new CodexRouteError(unreachableMessage(path), "socket-absent"); } -async function openSession(env = process.env, { experimental = false } = {}) { +export async function openCodexSession(env = process.env, { experimental = false, ...options } = {}) { const path = codexSocketPath(env); assertRoute(path); let client; try { - client = await connectCodexAppServer(path); + client = await connectCodexAppServer(path, options); } catch (err) { throw connectError(err, path); } @@ -541,10 +650,13 @@ async function openSession(env = process.env, { experimental = false } = {}) { client.close(); throw new CodexProtocolError("initialize", "result is not an object"); } - client.notify("initialized"); + try { client.notify("initialized"); } + catch (err) { client.close(); throw handshakeError(err); } return { client, path, init }; } +const openSession = openCodexSession; + export const CODEX_VERSION_PROBE_TIMEOUT_MS = 3000; /** Bounded `codex --version` probe: `{ version, probe }` where probe is ok | missing | timeout | error. */ diff --git a/test/codex-session.test.js b/test/codex-session.test.js new file mode 100644 index 0000000..0a68d30 --- /dev/null +++ b/test/codex-session.test.js @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import { getEventListeners } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import * as bridge from "../src/core/codex-bridge.js"; + +const tick = () => new Promise(resolve => setTimeout(resolve, 15)); +const frame = message => bridge.encodeFrame(1, Buffer.from(JSON.stringify(message))); +async function peer(t, { initial = [], initialize = { userAgent: "codex/0.154.0" } } = {}) { + const dir = mkdtempSync(join(tmpdir(), "gbot-session-")); + const path = join(dir, "sock"); + const server = createServer(); + const sockets = new Set(); + const received = []; + let socket; + server.on("upgrade", (req, connection) => { + socket = connection; + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(connection)); + socket.write(Buffer.concat([Buffer.from("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + bridge.websocketAccept(req.headers["sec-websocket-key"]) + "\r\n\r\n"), ...initial.map(frame)])); + let buf = Buffer.alloc(0); + socket.on("data", chunk => { + buf = Buffer.concat([buf, chunk]); + for (;;) { + const decoded = bridge.decodeFrame(buf); + if (!decoded) return; + buf = decoded.rest; + if (decoded.opcode === 8) { socket.end(); return; } + if (decoded.opcode !== 1) continue; + const message = JSON.parse(decoded.payload.toString()); + received.push(message); + if (message.method === "initialize") socket.write(frame({ id: message.id, result: initialize })); + if (message.method === "echo") socket.write(frame({ id: message.id, result: message.params })); + } + }); + }); + await new Promise(resolve => server.listen(path, resolve)); + t.after(async () => { + for (const connection of sockets) connection.destroy(); + await new Promise(resolve => server.close(resolve)); + rmSync(dir, { recursive: true, force: true }); + }); + return { path, received, get socket() { return socket; }, send: (...messages) => socket.write(Buffer.concat(messages.map(frame))) }; +} +async function connected(t, options, peerOptions) { + const p = await peer(t, peerOptions); + const client = await bridge.connectCodexAppServer(p.path, { timeoutMs: 100, ...options }); + t.after(() => client.close()); + return { p, client }; +} + +test("notifications preserve batching order, construction hooks, and unsubscribe", async t => { + const seen = []; + const { p, client } = await connected(t, { onNotification: m => seen.push(m) }, { initial: [{ method: "early" }] }); + const later = []; + const off = client.onNotification(m => later.push(m)); + p.send({ method: "turn/started" }, { method: "turn/completed" }); + await tick(); + assert.deepEqual(seen.map(m => m.method), ["early", "turn/started", "turn/completed"]); + assert.deepEqual(later.map(m => m.method), ["turn/started", "turn/completed"]); + off(); off(); + p.send({ method: "later" }); + await tick(); + assert.equal(later.length, 2); +}); + +test("server requests allow one explicit result or error and resolved IDs cannot be answered", async t => { + const { p, client } = await connected(t); + client.onServerRequest(m => { + if (m.id === "result") client.respond(m.id, { decision: "decline" }); + if (m.id === "error") client.rejectRequest(m.id, { code: -32601, message: "unsupported" }); + }); + p.send({ id: "result", method: "approval" }, { id: "error", method: "input" }, { id: "resolved", method: "approval" }, { method: "serverRequest/resolved", params: { requestId: "resolved" } }); + await tick(); + assert.deepEqual(p.received.map(m => m.id), ["result", "error"]); + assert.deepEqual(p.received[0].result, { decision: "decline" }); + assert.equal(p.received[1].error.code, -32601); + for (const id of ["result", "error", "resolved", "unknown"]) assert.throws(() => client.respond(id, {}), /pending|resolved|unknown/i); +}); + +test("passive observers leave foreign approvals silent", async t => { + const { p, client } = await connected(t); + const seen = []; + client.onServerRequest(m => seen.push(m)); + client.answerServerRequests = true; + client.expectedThreadId = "ours"; + client.expectedTurnId = "our-turn"; + p.send({ id: 1, method: "approval", params: { threadId: "foreign", turnId: "other" } }); + await tick(); + assert.equal(seen.length, 1); + assert.equal(p.received.length, 0); +}); + +test("idle survives RPC deadline but disconnect closes once and rejects future requests", async t => { + const { p, client } = await connected(t, { timeoutMs: 20 }); + const closes = []; + client.onClose(e => closes.push(e)); + await new Promise(resolve => setTimeout(resolve, 45)); + assert.equal(client.closed, false); + assert.deepEqual(await client.request("echo", { healthy: true }), { healthy: true }); + const closed = new Promise(resolve => client.onClose(resolve)); + p.socket.destroy(); + assert.match((await closed).message, /closed|disconnect|socket/i); + await assert.rejects(client.request("thread/list", {}), /closed|disconnect|socket/i); + client.close(); + assert.equal(closes.length, 1); + let late; + client.onClose(e => { late = e; }); + assert.equal(late, closes[0]); +}); + +test("abort clears pending operations and signal listeners", async t => { + const controller = new AbortController(); + const { client } = await connected(t, { signal: controller.signal }); + const pending = assert.rejects(client.request("wait", {}), /abort/i); + const closed = new Promise(resolve => client.onClose(resolve)); + controller.abort(); + await pending; + assert.match((await closed).message, /abort/i); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + assert.throws(() => client.notify("later"), /abort|closed/i); +}); + +test("throwing event listeners close cleanly without process exceptions", async t => { + const { p, client } = await connected(t); + const closed = new Promise(resolve => client.onClose(resolve)); + client.onClose(() => { throw new Error("close observer"); }); + client.onNotification(() => { throw new Error("broken observer"); }); + p.send({ method: "event" }, { method: "ignored-after-close" }); + assert.match((await closed).message, /listener|observer/i); + assert.equal(client.closed, true); +}); + +test("outgoing frames and nonreading peer pressure fail within an absolute byte budget", async t => { + const { p, client } = await connected(t); + p.socket.pause(); + const closed = new Promise(resolve => client.onClose(resolve)); + let writes = 0; + assert.throws(() => { + for (; writes < 20; writes++) client.notify("bulk", { text: "x".repeat(1024 * 1024) }); + }, /write|outbound|budget/i); + assert.ok(writes < 20); + assert.match((await closed).message, /write|outbound|budget/i); + const next = await connected(t); + assert.throws(() => next.client.notify("huge", { text: "x".repeat(8 * 1024 * 1024) }), /write|outbound|budget/i); +}); + +test("client requests, server history, deferred approvals and listeners have finite limits", async t => { + const first = await connected(t); + const requests = Array.from({ length: 129 }, () => first.client.request("wait", {}).catch(e => e)); + const errors = await Promise.all(requests); + assert.ok(errors.every(e => /limit|bound|128/i.test(e.message))); + const second = await connected(t); + const closed = new Promise(resolve => second.client.onClose(resolve)); + second.client.answerServerRequests = true; + second.client.expectedThreadId = "ours"; + second.p.send(...Array.from({ length: 129 }, (_, id) => ({ id, method: "approval", params: { threadId: "ours", turnId: "unknown-yet" } }))); + assert.match((await closed).message, /limit|bound|128/i); + assert.ok(second.client.refused.length <= 128); + assert.equal(second.client.deferred.length, 0); + const third = await connected(t); + for (let i = 0; i < 128; i++) third.client.onNotification(() => {}); + assert.throws(() => third.client.onNotification(() => {}), /limit|bound|128/i); +}); + +test("timeout validation and pre-aborted connections fail before connecting", async t => { + const p = await peer(t); + for (const timeoutMs of [0, -1, Infinity, NaN, 0.5, 2 ** 31]) { + await assert.rejects(async () => bridge.connectCodexAppServer(p.path, { timeoutMs }), /timeoutMs/i); + } + const controller = new AbortController(); + controller.abort(); + await assert.rejects(bridge.connectCodexAppServer(p.path, { signal: controller.signal }), /abort/i); +}); + +test("openCodexSession initializes with capabilities and initial hooks", async t => { + assert.equal(typeof bridge.openCodexSession, "function"); + const p = await peer(t, { initial: [{ method: "early" }] }); + const seen = []; + const { client, path, init } = await bridge.openCodexSession({ CODEX_APP_SERVER_SOCK: p.path }, { timeoutMs: 100, experimental: true, onNotification: m => seen.push(m) }); + t.after(() => client.close()); + await tick(); + assert.equal(path, p.path); + assert.equal(init.userAgent, "codex/0.154.0"); + assert.equal(seen[0].method, "early"); + assert.equal(p.received[0].params.capabilities.experimentalApi, true); + assert.equal(p.received[0].params.clientInfo.name, "gbot"); + assert.equal(p.received[1].method, "initialized"); +}); + +test("resolution invalidates deferred ownership before turn adoption", async t => { + const { p, client } = await connected(t); + client.answerServerRequests = true; + client.expectedThreadId = "ours"; + p.send({ id: "gone", method: "approval", params: { threadId: "ours", turnId: "turn" } }, { method: "serverRequest/resolved", params: { requestId: "gone", threadId: "ours" } }); + await tick(); + client._adoptTurn("turn"); + await tick(); + assert.equal(p.received.length, 0); + assert.throws(() => client.rejectRequest("gone", { code: -1, message: "late" }), /resolved|unknown/i); +}); + +test("RPC timeout releases capacity while local close disposes all pending work", async t => { + const controller = new AbortController(); + const { client } = await connected(t, { timeoutMs: 20, signal: controller.signal }); + await assert.rejects(client.request("wait", {}), /within 20ms/); + assert.equal(client.closed, false); + assert.equal(await client.request("echo", "still-alive"), "still-alive"); + const rejection = assert.rejects(client.request("wait", {}), /closed/); + const closed = new Promise(resolve => client.onClose(resolve)); + client.close(); + await rejection; + assert.match((await closed).message, /closed/); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); +}); + +test("async observer rejections close the transport and close rejections are consumed", async t => { + const { p, client } = await connected(t, { onClose: async () => { throw new Error("close failed"); } }); + const closed = new Promise(resolve => client.onClose(resolve)); + client.onServerRequest(async () => { throw new Error("async observer failed"); }); + p.send({ id: 1, method: "approval" }); + assert.match((await closed).message, /async observer failed/); + await tick(); +}); + +test("initialization validation closes the socket and notifies initial close hooks", async t => { + const p = await peer(t, { initialize: null }); + const errors = []; + const controller = new AbortController(); + await assert.rejects(bridge.openCodexSession({ CODEX_APP_SERVER_SOCK: p.path }, { timeoutMs: 50, signal: controller.signal, onClose: error => errors.push(error) }), /result is not an object/); + assert.equal(errors.length, 1); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + await tick(); + assert.equal(p.socket.destroyed, true); +}); + +test("a single stalled outbound write has an absolute drain deadline", { timeout: 500 }, async t => { + const { p, client } = await connected(t, { timeoutMs: 25 }); + p.socket.pause(); + const closed = new Promise(resolve => client.onClose(resolve)); + client.notify("bulk", { text: "x".repeat(4 * 1024 * 1024) }); + assert.match((await closed).message, /write|drain/i); + assert.equal(client.closed, true); +}); From ed30ed996205b6e43b954fab33a5ccce02d23aaa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:24:19 -0700 Subject: [PATCH 03/18] fix(codex): consume response ownership before serialization --- src/core/codex-bridge.js | 38 +++++++++++--------- test/codex-session.test.js | 71 +++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/core/codex-bridge.js b/src/core/codex-bridge.js index ed7eeb3..e82c297 100644 --- a/src/core/codex-bridge.js +++ b/src/core/codex-bridge.js @@ -308,7 +308,7 @@ export function connectCodexAppServer(path, { timeoutMs = 15000, signal, onNotif // the socket callback. Rejected async callbacks use the same cleanup path. const invoke = (listener, message, closing = false) => { const failed = (cause) => { - if (!closing) failAll(new Error("Codex event listener failed: " + String(cause?.message ?? cause), { cause })); + if (!closing) failAll(new Error("Codex event listener failed", { cause })); }; try { const result = listener(message); @@ -356,6 +356,21 @@ export function connectCodexAppServer(path, { timeoutMs = 15000, signal, onNotif } catch (err) { failAll(err); throw err; } }; const sendJson = (obj) => write(0x1, Buffer.from(JSON.stringify(obj))); + const respondToRequest = (id, payload) => { + if (closed) throw closeError; + if (!serverRequests.has(id)) throw new Error("Unknown or resolved server request: " + id); + // Consume ownership before JSON.stringify can invoke caller-owned getters + // or toJSON hooks. A failed serialization closes rather than restoring it. + serverRequests.delete(id); + client.deferred = client.deferred.filter(entry => entry.id !== id); + try { sendJson({ jsonrpc: "2.0", id, ...payload }); } + catch (cause) { + if (closed) throw closeError; + const err = new Error("Codex server response serialization failed", { cause }); + failAll(err); + throw err; + } + }; const client = { refused, @@ -363,18 +378,8 @@ export function connectCodexAppServer(path, { timeoutMs = 15000, signal, onNotif onNotification: (listener) => subscribe("notification", listener), onServerRequest: (listener) => subscribe("serverRequest", listener), onClose: (listener) => subscribe("close", listener), - respond(id, result) { - if (closed) throw closeError; - if (!serverRequests.has(id)) throw new Error("Unknown or resolved server request: " + id); - sendJson({ jsonrpc: "2.0", id, result }); - serverRequests.delete(id); - }, - rejectRequest(id, error) { - if (closed) throw closeError; - if (!serverRequests.has(id)) throw new Error("Unknown or resolved server request: " + id); - sendJson({ jsonrpc: "2.0", id, error }); - serverRequests.delete(id); - }, + respond: (id, result) => respondToRequest(id, { result }), + rejectRequest: (id, error) => respondToRequest(id, { error }), // Server-initiated requests are only *answered* once our own turn/start // is in flight, and then only when they name our own thread/turn: // anything earlier — or naming another thread or Desktop turn — belongs @@ -403,7 +408,7 @@ export function connectCodexAppServer(path, { timeoutMs = 15000, signal, onNotif const namedTurnId = params ? (params.turnId ?? params.turn_id ?? (params.turn && params.turn.id)) : null; - if (threadId === client.expectedThreadId && namedTurnId === turnId && serverRequests.has(entry.id)) { + if (threadId === client.expectedThreadId && namedTurnId === turnId && serverRequests.get(entry.id) === entry) { client.rejectRequest(entry.id, { code: -32601, message: "gbot codex does not answer " + entry.method + "; configure approval_policy on the daemon" }); entry.answered = true; } @@ -453,7 +458,8 @@ export function connectCodexAppServer(path, { timeoutMs = 15000, signal, onNotif if (serverRequests.size >= CODEX_MAX_REQUESTS || refused.length >= CODEX_MAX_REQUESTS || client.deferred.length >= CODEX_MAX_REQUESTS) { return failAll(new Error("Codex remembered server request limit exceeds " + CODEX_MAX_REQUESTS)); } - serverRequests.set(msg.id, msg); + const entry = { id: msg.id, method: msg.method, params: msg.params, answered: false }; + serverRequests.set(msg.id, entry); dispatch("serverRequest", msg); if (closed) return; let answered = client.answerServerRequests; @@ -475,7 +481,7 @@ export function connectCodexAppServer(path, { timeoutMs = 15000, signal, onNotif } answered = answered && serverRequests.has(msg.id); defer = defer && serverRequests.has(msg.id); - const entry = { id: msg.id, method: msg.method, params: msg.params, answered }; + entry.answered = answered; refused.push(entry); if (defer) { client.deferred.push(entry); diff --git a/test/codex-session.test.js b/test/codex-session.test.js index 0a68d30..17c8b82 100644 --- a/test/codex-session.test.js +++ b/test/codex-session.test.js @@ -223,7 +223,9 @@ test("async observer rejections close the transport and close rejections are con const closed = new Promise(resolve => client.onClose(resolve)); client.onServerRequest(async () => { throw new Error("async observer failed"); }); p.send({ id: 1, method: "approval" }); - assert.match((await closed).message, /async observer failed/); + const error = await closed; + assert.match(error.message, /listener failed/); + assert.equal(error.cause.message, "async observer failed"); await tick(); }); @@ -246,3 +248,70 @@ test("a single stalled outbound write has an absolute drain deadline", { timeout assert.match((await closed).message, /write|drain/i); assert.equal(client.closed, true); }); + +for (const method of ["respond", "rejectRequest"]) { + test(`${method} clears deferred ownership before a foreign request reuses its ID`, async t => { + const { p, client } = await connected(t); + client.answerServerRequests = true; + client.expectedThreadId = "ours"; + p.send({ id: 1, method: "approval", params: { threadId: "ours", turnId: "our-turn" } }); + await tick(); + client[method](1, method === "respond" ? { decision: "decline" } : { code: -32601, message: "declined" }); + p.send({ id: 1, method: "approval", params: { threadId: "foreign", turnId: "foreign-turn" } }); + await tick(); + client._adoptTurn("our-turn"); + await tick(); + assert.equal(p.received.length, 1); + assert.equal(client.refused[1].answered, false); + }); + + test(`${method} reserves ownership before serialization can reenter either response method`, async t => { + const { p, client } = await connected(t); + p.send({ id: "a", method: "approval" }); + await tick(); + const refused = []; + client[method]("a", { + toJSON() { + for (const reentrant of ["respond", "rejectRequest"]) { + try { client[reentrant]("a", {}); } + catch (error) { refused.push(error); } + } + return method === "respond" ? { decision: "decline" } : { code: -32601, message: "declined" }; + }, + }); + await tick(); + assert.equal(refused.length, 2); + assert.ok(refused.every(error => /resolved|unknown/.test(error.message))); + assert.equal(p.received.length, 1); + }); + + test(`${method} serialization failure closes and clears pending response ownership`, { timeout: 500 }, async t => { + const { p, client } = await connected(t); + p.send({ id: "a", method: "approval" }); + await tick(); + const closed = new Promise(resolve => client.onClose(resolve)); + const circular = {}; + circular.self = circular; + assert.throws(() => client[method]("a", circular)); + assert.equal(client.closed, true); + assert.match((await closed).message, /serializ|response/i); + assert.throws(() => client.respond("a", {})); + await tick(); + assert.equal(p.received.length, 0); + }); +} + +for (const kind of ["null prototype", "throwing message getter"]) { + test(`async observer ${kind} rejection closes without an unhandled rejection`, { timeout: 500 }, async t => { + const { p, client } = await connected(t); + const cause = kind === "null prototype" ? Object.create(null) : Object.defineProperty({}, "message", { get() { throw new Error("getter failed"); } }); + const closed = new Promise(resolve => client.onClose(resolve)); + client.onNotification(async () => { throw cause; }); + p.send({ method: "event" }); + const error = await closed; + assert.match(error.message, /listener/); + assert.equal(error.cause, cause); + assert.equal(client.closed, true); + await tick(); // node:test reports any unhandled rejection as a test failure. + }); +} From 7f0363220a5a53961641750ce311a911ef9159f5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:27:39 -0700 Subject: [PATCH 04/18] docs: define plugin and MCP Codex conversations --- .../plans/2026-09-15-codex-conversation.md | 52 +++++++++++++++++++ .../2026-09-15-codex-conversation-design.md | 37 +++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-15-codex-conversation.md create mode 100644 docs/superpowers/specs/2026-09-15-codex-conversation-design.md diff --git a/docs/superpowers/plans/2026-09-15-codex-conversation.md b/docs/superpowers/plans/2026-09-15-codex-conversation.md new file mode 100644 index 0000000..c8d8045 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-codex-conversation.md @@ -0,0 +1,52 @@ +# Codex Conversation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose thread discovery, guarded messaging and accurate completion receipts through generated plugin/MCP tools and CLI, as the shared foundation of an automatic Grok↔Codex relay. + +**Architecture:** A conversation wrapper scopes the persistent client to a thread and reconciles bounded history. Existing submit logic is shared by one-shot and persistent callers. Agent Bundle projects the same result/progress contract onto MCP tools, generated plugins and CLI commands. A following task owns the managed automatic relay; this task is not the complete user experience. + +**Tech Stack:** Node.js >=22.19.0, JavaScript/JSDoc core, TypeScript/TSX routes, node:test and existing route tests; Codex 0.154.0. + +**Spec:** docs/superpowers/specs/2026-09-15-codex-conversation-design.md + +## Global Constraints + +- No new dependency, no second daemon, no private Desktop API, no global permission change. +- Preserve accepted/rejected/queued/unknown submission semantics, allowlists and provenance. +- Observers never refuse or approve requests; waiting never interrupts work. +- Scratch sockets and explicit test environments for tests; preserve existing send behavior. + +### Task 1: Conversation collector, guarded messaging and plugin/MCP surfaces + +**Files:** Create `src/core/codex/conversation.js`, `test/codex-conversation.test.js`, `src/cli/codex/watch.tsx`, `src/cli/codex/wait.tsx`. Modify `src/core/codex-bridge.js` only to share existing submission behavior, `src/cli/codex/send.tsx`, `tests/route-unit/tools.test.ts` when appropriate, and add a feature changeset. Create the four `src/mcp/grok-bot/tools/codex_{threads,send,wait,watch}.tsx` routes and shared TypeScript adapters/schemas if useful. Update `src/skills/talk-to-grok-bot/SKILL.md`; tests for generated MCP may live under `test/`. Shared socket fixture code may live under `test/helpers/`. Do not change the existing gbot_send route yet (next task owns automatic reply routing). + +**Interfaces:** Consume `openCodexSession(env,options)` and the transport listeners. Produce exactly the `openCodexConversation`, send/wait/watch/close contracts in the named spec. A supplied session is shared and must not be closed by an individual conversation. + +- [ ] Write failing tests with a fake daemon where `turn/start` sends completion before its acknowledgment and where a resumed turn is already completed. Assert observable outputs: + +```js +const conversation = await openCodexConversation('thread-1', {env,expectedCwd:cwd}); +const sent = await conversation.send('hello', {envelope}); +const result = await conversation.wait({turnId:sent.turnId,messageId:sent.messageId}); +assert.equal(sent.delivery, 'accepted'); +assert.equal(result.execution.state, 'completed'); +assert.equal(result.reply.text, 'final answer'); +assert.deepEqual(result.reply.items.map(x => x.id), ['final-1']); +await conversation.close(); +``` + +Construct literal mixed commentary/final/reasoning fixtures, failed/empty turns, repeated pagination cursors and foreign request IDs. Run the new test file and record expected failures before implementing. + +- [ ] Implement the bounded collector, history reconciliation, shared-session ownership and canonical send delegation. Separate timeout/abort from turn interruption. Validate IDs, cwd, page shapes, timeouts and output budgets; retain correlation on uncertain outcomes. +- [ ] Add MCP discovery/send/wait/watch routes with actual socket-backed invocation tests and generated-server tools/list/call coverage. Implement explicit expected-turn guarded steering without fallback or observer auto-approval. Validate generated plugin artifacts and update installed skill descriptions. +- [ ] Add CLI routes/flags with `signal`, result-derived exits, render budget and framework progress. Ensure these actual commands work after building: + +```sh +gbot codex watch --timeout-ms 1000 --max-events 20 THREAD_ID --json +gbot codex wait --timeout-ms 1000 THREAD_ID TURN_ID --json +gbot codex send --wait --timeout-ms 1000 THREAD_ID hello --json +``` + +- [ ] Test that an accepted send followed by timeout is still `delivery: accepted`, has `execution.state: timeout`, and exits nonzero; a plain send retains its previous immediate receipt. Test command discovery/validation against the built CLI, not package metadata. +- [ ] Run `npm run check`, inspect the diff for duplicated submission logic and unbounded retained state, commit owned files and report exact test output. Parent will review before the durable binding consumes this API. diff --git a/docs/superpowers/specs/2026-09-15-codex-conversation-design.md b/docs/superpowers/specs/2026-09-15-codex-conversation-design.md new file mode 100644 index 0000000..0f43f89 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-codex-conversation-design.md @@ -0,0 +1,37 @@ +# Codex conversation messaging through plugins and MCP + +This is the second slice of the approved Grok↔Codex implementation. Zack clarified that Grokbot must communicate with Codex as naturally as Codex Desktop communicates with other threads, through the generated Grokbot/Cursor/Codex plugin and MCP surfaces. The finished product must receive messages and route replies automatically during active work; models must not poll wait/watch tools to operate the normal flow. This slice provides the shared conversation API and first-class MCP messaging surface; the immediately following durable relay slice provides the background lifecycle, automatic routing and recovery. Do not call this slice the complete user experience. + +Consume `openCodexSession` and the persistent transport APIs without adding another Codex daemon or changing global configuration. Package the same core behavior in every existing generated plugin target (Codex, Cursor, Claude and portable MCP for Grok Bot). Do not invent unsupported host APIs. + +## Public core API + +Create `src/core/codex/conversation.js` exporting `openCodexConversation(threadId, options = {})`. Options are `env`, `expectedCwd`, `signal`, `onEvent`, and optionally an already initialized `session` (`{client,path,init}`) for sharing one connection across explicit bindings. A conversation owns only its listeners/subscription when a session is supplied; otherwise it owns and closes its connection. Return `{threadId,cwd,send,wait,watch,close}`. + +Attach notification, server-request and disconnect listeners before `thread/resume`. Resume with `{threadId,excludeTurns:true}` and no execution/model/permission/workspace overrides. Verify returned thread ID and, when provided, the canonical expected working directory. Route through the existing socket and thread allowlists. A mismatch fails before submission. + +`send(text, {envelope,whenBusy='reject',expectedTurnId})` returns the existing canonical submission receipt. Reuse the one-shot submission/validation logic rather than implementing a second version of envelope, busy, queue and error semantics. A persistent conversation stays connected and records requests for observation rather than applying the one-shot owned-request refusal policy. Existing CLI `send` without waiting retains its current policy and disconnect behavior. Add `whenBusy: 'steer'` to the persistent and MCP send contract, requiring an explicit nonempty `expectedTurnId`. Send `turn/steer` with that guard and `clientUserMessageId`; never silently fall back to turn/start on guard rejection. The response shape is `{turnId}` (verify installed generated schema). It preserves canonical submission delivery and envelope fields. `reject` and `queue` remain compatible. The background relay can discover the active turn from bounded history and use the guarded API; a stale active turn returns a visible rejection, not a send into different work. + +`wait({turnId,messageId,timeoutMs=120000,signal,maxOutputBytes=1048576})` observes one submitted turn and returns `{threadId,turnId,messageId,execution:{state,error?},reply:{text,items,truncated},interactions:[]}`. Execution state is `completed`, `failed`, `interrupted`, `waiting-for-input`, `timeout`, `disconnected`, or `unknown`. Wait cancellation/timeout never interrupts the daemon's turn. `messageId` is optional for explicit turn waits, but when supplied it remains in every result. + +The collector installs before send/resume, tolerates events preceding the turn/start acknowledgment, ignores unrelated threads/turns, and reconciles history before waiting for future events. Use `thread/turns/list` with bounded pages and `thread/items/list` for the selected turn. Shapes are pinned in `/tmp/gbot-codex-protocol-20260915`: turns response `data:Turn[]`, items response `data:{turnId,item}[]`. Do not use deprecated unbounded full-history resume/read. Permit at most 20 pages of 100 entries per reconciliation and return `unknown` with a coverage explanation when the selected turn cannot be established. Protect against repeated pagination cursors. + +Return explicit `final_answer` agent items; if absent, use completed phase-null agent items as a documented fallback at terminal status. Do not forward commentary, reasoning or tool output as the final answer. Deduplicate by item ID. Empty successful output is still completed. Preserve failed/interrupted status even when text exists. Keep result text/items within maxOutputBytes (validated positive integer at most 4 MiB), and expose truncation. Bound retained notification and interaction state; surface overflow rather than losing a completion silently. + +`watch({timeoutMs=30000,maxEvents=100,signal})` returns a bounded observation `{threadId,events,reason,truncated}` and supports the `onEvent` callback for progress. Limit events to the selected thread plus relevant connection state; no automatic responses. Reason distinguishes timeout, event limit, cancellation and disconnect. A quiet healthy connection can time out without being reported disconnected. + +## MCP and generated plugins + +Add tools on the existing `grok-bot` MCP server: `codex_threads` (bounded discovery using existing listCodexThreads), `codex_send` (send with optional bounded completion wait), `codex_wait` and `codex_watch` (diagnostics/explicit observation). `codex_send` defaults to immediate acceptance; normal automatic reply routing will be added by the next relay slice. Descriptions must not imply accepted means finished, nor require a model to poll for the final product workflow. Expose delivery, execution and output as distinct fields in structured results, with concise human text. Support expectedCwd, guard/whenBusy and correlation inputs according to the core contracts. Keep schemas explicit and bounded. Route errors use the canonical outcomes and preserve known identities. + +Place surface-neutral route operations and schemas in a small shared TypeScript module as needed so CLI and MCP wrappers do not fork protocol behavior. Tool handlers receive cancellation from Agent Bundle invocation context when supported. New tools and existing gbot tools must all be discovered by actual generated MCP server tools/list. Use actual route invocation/socket fixtures for tools/call; test accepted then timeout and guarded active delivery. Build/validate the Codex and Cursor manifests plus portable MCP artifact. Update the installed talk-to-grok-bot skill with the new tool purposes and current availability; do not promise the next relay slice before it exists. + +## CLI + +Add `gbot codex watch ` and `gbot codex wait `, and `gbot codex send --wait ...`. Support timeout and expected-cwd flags, plus explicit guarded steer options; input timeouts are positive integers up to 600000 ms. Watch max-events is 1..500. Use existing Agent Bundle routes, CliRouteProps.signal and result-derived exit codes. Set rendered route maxElapsedMs to 660000 to encompass the maximum operation plus shutdown; use `agent().progress.report` for lifecycle updates and framework `--ndjson` rather than writing competing stdout records. Default JSON output remains one final result document. + +`send --wait` returns the original flat submission receipt plus `execution`, `reply` and `interactions`; accepted submission remains accepted after a wait timeout or execution failure. Queued/rejected/unknown submissions do not pretend a turn completed. Explicit wait/watch failures must preserve known thread/turn IDs. Exit 0 for a completed wait, nonzero for incomplete/failed execution; a bounded watch that reaches its requested timeout/event count is successful. Rendering or signal cleanup must not cause a duplicate submission. + +## Tests + +Use socket fixtures and the built CLI. Cover early completion in the same packet as acknowledgment, resumed completed turns, paginated item wrappers, unrelated events, phase filtering and empty output, failure/interruption, timeout/abort/disconnect, missing-turn coverage, pagination cycles, output bounds, cwd mismatch, foreign approvals remaining unanswered, and no observer-induced interruption. Retain old send tests and add route/built CLI checks for the new flags and receipt separation. Add MCP route tests and generated stdio server discovery/call tests. Verify guard mismatch never resubmits, and persistent sends never auto-answer approvals. From 0f86d73bc579a91a58774b11180731b72bce0cfc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:46:31 -0700 Subject: [PATCH 05/18] feat(codex): add bounded conversations and plugin messaging tools --- .changeset/fluid-codex-conversations.md | 5 + src/cli/codex/send.tsx | 134 +++++++-------- src/cli/codex/wait.tsx | 35 ++++ src/cli/codex/watch.tsx | 29 ++++ src/core/codex-bridge.js | 49 ++++-- src/core/codex/conversation.js | 208 +++++++++++++++++++++++ src/core/codex/routes.ts | 86 ++++++++++ src/mcp/grok-bot/tools/codex_send.tsx | 59 +++++++ src/mcp/grok-bot/tools/codex_threads.tsx | 22 +++ src/mcp/grok-bot/tools/codex_wait.tsx | 35 ++++ src/mcp/grok-bot/tools/codex_watch.tsx | 29 ++++ src/skills/talk-to-grok-bot/SKILL.md | 29 +++- test/codex-conversation.test.js | 135 +++++++++++++++ test/codex-surfaces.test.js | 36 ++++ test/helpers/codex-server.js | 56 ++++++ tests/route-unit/tools.test.ts | 2 +- 16 files changed, 857 insertions(+), 92 deletions(-) create mode 100644 .changeset/fluid-codex-conversations.md create mode 100644 src/cli/codex/wait.tsx create mode 100644 src/cli/codex/watch.tsx create mode 100644 src/core/codex/conversation.js create mode 100644 src/core/codex/routes.ts create mode 100644 src/mcp/grok-bot/tools/codex_send.tsx create mode 100644 src/mcp/grok-bot/tools/codex_threads.tsx create mode 100644 src/mcp/grok-bot/tools/codex_wait.tsx create mode 100644 src/mcp/grok-bot/tools/codex_watch.tsx create mode 100644 test/codex-conversation.test.js create mode 100644 test/codex-surfaces.test.js create mode 100644 test/helpers/codex-server.js diff --git a/.changeset/fluid-codex-conversations.md b/.changeset/fluid-codex-conversations.md new file mode 100644 index 0000000..0ac02af --- /dev/null +++ b/.changeset/fluid-codex-conversations.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": minor +--- + +Add Codex conversation tools to generated plugins and MCP: bounded thread discovery, guarded sends, completion waits, and event watching. Add CLI wait/watch and send --wait while preserving immediate send receipts. diff --git a/src/cli/codex/send.tsx b/src/cli/codex/send.tsx index 8c95b35..17be300 100644 --- a/src/cli/codex/send.tsx +++ b/src/cli/codex/send.tsx @@ -1,84 +1,64 @@ -import { Agent } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; - -import { buildEnvelope, sendToCodexThread } from '../../core/codex-bridge.js'; -import { outcomeFromError } from '../../core/codex/contract.js'; - +import { sendFields, resultSchema, sendOperation, resultText } from '../../core/codex/routes.js'; +export { resultSchema }; +export const inputSchema = z.object({ ...sendFields, correlationId: z.string().min(1).optional(), replyTo: z.string().min(1).optional(), message: z.array(z.string()).min(1) }).strict(); export const config = { - description: - 'Send a message to a Codex thread. Options go before ; `--` protects flag-like text.', - exitCode: 'result', - inputJsonSchema: { - additionalProperties: false, - properties: { - correlationId: { description: 'Stable correlation id for multi-hop replies', type: 'string' }, - envelope: { - description: 'Prepend the [gbot …] header to the message body', - type: 'boolean', + description: 'Send to Codex; acceptance is distinct from completion. Options precede threadId.', + exitCode: 'result', render: { maxElapsedMs: 660000 }, positionals: ['threadId', 'message'], + inputJsonSchema: { type: 'object', additionalProperties: false, properties: { + "threadId": { + "type": "string" }, - hop: { description: 'Hop count; refused at GROK_BOT_MAX_HOPS', type: 'number' }, - message: { items: { type: 'string' }, type: 'array' }, - replyTo: { description: 'Prior message id this send replies to', type: 'string' }, - threadId: { type: 'string' }, - whenBusy: { - description: 'reject (default) or queue (needs GROK_BOT_CODEX_EXPERIMENTAL=1)', - enum: ['reject', 'queue'], - type: 'string', + "expectedCwd": { + "type": "string" }, - }, - required: ['threadId', 'message'], - type: 'object', - }, - positionals: ['threadId', 'message'], + "timeoutMs": { + "type": "number", + "description": "Observation timeout: 1-600000 milliseconds." + }, + "correlationId": { + "type": "string" + }, + "envelope": { + "type": "boolean" + }, + "hop": { + "type": "number" + }, + "replyTo": { + "type": "string" + }, + "expectedTurnId": { + "type": "string", + "description": "Required active-turn guard for steer; stale guards reject." + }, + "wait": { + "type": "boolean" + }, + "maxOutputBytes": { + "type": "number", + "description": "Reply budget: 1-4194304 bytes." + }, + "whenBusy": { + "type": "string", + "enum": [ + "reject", + "queue", + "steer" + ] + }, + "message": { + "type": "array", + "items": { + "type": "string" + } + } + }, required: ['threadId', 'message'] }, } satisfies CliRouteConfig; - -export const inputSchema = z - .object({ - correlationId: z.string().min(1).optional(), - envelope: z.boolean().optional(), - hop: z.number().int().min(0).optional(), - message: z.array(z.string()).min(1), - replyTo: z.string().min(1).optional(), - threadId: z.string().min(1), - whenBusy: z.enum(['reject', 'queue']).default('reject'), - }) - .strict(); - -export const resultSchema = z - .object({ - delivery: z.enum(['accepted', 'queued', 'rejected', 'unknown']), - exitCode: z.union([z.literal(0), z.literal(1)]), - }) - .passthrough(); - -export default async function codexSend({ input }: CliRouteProps) { - let out; - try { - const envelope = buildEnvelope({ - correlationId: input.correlationId, - envelope: Boolean(input.envelope), - hop: input.hop, - replyTo: input.replyTo, - }); - out = await sendToCodexThread(input.threadId, input.message.join(' ').trim(), { - envelope, - whenBusy: input.whenBusy, - }); - } catch (error) { - out = outcomeFromError(error); - } - const text = - out.delivery === 'queued' && typeof out.queuedSubmissionId === 'string' - ? `Queued ${out.queuedSubmissionId} on busy Codex thread ${out.threadId}; message ${out.messageId}` - : out.exitCode === 0 - ? `Started turn ${out.turnId} (${out.turnStatus}) on Codex thread ${out.threadId}; message ${out.messageId}` - : typeof out.error === 'string' - ? out.error - : `Codex delivery ${out.delivery} on thread ${out.threadId}`; - return ( - - {text} - - ); +export default async function route({ input, signal }: CliRouteProps) { + const context = await agent(); + const out = await sendOperation({ ...input, message: input.message.join(' ').trim() }, signal, message => context.progress.report({ message }), true); + return {resultText(out)}; } diff --git a/src/cli/codex/wait.tsx b/src/cli/codex/wait.tsx new file mode 100644 index 0000000..8a5feb1 --- /dev/null +++ b/src/cli/codex/wait.tsx @@ -0,0 +1,35 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { waitSchema as inputSchema, resultSchema, observeOperation, resultText } from '../../core/codex/routes.js'; +export { inputSchema, resultSchema }; +export const config = { + description: 'Bounded Codex wait observation; never interrupts execution.', exitCode: 'result', render: { maxElapsedMs: 660000 }, + positionals: ['threadId', 'turnId'], + inputJsonSchema: { type: 'object', additionalProperties: false, properties: { + "threadId": { + "type": "string" + }, + "expectedCwd": { + "type": "string" + }, + "timeoutMs": { + "type": "number", + "description": "Observation timeout: 1-600000 milliseconds." + }, + "turnId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "maxOutputBytes": { + "type": "number", + "description": "Reply budget: 1-4194304 bytes." + } + }, required: ['threadId', 'turnId'] }, +} satisfies CliRouteConfig; +export default async function route({ input, signal }: CliRouteProps) { + const context = await agent(); + const out = await observeOperation('wait', input, signal, message => context.progress.report({ message })); + return {resultText(out)}; +} diff --git a/src/cli/codex/watch.tsx b/src/cli/codex/watch.tsx new file mode 100644 index 0000000..099cc9f --- /dev/null +++ b/src/cli/codex/watch.tsx @@ -0,0 +1,29 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { watchSchema as inputSchema, resultSchema, observeOperation, resultText } from '../../core/codex/routes.js'; +export { inputSchema, resultSchema }; +export const config = { + description: 'Bounded Codex watch observation; never interrupts execution.', exitCode: 'result', render: { maxElapsedMs: 660000 }, + positionals: ['threadId'], + inputJsonSchema: { type: 'object', additionalProperties: false, properties: { + "threadId": { + "type": "string" + }, + "expectedCwd": { + "type": "string" + }, + "timeoutMs": { + "type": "number", + "description": "Observation timeout: 1-600000 milliseconds." + }, + "maxEvents": { + "type": "number", + "description": "Maximum observed events: 1-500." + } + }, required: ['threadId'] }, +} satisfies CliRouteConfig; +export default async function route({ input, signal }: CliRouteProps) { + const context = await agent(); + const out = await observeOperation('watch', input, signal, message => context.progress.report({ message })); + return {resultText(out)}; +} diff --git a/src/core/codex-bridge.js b/src/core/codex-bridge.js index e82c297..bd9c9e2 100644 --- a/src/core/codex-bridge.js +++ b/src/core/codex-bridge.js @@ -1,5 +1,5 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { statSync } from "node:fs"; +import { statSync, realpathSync } from "node:fs"; import { createConnection } from "node:net"; import { homedir, hostname } from "node:os"; import { join } from "node:path"; @@ -1002,25 +1002,34 @@ export async function listCodexQueue(threadId, { env = process.env, limit = 50, /** * @param {string} threadId * @param {string} text - * @param {{ env?: NodeJS.ProcessEnv, envelope?: object, whenBusy?: "reject"|"queue" }} [opts] + * @param {{ env?: NodeJS.ProcessEnv, envelope?: object, whenBusy?: "reject"|"queue"|"steer", session?: object, expectedTurnId?: string, expectedCwd?: string, signal?: AbortSignal }} [opts] */ -export async function sendToCodexThread(threadId, text, { env = process.env, envelope = buildEnvelope({ env }), whenBusy = "reject" } = {}) { +export async function sendToCodexThread(threadId, text, { env = process.env, envelope = buildEnvelope({ env }), whenBusy = "reject", session, expectedTurnId, expectedCwd, signal } = {}) { try { - const receipt = await sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy }); + const receipt = await sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy, session, expectedTurnId, expectedCwd, signal }); return outcomeFromReceipt(receipt); } catch (err) { // Every receipt names the message, including refusals that never reached the daemon. - if ((err instanceof CodexSendError || err instanceof CodexRouteError || err instanceof CodexProtocolError) && err.envelope === undefined) err.envelope = envelope; + if (err && typeof err === "object") { + err.envelope = envelope; + if (err.threadId === undefined) err.threadId = threadId; + } return outcomeFromError(err); } } -async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy }) { - if (whenBusy !== "reject" && whenBusy !== "queue") throw new RangeError("--when-busy must be reject or queue"); +async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy, session, expectedTurnId, expectedCwd, signal }) { + if (!["reject", "queue", ...(session ? ["steer"] : [])].includes(whenBusy)) throw new RangeError("--when-busy must be reject, queue, or persistent steer"); + if (whenBusy === "steer" && (typeof expectedTurnId !== "string" || !ID_PATTERN.test(expectedTurnId))) throw new RangeError("steer requires expectedTurnId"); + if (typeof threadId !== "string" || !ID_PATTERN.test(threadId)) throw new RangeError("Invalid threadId"); + if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > WS_MAX_MESSAGE_BYTES) throw new RangeError("Message must contain text within 4 MiB"); + if (!envelope || typeof envelope.messageId !== "string" || !ID_PATTERN.test(envelope.messageId)) throw new RangeError("Invalid envelope messageId"); + const validated = buildEnvelope({ correlationId: envelope.correlationId, replyTo: envelope.replyTo, hop: envelope.hop, env }); + envelope = { ...validated, messageId: envelope.messageId, header: Boolean(envelope.header) }; assertThreadAllowed(threadId, env); if (whenBusy === "queue") requireExperimental(env, "--when-busy queue"); const body = withEnvelopeHeader(text, envelope, env); - const { client } = await openSession(env, { experimental: whenBusy === "queue" }); + const { client } = session ?? await openSession(env, { experimental: whenBusy === "queue", signal }); try { let resumed; try { @@ -1038,6 +1047,8 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy if (!isObject(resumed) || !isObject(resumed.thread) || typeof resumed.thread.id !== "string") { throw new CodexProtocolError("thread/resume", "missing `thread.id`"); } + if (resumed.thread.id !== threadId) throw new CodexSendError("thread/resume returned a different thread ID", { delivery: "rejected", reason: "bad-response", threadId, envelope }); + if (expectedCwd !== undefined && realpathSync(resumed.cwd ?? resumed.thread.cwd) !== realpathSync(expectedCwd)) throw new CodexSendError("Codex thread cwd does not match expectedCwd", { delivery: "rejected", reason: "cwd-mismatch", threadId, envelope }); const state = threadState(resumed, threadId); const receiptBase = { threadId: resumed.thread.id, @@ -1059,6 +1070,16 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy { delivery: "rejected", reason: "busy", threadId, envelope }, ); } + if (whenBusy === "steer") { + let steered; + try { + steered = await client.request("turn/steer", { threadId, expectedTurnId, clientUserMessageId: envelope.messageId, input: [{ type: "text", text: body }] }); + } catch (err) { throw unsupportedOrRpc(err, "turn/steer", threadId, envelope); } + if (typeof steered?.turnId !== "string" || steered.turnId !== expectedTurnId) { + throw new CodexSendError("Malformed turn/steer acknowledgment", { delivery: "unknown", reason: "bad-response", threadId, turnId: expectedTurnId, envelope }); + } + return { delivery: "accepted", ...receiptBase, turnId: steered.turnId }; + } if (state.busy) { let queued; try { @@ -1084,9 +1105,11 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy // that arrives before the acknowledgment supplies our turn id wait in // client.deferred and are adopted only on a match. Requests missing // thread or turn IDs remain unanswered because ownership is unknown. - client.expectedThreadId = threadId; - client.expectedTurnId = null; - client.answerServerRequests = true; + if (!session) { + client.expectedThreadId = threadId; + client.expectedTurnId = null; + client.answerServerRequests = true; + } let turn; try { turn = await client.request("turn/start", { @@ -1112,7 +1135,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy { delivery: "unknown", reason: "bad-response", threadId, envelope }, ); } - client._adoptTurn(turnId); + if (!session) client._adoptTurn(turnId); const freshRefused = client.refused.slice(seenRefused) .filter((r) => { if (!r.answered) return false; @@ -1134,6 +1157,6 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy } return { delivery: "accepted", ...receiptBase, turnId, turnStatus: turn.turn.status }; } finally { - client.close(); + if (!session) client.close(); } } diff --git a/src/core/codex/conversation.js b/src/core/codex/conversation.js new file mode 100644 index 0000000..21ca897 --- /dev/null +++ b/src/core/codex/conversation.js @@ -0,0 +1,208 @@ +import { outcomeFromError } from './contract.js'; +import { realpathSync } from 'node:fs'; +import { assertThreadAllowed, buildEnvelope, experimentalEnabled, openCodexSession, sendToCodexThread } from '../codex-bridge.js'; + +const ID = /^[A-Za-z0-9_.:-]{1,128}$/; +const BUDGET = 4 * 1024 * 1024; +function conversationId(value, name = 'threadId') { + if (typeof value !== 'string' || !ID.test(value)) throw new RangeError(`${name} must be 1-128 identifier characters`); + return value; +} +function boundedInteger(value, max, name) { + if (!Number.isInteger(value) || value < 1 || value > max) throw new RangeError(`${name} must be an integer 1-${max}`); + return value; +} +const terminal = status => ['completed', 'failed', 'interrupted'].includes(status); +const namedTurn = p => p?.turnId ?? p?.turn_id ?? p?.turn?.id; + +/** Bounded traversal; a coverage failure throws and must never be interpreted as not found. */ +export async function visitCodexHistory(session, threadId, method, params, visit, { signal, stopped = () => false } = {}) { + if (!['thread/turns/list', 'thread/items/list'].includes(method)) throw new RangeError('Unsupported history method'); + conversationId(threadId); + const client = session.client; + let cursor; + const seen = new Set(); + for (let page = 0; page < 20; page++) { + if (stopped() || signal?.aborted) throw new Error('History coverage incomplete: observation cancelled'); + const result = await client.request(method, { ...params, threadId, limit: 100, ...(cursor ? { cursor } : {}) }); + if (stopped() || signal?.aborted) throw new Error('History coverage incomplete: observation cancelled'); + if (!Array.isArray(result?.data) || result.data.length > 100 || (result.nextCursor != null && (typeof result.nextCursor !== 'string' || !result.nextCursor || result.nextCursor.length > 4096))) throw new Error(`Invalid ${method} page`); + if (visit(result.data)) return { complete: false, reason: 'matched', pages: page + 1 }; + if (result.nextCursor == null) return { complete: true, reason: 'exhausted', pages: page + 1 }; + if (seen.has(result.nextCursor)) throw new Error('History coverage incomplete: repeated pagination cursor'); + seen.add(result.nextCursor); cursor = result.nextCursor; + } + throw new Error('History coverage incomplete: 20 page limit'); +} + +/** A bounded observer. Cancellation ends observation, never the daemon's turn. */ +export async function openCodexConversation(threadId, options = {}) { + conversationId(threadId); + const { env = process.env, expectedCwd, signal, onEvent } = options; + assertThreadAllowed(threadId, env); + if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal'); + if (onEvent !== undefined && typeof onEvent !== 'function') throw new TypeError('onEvent must be a function'); + if (signal?.aborted) throw new Error('Observation cancelled'); + const expected = expectedCwd === undefined ? undefined : realpathSync(expectedCwd); + const session = options.session ?? await openCodexSession(env, { signal, experimental: experimentalEnabled(env) }); + const { client } = session; + const events = []; + const acceptedTurns = new Set(); + const wake = new Set(); + let bytes = 0, overflow = false, disconnected = client.closed, closed = false; + const record = event => { + const size = Buffer.byteLength(JSON.stringify(event)); + if (size > BUDGET) { overflow = true; } else { + while (events.length >= 500 || bytes + size > BUDGET) { bytes -= events.shift().bytes; overflow = true; } + events.push({ event, bytes: size }); bytes += size; + } + for (const listener of [...wake]) listener(); + return onEvent?.(event); + }; + const observe = kind => message => { + const p = message.params; + if ((p?.threadId ?? p?.thread_id) !== threadId) return; + return record({ kind, ...message }); + }; + const unsubs = [client.onNotification(observe('notification')), client.onServerRequest(observe('interaction')), + client.onClose(error => { disconnected = true; return record({ kind: 'disconnect', error: error?.message }); })]; + const close = async () => { + if (closed) return; + closed = true; + for (const unsubscribe of unsubs) unsubscribe(); + for (const listener of wake) listener(); + if (!options.session) client.close(); + }; + let resumed; + try { + resumed = await client.request('thread/resume', { threadId, excludeTurns: true }); + if (resumed?.thread?.id !== threadId) throw new Error('thread/resume returned a different thread ID'); + const cwd = resumed.cwd ?? resumed.thread.cwd; + if (expected !== undefined && (typeof cwd !== 'string' || realpathSync(cwd) !== expected)) throw new Error('Codex thread cwd does not match expectedCwd'); + } catch (error) { await close(); throw error; } + + /** @param {{turnId: string, messageId?: string, timeoutMs?: number, signal?: AbortSignal, maxOutputBytes?: number}} options */ + async function wait({ turnId, messageId, timeoutMs = 120000, signal: waitSignal, maxOutputBytes = 1048576 }) { + conversationId(turnId, 'turnId'); + if (messageId !== undefined) conversationId(messageId, 'messageId'); + boundedInteger(timeoutMs, 600000, 'timeoutMs'); boundedInteger(maxOutputBytes, BUDGET, 'maxOutputBytes'); + if (waitSignal !== undefined && !(waitSignal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal'); + let done = false, status = acceptedTurns.has(turnId) ? 'inProgress' : undefined, error, historyError, itemBytes = 0, truncated = overflow; + const items = new Map(); + const add = item => { + if (!item || typeof item.id !== 'string' || typeof item.type !== 'string') throw new Error('Invalid history item'); + if (item.type !== 'agentMessage' || typeof item.text !== 'string' || (item.phase !== 'final_answer' && item.phase != null)) return; + const normalized = { id: item.id, type: item.type, phase: item.phase ?? null, text: item.text }; + const size = Buffer.byteLength(JSON.stringify(normalized)); + const prior = items.get(item.id); + if (itemBytes - (prior?.bytes ?? 0) + size > BUDGET || (!prior && items.size >= 2000)) { truncated = true; return; } + itemBytes += size - (prior?.bytes ?? 0); items.set(item.id, { item: normalized, bytes: size }); + }; + const scan = () => { + const interactions = new Map(); + for (const { event } of events) { + const p = event.params; + if (event.method === 'serverRequest/resolved') { interactions.delete(p.requestId); continue; } + if (namedTurn(p) !== turnId) continue; + if (event.kind === 'interaction') interactions.set(event.id, event); + if (event.method === 'turn/started' && !terminal(status)) status = p.turn?.status; + if (event.method === 'item/completed') add(p.item); + if (event.method === 'turn/completed') { + status = p.turn?.status; error = p.turn?.error; + for (const item of p.turn?.items ?? []) add(item); + } + } + return [...interactions.values()]; + }; + const result = (state, detail) => { + const interactions = scan(); + const candidates = [...items.values()].map(x => x.item); + const finals = candidates.filter(i => i.phase === 'final_answer'); + // Old app-servers omit phase; use their completed agent items only at terminal status. + const selected = finals.length ? finals : terminal(state) ? candidates : []; + const reply = { text: '', items: [], truncated: truncated || overflow }; + for (const item of selected) { + const text = reply.text ? `${reply.text}\n${item.text}` : item.text; + if (Buffer.byteLength(JSON.stringify({ text, items: [...reply.items, item] })) > maxOutputBytes) { reply.truncated = true; break; } + reply.text = text; reply.items.push(item); + } + return { threadId, turnId, ...(messageId === undefined ? {} : { messageId }), execution: { state, ...(detail ? { error: detail } : {}) }, reply, interactions }; + }; + let timer, notify; + const abortSignals = [signal, waitSignal].filter(Boolean); + const stopped = new Promise(resolve => { + notify = () => { + if (abortSignals.some(s => s.aborted) || closed) resolve(['unknown', 'Observation cancelled']); + else if (disconnected) resolve(['disconnected', 'Codex connection closed']); + }; + timer = setTimeout(() => resolve(['timeout', 'Observation deadline reached']), timeoutMs); + for (const s of abortSignals) s.addEventListener('abort', notify, { once: true }); + wake.add(notify); notify(); + }); + const collect = async () => { + scan(); + try { + let found = false; + await visitCodexHistory(session, threadId, 'thread/turns/list', {}, data => { + for (const turn of data) if (!turn || typeof turn.id !== 'string' || typeof turn.status !== 'string') throw new Error('Invalid turn history'); + const turn = data.find(t => t.id === turnId); + if (!turn) return false; + found = true; if (!terminal(status)) { status = turn.status; error = turn.error; } return true; + }, { stopped: () => done }); + if (!found && !status) return ['unknown', 'History coverage incomplete: selected turn not found']; + await visitCodexHistory(session, threadId, 'thread/items/list', { turnId }, data => { + for (const row of data) { + if (!row || row.turnId !== turnId || !row.item) throw new Error('Invalid thread/items/list wrapper'); + add(row.item); + } + return false; + }, { stopped: () => done }); + } catch (err) { historyError = err.message; truncated = true; } + while (!done) { + const interactions = scan(); + if (terminal(status)) return [status, error ?? historyError]; + if (historyError || overflow) return ['unknown', historyError ?? 'Notification coverage overflow']; + if (interactions.length) return ['waiting-for-input']; + await new Promise(resolve => { + const listener = () => { wake.delete(listener); resolve(); }; + wake.add(listener); + }); + } + return ['unknown', 'Observation ended']; + }; + try { const [state, detail] = await Promise.race([stopped, collect()]); return result(state, detail); } + finally { done = true; clearTimeout(timer); wake.delete(notify); for (const s of abortSignals) s.removeEventListener('abort', notify); for (const listener of [...wake]) listener(); } + } + + /** @param {{timeoutMs?: number, maxEvents?: number, signal?: AbortSignal}} [options] */ + async function watch({ timeoutMs = 30000, maxEvents = 100, signal: watchSignal } = {}) { + boundedInteger(timeoutMs, 600000, 'timeoutMs'); boundedInteger(maxEvents, 500, 'maxEvents'); + if (watchSignal !== undefined && !(watchSignal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal'); + const signals = [signal, watchSignal].filter(Boolean); + let timer, listener; + const reason = await new Promise(resolve => { + listener = () => { + if (signals.some(s => s.aborted) || closed) resolve('cancelled'); + else if (disconnected) resolve('disconnected'); + else if (events.length >= maxEvents) resolve('event-limit'); + }; + timer = setTimeout(() => resolve('timeout'), timeoutMs); + wake.add(listener); for (const s of signals) s.addEventListener('abort', listener, { once: true }); listener(); + }); + clearTimeout(timer); wake.delete(listener); for (const s of signals) s.removeEventListener('abort', listener); + return { threadId, events: events.slice(0, maxEvents).map(x => x.event), reason, truncated: overflow || events.length > maxEvents }; + } + return { threadId, cwd: resumed.cwd ?? resumed.thread.cwd, wait, watch, close, + /** @param {string} text + * @param {{envelope?: object, whenBusy?: string, expectedTurnId?: string}} [options] */ + async send(text, { envelope = buildEnvelope({ env }), whenBusy = 'reject', expectedTurnId } = {}) { + if (closed) return outcomeFromError(Object.assign(new Error('Conversation closed'), { delivery: 'rejected', reason: 'closed', threadId, envelope })); + const receipt = await sendToCodexThread(threadId, text, { env, envelope, whenBusy, expectedTurnId, session, expectedCwd }); + if (receipt.delivery === 'accepted' && receipt.turnId) { + acceptedTurns.add(receipt.turnId); + if (acceptedTurns.size > 100) acceptedTurns.delete(acceptedTurns.values().next().value); + } + return receipt; + }, + }; +} diff --git a/src/core/codex/routes.ts b/src/core/codex/routes.ts new file mode 100644 index 0000000..9ab64e3 --- /dev/null +++ b/src/core/codex/routes.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { buildEnvelope, listCodexThreads, sendToCodexThread } from '../codex-bridge.js'; +import { outcomeFromError } from './contract.js'; +import { openCodexConversation } from './conversation.js'; + +const id = z.string().regex(/^[A-Za-z0-9_.:-]{1,128}$/); +export const observationFields = { + expectedCwd: z.string().min(1).optional(), + threadId: id, + timeoutMs: z.number().int().min(1).max(600000).optional(), +}; +export const sendFields = { + ...observationFields, + correlationId: id.optional(), envelope: z.boolean().optional(), hop: z.number().int().min(0).optional(), + replyTo: id.optional(), expectedTurnId: id.optional(), + whenBusy: z.enum(['reject', 'queue', 'steer']).default('reject'), wait: z.boolean().default(false), + maxOutputBytes: z.number().int().min(1).max(4194304).optional(), +}; +export const sendSchema = z.object({ ...sendFields, message: z.string().min(1).max(4194304) }).strict(); +export const waitSchema = z.object({ ...observationFields, turnId: id, messageId: id.optional(), maxOutputBytes: z.number().int().min(1).max(4194304).optional() }).strict(); +export const watchSchema = z.object({ ...observationFields, maxEvents: z.number().int().min(1).max(500).default(100) }).strict(); +export const threadsSchema = z.object({ limit: z.number().int().min(1).max(200).default(20), cursor: z.string().min(1).max(4096).optional() }).strict(); +export const resultSchema = z.object({ + exitCode: z.number().int().min(0).max(1), + threadId: z.string().optional(), turnId: z.string().optional(), messageId: z.string().optional(), + delivery: z.enum(['accepted', 'queued', 'rejected', 'unknown']).optional(), + execution: z.object({ state: z.enum(['completed', 'failed', 'interrupted', 'waiting-for-input', 'timeout', 'disconnected', 'unknown']), error: z.unknown().optional() }).optional(), + reply: z.object({ text: z.string().max(4194304), items: z.array(z.object({ id: z.string(), type: z.literal('agentMessage'), phase: z.enum(['final_answer']).nullable(), text: z.string().max(4194304) })).max(2000), truncated: z.boolean() }).optional(), + interactions: z.array(z.record(z.string(), z.unknown())).max(500).optional(), + events: z.array(z.record(z.string(), z.unknown())).max(500).optional(), + reason: z.string().optional(), truncated: z.boolean().optional(), +}).passthrough(); +type Progress = (message: string) => Promise; + +export async function sendOperation(input: z.infer, signal?: AbortSignal, progress?: Progress, oneShot = false) { + let envelope; + let receipt; + try { + envelope = buildEnvelope(input); + if (oneShot && !input.wait && input.whenBusy !== 'steer') { + return await sendToCodexThread(input.threadId, input.message, { envelope, whenBusy: input.whenBusy, expectedCwd: input.expectedCwd, signal }); + } + await progress?.('Opening Codex conversation'); + const conversation = await openCodexConversation(input.threadId, { expectedCwd: input.expectedCwd, signal }); + try { + receipt = await conversation.send(input.message, { envelope, whenBusy: input.whenBusy, expectedTurnId: input.expectedTurnId }); + if (!input.wait || receipt.delivery !== 'accepted' || !receipt.turnId) return receipt; + await progress?.(`Accepted message ${receipt.messageId}; observing turn ${receipt.turnId}`); + const result = await conversation.wait({ turnId: receipt.turnId, messageId: receipt.messageId, timeoutMs: input.timeoutMs, maxOutputBytes: input.maxOutputBytes, signal }); + return { ...receipt, execution: result.execution, reply: result.reply, interactions: result.interactions, exitCode: result.execution.state === 'completed' ? 0 : 1 }; + } finally { await conversation.close(); } + } catch (error) { + if (receipt?.delivery === 'accepted') return { ...receipt, execution: { state: 'unknown', error: outcomeFromError(error).error }, reply: { text: '', items: [], truncated: true }, interactions: [], exitCode: 1 }; + return { ...outcomeFromError(error), threadId: input.threadId, ...(envelope ? { messageId: envelope.messageId, correlationId: envelope.correlationId, hop: envelope.hop } : {}) }; + } +} +export async function observeOperation(kind: 'wait' | 'watch', input: z.infer | z.infer, signal?: AbortSignal, progress?: Progress) { + try { + await progress?.(`Observing Codex thread ${input.threadId}`); + const conversation = await openCodexConversation(input.threadId, { expectedCwd: input.expectedCwd, signal }); + try { + if (kind === 'wait') { + const result = await conversation.wait({ ...input as z.infer, signal }); + return { ...result, exitCode: result.execution.state === 'completed' ? 0 : 1 }; + } + const result = await conversation.watch({ ...input as z.infer, signal }); + return { ...result, exitCode: ['timeout', 'event-limit'].includes(result.reason) ? 0 : 1 }; + } finally { await conversation.close(); } + } catch (error) { + return { ...outcomeFromError(error), threadId: input.threadId, ...('turnId' in input ? { turnId: input.turnId, messageId: input.messageId } : {}) }; + } +} +export async function threadsOperation(input: z.infer) { + try { return { ...await listCodexThreads(input), exitCode: 0 }; } + catch (error) { return outcomeFromError(error); } +} +export function resultText(result: Record) { + if (result.execution && typeof result.execution === 'object' && 'state' in result.execution) return `Codex turn ${result.turnId}: ${result.execution.state}`; + if (result.delivery === 'queued') return `Queued ${result.queuedSubmissionId} on busy Codex thread ${result.threadId}; message ${result.messageId}`; + if (result.error) return String(result.error); + if (result.delivery === 'accepted') return `Started turn ${result.turnId} (${result.turnStatus}) on Codex thread ${result.threadId}; message ${result.messageId}`; + if (result.delivery) return `Codex delivery ${result.delivery} on thread ${result.threadId}`; + if (result.reason) return `Codex observation: ${result.reason}`; + if (Array.isArray(result.threads)) return `${result.threads.length} Codex threads`; + return String(result.error ?? 'Codex observation complete'); +} diff --git a/src/mcp/grok-bot/tools/codex_send.tsx b/src/mcp/grok-bot/tools/codex_send.tsx new file mode 100644 index 0000000..945d8a5 --- /dev/null +++ b/src/mcp/grok-bot/tools/codex_send.tsx @@ -0,0 +1,59 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { sendSchema as inputSchema, resultSchema, sendOperation, resultText } from '../../../core/codex/routes.js'; +export { inputSchema }; +export default defineTool({ + description: 'Send to a Codex thread. Accepted means submitted, not finished; optional wait observes bounded completion. Guarded steer requires expectedTurnId.', title: 'Codex send', annotations: { readOnlyHint: false }, + render: { maxElapsedMs: 660000 }, + inputSchema, resultSchema, + inputJsonSchema: { type: 'object', additionalProperties: false, properties: { + "threadId": { + "type": "string" + }, + "expectedCwd": { + "type": "string" + }, + "timeoutMs": { + "type": "number", + "description": "Observation timeout: 1-600000 milliseconds." + }, + "correlationId": { + "type": "string" + }, + "envelope": { + "type": "boolean" + }, + "hop": { + "type": "number" + }, + "replyTo": { + "type": "string" + }, + "expectedTurnId": { + "type": "string", + "description": "Required active-turn guard for steer; stale guards reject." + }, + "wait": { + "type": "boolean" + }, + "maxOutputBytes": { + "type": "number", + "description": "Reply budget: 1-4194304 bytes." + }, + "whenBusy": { + "type": "string", + "enum": [ + "reject", + "queue", + "steer" + ] + }, + "message": { + "type": "string" + } + }, required: ['threadId', 'message'] }, +}, async input => { + const context = await agent(); + const out = await sendOperation(input, context.signal, message => context.progress.report({ message })); + return {resultText(out)}; +}); diff --git a/src/mcp/grok-bot/tools/codex_threads.tsx b/src/mcp/grok-bot/tools/codex_threads.tsx new file mode 100644 index 0000000..a66c0fa --- /dev/null +++ b/src/mcp/grok-bot/tools/codex_threads.tsx @@ -0,0 +1,22 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { threadsSchema as inputSchema, resultSchema, threadsOperation, resultText } from '../../../core/codex/routes.js'; +export { inputSchema }; +export default defineTool({ + description: 'Discover a bounded page of Codex daemon threads.', title: 'Codex threads', annotations: { readOnlyHint: true }, + render: { maxElapsedMs: 660000 }, + inputSchema, resultSchema, + inputJsonSchema: { type: 'object', additionalProperties: false, properties: { + "limit": { + "type": "number", + "description": "Maximum threads in this page: 1-200." + }, + "cursor": { + "type": "string" + } + }, required: [] }, +}, async input => { + const context = await agent(); + const out = await threadsOperation(input); + return {resultText(out)}; +}); diff --git a/src/mcp/grok-bot/tools/codex_wait.tsx b/src/mcp/grok-bot/tools/codex_wait.tsx new file mode 100644 index 0000000..2cd006b --- /dev/null +++ b/src/mcp/grok-bot/tools/codex_wait.tsx @@ -0,0 +1,35 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { waitSchema as inputSchema, resultSchema, observeOperation, resultText } from '../../../core/codex/routes.js'; +export { inputSchema }; +export default defineTool({ + description: 'Explicit diagnostic observation of one Codex turn; returns execution and final reply without interrupting it.', title: 'Codex wait', annotations: { readOnlyHint: true }, + render: { maxElapsedMs: 660000 }, + inputSchema, resultSchema, + inputJsonSchema: { type: 'object', additionalProperties: false, properties: { + "threadId": { + "type": "string" + }, + "expectedCwd": { + "type": "string" + }, + "timeoutMs": { + "type": "number", + "description": "Observation timeout: 1-600000 milliseconds." + }, + "turnId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "maxOutputBytes": { + "type": "number", + "description": "Reply budget: 1-4194304 bytes." + } + }, required: ['threadId', 'turnId'] }, +}, async input => { + const context = await agent(); + const out = await observeOperation('wait', input, context.signal, message => context.progress.report({ message })); + return {resultText(out)}; +}); diff --git a/src/mcp/grok-bot/tools/codex_watch.tsx b/src/mcp/grok-bot/tools/codex_watch.tsx new file mode 100644 index 0000000..68ba3b2 --- /dev/null +++ b/src/mcp/grok-bot/tools/codex_watch.tsx @@ -0,0 +1,29 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { watchSchema as inputSchema, resultSchema, observeOperation, resultText } from '../../../core/codex/routes.js'; +export { inputSchema }; +export default defineTool({ + description: 'Watch bounded Codex thread events for diagnostics without answering approvals.', title: 'Codex watch', annotations: { readOnlyHint: true }, + render: { maxElapsedMs: 660000 }, + inputSchema, resultSchema, + inputJsonSchema: { type: 'object', additionalProperties: false, properties: { + "threadId": { + "type": "string" + }, + "expectedCwd": { + "type": "string" + }, + "timeoutMs": { + "type": "number", + "description": "Observation timeout: 1-600000 milliseconds." + }, + "maxEvents": { + "type": "number", + "description": "Maximum observed events: 1-500." + } + }, required: ['threadId'] }, +}, async input => { + const context = await agent(); + const out = await observeOperation('watch', input, context.signal, message => context.progress.report({ message })); + return {resultText(out)}; +}); diff --git a/src/skills/talk-to-grok-bot/SKILL.md b/src/skills/talk-to-grok-bot/SKILL.md index d4e156e..b1601ee 100644 --- a/src/skills/talk-to-grok-bot/SKILL.md +++ b/src/skills/talk-to-grok-bot/SKILL.md @@ -1,6 +1,6 @@ --- name: talk-to-grok-bot -description: Send or read a Grok Bot thread via gbot_send/gbot_thread. Use when handing off to a named bot/group or posting a status note agents watch — not for work you can finish yourself. +description: Send or read Grok Bot threads via gbot_send/gbot_thread and Codex daemon threads via codex_send/codex_threads. Use when handing off to a named bot/group or posting a status note agents watch — not for work you can finish yourself. --- # Talk to Grok Bot @@ -35,3 +35,30 @@ Framework argument/schema errors use stderr and exit 2. `--json` is reserved bef ## Auth Same order as `gbot`: explicit `GROK_BOT_GATEWAY_*`, else Grok Bot app session, else `CURSOR_ACCESS_TOKEN`. `gbot doctor` shows which source is present. + +## Codex conversation tools + +The generated Codex, Cursor, and Claude plugins and portable MCP artifact expose these +additional tools on the same `grok-bot` MCP server. Codex tools use the local Codex +app-server control socket; Grok tools use the Grok gateway. Portable MCP artifacts +must be configured in an MCP-capable host; they are not automatically loaded by the Grok app. + +- `codex_threads`: bounded discovery of daemon-managed Codex threads. +- `codex_send`: submit a message with a correlation envelope. Default delivery is + immediate acceptance, which does not mean execution finished. `wait: true` adds + bounded execution and final reply fields. An accepted message remains accepted + when observation times out or execution fails. +- `codex_wait`: explicitly observe a known thread/turn and recover its final output. +- `codex_watch`: diagnostic observation of bounded thread events. It never answers approvals. + +Use `expectedCwd` to verify the destination workspace. Busy sends reject by default; +`whenBusy: queue` needs `GROK_BOT_CODEX_EXPERIMENTAL=1`. Explicit `whenBusy: steer` +requires `expectedTurnId` and visibly rejects a stale guard without retrying another turn. +Final replies omit commentary/reasoning; older phase-null agent messages are a fallback +only after terminal execution. Inspect `reply.truncated` and execution errors for coverage limits. + +CLI equivalents are `gbot codex send --wait --timeout-ms 1000 THREAD_ID hello --json`, +`gbot codex wait --timeout-ms 1000 THREAD_ID TURN_ID --json`, and +`gbot codex watch --timeout-ms 1000 --max-events 20 THREAD_ID --json`. +Use framework `--ndjson` for progress. Wait/watch are explicit diagnostics; automatic +background reply routing is not provided by this conversation slice. diff --git a/test/codex-conversation.test.js b/test/codex-conversation.test.js new file mode 100644 index 0000000..0f86f77 --- /dev/null +++ b/test/codex-conversation.test.js @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fakeAppServer } from "./helpers/codex-server.js"; +import { openCodexConversation } from "../src/core/codex/conversation.js"; +const final = {id:"final-1",type:"agentMessage",phase:"final_answer",text:"final answer"}; +const mixed = [{id:"comment",type:"agentMessage",phase:"commentary",text:"working"},{id:"reason",type:"reasoning",text:"secret"},final,final]; +const handlers = { + initialize: (_,ok)=>ok({}), + "thread/resume":(p,ok)=>ok({thread:{id:p.threadId,cwd:process.cwd(),status:{type:"idle"}},cwd:process.cwd()}), + "thread/turns/list":(_,ok)=>ok({data:[{id:"turn-1",status:"completed"}],nextCursor:null}), + "thread/items/list":(_,ok)=>ok({data:mixed.map(item=>({turnId:"turn-1",item})),nextCursor:null}), + "turn/start":(p,ok,err,send)=>{send({method:"turn/completed",params:{threadId:p.threadId,turn:{id:"turn-1",status:"completed",items:mixed}}});ok({turn:{id:"turn-1",status:"inProgress"}});}, +}; +for(const send of [true,false]) test(`collect ${send?'early completion':'resumed completion'} and only final output`,async()=>{ + const fake=await fakeAppServer(handlers); let c; + try {c=await openCodexConversation("thread-1",{env:{CODEX_HOME:fake.home},expectedCwd:process.cwd()}); + const sent=send?await c.send("hello"):{turnId:"turn-1"}; if(send)assert.equal(sent.delivery,"accepted"); + const r=await c.wait({turnId:sent.turnId,messageId:sent.messageId}); assert.equal(r.execution.state,"completed");assert.equal(r.reply.text,"final answer");assert.deepEqual(r.reply.items.map(i=>i.id),["final-1"]); + }finally{await c?.close();await fake.close();} +}); + +for (const status of ['failed','interrupted','completed']) test(`preserve ${status} with empty output`,async()=>{ + const fake=await fakeAppServer({...handlers,'thread/turns/list':(_,ok)=>ok({data:[{id:'turn-1',status}],nextCursor:null}),'thread/items/list':(_,ok)=>ok({data:[],nextCursor:null})}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const r=await c.wait({turnId:'turn-1'});assert.equal(r.execution.state,status);assert.equal(r.reply.text,'');}finally{await c.close();await fake.close();} +}); +test('guarded steer never falls back and persistent approvals remain unanswered',async()=>{ + const fake=await fakeAppServer({...handlers,'turn/steer':(p,ok,err,send)=>{ + send({id:'foreign-'+p.expectedTurnId,method:'item/commandExecution/requestApproval',params:{threadId:'other',turnId:'turn-1'}}); + send({id:'own-'+p.expectedTurnId,method:'item/commandExecution/requestApproval',params:{threadId:p.threadId,turnId:'turn-1'}}); + if(p.expectedTurnId==='stale')err({code:-32600,message:'guard mismatch'});else ok({turnId:'turn-1'}); + }}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try { + assert.equal((await c.send('hello',{whenBusy:'steer',expectedTurnId:'turn-1'})).delivery,'accepted'); + assert.equal((await c.send('hello',{whenBusy:'steer',expectedTurnId:'stale'})).delivery,'rejected'); + assert.equal((await c.send('hello',{whenBusy:'steer'})).delivery,'rejected'); + assert.equal(fake.received.filter(x=>x.method==='turn/start').length,0); + assert.equal(fake.received.filter(x=>/^(own|foreign)-/.test(x.id)).length,0); + }finally{await c.close();await fake.close();} +}); +for(const mode of ['timeout','abort','missing','cycle','bounds'])test(`bounded observation ${mode}`,async()=>{ + const fake=await fakeAppServer({...handlers,'thread/turns/list':(_,ok)=>ok({data:mode==='missing'||mode==='cycle'?[]:[{id:'turn-1',status:mode==='bounds'?'completed':'inProgress'}],nextCursor:mode==='cycle'?'again':null})}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const controller=new AbortController();if(mode==='abort')setTimeout(()=>controller.abort(),5); + const r=await c.wait({turnId:'turn-1',messageId:'msg',timeoutMs:20,signal:controller.signal,maxOutputBytes:mode==='bounds'?1:1000}); + assert.equal(r.execution.state,({timeout:'timeout',abort:'unknown',missing:'unknown',cycle:'unknown',bounds:'completed'})[mode]);assert.equal(r.messageId,'msg'); + if(mode==='bounds')assert.equal(r.reply.truncated,true); + assert.equal(fake.received.filter(x=>x.method==='turn/interrupt').length,0); + }finally{await c.close();await fake.close();} +}); +test('cwd mismatch fails before submission',async()=>{ + const fake=await fakeAppServer(handlers); + try {await assert.rejects(openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home},expectedCwd:'/tmp'}),/cwd/);assert.equal(fake.received.filter(x=>x.method==='turn/start').length,0);}finally{await fake.close();} +}); + +test('shared conversation close leaves connection alive and quiet watch is timeout',async()=>{ + const {openCodexSession}=await import('../src/core/codex-bridge.js'); + const fake=await fakeAppServer(handlers);const env={CODEX_HOME:fake.home};const session=await openCodexSession(env); + try {const c=await openCodexConversation('thread-1',{env,session});const r=await c.watch({timeoutMs:5});assert.equal(r.reason,'timeout');await c.close();assert.equal(session.client.closed,false);assert.ok(await session.client.request('thread/turns/list',{threadId:'thread-1'}));}finally{session.client.close();await fake.close();} +}); +test('disconnect is distinct from timeout',async()=>{ + const fake=await fakeAppServer({...handlers,'thread/turns/list':(_,ok,err,send,socket)=>socket.destroy()}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const r=await c.wait({turnId:'turn-1',timeoutMs:50});assert.equal(r.execution.state,'disconnected');}finally{await c.close();await fake.close();} +}); +test('paginated wrapped items use null-phase fallback only when final is absent',async()=>{ + const fake=await fakeAppServer({...handlers,'thread/items/list':(p,ok)=>ok(p.cursor?{data:[{turnId:'turn-1',item:{id:'legacy',type:'agentMessage',phase:null,text:'legacy answer'}}],nextCursor:null}:{data:[{turnId:'turn-1',item:{id:'comment',type:'agentMessage',phase:'commentary',text:'ignore'}}],nextCursor:'next'})}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const r=await c.wait({turnId:'turn-1'});assert.equal(r.reply.text,'legacy answer');assert.equal(fake.received.filter(x=>x.method==='thread/items/list').length,2);}finally{await c.close();await fake.close();} +}); +test('incomplete item coverage cannot claim complete final output',async()=>{ + const fake=await fakeAppServer({...handlers,'thread/items/list':(_,ok)=>ok({data:[{item:final}],nextCursor:null})}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const r=await c.wait({turnId:'turn-1'});assert.equal(r.execution.state,'completed');assert.equal(r.reply.truncated,true);assert.match(r.execution.error,/Invalid/);}finally{await c.close();await fake.close();} +}); +test('waiting-for-input includes only owned turn interaction',async()=>{ + const fake=await fakeAppServer({...handlers,'thread/resume':(p,ok,err,send)=>{handlers['thread/resume'](p,ok);send({id:'ask',method:'item/commandExecution/requestApproval',params:{threadId:p.threadId,turnId:'turn-1'}});send({id:'foreign',method:'item/commandExecution/requestApproval',params:{threadId:'other',turnId:'turn-1'}});},'thread/turns/list':(_,ok)=>ok({data:[{id:'turn-1',status:'inProgress'}],nextCursor:null})}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const r=await c.wait({turnId:'turn-1'});assert.equal(r.execution.state,'waiting-for-input');assert.deepEqual(r.interactions.map(i=>i.id),['ask']);}finally{await c.close();await fake.close();} +}); + +test('uncertain send keeps supplied correlation even when resume disconnects',async()=>{ + let resumes=0; + const fake=await fakeAppServer({...handlers,'thread/resume':(p,ok,err,send,socket)=>{if(++resumes===1)handlers['thread/resume'](p,ok);else socket.destroy();}}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + const {buildEnvelope}=await import('../src/core/codex-bridge.js');const envelope=buildEnvelope({correlationId:'corr'}); + try {const receipt=await c.send('hello',{envelope});assert.equal(receipt.threadId,'thread-1');assert.equal(receipt.correlationId,'corr');assert.equal(receipt.messageId,envelope.messageId);assert.equal(receipt.delivery,'unknown');}finally{await c.close();await fake.close();} +}); +test('persistent send refuses a supplied envelope at hop bound',async()=>{ + const fake=await fakeAppServer(handlers);const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const receipt=await c.send('hello',{envelope:{messageId:'msg',correlationId:'corr',hop:4,maxHops:100,header:false}});assert.equal(receipt.delivery,'rejected');assert.equal(receipt.reason,'hop-limit');assert.equal(fake.received.filter(x=>x.method==='turn/start').length,0);}finally{await c.close();await fake.close();} +}); + +test('resolved requests without turnId no longer block the turn',async()=>{ + const fake=await fakeAppServer({...handlers,'thread/resume':(p,ok,err,send)=>{ + handlers['thread/resume'](p,ok); + send({id:'ask',method:'item/commandExecution/requestApproval',params:{threadId:p.threadId,turnId:'turn-1'}}); + send({method:'serverRequest/resolved',params:{threadId:p.threadId,requestId:'ask'}}); + },'thread/turns/list':(_,ok)=>ok({data:[{id:'turn-1',status:'inProgress'}],nextCursor:null})}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const r=await c.wait({turnId:'turn-1',timeoutMs:5});assert.equal(r.execution.state,'timeout');assert.deepEqual(r.interactions,[]);}finally{await c.close();await fake.close();} +}); + +test('history traversal enforces page and method bounds',async()=>{ + const {openCodexSession}=await import('../src/core/codex-bridge.js'); + const {visitCodexHistory}=await import('../src/core/codex/conversation.js'); + let n=0; + const fake=await fakeAppServer({...handlers,'thread/turns/list':(_,ok)=>ok({data:[],nextCursor:'page-'+(++n)})}); + const session=await openCodexSession({CODEX_HOME:fake.home}); + try { + await assert.rejects(visitCodexHistory(session,'thread-1','thread/turns/list',{},()=>false),/20 page limit/);assert.equal(n,20); + await assert.rejects(visitCodexHistory(session,'thread-1','turn/interrupt',{},()=>false),/Unsupported/); + const controller=new AbortController();controller.abort();await assert.rejects(visitCodexHistory(session,'thread-1','thread/items/list',{},()=>false,{signal:controller.signal}),/cancelled/); + }finally{session.client.close();await fake.close();} +}); +test('watch filters foreign events, reports overflow and supports cancellation',async()=>{ + const seen=[]; + const fake=await fakeAppServer({...handlers,'thread/resume':(p,ok,err,send)=>{ + for(let i=0;i<510;i++)send({method:'item/started',params:{threadId:p.threadId,turnId:'turn-1',item:{id:String(i),type:'reasoning'}}}); + send({method:'item/started',params:{threadId:'foreign',turnId:'turn-1',item:{id:'foreign',type:'reasoning'}}});handlers['thread/resume'](p,ok); + }}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home},onEvent:e=>seen.push(e)}); + try { + const r=await c.watch({maxEvents:20,timeoutMs:10});assert.equal(r.reason,'event-limit');assert.equal(r.events.length,20);assert.equal(r.truncated,true);assert.equal(seen.length,510); + const controller=new AbortController();controller.abort();assert.equal((await c.watch({signal:controller.signal})).reason,'cancelled'); + await assert.rejects(c.wait({turnId:'turn-1',timeoutMs:0}),/timeoutMs/);await assert.rejects(c.watch({maxEvents:501}),/maxEvents/); + }finally{await c.close();await fake.close();} +}); + +test('accepted turn remains observable while history has not caught up',async()=>{ + const fake=await fakeAppServer({...handlers,'turn/start':(_,ok)=>ok({turn:{id:'turn-1',status:'inProgress'}}),'thread/turns/list':(_,ok)=>ok({data:[],nextCursor:null}),'thread/items/list':(_,ok)=>ok({data:[],nextCursor:null})}); + const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try {const receipt=await c.send('hello');const r=await c.wait({turnId:receipt.turnId,timeoutMs:10});assert.equal(r.execution.state,'timeout');}finally{await c.close();await fake.close();} +}); diff --git a/test/codex-surfaces.test.js b/test/codex-surfaces.test.js new file mode 100644 index 0000000..a801e03 --- /dev/null +++ b/test/codex-surfaces.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { spawn, execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { fakeAppServer } from './helpers/codex-server.js'; +const handlers={initialize:(_,ok)=>ok({}),'thread/list':(_,ok)=>ok({data:[],nextCursor:null}),'thread/resume':(p,ok)=>ok({thread:{id:p.threadId,status:{type:'idle'}}}),'turn/start':(_,ok)=>ok({turn:{id:'turn-1',status:'inProgress'}}),'turn/steer':(p,ok)=>ok({turnId:p.expectedTurnId}),'thread/turns/list':(_,ok)=>ok({data:[{id:'turn-1',status:'inProgress'}],nextCursor:null}),'thread/items/list':(_,ok)=>ok({data:[],nextCursor:null})}; +const fixtureEnv=home=>({...process.env,GROK_BOT_TEST:'1',CODEX_HOME:home,CODEX_APP_SERVER_SOCK:'',GROK_BOT_CODEX_THREADS:'',GROK_BOT_CODEX_EXPERIMENTAL:''}); +const cli=(home,...args)=>new Promise(resolveResult=>execFile(process.execPath,['dist/bin/gbot.mjs',...args,'--json'],{env:fixtureEnv(home)},(error,out,err)=>resolveResult({code:error?.code??0,out,err}))); +test('built CLI discovers wait/watch and separates accepted delivery from timeout',async()=>{ + const fake=await fakeAppServer(handlers); + try { + for(const args of [['send','--wait','--timeout-ms','20','thread-1','hello'],['wait','--timeout-ms','20','thread-1','turn-1']]){ + const r=await cli(fake.home,'codex',...args);assert.equal(r.code,1,r.err);const value=JSON.parse(r.out);assert.equal(value.execution.state,'timeout');if(args[0]==='send')assert.equal(value.delivery,'accepted'); + } + const watch=await cli(fake.home,'codex','watch','--timeout-ms','20','--max-events','20','thread-1');assert.equal(watch.code,0,watch.err);assert.equal(JSON.parse(watch.out).reason,'timeout'); + const plain=await cli(fake.home,'codex','send','thread-1','hello');assert.equal(plain.code,0,plain.err);assert.equal(JSON.parse(plain.out).execution,undefined); + const bad=await cli(fake.home,'codex','wait','--timeout-ms','0','thread-1','turn-1');assert.equal(bad.code,2); + }finally{await fake.close();} +}); +test('generated MCP discovers old and new tools and invokes socket-backed calls',async()=>{ + const fake=await fakeAppServer(handlers); + const manifest=JSON.parse(readFileSync('artifact/mcp.json','utf8')); + const child=spawn(process.execPath,manifest.mcpServers['grok-bot'].args,{cwd:resolve('artifact'),env:fixtureEnv(fake.home),stdio:['pipe','pipe','pipe']}); + let buf='',seq=0,stderr='';const pending=new Map();child.stderr.on('data',x=>stderr+=x); + child.stdout.on('data',x=>{buf+=x;for(;;){const i=buf.indexOf('\n');if(i<0)break;const line=buf.slice(0,i);buf=buf.slice(i+1);if(!line.trim())continue;const msg=JSON.parse(line);pending.get(msg.id)?.(msg);pending.delete(msg.id);}}); + const rpc=(method,params)=>new Promise((resolveRpc,reject)=>{const id=++seq;const timer=setTimeout(()=>{pending.delete(id);reject(new Error(`MCP timeout ${stderr}`));},15000);pending.set(id,msg=>{clearTimeout(timer);resolveRpc(msg);});child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');}); + try { + const init=await rpc('initialize',{protocolVersion:'2024-11-05',clientInfo:{name:'test',version:'1'},capabilities:{}});assert.ok(init.result,JSON.stringify(init));child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n'); + const listed=await rpc('tools/list',{});const names=listed.result.tools.map(t=>t.name);for(const name of ['gbot_send','gbot_thread','codex_threads','codex_send','codex_wait','codex_watch'])assert.ok(names.includes(name),name); + for(const [name,args] of [['codex_threads',{}],['codex_send',{threadId:'thread-1',message:'hello',wait:true,timeoutMs:20}],['codex_send',{threadId:'thread-1',message:'hello',whenBusy:'steer',expectedTurnId:'turn-1'}],['codex_wait',{threadId:'thread-1',turnId:'turn-1',timeoutMs:20}],['codex_watch',{threadId:'thread-1',timeoutMs:20}]]){ + const response=await rpc('tools/call',{name,arguments:args});assert.ok(response.result,!response.error&&stderr);assert.equal(response.result.isError,undefined,JSON.stringify(response)); + const value=response.result.structuredContent;assert.ok(value,JSON.stringify(response));if(args.wait){assert.equal(value.delivery,'accepted');assert.equal(value.execution.state,'timeout');} + } + }finally{child.kill();await fake.close();} +}); diff --git a/test/helpers/codex-server.js b/test/helpers/codex-server.js new file mode 100644 index 0000000..3d248b5 --- /dev/null +++ b/test/helpers/codex-server.js @@ -0,0 +1,56 @@ +import { mkdirSync, mkdtempSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { decodeFrame, encodeFrame, websocketAccept } from "../../src/core/codex-bridge.js"; +export async function fakeAppServer(handlers) { + const home = mkdtempSync(join(tmpdir(), "gbot-codex-")); + mkdirSync(join(home, "app-server-control")); + const socketPath = join(home, "app-server-control", "app-server-control.sock"); + const received = []; + const sockets = new Set(); + let resolveDisconnected; + const disconnected = new Promise((resolve) => { resolveDisconnected = resolve; }); + const server = createServer(); + server.on("upgrade", (req, socket) => { + sockets.add(socket); + socket.on("close", () => { + sockets.delete(socket); + resolveDisconnected(); + }); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + websocketAccept(req.headers["sec-websocket-key"]) + "\r\n\r\n", + ); + const send = (obj) => socket.write(encodeFrame(0x1, Buffer.from(JSON.stringify(obj)))); + let buf = Buffer.alloc(0); + socket.on("data", (chunk) => { + buf = Buffer.concat([buf, chunk]); + for (;;) { + const frame = decodeFrame(buf); + if (!frame) return; + buf = frame.rest; + if (frame.opcode === 0x8) { socket.end(); return; } + if (frame.opcode !== 0x1) continue; + const msg = JSON.parse(frame.payload.toString()); + received.push(msg); + if (msg.method && msg.id != null) { + const handler = handlers[msg.method]; + if (!handler) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "unknown method " + msg.method } }); + else handler(msg.params, (result) => send({ jsonrpc: "2.0", id: msg.id, result }), (error) => send({ jsonrpc: "2.0", id: msg.id, error }), send, socket); + } + } + }); + socket.on("error", () => {}); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + return { + home, + received, + disconnected, + close: () => new Promise((resolve) => { + for (const sock of sockets) sock.destroy(); + server.close(resolve); + }), + }; +} diff --git a/tests/route-unit/tools.test.ts b/tests/route-unit/tools.test.ts index 76042b7..f3fd5ec 100644 --- a/tests/route-unit/tools.test.ts +++ b/tests/route-unit/tools.test.ts @@ -133,7 +133,7 @@ beforeEach(() => { describe('grok-bot MCP server', () => { it('registers exactly the two gbot tools', async () => { const surface = await listMcpSurface({ server: 'grok-bot' }); - expect([...surface.tools].sort()).toEqual(['gbot_send', 'gbot_thread']); + expect([...surface.tools].sort()).toEqual(['codex_send', 'codex_threads', 'codex_wait', 'codex_watch', 'gbot_send', 'gbot_thread']); }); it('gbot_send resolves the target by name and posts the prompt with the gateway token', async () => { From 9d75150da0a6a810b809a372460d91988aeb8f29 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:53:59 -0700 Subject: [PATCH 06/18] fix(codex): enforce cancellation and preserve reconciliation identity --- src/core/codex-bridge.js | 25 ++++++-- src/core/codex/conversation.js | 4 +- test/codex-conversation.test.js | 101 ++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/core/codex-bridge.js b/src/core/codex-bridge.js index bd9c9e2..08bfabf 100644 --- a/src/core/codex-bridge.js +++ b/src/core/codex-bridge.js @@ -963,14 +963,18 @@ function requireExperimental(env, what) { { delivery: "rejected", reason: "experimental-disabled" }); } -function unsupportedOrRpc(err, method, threadId, envelope) { +function unsupportedOrRpc(err, method, threadId, envelope, turnId) { + const guarded = method === "turn/steer"; if (err instanceof CodexRpcError && err.rpc && err.rpc.code === -32601) { return new CodexSendError("Codex app-server does not offer " + method + " (daemon predates it, or experimentalApi was not granted). " - + "Upgrade Codex or send without --when-busy queue.", { delivery: "rejected", reason: "unsupported", threadId, envelope }); + + (guarded ? "Upgrade Codex before retrying guarded steering." : "Upgrade Codex or send without --when-busy queue."), + { delivery: "rejected", reason: "unsupported", threadId, turnId, envelope }); } - if (err instanceof CodexRpcError) return new CodexSendError(err.message, { delivery: "rejected", reason: "rejected", threadId, envelope }); + if (err instanceof CodexRpcError) return new CodexSendError(err.message, { delivery: "rejected", reason: "rejected", threadId, turnId, envelope }); return new CodexSendError("Lost the Codex " + method + " response for thread " + threadId + ": " + ((err && err.message) || err) - + ". Delivery is unknown; list the queue before resending.", { delivery: (err && err.delivery) || "unknown", reason: "transport", threadId, envelope }); + + (guarded ? ". Delivery is unknown; check thread " + threadId + " turn " + turnId + " before resending." + : ". Delivery is unknown; list the queue before resending."), + { delivery: (err && err.delivery) || "unknown", reason: "transport", threadId, turnId, envelope }); } /** Read the daemon's queue for one thread (experimental `thread/queue/list`). */ @@ -1028,6 +1032,13 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy, envelope = { ...validated, messageId: envelope.messageId, header: Boolean(envelope.header) }; assertThreadAllowed(threadId, env); if (whenBusy === "queue") requireExperimental(env, "--when-busy queue"); + if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("signal must be an AbortSignal"); + const assertNotCancelled = () => { + if (signal?.aborted) throw new CodexSendError("Codex submission cancelled before delivery", { + delivery: "rejected", reason: "cancelled", threadId, turnId: whenBusy === "steer" ? expectedTurnId : undefined, envelope, + }); + }; + assertNotCancelled(); const body = withEnvelopeHeader(text, envelope, env); const { client } = session ?? await openSession(env, { experimental: whenBusy === "queue", signal }); try { @@ -1035,6 +1046,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy, try { resumed = await client.request("thread/resume", { threadId, excludeTurns: true }); } catch (err) { + assertNotCancelled(); if (err instanceof CodexSendError) throw err; throw new CodexSendError(explainSendError(err, threadId).message, { delivery: err instanceof CodexRpcError ? "rejected" : (err && err.delivery) || "unknown", @@ -1044,6 +1056,9 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy, envelope, }); } + // A shared session stays connected when this conversation is cancelled. + // Recheck after the last awaited preflight, before any submission request. + assertNotCancelled(); if (!isObject(resumed) || !isObject(resumed.thread) || typeof resumed.thread.id !== "string") { throw new CodexProtocolError("thread/resume", "missing `thread.id`"); } @@ -1074,7 +1089,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy, let steered; try { steered = await client.request("turn/steer", { threadId, expectedTurnId, clientUserMessageId: envelope.messageId, input: [{ type: "text", text: body }] }); - } catch (err) { throw unsupportedOrRpc(err, "turn/steer", threadId, envelope); } + } catch (err) { throw unsupportedOrRpc(err, "turn/steer", threadId, envelope, expectedTurnId); } if (typeof steered?.turnId !== "string" || steered.turnId !== expectedTurnId) { throw new CodexSendError("Malformed turn/steer acknowledgment", { delivery: "unknown", reason: "bad-response", threadId, turnId: expectedTurnId, envelope }); } diff --git a/src/core/codex/conversation.js b/src/core/codex/conversation.js index 21ca897..b05062e 100644 --- a/src/core/codex/conversation.js +++ b/src/core/codex/conversation.js @@ -149,6 +149,8 @@ export async function openCodexConversation(threadId, options = {}) { if (!turn) return false; found = true; if (!terminal(status)) { status = turn.status; error = turn.error; } return true; }, { stopped: () => done }); + // Notifications can establish completion while the history response is in flight. + scan(); if (!found && !status) return ['unknown', 'History coverage incomplete: selected turn not found']; await visitCodexHistory(session, threadId, 'thread/items/list', { turnId }, data => { for (const row of data) { @@ -197,7 +199,7 @@ export async function openCodexConversation(threadId, options = {}) { * @param {{envelope?: object, whenBusy?: string, expectedTurnId?: string}} [options] */ async send(text, { envelope = buildEnvelope({ env }), whenBusy = 'reject', expectedTurnId } = {}) { if (closed) return outcomeFromError(Object.assign(new Error('Conversation closed'), { delivery: 'rejected', reason: 'closed', threadId, envelope })); - const receipt = await sendToCodexThread(threadId, text, { env, envelope, whenBusy, expectedTurnId, session, expectedCwd }); + const receipt = await sendToCodexThread(threadId, text, { env, envelope, whenBusy, expectedTurnId, session, expectedCwd, signal }); if (receipt.delivery === 'accepted' && receipt.turnId) { acceptedTurns.add(receipt.turnId); if (acceptedTurns.size > 100) acceptedTurns.delete(acceptedTurns.values().next().value); diff --git a/test/codex-conversation.test.js b/test/codex-conversation.test.js index 0f86f77..aec181e 100644 --- a/test/codex-conversation.test.js +++ b/test/codex-conversation.test.js @@ -133,3 +133,104 @@ test('accepted turn remains observable while history has not caught up',async()= const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); try {const receipt=await c.send('hello');const r=await c.wait({turnId:receipt.turnId,timeoutMs:10});assert.equal(r.execution.state,'timeout');}finally{await c.close();await fake.close();} }); + +for (const whenBusy of ['reject', 'queue', 'steer']) { + for (const abortAt of ['before-send', 'during-resume']) { + test(`shared ${whenBusy} cancellation ${abortAt} rejects before submission`, async () => { + const { openCodexSession, buildEnvelope } = await import('../src/core/codex-bridge.js'); + const controller = new AbortController(); + let resumes = 0; + const fake = await fakeAppServer({ + ...handlers, + 'thread/resume': (params, ok) => { + if (++resumes === 2 && abortAt === 'during-resume') controller.abort(); + ok({ thread: { id: params.threadId, status: { type: whenBusy === 'reject' ? 'idle' : 'active', activeFlags: [] } } }); + }, + 'thread/queue/add': (_, ok) => ok({ queuedSubmission: { id: 'queued-1' } }), + 'turn/steer': (params, ok) => ok({ turnId: params.expectedTurnId }), + }); + const env = { CODEX_HOME: fake.home, GROK_BOT_CODEX_EXPERIMENTAL: '1' }; + const session = await openCodexSession(env, { experimental: true }); + const conversation = await openCodexConversation('thread-1', { env, session, signal: controller.signal }); + const envelope = buildEnvelope({ correlationId: 'cancel-correlation' }); + try { + if (abortAt === 'before-send') controller.abort(); + const receipt = await conversation.send('must not submit', { whenBusy, expectedTurnId: 'turn-1', envelope }); + assert.equal(receipt.delivery, 'rejected'); + assert.equal(receipt.reason, 'cancelled'); + assert.equal(receipt.threadId, 'thread-1'); + assert.equal(receipt.messageId, envelope.messageId); + assert.equal(receipt.correlationId, envelope.correlationId); + assert.equal(receipt.exitCode, 1); + assert.equal(fake.received.filter(message => ['turn/start', 'turn/steer', 'thread/queue/add'].includes(message.method)).length, 0); + assert.equal(session.client.closed, false); + const other = await openCodexConversation('thread-1', { env, session }); + try { assert.equal((await other.wait({ turnId: 'turn-1' })).execution.state, 'completed'); } + finally { await other.close(); } + assert.equal(session.client.closed, false); + } finally { + await conversation.close(); + session.client.close(); + await fake.close(); + } + }); + } +} + +for (const status of ['completed', 'failed']) { + test(`completion during empty history reconciliation preserves ${status}`, async () => { + const fake = await fakeAppServer({ + ...handlers, + 'thread/turns/list': (params, ok, err, send) => { + send({ method: 'turn/completed', params: { threadId: params.threadId, turn: { + id: 'turn-1', status, error: status === 'failed' ? { message: 'execution failed' } : null, + items: [{ id: 'late-final', type: 'agentMessage', phase: 'final_answer', text: 'done' }], + } } }); + ok({ data: [], nextCursor: null }); + }, + 'thread/items/list': (_, ok) => ok({ data: [], nextCursor: null }), + }); + const conversation = await openCodexConversation('thread-1', { env: { CODEX_HOME: fake.home } }); + try { + const result = await conversation.wait({ turnId: 'turn-1', messageId: 'message-1' }); + assert.equal(result.execution.state, status); + assert.equal(result.reply.text, 'done'); + assert.equal(result.reply.truncated, false); + assert.equal(result.messageId, 'message-1'); + if (status === 'completed') assert.equal(result.execution.error, undefined); + else assert.deepEqual(result.execution.error, { message: 'execution failed' }); + } finally { + await conversation.close(); + await fake.close(); + } + }); +} + +for (const failure of ['disconnect', 'reject']) { + test(`guarded steer ${failure} retains known turn and correlation`, async () => { + const { buildEnvelope } = await import('../src/core/codex-bridge.js'); + const fake = await fakeAppServer({ + ...handlers, + 'turn/steer': (_, ok, err, send, socket) => { + if (failure === 'disconnect') socket.destroy(); + else err({ code: -32600, message: 'guard mismatch' }); + }, + }); + const conversation = await openCodexConversation('thread-1', { env: { CODEX_HOME: fake.home } }); + const envelope = buildEnvelope({ correlationId: 'steer-correlation' }); + try { + const receipt = await conversation.send('guarded work', { whenBusy: 'steer', expectedTurnId: 'turn-1', envelope }); + assert.equal(receipt.delivery, failure === 'disconnect' ? 'unknown' : 'rejected'); + assert.equal(receipt.threadId, 'thread-1'); + assert.equal(receipt.turnId, 'turn-1'); + assert.equal(receipt.messageId, envelope.messageId); + assert.equal(receipt.correlationId, envelope.correlationId); + assert.doesNotMatch(receipt.error, /queue/); + if (failure === 'disconnect') assert.match(receipt.error, /check.*turn turn-1/i); + assert.equal(fake.received.filter(message => message.method === 'turn/start').length, 0); + } finally { + await conversation.close(); + await fake.close(); + } + }); +} From c1573bfa7687961a922036945755a78478b50e04 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:56:07 -0700 Subject: [PATCH 07/18] docs: define managed plugin conversation relay and live proof --- .../plans/2026-09-15-managed-relay.md | 29 +++++++ .../specs/2026-09-15-managed-relay-design.md | 80 +++++++++++++++++++ docs/verification/2026-09-15-duplex.md | 29 +++++++ 3 files changed, 138 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-15-managed-relay.md create mode 100644 docs/superpowers/specs/2026-09-15-managed-relay-design.md create mode 100644 docs/verification/2026-09-15-duplex.md diff --git a/docs/superpowers/plans/2026-09-15-managed-relay.md b/docs/superpowers/plans/2026-09-15-managed-relay.md new file mode 100644 index 0000000..71000ab --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-managed-relay.md @@ -0,0 +1,29 @@ +# Managed Grokbot and Codex relay implementation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development, task review before dependent implementation. + +**Goal:** Deliver Grokbot↔Codex messages and replies automatically through generated plugins/MCP while callers continue working. +**Spec:** docs/superpowers/specs/2026-09-15-managed-relay-design.md +**Architecture:** One durable relay engine and single-writer state store; managed local worker provides the same API to MCP and CLI. Existing gateway, app-server and conversation contracts remain canonical. +**Constraints:** No private Desktop interfaces or global permission edits. Unknown delivery never blindly retries. No automatic approvals. Exact observed source identity or explicit route. Bounded state and explicit coverage gaps. Parent owns live calls. + +### Task 1: Durable conversation relay engine + +**Files:** Create `src/core/relay/state.js`, `src/core/relay/engine.js` and focused helper modules if needed, `test/relay-state.test.js`, `test/relay-engine.test.js`, optional `test/helpers/` fixtures. May add narrowly needed public conversation/history helpers in `src/core/codex/conversation.js` with tests; avoid duplicated protocol logic. Do not modify surface routes, runtime config or package files in this task. + +- [ ] Read exact spec and reviewed conversation interfaces; write failing state/engine tests for automatic request replies and explicit linked Grok→Codex→Grok return. +- [ ] Implement a bounded, validated relay adapter over the existing Agent Bundle SQLite state kernel (do not duplicate its journal/transaction code) and intake/receipt/checkpoint transitions, scoped transcript correlation and echo prevention. +- [ ] Implement bounded reconciliation after uncertain sends/restarts, guarded active delivery, output coalescing and completion, and cancellation/backoff/visible pause behavior. +- [ ] Expose bounded status and generation-scoped interactions, method-specific response validation without auto-approval. +- [ ] Prove duplicate polling, crash boundaries, missing cursors, empty startup, out-of-order correlation, state bounds/corruption, own-response echo suppression, foreign/stale requests and disconnect behavior using fixtures. Test expected failures before fixes. +- [ ] Run affected core and conversation suites plus typecheck/build; self-review, commit owned files and report. Parent reviews before Task2 consumes API. + +### Task 2: Managed worker and fluid plugin/MCP experience + +**Files:** Create process/control modules in `src/core/relay/`, built worker entry `src/gbot-relay.ts` or equivalent; modify `agent-bundle.config.ts` only to package worker, `src/mcp/grok-bot/tools/gbot_send.tsx`, `codex_send.tsx`, new bridge start/status/stop/respond tools, shared TS adapter/schema module, CLI bridge routes and explicit gbot send auto-route options. Update installed skill, README, feature changeset and route/packed-worker tests. + +- [ ] Write failing generated MCP tests for native source identity automatic routing, source-unavailable manual receipt and explicit return route, plus worker survival after caller exits and concurrent starts. +- [ ] Implement private bounded worker control protocol, verified startup/profile identity, stable mutation request IDs, concurrency-safe lifetime and foreground mode. Locate packaged worker correctly from CLI and generated MCP/host installations, including paths with spaces. +- [ ] Wire gbot_send automatic reply routing from native Codex lineage, explicit links and codex_send return routes. No silent fallback to untracked sending on worker failure. Add bridge lifecycle/status and explicit method-specific operator response surfaces. +- [ ] Verify actual packed stdio tool discovery/calls and persisted worker lifecycle against fixtures, all generated Codex/Cursor/portable artifacts, cancellation and compatibility of existing manual/CLI paths. +- [ ] Update installed skill and README to explain normal asynchronous delivery and one-time explicit binding, without claims beyond verified host support. Run full npm run check plus packed smoke at supported minimum Node22.19.0; self-review, commit, report. Parent performs live proof and whole-branch review before merge. diff --git a/docs/superpowers/specs/2026-09-15-managed-relay-design.md b/docs/superpowers/specs/2026-09-15-managed-relay-design.md new file mode 100644 index 0000000..a061de0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-managed-relay-design.md @@ -0,0 +1,80 @@ +# Managed Grokbot and Codex conversation relay + +## User outcome + +Grokbot must communicate with Codex like Desktop communicates with its other threads. Plugin/MCP is the primary flow, including the generated Codex and Cursor plugins. Grok Bot receives/sends through its existing gateway conversation, which the relay connects to Codex; native Grok Bot loading of this generated plugin is not established and must not be claimed. CLI commands expose the same controls for administration. A model sends once, continues work, receives the other agent's message in its thread, and answers normally. Session Miner verified local host stdio MCP→HTTPS Grok gateway and a separate Grok Computers local-execution facility; no native Grok plugin loader or remote stdio MCP tunnel was found. Do not invent either to complete this task. Models do not run polling loops or keep a tool call open to receive messages. + +This consumes the reviewed persistent transport and conversation APIs. It is a client of the existing Codex app-server, not another Codex daemon. Use supported app-server and Grok gateway contracts only; no private Desktop pipes, binary patches, or global permission/model changes. Preserve existing plain CLI sends and explicit diagnostic tools. + +## Routing and ownership + +Two supported flows share the same durable relay engine: + +1. **A Codex thread sends to Grok.** `gbot_send` defaults to automatic reply delivery when the invocation has a host-observed Codex conversation identity. Snapshot the target tail first when its tracking state is new, then persist a request record, that baseline and stable gateway nonce before sending, return the submission receipt promptly, then follow only Grok messages sharing the outbound user entry's actual `requestId`. Deliver each new visible Grok response to the originating Codex thread automatically. These reply deliveries do not return Codex's next final answer to Grok by default, preventing an implicit ping-pong. A model can deliberately send the next message as another request. +2. **A Grok conversation is linked to a Codex thread.** `gbot_bridge_start` creates one explicit durable bot/group-to-Codex binding. Subsequent visible Grok bot `send-message` entries are delivered to that Codex thread; its corresponding terminal final answer and status return to that Grok conversation automatically. `codex_send` may also explicitly request a Grok return target, using the same tracked delivery path. Explicit links allow proactive Grok messages while Codex works. Binding creation snapshots the existing tail and starts with new entries; no historical replay by default. + +A binding identifies exact resolved Grok target ID, Codex thread ID, canonical expected cwd, endpoint/profile, busy policy and creation checkpoint. Resolve target names once; persist IDs. Resume verifies exact thread and cwd before any submission. Existing socket/thread allowlists apply. Do not attach unrelated threads or infer destinations from filesystem recency. + +Source auto-detection uses `await agent()` request context: available host `codex`, available lineage with `source: native` and `resolution: native`, then `lineage.value.conversation`. Installed Agent Bundle 8e55ab832d derives this from MCP `_meta['x-codex-turn-metadata']` thread_id/session_id. Require exact source identity; do not treat Cursor tool-window inference, arbitrary session IDs, process-wide CODEX_THREAD_ID, or stale environment as a current Codex thread. Explicit `codexThreadId` works when the host omits identity. An existing explicit binding may be selected by bindingId. For an unidentifiable source, preserve ordinary gbot_send and return `replyRoute: {mode:'manual',reason:'source-unavailable'}` instead of claiming automatic delivery. `replyMode: 'manual'` explicitly selects old behavior. Once an automatic route is requested, do not silently downgrade a relay startup/storage/route failure into an untracked send. + +Inbound text names its Grok sender and message identity and explains whether a normal final answer returns automatically. This is ordinary user-message provenance, not an authentication or system-instruction boundary. Actual correlation, reply destinations and hop ancestry are engine-owned records; models need not copy text headers. + +## Shared core and durable state + +For auto-routed sends without an explicit expectedCwd, verify the source via resume, adopt its canonical cwd once and persist that expectation. If invocation workspace evidence is available, check it consistently rather than treating plugin installation cwd as the user workspace. + +Create a small relay core under `src/core/relay/`, separating state storage, engine transitions and process control. Public engine operations should cover `startBinding`, `stopBinding`, `status`, `sendToGrok`, `sendToCodex`, `tick` or a cancellable `run`, and `close`. Exact function signatures may fit the existing code, but route adapters must share them; never duplicate network/delivery policy in MCP and CLI. Test seams inject the gateway and conversation/session interfaces, clock and state directory. + +Reuse the already-installed Agent Bundle state kernel (`@agent-bundle/runtime/state` and its `/sqlite` durable driver) for versioned JSON-safe state, schema validation, atomic commits and idempotent state events. It supports the minimum Node22.19.0; no new dependency or custom database engine is needed. Wrap it in the relay state module, rather than reimplementing its transaction/journal machinery. Persist in a user-owned relay directory, default `~/.grok-bot-cli/relay/` with explicit `GROK_BOT_RELAY_DIR` override. Do not place state in a versioned plugin cache. A single worker owns mutation, enforced by an exclusive lock and control endpoint. Use the kernel's atomic durable commits and bounded state/journal policies, with bounded domain events and a pure reducer. Avoid journaling the entire accumulated state for every poll/checkpoint; unchanged polls commit nothing. Load the optional SQLite driver only when durable relay state is opened so ordinary stateless commands keep their existing runtime behavior. Directories mode0700, files0600; inspect the database, WAL/SHM and lock paths and reject symlink/nonregular targets before opening. A corrupt, oversized or unsupported-version ledger fails visibly and does not reset or resend. Keep service ownership separate from the state driver's SQLite locking; an atomic state commit does not prevent two workers from sending the same prepared record. Store only routing, necessary bounded message text, delivery receipts and dedupe state; never credentials, raw gateway results or unrelated transcripts. Persisted text is an inherent part of the requested durable relay, independent of opt-in general history. + +Record source entry IDs, clientNonce/clientUserMessageId, correlation/replyTo/hop, target/thread/turn IDs, submission state, execution state, reply delivery and checkpoints separately. States include prepared (definitely not submitted), sending (uncertain after crash), accepted, rejected, unknown, completed, needs-input, paused. Write intent before network submission and acknowledgment after. A missing ack cannot become accepted; an accepted submission cannot become rejected because waiting timed out. A client ID is correlation evidence, not an assumed upstream idempotency guarantee. + +Use one bounded serialized state mutation path; do not hold its lock across network waits. Intake checkpoints advance only after all entries through that checkpoint are durably recorded or deliberately ignored. Bound active records, text bytes, historical receipts, interactions and total ledger bytes. Never evict pending/uncertain records to make space. If capacity is exhausted, pause intake with a visible reason and retain the last safe cursor. Completed record compaction may drop text, but must preserve dedupe/echo evidence until a safe coverage boundary proves it irrelevant; otherwise pause instead of guessing. No silent drop/replay. + +## Transcript correlation and loops + +Use existing raw `getTranscriptTail` plus `sourceEntryId` and `entryText`; the MCP thread presentation strips metadata and is not the relay's input. Treat IDs as opaque strings. Gateway `sendPrompt` supports stable `clientNonce` and optional `replyToId`. Actual transcript user entries contain clientNonce and requestId, and matching bot send-message entries share requestId (verified live). Persist that relationship. For accepted gateway sends without observed user entry, keep matching pending; for unknown sends, reconcile the nonce in bounded history before any resend. No match within available coverage remains unknown and needs attention; do not automatically resend it. + +Read up to200 entries per bounded tail poll (default2s, backoff on errors). On initial empty tail, retain an explicit empty-baseline state so the first new entry is handled once. If a nonempty saved cursor is absent, pause with `gap` and expose its previous/current checkpoints. Do not treat transcriptDelta's reset snapshot as new messages. Validate all consumed entry IDs and required kinds/metadata; unidentifiable message entries cause a visible coverage problem rather than checkpoint advancement past possibly relevant content. Tool/reasoning entries are never forwarded. + +Bindings forward visible bot `send-message` entries. Ordinary user posts are used for nonce/requestId correlation, not forwarded as duplicate bot messages. Ignore the relay's own posted user entries and **all** bot outputs sharing the requestId of a returned Codex result. Own outgoing user entries may appear after their bot result in a fetched page: gather correlations for the entire page before classifying outputs. An automatic Codex→Grok request is a distinct tracked request, and its matching Grok replies are injected once; do not also forward them as binding unsolicited messages. Stable source IDs dedupe repeated polls and restarts. A single Grok target may have multiple request routes to different Codex threads; requestId matching routes each reply to only its originating thread. An unsolicited persistent link for a target is unambiguous (reject a second conflicting link unless stopped). + +Default behavior bounds an exchange to request and reply, with no automatic follow-replies loop. Preserve envelope hop/maxHops and reject at the bound for explicit chained requests. No infinite autonomous conversations or inferred ancestry from freeform body text. + +## Codex delivery and completion + +One persistent initialized session per endpoint generation can serve explicit conversations; close/disconnect invalidates that generation's requests. Use `openCodexConversation` with shared session where practical. Attach listeners before resume/send. Use the reviewed bounded history API to reconcile reconnects and uncertain Codex submissions by exact userMessage.clientId. Scan at most20 pages of100 entries, detect repeated cursors, and distinguish missing coverage from known rejection. If a sent-but-unacknowledged clientId is found, adopt its observed turn and resume completion; never resubmit merely because receipt persistence was interrupted. + +Incoming messages should reach active work. The managed route's default busy policy is explicit guarded steering: discover the actual active turn from bounded thread state/history, then send `turn/steer` with expectedTurnId. If no active turn exists, use canonical normal send. A stale guard is a definite rejection: re-observe and make at most3 guarded attempts, never retry unknown delivery. Do not use turn/start as fallback after a steer error. Preserve current documented idle-check/turn-start race as a protocol limit; no local lock can serialize another Desktop client. A `reject` policy remains available. Native queue mode remains explicitly experimental and is not required for normal relay delivery. + +Each accepted message is associated with the returned turn ID. Multiple messages steered into the same turn may share its terminal answer: coalesce the automatic Grok return per target+thread+turn and include the source message IDs/correlation records, rather than posting the same final repeatedly. Use the collector's completed final_answer items or terminal phase-null fallback; never forward commentary, reasoning, tool logs or unrelated turns. Preserve failed/interrupted status and empty successful replies. Default outbound text at most64KiB, explicit truncation. Send status once if no final text is present. A needs-input turn is visible and remains resumable; timeout does not interrupt it. No automatic approvals/refusals from observers. + +After reconnect, re-establish subscriptions, reconcile pending receipt IDs and turn completion, then resume new intake. Backoff bounded1..30s; no tight reconnect loop. Auth failures and gaps are visible route states. Stop cancels relay observation/submissions and closes owned sockets but never cancels another client's Codex turn or deletes pending records. Restart resumes from safe checkpoints. A worker reconnects lost network/app-server connections on its own. Distinguish this from process/OS supervision: if login persistence is not installed, report that a stopped/crashed process needs restart, and ensure the next tool-driven worker start resumes saved routes. Never report a dead pid as a running route. + +## Managed process and tools + +Start a background worker on demand for tracked sends or binding start. A successful MCP call must not depend on its render promise or stdio process staying alive. Prefer one local worker per endpoint/state directory with a private Unix-domain control socket; no unauthenticated public listener. Worker readiness is a successful version/profile handshake, not a pid or file. Concurrent starters must converge on the same owner. Never unlink a live listener or kill a PID based only on stale metadata. Verify endpoint ownership and protocol identity; close bounded requests/connections. Startup timeout is a visible failure before an untracked send. A worker disconnection never causes automatic re-execution of a control send without a durable idempotent request ID. + +Ship the worker as an actual built/packed entry asset. Resolve it from the emitting package/plugin root, verify existence, and spawn with process.execPath plus argv arrays (no shell). Do not assume process.argv[1] is the CLI when invoked via MCP. Avoid copying credential text into command lines/logs/state. The worker may inherit already-authorized environment and refresh app auth through existing connectGateway logic. Profile mismatch between an existing worker and caller is an error, not credential replacement. Test installed artifact paths containing spaces. Windows should report the existing Codex Unix-socket limitation clearly. + +Tools: +- `gbot_send`: retain target/message, add optional replyMode(auto/manual), codexThreadId, expectedCwd, bindingId as needed; native Codex invocation auto-routes replies. Return existing submission receipt plus replyRoute and durable exchange ID. Explicit auto route first ensures worker and valid destination, then sends exactly once. +- `gbot_bridge_start`: grokTarget and codexThreadId (or proven native source) with expectedCwd and busy policy; return binding ID and verified ready/running or explicit paused/error state. +- `gbot_bridge_status`: optional bindingId, bounded receipts and current pending interactions. Distinguish worker health, binding coverage, submission, execution and return delivery; no raw transcript dump. +- `gbot_bridge_stop`: bindingId, preserving ledger. Provide optional all/worker shutdown only through explicit input. +- Extend `codex_send` with optional replyToGrok target/binding so the same worker returns its answer automatically. Without a return route, retain the conversation tool's immediate/explicit-wait behavior. +- CLI `gbot codex bridge start/status/stop/run` and existing gbot send equivalent auto-route flags when explicitly requested. All adapters share core behavior. `run` is the foreground worker entry for service managers; ordinary plugin users need no terminal process. + +Correct the existing README claim of a permanent Desktop app-tools impossibility: the current shim does not forward spawn-time overrides, and no restoration path has been demonstrated here; do not claim a permanent protocol impossibility or app-tools parity. + +Install/skill documentation describes the fluid send/receive sequence, explicit one-time binding when native identity is unavailable, current Grok host delivery, and recovery statuses. Do not require users to understand transport internals or pretend a generated config means a host loaded it. Verify generated Codex/Cursor plugins and portable MCP tools on packed installation. Describe persistent background lifetime and stop control clearly. Optional login service is not necessary for basic on-demand worker survival, but if supplied it is explicitly installed/removed and scoped to this relay, not the Codex daemon. + +## Operator interactions + +Expose pending scoped interactions as human-readable status, with connection generation and opaque interaction ID, exact thread/turn and method. Return only supported bounded question/approval fields, never credentials/token refresh or arbitrary tool execution requests. Never automatically answer an approval. Provide `gbot_codex_respond` / CLI bridge respond for an explicit operator response to supported command/file approvals and request-user-input questions. Command/file approval responses support only one-time accept, decline or cancel, respecting availableDecisions when present. Session-wide acceptance, policy amendments and file grantRoot requests remain unsupported here and require the owning Codex UI. User-input answers are keyed by the exact pending question IDs with bounded string arrays. A response validates method-specific result shape, matching binding/thread/turn/generation and pending identity before sending; consume ownership before serialization and mark resolved. Reject foreign/unscoped requests, stale connection IDs, already-resolved IDs and unsupported methods. `serverRequest/resolved` invalidates local pending entries. No generic arbitrary JSON-RPC passthrough or permission widening. Requests needing unsupported UI remain visible for the owning Codex client. + +## Acceptance and proof + +Use hermetic gateway/socket fixtures first; tests must not read real app auth. Prove crash boundaries before/after both submissions and acknowledgment persistence, duplicate polls, out-of-order page correlation, cursor loss, empty baseline, failed/empty Codex output, disconnect/backoff, concurrent starters, stopping, malformed ledger, bounds, symlink refusal, isolated profiles and stale interactions. Test actual generated stdio tools/list and tools/call plus a packed worker lifecycle after caller exit, not only module mocks or executable metadata. + +Controlled live test artifacts are /tmp/gbot-live-bot.json and /tmp/gbot-live-codex-thread.json; created only for this task. Parent owns all live calls. Test one Grok visible message → one Codex accepted/steered message → correct final return. Test Codex MCP-originated gbot_send with native metadata → automatic incoming reply on that exact Codex thread. Repeat through worker restart and bounded disconnect; validate IDs and counts. Test active guarded delivery without interrupting unrelated work. Never use Session Miner, General or unrelated real threads as fixtures. No automatic cleanup of user data; stop verification bindings after receipts are captured. diff --git a/docs/verification/2026-09-15-duplex.md b/docs/verification/2026-09-15-duplex.md new file mode 100644 index 0000000..0b626b4 --- /dev/null +++ b/docs/verification/2026-09-15-duplex.md @@ -0,0 +1,29 @@ +# Grokbot and Codex integration verification + +This record separates protocol/packaging receipts from the completed relay acceptance test. All live test messages use a dedicated verification bot and thread; no unrelated thread is a fixture. + +## Protocol receipts (September 15 PT) + +- Installed CLI and managed daemon: 0.154.0, exact pinned schema compatibility; configured Desktop shim, attachment status unknown. +- A second connection resumed the currently active work conversation with matching thread ID and cwd and saw its in-progress turn through bounded `thread/turns/list`. This proves access to the running thread, beyond shared storage or configured wrapper paths. No input or approval was submitted to that thread by the probe. +- Dedicated Codex verification thread `01a0a8ee-e17f-7900-b215-ea4e8780bb78`, cwd `/tmp/gbot-duplex-verification-20260915`, inherited model `gpt-5.6-sol`, read-only sandbox. Initial turn `01a0a8ee-e4b1-7f20-bdee-6237b24262e9`, client message `53b20452-259d-4e49-b5bc-c3b5add8c2e2`: completed, exact final `GBOT_PROTOCOL_OK_20260915`, agent item phase `final_answer`. +- Reconnecting to the verification thread succeeded. `thread/items/list` returned `{turnId,item}` entries, and the user item retained the exact client message ID as `clientId`, confirming the crash-reconciliation field. +- Creating a thread without a first turn and then closing its only connection did not produce a resumable rollout. The verification setup therefore creates and submits the first turn on one session; the product routes to existing threads. +- Dedicated Grok bot `46cc2abb-bb8f-4f6a-b657-f9e675708391`, named Gbot Duplex Verification 20260915. Its probe send returned `delivery: unknown` (gateway did not supply messageId). The next bounded transcript read matched exact nonce `065f21a6-458e-4f30-b495-09afbfaa8d6a` to user entry `t0u` and requestId `cf00e53d-4bb4-496e-aa56-83d2d996dbae`, then bot entry `t0s0` with the same requestId and exact `GBOT_GROK_PROTOCOL_OK_20260915`. No resend was performed. This proves the nonce/requestId reconciliation path against the real gateway. +- Managed daemon MCP inventory for the dedicated Codex thread showed the installed `grok-bot` server connected under `gbot@gbot-marketplace`, with the existing two tools `gbot_send` and `gbot_thread`. New tool activation must be measured again after installation. + +Local raw receipts: `/tmp/gbot-live-protocol-receipt.json`, `/tmp/gbot-live-history-receipt.json`, `/tmp/gbot-live-mcp-inventory.json`, `/tmp/gbot-live-grok-history-receipt.json`. These contain bounded test metadata/output, not credentials. + +## Delivery/install boundaries + +The current supported plugin host flow is Codex/Cursor/Claude spawning local stdio MCP, whose gateway client communicates with Grok Bot over HTTPS. Grok Bot Computers local execution is a separate facility. Native Grok Bot loading of this generated plugin or a remote stdio MCP tunnel has not been established. The managed relay uses Grok gateway conversations for Grok-origin messages. + +At this check, live gbot plugin copies in Codex and Cursor were 0.4.2 while source was 0.4.4. A generated artifact or installer receipt alone is not proof a live host loaded new tools. Hosted releases remained pending and npm latest was 0.4.2; publication and local activation require their own final receipts. + +## Conversation implementation receipt + +Built CLI at conversation commit `0f86d73` ran `codex wait` against the already-completed dedicated live turn and returned exit0, execution completed, exact final marker and item ID, no truncation and no interactions. Receipt: `/tmp/gbot-live-wait-receipt.json`. This exercises real bounded history through the new collector without another model submission. + +## Relay acceptance + +Pending implementation and final verification. The protocol receipts above do not yet claim automatic Grok→Codex→Grok delivery, worker restart recovery, or Desktop app-tools parity. From 21e9efb698ffc69e0078c86e3e5346d296b0013d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 23:57:21 -0700 Subject: [PATCH 08/18] docs: anchor active-turn replies to observed message identity --- docs/superpowers/specs/2026-09-15-managed-relay-design.md | 2 +- docs/verification/2026-09-15-duplex.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-09-15-managed-relay-design.md b/docs/superpowers/specs/2026-09-15-managed-relay-design.md index a061de0..28bbdf5 100644 --- a/docs/superpowers/specs/2026-09-15-managed-relay-design.md +++ b/docs/superpowers/specs/2026-09-15-managed-relay-design.md @@ -47,7 +47,7 @@ One persistent initialized session per endpoint generation can serve explicit co Incoming messages should reach active work. The managed route's default busy policy is explicit guarded steering: discover the actual active turn from bounded thread state/history, then send `turn/steer` with expectedTurnId. If no active turn exists, use canonical normal send. A stale guard is a definite rejection: re-observe and make at most3 guarded attempts, never retry unknown delivery. Do not use turn/start as fallback after a steer error. Preserve current documented idle-check/turn-start race as a protocol limit; no local lock can serialize another Desktop client. A `reject` policy remains available. Native queue mode remains explicitly experimental and is not required for normal relay delivery. -Each accepted message is associated with the returned turn ID. Multiple messages steered into the same turn may share its terminal answer: coalesce the automatic Grok return per target+thread+turn and include the source message IDs/correlation records, rather than posting the same final repeatedly. Use the collector's completed final_answer items or terminal phase-null fallback; never forward commentary, reasoning, tool logs or unrelated turns. Preserve failed/interrupted status and empty successful replies. Default outbound text at most64KiB, explicit truncation. Send status once if no final text is present. A needs-input turn is visible and remains resumable; timeout does not interrupt it. No automatic approvals/refusals from observers. +Each accepted message is associated with the returned turn ID. Multiple messages steered into the same turn may share its terminal answer: coalesce the automatic Grok return per target+thread+turn and include the source message IDs/correlation records, rather than posting the same final repeatedly. A live steer probe confirmed that an active turn can contain an already-emitted final_answer before the steered user message and another final_answer afterward. Anchor automatic returns to the earliest associated clientUserMessageId in that turn and exclude agent items before its userMessage.clientId. Extend the shared collector with optional `afterMessageId` (or an equivalent narrow reply-selection helper) and test this actual ordering; a plain turn-level wait can keep returning all final items. Missing anchor/coverage must be explicit, never forward earlier unrelated content as the reply. The anchor bounds history selection; a shared active turn can still combine subsequent inputs, which should be described accurately. Use the collector's completed final_answer items or terminal phase-null fallback; never forward commentary, reasoning, tool logs or unrelated turns. Preserve failed/interrupted status and empty successful replies. Default outbound text at most64KiB, explicit truncation. Send status once if no final text is present. A needs-input turn is visible and remains resumable; timeout does not interrupt it. No automatic approvals/refusals from observers. After reconnect, re-establish subscriptions, reconcile pending receipt IDs and turn completion, then resume new intake. Backoff bounded1..30s; no tight reconnect loop. Auth failures and gaps are visible route states. Stop cancels relay observation/submissions and closes owned sockets but never cancels another client's Codex turn or deletes pending records. Restart resumes from safe checkpoints. A worker reconnects lost network/app-server connections on its own. Distinguish this from process/OS supervision: if login persistence is not installed, report that a stopped/crashed process needs restart, and ensure the next tool-driven worker start resumes saved routes. Never report a dead pid as a running route. diff --git a/docs/verification/2026-09-15-duplex.md b/docs/verification/2026-09-15-duplex.md index 0b626b4..db98091 100644 --- a/docs/verification/2026-09-15-duplex.md +++ b/docs/verification/2026-09-15-duplex.md @@ -24,6 +24,8 @@ At this check, live gbot plugin copies in Codex and Cursor were 0.4.2 while sour Built CLI at conversation commit `0f86d73` ran `codex wait` against the already-completed dedicated live turn and returned exit0, execution completed, exact final marker and item ID, no truncation and no interactions. Receipt: `/tmp/gbot-live-wait-receipt.json`. This exercises real bounded history through the new collector without another model submission. +A live two-client probe at `9d75150` accepted initial message `e6b71e1a-4c8a-4fd7-a9b6-426a51248031` and guarded active message `515df77a-5915-4bb2-b588-847f2288c495` into turn `01a0a8ff-ce3d-7132-92e9-977e6daf0152`. A second client saw ordered user/agent/completion events, and remained usable after the sender closed. The turn had one final_answer before the second user message and another after it; automatic reply selection therefore needs a message-ID anchor. Receipt: `/tmp/gbot-live-steer-receipt.json`. + ## Relay acceptance Pending implementation and final verification. The protocol receipts above do not yet claim automatic Grok→Codex→Grok delivery, worker restart recovery, or Desktop app-tools parity. From 9a41c77d655c43ce96f2c5f3d3f95208c39cb30b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 00:30:25 -0700 Subject: [PATCH 09/18] feat: add durable managed conversation relay core --- src/core/codex/conversation.js | 24 +- src/core/relay/codex.js | 206 ++++++++++++ src/core/relay/completion.js | 132 ++++++++ src/core/relay/engine.js | 361 ++++++++++++++++++++ src/core/relay/intake.js | 220 +++++++++++++ src/core/relay/interactions.js | 168 ++++++++++ src/core/relay/records.js | 124 +++++++ src/core/relay/state.js | 263 +++++++++++++++ test/codex-bridge.test.js | 67 +--- test/codex-conversation.test.js | 18 + test/helpers/codex-server.js | 20 +- test/relay-codex.test.js | 76 +++++ test/relay-completion.test.js | 51 +++ test/relay-engine.test.js | 566 ++++++++++++++++++++++++++++++++ test/relay-interactions.test.js | 137 ++++++++ test/relay-state.test.js | 102 ++++++ 16 files changed, 2462 insertions(+), 73 deletions(-) create mode 100644 src/core/relay/codex.js create mode 100644 src/core/relay/completion.js create mode 100644 src/core/relay/engine.js create mode 100644 src/core/relay/intake.js create mode 100644 src/core/relay/interactions.js create mode 100644 src/core/relay/records.js create mode 100644 src/core/relay/state.js create mode 100644 test/relay-codex.test.js create mode 100644 test/relay-completion.test.js create mode 100644 test/relay-engine.test.js create mode 100644 test/relay-interactions.test.js create mode 100644 test/relay-state.test.js diff --git a/src/core/codex/conversation.js b/src/core/codex/conversation.js index b05062e..d50c2e9 100644 --- a/src/core/codex/conversation.js +++ b/src/core/codex/conversation.js @@ -81,10 +81,13 @@ export async function openCodexConversation(threadId, options = {}) { if (expected !== undefined && (typeof cwd !== 'string' || realpathSync(cwd) !== expected)) throw new Error('Codex thread cwd does not match expectedCwd'); } catch (error) { await close(); throw error; } - /** @param {{turnId: string, messageId?: string, timeoutMs?: number, signal?: AbortSignal, maxOutputBytes?: number}} options */ - async function wait({ turnId, messageId, timeoutMs = 120000, signal: waitSignal, maxOutputBytes = 1048576 }) { + /** @param {{turnId: string, messageId?: string, afterMessageId?: string|string[], timeoutMs?: number, signal?: AbortSignal, maxOutputBytes?: number}} options */ + async function wait({ turnId, messageId, afterMessageId, timeoutMs = 120000, signal: waitSignal, maxOutputBytes = 1048576 }) { conversationId(turnId, 'turnId'); if (messageId !== undefined) conversationId(messageId, 'messageId'); + const anchors = afterMessageId === undefined ? [] : Array.isArray(afterMessageId) ? afterMessageId : [afterMessageId]; + if (afterMessageId !== undefined) boundedInteger(anchors.length, 200, 'afterMessageId count'); + for (const anchor of anchors) conversationId(anchor, 'afterMessageId'); boundedInteger(timeoutMs, 600000, 'timeoutMs'); boundedInteger(maxOutputBytes, BUDGET, 'maxOutputBytes'); if (waitSignal !== undefined && !(waitSignal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal'); let done = false, status = acceptedTurns.has(turnId) ? 'inProgress' : undefined, error, historyError, itemBytes = 0, truncated = overflow; @@ -106,10 +109,10 @@ export async function openCodexConversation(threadId, options = {}) { if (namedTurn(p) !== turnId) continue; if (event.kind === 'interaction') interactions.set(event.id, event); if (event.method === 'turn/started' && !terminal(status)) status = p.turn?.status; - if (event.method === 'item/completed') add(p.item); + if (event.method === 'item/completed' && afterMessageId === undefined) add(p.item); if (event.method === 'turn/completed') { status = p.turn?.status; error = p.turn?.error; - for (const item of p.turn?.items ?? []) add(item); + if (afterMessageId === undefined) for (const item of p.turn?.items ?? []) add(item); } } return [...interactions.values()]; @@ -142,24 +145,29 @@ export async function openCodexConversation(threadId, options = {}) { const collect = async () => { scan(); try { - let found = false; + let found = false, historyTerminal = false, anchorFound = afterMessageId === undefined; + const missingAnchors = new Set(anchors); await visitCodexHistory(session, threadId, 'thread/turns/list', {}, data => { for (const turn of data) if (!turn || typeof turn.id !== 'string' || typeof turn.status !== 'string') throw new Error('Invalid turn history'); const turn = data.find(t => t.id === turnId); if (!turn) return false; - found = true; if (!terminal(status)) { status = turn.status; error = turn.error; } return true; + found = true; historyTerminal = terminal(turn.status); if (!terminal(status)) { status = turn.status; error = turn.error; } return true; }, { stopped: () => done }); // Notifications can establish completion while the history response is in flight. scan(); if (!found && !status) return ['unknown', 'History coverage incomplete: selected turn not found']; - await visitCodexHistory(session, threadId, 'thread/items/list', { turnId }, data => { + await visitCodexHistory(session, threadId, 'thread/items/list', { turnId, sortDirection: 'asc' }, data => { for (const row of data) { if (!row || row.turnId !== turnId || !row.item) throw new Error('Invalid thread/items/list wrapper'); - add(row.item); + if (row.item.type === 'userMessage' && anchors.includes(row.item.clientId)) { anchorFound = true; missingAnchors.delete(row.item.clientId); } + if (anchorFound) add(row.item); } return false; }, { stopped: () => done }); + if (!anchorFound || missingAnchors.size) { items.clear(); return ['unknown', 'History coverage incomplete: reply anchor not found']; } + if (afterMessageId !== undefined && !historyTerminal) return scan().length ? ['waiting-for-input'] : ['unknown', 'Anchored reply awaits terminal history']; } catch (err) { historyError = err.message; truncated = true; } + if (afterMessageId !== undefined && historyError) { items.clear(); return ['unknown', historyError]; } while (!done) { const interactions = scan(); if (terminal(status)) return [status, error ?? historyError]; diff --git a/src/core/relay/codex.js b/src/core/relay/codex.js new file mode 100644 index 0000000..0a6946f --- /dev/null +++ b/src/core/relay/codex.js @@ -0,0 +1,206 @@ +import { realpathSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { openCodexSession } from "../codex-bridge.js"; +import { + openCodexConversation, + visitCodexHistory, +} from "../codex/conversation.js"; +import { InteractionRegistry } from "./interactions.js"; + +/** One initialized endpoint generation, with conversation-scoped subscriptions. */ +export function createRelayCodex({ + env, + read, + signal, + clock = Date.now, + openSession = openCodexSession, + openConversation = openCodexConversation, +}) { + let session, + opening, + generation = null, + failures = 0, + nextConnect = 0; + const conversations = new Map(); + const interactions = new InteractionRegistry(read); + async function connect() { + if (session && !session.client.closed) return session; + if (opening) return opening; + if (clock() < nextConnect) throw new Error("Codex reconnect backoff"); + opening = (async () => { + for (const c of conversations.values()) await c.close(); + conversations.clear(); + try { + session = await openSession(env, { signal }); + } catch (error) { + nextConnect = + clock() + Math.min(30000, 1000 * 2 ** Math.min(failures++, 5)); + throw error; + } + failures = 0; + nextConnect = 0; + generation = randomUUID(); + interactions.reset(generation, session.client); + const connected = session; + session.client.onClose(() => { + if (session !== connected) return; + interactions.reset(null, null); + nextConnect = clock() + 1000; + }); + return session; + })(); + try { + return await opening; + } finally { + opening = null; + } + } + async function conversation(route) { + const s = await connect(), + key = JSON.stringify([route.threadId, route.expectedCwd ?? null]); + if (!conversations.has(key)) + conversations.set( + key, + await openConversation(route.threadId, { + env, + expectedCwd: route.expectedCwd ?? undefined, + session: s, + signal, + onEvent: (event) => interactions.observe(event), + }), + ); + return conversations.get(key); + } + return { + get connection() { + return { + state: + session && !session.client.closed + ? "connected" + : nextConnect > clock() + ? "backoff" + : "disconnected", + nextConnect, + }; + }, + async prepare(record) { + await conversation(record); + }, + get generation() { + return session?.client.closed ? null : generation; + }, + interactions, + async verify({ threadId, expectedCwd }) { + const c = await conversation({ threadId, expectedCwd }); + return { threadId, cwd: realpathSync(c.cwd) }; + }, + async send(record) { + const c = await conversation(record), + s = await connect(); + let receipt; + for (let attempt = 0; attempt < 3; attempt++) { + let active = null; + if (record.busyPolicy === "steer") + await visitCodexHistory( + s, + record.threadId, + "thread/turns/list", + {}, + (rows) => { + for (const turn of rows) { + if ( + !turn || + typeof turn.id !== "string" || + typeof turn.status !== "string" + ) + throw new Error("Invalid turn history"); + if (turn.status === "inProgress") { + active = turn.id; + return true; + } + } + return false; + }, + { signal }, + ); + // Once a steer guard is rejected, retry only another observed guarded steer. + if (attempt && !active) return receipt; + receipt = await c.send(record.text, { + envelope: { + messageId: record.clientId, + correlationId: record.correlationId, + hop: record.hop, + maxHops: record.maxHops, + header: false, + }, + whenBusy: active ? "steer" : "reject", + ...(active ? { expectedTurnId: active } : {}), + }); + if ( + !active || + receipt.delivery !== "rejected" || + receipt.reason !== "rejected" || + !/guard|expected.*turn|turn.*mismatch|stale/i.test( + receipt.error ?? "", + ) + ) + return receipt; + } + return receipt; + }, + async reconcile(record) { + await conversation(record); + const s = await connect(); + let found; + await visitCodexHistory( + s, + record.threadId, + "thread/items/list", + { sortDirection: "asc" }, + (rows) => { + for (const row of rows) { + if ( + typeof row?.turnId !== "string" || + typeof row?.item?.type !== "string" + ) + throw new Error("Invalid item history wrapper"); + if ( + row.item.type === "userMessage" && + row.item.clientId === record.clientId + ) { + found = { + delivery: "accepted", + turnId: row.turnId, + messageId: record.clientId, + }; + return true; + } + } + return false; + }, + { signal }, + ); + return found; + }, + async wait(record, group = [record]) { + const c = await conversation(record); + return c.wait({ + turnId: record.turnId, + afterMessageId: group.map((r) => r.clientId), + timeoutMs: 2000, + maxOutputBytes: 65536, + signal, + }); + }, + async close() { + for (const c of conversations.values()) await c.close(); + conversations.clear(); + const previous = session; + session = null; + generation = null; + nextConnect = 0; + previous?.client.close(); + interactions.reset(null, null); + }, + }; +} diff --git a/src/core/relay/completion.js b/src/core/relay/completion.js new file mode 100644 index 0000000..aae15c4 --- /dev/null +++ b/src/core/relay/completion.js @@ -0,0 +1,132 @@ +import { hash, op, records, terminal, MAX_TEXT } from "./records.js"; + +/** Each target/thread/turn gets one durable return intent, anchored to its associated inputs. */ +export function createCompletion({ + state, + codex, + update, + newRecord, + runnable, +}) { + let offset = 0; + async function completions() { + const groups = new Map(); + for (const r of records(state.read())) + if ( + r.kind === "codex" && + r.submission === "accepted" && + r.turnId && + !terminal(r) && + !["capacity", "hop-limit"].includes(r.reason) && + runnable(r) + ) { + const key = JSON.stringify([ + r.targetId, + r.threadId, + r.turnId, + r.returnToGrok, + ]); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(r); + } + const all = [...groups.values()]; + const selected = Array.from( + { length: Math.min(20, all.length) }, + (_, i) => all[(offset + i) % all.length], + ); + offset = all.length ? (offset + selected.length) % all.length : 0; + for (const group of selected) { + if (group.length > 200) { + for (const r of group) + await update(r.id, { execution: "paused", reason: "capacity" }); + continue; + } + const first = group[0]; + let result; + try { + result = await codex.wait(first, group); + } catch { + continue; + } + const execution = result.execution.state; + if (!["completed", "failed", "interrupted"].includes(execution)) { + const mapped = + execution === "waiting-for-input" ? "needs-input" : "pending"; + for (const r of group) + await update(r.id, { + execution: mapped, + reason: + execution === "unknown" + ? "reply-coverage-pending" + : execution === "disconnected" + ? "codex-disconnected" + : null, + }); + continue; + } + if ( + result.execution.error && + /coverage|Invalid|anchor/.test(result.execution.error) + ) + continue; + const changes = []; + let returnId = null; + if (first.returnToGrok) { + returnId = + "return:" + hash([first.targetId, first.threadId, first.turnId]); + if (!state.read().records[returnId]) { + if (first.hop + 1 >= first.maxHops || group.length > 200) { + for (const r of group) + await update(r.id, { + execution: "paused", + reason: group.length > 200 ? "capacity" : "hop-limit", + }); + continue; + } + const sources = group.flatMap((r) => r.sourceIds), + suffix = result.reply.truncated ? "\n[Output truncated]" : ""; + let output = + result.reply.text || `Codex turn ${execution} with no final text.`; + const prefix = `[Codex ${first.threadId}; turn ${first.turnId}; status ${execution}; sources ${sources + .slice(0, 8) + .map((id) => id.slice(0, 128)) + .join( + ", ", + )}${sources.length > 8 ? " (additional source IDs retained in relay state)" : ""}]\n`; + // Bound UTF-8 without cutting a code point, and make truncation explicit. + const budget = MAX_TEXT - Buffer.byteLength(prefix + suffix) - 32; + let truncated = false; + while (Buffer.byteLength(output) > budget) { + output = output + .slice(0, Math.max(0, output.length - 1024)) + .replace(/[\uD800-\uDBFF]$/, ""); + truncated = true; + } + const outgoing = newRecord( + "grok-return", + returnId, + first, + prefix + + output + + suffix + + (truncated ? "\n[Output truncated]" : ""), + { + sourceIds: sources, + parentId: first.id, + correlationId: first.correlationId, + hop: first.hop + 1, + maxHops: first.maxHops, + }, + ); + changes.push(op("records", outgoing)); + } + } + for (const r of group) + changes.push( + op("records", { ...r, execution, reason: null, returnId }), + ); + await state.commit(changes); + } + } + return completions; +} diff --git a/src/core/relay/engine.js b/src/core/relay/engine.js new file mode 100644 index 0000000..5e7e0f0 --- /dev/null +++ b/src/core/relay/engine.js @@ -0,0 +1,361 @@ +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { openRelayState, relayId } from "./state.js"; +import { createRelayCodex } from "./codex.js"; +import { + hash, + op, + records, + messageText, + receipt, + createRecordFactory, +} from "./records.js"; +import { createIntake } from "./intake.js"; +import { createCompletion } from "./completion.js"; +import { + connectGateway, + resolveRef, + getTranscriptTail, + sendPrompt, +} from "../gateway.js"; + +function createGateway() { + return { + resolve: async (ref) => resolveRef(await connectGateway(), ref), + tail: async (id) => getTranscriptTail(await connectGateway(), id, 200), + send: async (id, text, extra) => + sendPrompt(await connectGateway(), id, text, extra), + }; +} +/** Core owns policy and durable intents; its caller must hold the single-worker lock. */ +export async function openRelayEngine({ + stateDir, + profile, + env = process.env, + gateway = createGateway(), + clock = Date.now, + openSession, + openConversation, + limits, +} = {}) { + relayId.parse(profile); + const state = await openRelayState({ + dir: + stateDir ?? + env.GROK_BOT_RELAY_DIR ?? + join(homedir(), ".grok-bot-cli", "relay", hash(profile).slice(0, 24)), + profile, + limits, + }); + const controller = new AbortController(); + let closed = false, + queue = Promise.resolve(), + lastError = null; + const codex = createRelayCodex({ + env, + read: state.read, + signal: controller.signal, + clock, + openSession, + openConversation, + }); + const stoppedBindings = new Set(); + const runnable = (r) => + !closed && + (!r.bindingId || + (!stoppedBindings.has(r.bindingId) && + state.read().bindings[r.bindingId]?.state === "running")); + const change = async (section, value) => state.commit([op(section, value)]); + const update = async (id, patch) => + change("records", { ...state.read().records[id], ...patch }); + const { envelope, newRecord } = createRecordFactory({ env, clock }); + const { baseline, poll } = createIntake({ + state, + gateway, + clock, + newRecord, + stoppedBindings, + }); + const completions = createCompletion({ + state, + codex, + update, + newRecord, + runnable, + }); + function serial(fn) { + const work = queue.then(() => { + if (closed) throw new Error("Relay engine closed"); + return fn(); + }); + queue = work.then( + () => {}, + () => {}, + ); + return work; + } + async function route(input) { + if (input.bindingId) { + const b = state.read().bindings[input.bindingId]; + if (!b || b.state !== "running") + throw new Error("Unknown or stopped binding"); + await codex.verify(b); + return b; + } + const threadId = relayId.parse(input.codexThreadId), + target = await gateway.resolve(input.grokTarget), + targetId = relayId.parse(target.id); + const verified = await codex.verify({ + threadId, + expectedCwd: input.expectedCwd, + }); + const busyPolicy = input.busyPolicy ?? "steer"; + if (!["steer", "reject"].includes(busyPolicy)) + throw new Error("Unsupported busy policy"); + return { targetId, threadId, expectedCwd: verified.cwd, busyPolicy }; + } + async function submit(id) { + let r = state.read().records[id]; + if (r.submission !== "prepared" || !runnable(r)) return r; + if (r.kind === "codex") { + try { + await codex.prepare(r); + } catch { + await update(id, { execution: "paused", reason: "codex-disconnected" }); + return state.read().records[id]; + } + } + if (!runnable(r)) return state.read().records[id]; + // A persisted sending intent is always uncertain on restart, regardless of client ID. + await update(id, { + submission: "sending", + execution: "pending", + reason: null, + }); + r = state.read().records[id]; + let result; + try { + result = + r.kind === "codex" + ? await codex.send(r) + : await gateway.send(r.targetId, r.text, { + clientNonce: r.clientId, + ...(r.sourceIds[0] ? { replyToId: r.sourceIds[0] } : {}), + }); + } catch (error) { + result = { + delivery: error.delivery === "rejected" ? "rejected" : "unknown", + reason: + error.delivery === "rejected" + ? "submission-rejected" + : "transport-uncertain", + }; + } + const delivery = ["accepted", "rejected", "unknown"].includes( + result?.delivery, + ) + ? result.delivery + : "unknown"; + await update(id, { + submission: delivery, + messageId: + typeof result?.messageId === "string" ? result.messageId : null, + turnId: typeof result?.turnId === "string" ? result.turnId : null, + reason: + delivery === "unknown" + ? "delivery-unknown" + : delivery === "rejected" + ? (result.reason ?? "rejected").slice(0, 1024) + : null, + }); + return state.read().records[id]; + } + async function startBinding(input) { + const id = "binding:" + hash(input.requestId ?? randomUUID()), + fingerprint = hash({ ...input, requestId: undefined }); + const old = state.read().bindings[id]; + if (old) { + if (old.fingerprint !== fingerprint) + throw new Error("Idempotency conflict"); + return old; + } + const r = await route(input); + if ( + Object.values(state.read().bindings).some( + (b) => b.targetId === r.targetId && b.state === "running", + ) + ) + throw new Error("Grok target already has an active binding"); + // Bring existing tracked requests to a safe checkpoint before starting the new link. + if (state.read().targets[r.targetId]) await poll(r.targetId, true); + await baseline(r.targetId); + if (state.read().targets[r.targetId].state !== "running") + throw new Error("Target coverage is paused"); + const binding = { + id, + ...r, + state: "running", + createdAt: clock(), + createdCursor: state.read().targets[r.targetId].cursor, + fingerprint, + }; + await change("bindings", binding); + return binding; + } + async function send(kind, input) { + const id = "exchange:" + hash(input.requestId ?? randomUUID()), + fingerprint = hash({ kind, ...input, requestId: undefined }); + const old = state.read().records[id]; + if (old) { + if (old.fingerprint !== fingerprint) + throw new Error("Idempotency conflict"); + return receipt(old); + } + const text = messageText(input.message); + envelope(input); + const r = await route(input); + await baseline(r.targetId); + if (state.read().targets[r.targetId].state !== "running") + throw new Error("Target coverage is paused"); + const record = newRecord(kind, id, r, text, { + ...input, + fingerprint, + returnToGrok: kind === "codex", + }); + await change("records", record); + return receipt(await submit(id)); + } + async function reconcile() { + for (const r of records(state.read()) + .filter( + (r) => + r.kind === "codex" && + ["sending", "unknown"].includes(r.submission) && + runnable(r), + ) + .slice(0, 20)) { + try { + const observed = await codex.reconcile(r); + if (observed) + await update(r.id, { + submission: "accepted", + turnId: observed.turnId, + messageId: r.clientId, + reason: null, + }); + else + await update(r.id, { + submission: "unknown", + reason: "client-id-not-found", + }); + } catch { + await update(r.id, { + submission: "unknown", + reason: "history-coverage-unavailable", + }); + } + } + } + async function tick() { + lastError = null; + try { + // Reconnect/reconcile receipts before admitting new inbound deliveries. + await reconcile(); + await completions(); + for (const target of Object.values(state.read().targets)) { + const needed = + Object.values(state.read().bindings).some( + (b) => b.targetId === target.id && b.state === "running", + ) || + records(state.read()).some( + (r) => + r.targetId === target.id && runnable(r) && r.kind !== "codex", + ); + if (needed) await poll(target.id); + } + for (const r of records(state.read()) + .filter((r) => r.submission === "prepared" && runnable(r)) + .slice(0, 20)) + await submit(r.id); + await completions(); + for (const r of records(state.read()) + .filter( + (r) => + r.kind === "grok-return" && + r.submission === "prepared" && + runnable(r), + ) + .slice(0, 20)) + await submit(r.id); + } catch (error) { + lastError = /capacity|budget/i.test(error.message) + ? "capacity" + : "relay-error"; + throw error; + } + return status(); + } + function status({ bindingId, limit = 50 } = {}) { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) + throw new Error("Status limit must be 1..100"); + const s = state.read(); + if (bindingId && !s.bindings[bindingId]) throw new Error("Unknown binding"); + const selected = records(s).filter( + (r) => !bindingId || r.bindingId === bindingId, + ); + return { + profile, + generation: codex.generation, + codex: codex.connection, + state: closed ? "stopped" : lastError ? "paused" : "running", + reason: lastError, + bindings: Object.values(s.bindings).filter( + (b) => !bindingId || b.id === bindingId, + ), + targets: Object.values(s.targets).filter( + (t) => !bindingId || s.bindings[bindingId].targetId === t.id, + ), + receipts: selected.slice(-limit).map((r) => ({ + ...receipt(r), + returnDelivery: r.returnId ? s.records[r.returnId]?.submission : null, + })), + receiptCount: selected.length, + interactions: codex.interactions + .list() + .filter((i) => !bindingId || i.bindingIds.includes(bindingId)), + interactionOverflow: codex.interactions.overflow, + }; + } + return { + stateDir: state.dir, + startBinding: (input) => serial(() => startBinding(input)), + sendToGrok: (input) => serial(() => send("grok-request", input)), + sendToCodex: (input) => serial(() => send("codex", input)), + tick: () => serial(tick), + status, + async stopBinding({ bindingId }) { + const b = state.read().bindings[bindingId]; + if (!b) throw new Error("Unknown binding"); + stoppedBindings.add(bindingId); + await change("bindings", { ...b, state: "stopped" }); + if ( + !Object.values(state.read().bindings).some( + (b) => b.state === "running", + ) && + !records(state.read()).some((r) => !r.bindingId) + ) + await codex.close(); + return state.read().bindings[bindingId]; + }, + respond: (input) => codex.interactions.respond(input), + async close() { + if (closed) return; + closed = true; + controller.abort(); + await codex.close(); + await queue; + await state.close(); + }, + }; +} diff --git a/src/core/relay/intake.js b/src/core/relay/intake.js new file mode 100644 index 0000000..34566a9 --- /dev/null +++ b/src/core/relay/intake.js @@ -0,0 +1,220 @@ +import { entryText, sourceEntryId } from "../transcript.js"; +import { op, hash, MAX_TEXT, pageEntries, messageText } from "./records.js"; + +/** Correlate the whole page before atomically recording intake and advancing its checkpoint. */ +export function createIntake({ + state, + gateway, + clock, + newRecord, + stoppedBindings, +}) { + const change = (section, value) => state.commit([op(section, value)]); + async function baseline(targetId) { + if (state.read().targets[targetId]) return; + const page = pageEntries(await gateway.tail(targetId)); + await change("targets", { + id: targetId, + cursor: page.length ? sourceEntryId(page.at(-1)) : null, + baseline: true, + state: "running", + reason: null, + nextPoll: 0, + failures: 0, + }); + } + async function poll(targetId, force = false) { + let target = state.read().targets[targetId]; + if (target.state === "paused" || (!force && target.nextPoll > clock())) + return; + let page; + try { + page = pageEntries(await gateway.tail(targetId)); + } catch (error) { + const coverage = + error.name === "ZodError" || /coverage/.test(error.message), + auth = [401, 403].includes(error.status); + await change("targets", { + ...target, + state: coverage || auth ? "paused" : "backoff", + reason: coverage + ? "invalid-coverage" + : auth + ? "auth" + : "gateway-disconnected", + failures: target.failures + 1, + nextPoll: + clock() + Math.min(30000, 1000 * 2 ** Math.min(target.failures, 5)), + }); + return; + } + const current = page.length ? sourceEntryId(page.at(-1)) : null; + const index = + target.cursor === null + ? -1 + : page.findIndex((e) => sourceEntryId(e) === target.cursor); + if ( + (target.cursor !== null && index === -1) || + (target.cursor === null && page.length === 200) + ) { + await change("targets", { + ...target, + state: "paused", + reason: "gap", + observedCursor: current, + }); + return; + } + const local = { ...state.read().records }, + changes = []; + // Gather every nonce before classifying bot outputs, even if its user row comes later. + for (const entry of page) { + if (typeof entry.clientNonce !== "string") continue; + const matches = Object.values(local).filter( + (r) => + r.targetId === targetId && + r.kind !== "codex" && + r.clientId === entry.clientNonce, + ); + if (!matches.length) continue; + if (typeof entry.requestId !== "string" || !entry.requestId) { + await change("targets", { + ...target, + state: "paused", + reason: "correlation-missing", + }); + return; + } + for (const r of matches) { + if (r.requestId && r.requestId !== entry.requestId) { + await change("targets", { + ...target, + state: "paused", + reason: "correlation-conflict", + }); + return; + } + const next = { + ...r, + submission: "accepted", + requestId: entry.requestId, + messageId: sourceEntryId(entry), + reason: null, + }; + local[r.id] = next; + changes.push(op("records", next)); + } + } + const own = new Set( + Object.values(local) + .filter( + (r) => + r.targetId === targetId && r.kind === "grok-return" && r.requestId, + ) + .map((r) => r.requestId), + ); + const requests = new Map( + Object.values(local) + .filter( + (r) => + r.targetId === targetId && r.kind === "grok-request" && r.requestId, + ) + .map((r) => [r.requestId, r]), + ); + const binding = Object.values(state.read().bindings).find( + (b) => + b.targetId === targetId && + b.state === "running" && + !stoppedBindings.has(b.id), + ); + const unresolved = Object.values(local).some( + (r) => + r.targetId === targetId && + r.kind !== "codex" && + ["sending", "unknown", "accepted"].includes(r.submission) && + !r.requestId, + ); + const incoming = page.slice(index + 1); + for (const entry of incoming) { + if (entry.kind !== "send-message" || own.has(entry.requestId)) continue; + const parent = requests.get(entry.requestId); + // Without nonce coverage, unsolicited classification could echo a return or misroute a reply. + if (!parent && unresolved) { + await state.commit([ + ...changes, + op("targets", { + ...target, + state: "backoff", + reason: "correlation-pending", + nextPoll: clock() + 2000, + }), + ]); + return; + } + const destination = parent ?? binding; + if (!destination) continue; + if (parent && parent.hop + 1 >= parent.maxHops) { + await state.commit([ + ...changes, + op("targets", { ...target, state: "paused", reason: "hop-limit" }), + ]); + return; + } + const sourceId = sourceEntryId(entry), + id = "inbound:" + hash([targetId, sourceId]); + if (local[id]) continue; + let body; + try { + body = messageText(entryText(entry)); + } catch { + await change("targets", { + ...target, + state: "paused", + reason: "message-size", + }); + return; + } + const text = `[Grok sender ${targetId}; message ${sourceId}]\n${parent ? "Reply to a tracked request. Your next final answer is not returned automatically." : "Linked conversation. Your corresponding final answer returns automatically to Grok."}\n\n${body}`; + if (Buffer.byteLength(text) > MAX_TEXT) { + await change("targets", { + ...target, + state: "paused", + reason: "message-size", + }); + return; + } + const record = newRecord("codex", id, destination, text, { + sourceIds: [sourceId], + parentId: parent?.id, + returnToGrok: !parent, + correlationId: parent?.correlationId, + hop: parent ? parent.hop + 1 : 0, + maxHops: parent?.maxHops, + }); + local[id] = record; + changes.push(op("records", record)); + } + target = { + ...target, + cursor: current, + reason: null, + state: "running", + failures: 0, + nextPoll: 0, + }; + try { + await state.commit([...changes, op("targets", target)]); + } catch (error) { + if (/capacity|budget/i.test(error.message)) { + await change("targets", { + ...state.read().targets[targetId], + state: "paused", + reason: "capacity", + }); + return; + } + throw error; + } + } + return { baseline, poll }; +} diff --git a/src/core/relay/interactions.js b/src/core/relay/interactions.js new file mode 100644 index 0000000..0ab7d51 --- /dev/null +++ b/src/core/relay/interactions.js @@ -0,0 +1,168 @@ +import { z } from "zod"; +const approvals = [ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", +]; +const questionMethod = "item/tool/requestUserInput"; +const bound = (s) => (typeof s === "string" ? s.slice(0, 4096) : undefined); +const answerSchema = z.strictObject({ + answers: z.record( + z.string().min(1).max(128), + z.strictObject({ answers: z.array(z.string().max(4096)).max(10) }), + ), +}); + +/** Volatile connection ownership: requests never survive a connection generation. */ +export class InteractionRegistry { + constructor(read) { + this.read = read; + this.pending = new Map(); + this.reset(null, null); + } + reset(generation, client) { + this.generation = generation; + this.client = client; + this.pending.clear(); + this.sequence = 0; + this.overflow = false; + } + observe(event) { + if (event.method === "serverRequest/resolved") { + this.pending.delete(event.params?.requestId); + return; + } + if (event.kind !== "interaction" || !this.generation) return; + const p = event.params, + threadId = p?.threadId ?? p?.thread_id, + turnId = p?.turnId ?? p?.turn_id; + if (typeof threadId !== "string" || typeof turnId !== "string") return; + if (this.pending.has(event.id)) return; + if (this.pending.size >= 100) { + this.overflow = true; + return; + } + let supported = + (approvals.includes(event.method) && p.grantRoot == null) || + (event.method === questionMethod && + Array.isArray(p.questions) && + p.questions.length <= 10 && + !p.questions.some((q) => q.isSecret)); + let fields = {}; + for (const key of ["reason", "command", "cwd"]) + if (typeof p[key] === "string") fields[key] = bound(p[key]); + if (Array.isArray(p.availableDecisions)) + fields.availableDecisions = p.availableDecisions.filter((d) => + ["accept", "decline", "cancel"].includes(d), + ); + if (supported && event.method === questionMethod) + fields.questions = p.questions.map((q) => ({ + id: bound(q.id), + header: bound(q.header), + question: bound(q.question), + options: Array.isArray(q.options) + ? q.options.slice(0, 10).map((o) => ({ + label: bound(o.label), + description: bound(o.description), + })) + : null, + })); + if (Buffer.byteLength(JSON.stringify(fields)) > 8192) { + fields = { + reason: "Interaction details exceed relay bounds; use owning Codex UI", + }; + supported = false; + } + this.pending.set(event.id, { + interactionId: this.generation + ":" + ++this.sequence, + rpcId: event.id, + generation: this.generation, + threadId, + turnId, + method: event.method, + supported, + ...fields, + }); + } + owners(item) { + return Object.values(this.read().records).filter( + (r) => + r.kind === "codex" && + r.threadId === item.threadId && + r.turnId === item.turnId && + r.submission === "accepted" && + (!r.bindingId || + this.read().bindings[r.bindingId]?.state === "running"), + ); + } + list() { + return [...this.pending.values()].flatMap(({ rpcId, ...item }) => { + const owners = this.owners(item); + return owners.length + ? [ + { + ...item, + exchangeIds: owners.map((r) => r.id), + bindingIds: [ + ...new Set(owners.map((r) => r.bindingId).filter(Boolean)), + ], + }, + ] + : []; + }); + } + respond({ + interactionId, + generation, + threadId, + turnId, + bindingId, + exchangeId, + result, + }) { + const item = [...this.pending.values()].find( + (item) => item.interactionId === interactionId, + ); + if ( + !item || + generation !== this.generation || + generation !== item.generation || + threadId !== item.threadId || + turnId !== item.turnId + ) + throw new Error("Stale or foreign Codex interaction"); + const owners = this.owners(item); + if ( + !owners.some( + (r) => + (bindingId || exchangeId) && + (!bindingId || r.bindingId === bindingId) && + (!exchangeId || r.id === exchangeId), + ) + ) + throw new Error("Response requires exact binding or exchange ownership"); + if (!item.supported) + throw new Error("Interaction requires the owning Codex UI"); + let response; + if (approvals.includes(item.method)) { + response = z + .strictObject({ decision: z.enum(["accept", "decline", "cancel"]) }) + .parse(result); + if ( + item.availableDecisions && + !item.availableDecisions.includes(response.decision) + ) + throw new Error("Decision not offered by Codex"); + } else { + response = answerSchema.parse(result); + const ids = item.questions.map((q) => q.id); + if ( + Object.keys(response.answers).length !== ids.length || + !ids.every((id) => Object.hasOwn(response.answers, id)) + ) + throw new Error("Answers must match exact pending question IDs"); + } + this.pending.delete(item.rpcId); + this.client.respond(item.rpcId, response); + return { resolved: true, interactionId, generation }; + } +} diff --git a/src/core/relay/records.js b/src/core/relay/records.js new file mode 100644 index 0000000..0534772 --- /dev/null +++ b/src/core/relay/records.js @@ -0,0 +1,124 @@ +import { createHash, randomUUID } from "node:crypto"; +import { sourceEntryId, transcriptEntries } from "../transcript.js"; +import { canonicalJson } from "@agent-bundle/runtime/state"; +import { buildEnvelope } from "../codex-bridge.js"; +import { relayId } from "./state.js"; +export const hash = (value) => + createHash("sha256") + .update(canonicalJson(JSON.parse(JSON.stringify(value)))) + .digest("hex"); +export const op = (section, value) => ({ section, key: value.id, value }); +export const terminal = (r) => + ["completed", "failed", "interrupted"].includes(r.execution); +export const records = (s) => Object.values(s.records); +export const MAX_TEXT = 65536; +export function messageText(value) { + if ( + typeof value !== "string" || + !value.trim() || + Buffer.byteLength(value) > MAX_TEXT + ) + throw new Error("Relay message must contain text within 64 KiB"); + return value; +} +export function pageEntries(payload) { + const raw = payload?.transcript ?? payload; + if ( + !Array.isArray(raw) && + (!raw || ![raw.entries, raw.messages, raw.items].some(Array.isArray)) + ) + throw new Error("Invalid transcript coverage envelope"); + const page = Array.isArray(payload) + ? payload + : transcriptEntries(payload?.transcript ?? payload); + if (page.length > 200) + throw new Error("Transcript coverage exceeds 200 entries"); + const ids = new Set(); + for (const e of page) { + const id = sourceEntryId(e); + relayId.parse(id); + if (ids.has(id) || typeof e.kind !== "string" || !e.kind) + throw new Error("Invalid transcript coverage"); + ids.add(id); + if ( + e.kind === "send-message" && + (typeof e.requestId !== "string" || !e.requestId) + ) + throw new Error("Message coverage lacks requestId"); + } + return page; +} +export function receipt(record) { + return { + exchangeId: record.id, + kind: record.kind, + target: { id: record.targetId }, + clientId: record.clientId, + correlationId: record.correlationId, + requestId: record.requestId, + sourceIds: record.sourceIds.slice(0, 8), + sourceCount: record.sourceIds.length, + returnId: record.returnId, + hop: record.hop, + maxHops: record.maxHops, + delivery: record.submission, + ...(record.messageId ? { messageId: record.messageId } : {}), + ...(record.turnId ? { turnId: record.turnId } : {}), + replyRoute: { + mode: "auto", + threadId: record.threadId, + targetId: record.targetId, + bindingId: record.bindingId, + }, + execution: record.execution, + reason: record.reason, + }; +} + +export function createRecordFactory({ env, clock }) { + function envelope(input = {}) { + const canonical = buildEnvelope({ + correlationId: input.correlationId, + hop: input.hop, + env, + }); + const maxHops = Math.min( + input.maxHops ?? canonical.maxHops, + canonical.maxHops, + ); + if (!Number.isInteger(maxHops) || canonical.hop >= maxHops) + throw new Error("Relay hop limit reached"); + return { + hop: canonical.hop, + maxHops, + correlationId: canonical.correlationId, + }; + } + function newRecord(kind, id, r, text, input = {}) { + return { + id, + kind, + targetId: r.targetId, + threadId: r.threadId, + expectedCwd: r.expectedCwd, + busyPolicy: r.busyPolicy, + bindingId: r.bindingId ?? (r.id?.startsWith("binding:") ? r.id : null), + text, + sourceIds: input.sourceIds ?? [], + parentId: input.parentId ?? null, + returnToGrok: input.returnToGrok ?? false, + clientId: randomUUID(), + ...envelope(input), + submission: "prepared", + execution: "pending", + reason: null, + turnId: null, + requestId: null, + messageId: null, + returnId: null, + createdAt: clock(), + fingerprint: input.fingerprint ?? hash([kind, id]), + }; + } + return { envelope, newRecord }; +} diff --git a/src/core/relay/state.js b/src/core/relay/state.js new file mode 100644 index 0000000..4d30de9 --- /dev/null +++ b/src/core/relay/state.js @@ -0,0 +1,263 @@ +import { lstat, mkdir, chmod } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { defineState } from "@agent-bundle/runtime/state"; + +export const relayId = z.string().min(1).max(512); +const text = z + .string() + .refine((s) => Buffer.byteLength(s) <= 65536, "text exceeds 64 KiB"); +const reason = z.string().max(1024).nullable(); +const targetSchema = z.strictObject({ + id: relayId, + cursor: relayId.nullable(), + observedCursor: relayId.nullable().optional(), + baseline: z.literal(true), + state: z.enum(["running", "paused", "backoff"]), + reason, + nextPoll: z.number().nonnegative(), + failures: z.number().int().nonnegative(), +}); +const bindingSchema = z.strictObject({ + id: relayId, + targetId: relayId, + threadId: relayId, + expectedCwd: z.string().max(4096), + busyPolicy: z.enum(["steer", "reject"]), + state: z.enum(["running", "stopped"]), + createdAt: z.number(), + createdCursor: relayId.nullable(), + fingerprint: z.string(), +}); +const recordSchema = z.strictObject({ + id: relayId, + kind: z.enum(["grok-request", "grok-return", "codex"]), + targetId: relayId, + threadId: relayId, + expectedCwd: z.string().max(4096), + busyPolicy: z.enum(["steer", "reject"]), + bindingId: relayId.nullable(), + text, + sourceIds: z.array(relayId).max(200), + parentId: relayId.nullable(), + returnToGrok: z.boolean(), + clientId: relayId, + correlationId: relayId, + hop: z.number().int().nonnegative(), + maxHops: z.number().int().positive(), + submission: z.enum([ + "prepared", + "sending", + "accepted", + "rejected", + "unknown", + ]), + execution: z.enum([ + "pending", + "completed", + "failed", + "interrupted", + "needs-input", + "paused", + ]), + reason, + turnId: relayId.nullable(), + requestId: relayId.nullable(), + messageId: relayId.nullable(), + returnId: relayId.nullable(), + createdAt: z.number(), + fingerprint: z.string(), +}); +const sections = { + targets: targetSchema, + bindings: bindingSchema, + records: recordSchema, +}; +const operation = z.discriminatedUnion( + "section", + Object.entries(sections).map(([section, schema]) => + z.strictObject({ + section: z.literal(section), + key: relayId, + value: schema, + }), + ), +); +const DEFAULTS = { + targets: 100, + bindings: 100, + records: 1000, + stateBytes: 8 * 1024 * 1024, + diskBytes: 64 * 1024 * 1024, +}; + +/** Check before SQLite opens any database, WAL, SHM or worker control path. */ +export async function protectRelayDirectory( + dir, + maxBytes = DEFAULTS.diskBytes, +) { + await mkdir(dir, { recursive: true, mode: 0o700 }); + const root = await lstat(dir); + if ( + !root.isDirectory() || + root.isSymbolicLink() || + (process.getuid && root.uid !== process.getuid()) + ) + throw new Error("Relay directory must be owned and not a symlink"); + await chmod(dir, 0o700); + let totalBytes = 0; + for (const name of [ + "relay.sqlite", + "relay.sqlite-wal", + "relay.sqlite-shm", + "worker.lock", + ]) { + const path = join(dir, name); + let info; + try { + info = await lstat(path); + } catch (error) { + if (error.code === "ENOENT") continue; + throw error; + } + if ( + !info.isFile() || + info.isSymbolicLink() || + (process.getuid && info.uid !== process.getuid()) + ) + throw new Error( + "Relay storage must be an owned regular file, never a symlink", + ); + totalBytes += info.size; + if (totalBytes > maxBytes) + throw new Error("Relay storage capacity exceeded"); + await chmod(path, 0o600); + } +} + +/** The worker owns exclusivity; this wrapper serializes only durable mutations, never network waits. */ +export async function openRelayState({ dir, profile, limits: overrides = {} }) { + const limits = { ...DEFAULTS, ...overrides }; + for (const value of Object.values(limits)) + if (!Number.isSafeInteger(value) || value < 1) + throw new Error("Invalid relay capacity"); + relayId.parse(profile); + dir = resolve(dir); + await protectRelayDirectory(dir, limits.diskBytes); + const schema = z + .strictObject({ + version: z.literal(1), + profile: relayId, + ...Object.fromEntries( + Object.entries(sections).map(([name, s]) => [ + name, + z.record(relayId, s), + ]), + ), + }) + .superRefine((state, ctx) => { + for (const section of Object.keys(sections)) { + if (Object.keys(state[section]).length > limits[section]) + ctx.addIssue({ code: "custom", message: "Relay capacity exceeded" }); + for (const [key, value] of Object.entries(state[section])) + if (key !== value.id) + ctx.addIssue({ code: "custom", message: "Relay key mismatch" }); + } + }); + const definition = defineState({ + id: "grok-codex-relay", + version: 1, + lifetime: "workspace-durable", + schema, + initial: { version: 1, profile, targets: {}, bindings: {}, records: {} }, + events: { transition: z.array(operation).min(1).max(250) }, + budgets: { + maxStateBytes: limits.stateBytes, + maxEventBytes: 1024 * 1024, + maxRevisions: 20000, + maxCommitMs: 5000, + }, + reduce(state, event) { + const next = { ...state }; + for (const op of event.payload) + next[op.section] = { ...next[op.section], [op.key]: op.value }; + return next; + }, + }); + // Keep node:sqlite out of ordinary stateless commands. + const { createSqliteStateDriver } = await import( + "@agent-bundle/runtime/state/sqlite" + ); + const driver = createSqliteStateDriver({ file: join(dir, "relay.sqlite") }); + let store; + try { + store = await driver.open(definition); + await protectRelayDirectory(dir, limits.diskBytes); + } catch (error) { + await driver.close(); + throw new Error( + "Relay state/profile could not be opened: " + error.message, + { cause: error }, + ); + } + let snapshot = await store.read(), + queue = Promise.resolve(), + closed = false; + if (snapshot.state.profile !== profile) { + await driver.close(); + throw new Error("Relay profile mismatch"); + } + return { + dir, + limits, + read: () => snapshot.state, + inspect: () => store.inspect(), + commit(ops) { + const run = queue.then(async () => { + if (closed) throw new Error("Relay state closed"); + operation.array().parse(ops); + const changes = ops.filter( + (op) => + JSON.stringify(snapshot.state[op.section][op.key]) !== + JSON.stringify(op.value), + ); + if (!changes.length) return snapshot.state; + for (const section of Object.keys(sections)) { + const keys = new Set([ + ...Object.keys(snapshot.state[section]), + ...changes + .filter((op) => op.section === section) + .map((op) => op.key), + ]); + if (keys.size > limits[section]) + throw new Error("Relay capacity exceeded"); + } + await protectRelayDirectory(dir, limits.diskBytes); + snapshot = await store.dispatch("transition", changes, { + idempotencyKey: randomUUID(), + expectedRevision: snapshot.revision, + }); + const journal = await store.inspect(); + if (journal.records >= 8 || journal.journalBytes > 8 * 1024 * 1024) + snapshot = await store.compact({ + expectedRevision: snapshot.revision, + }); + await protectRelayDirectory(dir, limits.diskBytes); + return snapshot.state; + }); + queue = run.then( + () => {}, + () => {}, + ); + return run; + }, + async close() { + await queue; + if (!closed) { + closed = true; + await driver.close(); + } + }, + }; +} diff --git a/test/codex-bridge.test.js b/test/codex-bridge.test.js index 62c13bc..c14de32 100644 --- a/test/codex-bridge.test.js +++ b/test/codex-bridge.test.js @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { fakeAppServer, createCodexFixtureHome } from "./helpers/codex-server.js"; import { decodeFrame, encodeFrame, websocketAccept, connectCodexAppServer, sendToCodexThread, codexSocketPath, codexStatus, detectDesktopPrivateAppServer, unreachableMessage } from "../src/core/codex-bridge.js"; import { desktopShimStatus } from "../src/core/desktop-shim.js"; @@ -19,62 +20,6 @@ const THREADS = [ { id: "t-2", status: { type: "notLoaded" }, name: null, preview: "second thread\npreview", cwd: "/repo/b", source: "cli", updatedAt: 1700000000 }, ]; -/** - * Fake Codex app-server: WebSocket over a Unix socket under a scratch CODEX_HOME. - * `handlers[method](params, reply)` answers each request; `received` keeps every inbound message. - */ -async function fakeAppServer(handlers) { - const home = mkdtempSync(join(tmpdir(), "gbot-codex-")); - mkdirSync(join(home, "app-server-control")); - const socketPath = join(home, "app-server-control", "app-server-control.sock"); - const received = []; - const sockets = new Set(); - let resolveDisconnected; - const disconnected = new Promise((resolve) => { resolveDisconnected = resolve; }); - const server = createServer(); - server.on("upgrade", (req, socket) => { - sockets.add(socket); - socket.on("close", () => { - sockets.delete(socket); - resolveDisconnected(); - }); - socket.write( - "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" - + "Sec-WebSocket-Accept: " + websocketAccept(req.headers["sec-websocket-key"]) + "\r\n\r\n", - ); - const send = (obj) => socket.write(encodeFrame(0x1, Buffer.from(JSON.stringify(obj)))); - let buf = Buffer.alloc(0); - socket.on("data", (chunk) => { - buf = Buffer.concat([buf, chunk]); - for (;;) { - const frame = decodeFrame(buf); - if (!frame) return; - buf = frame.rest; - if (frame.opcode === 0x8) { socket.end(); return; } - if (frame.opcode !== 0x1) continue; - const msg = JSON.parse(frame.payload.toString()); - received.push(msg); - if (msg.method && msg.id != null) { - const handler = handlers[msg.method]; - if (!handler) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "unknown method " + msg.method } }); - else handler(msg.params, (result) => send({ jsonrpc: "2.0", id: msg.id, result }), (error) => send({ jsonrpc: "2.0", id: msg.id, error }), send, socket); - } - } - }); - socket.on("error", () => {}); - }); - await new Promise((resolve) => server.listen(socketPath, resolve)); - return { - home, - received, - disconnected, - close: () => new Promise((resolve) => { - for (const sock of sockets) sock.destroy(); - server.close(resolve); - }), - }; -} - const baseHandlers = { initialize: (params, ok) => ok({ userAgent: "gbot/0.154.0 (Ubuntu 24.4.0; x86_64) dumb (" + params.clientInfo.name + ")", codexHome: "/fake" }), "thread/list": (params, ok) => ok({ data: THREADS.slice(0, params.limit), nextCursor: params.limit < THREADS.length ? "cursor-2" : null }), @@ -424,7 +369,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 = createCodexFixtureHome("gbot-codex-badhs-"); mkdirSync(join(home, "app-server-control")); const socketPath = join(home, "app-server-control", "app-server-control.sock"); let serverSocket = null; @@ -560,7 +505,7 @@ test("a JSON null message fails as malformed instead of crashing on msg.id", asy }); test("handshake timeout is absolute; trickled bytes do not extend it", async () => { - const home = mkdtempSync(join(tmpdir(), "gbot-codex-trickle-")); + const home = createCodexFixtureHome("gbot-codex-trickle-"); mkdirSync(join(home, "app-server-control")); const socketPath = join(home, "app-server-control", "app-server-control.sock"); const server = createTcpServer((sock) => { @@ -581,7 +526,7 @@ test("handshake timeout is absolute; trickled bytes do not extend it", async () }); test("codex status rejects terminated oversized handshake headers", async () => { - const home = mkdtempSync(join(tmpdir(), "gbot-codex-bighdr-")); + const home = createCodexFixtureHome("gbot-codex-bighdr-"); mkdirSync(join(home, "app-server-control")); const socketPath = join(home, "app-server-control", "app-server-control.sock"); const server = createTcpServer((sock) => { @@ -729,7 +674,7 @@ test("codex status distinguishes permission-denied and stale files from an absen }); test("codex status reports connect-failed when the socket exists but nothing answers", async () => { - const home = mkdtempSync(join(tmpdir(), "gbot-codex-dead-")); + const home = createCodexFixtureHome("gbot-codex-dead-"); mkdirSync(join(home, "app-server-control")); const socketPath = join(home, "app-server-control", "app-server-control.sock"); const server = createTcpServer((sock) => sock.destroy()); @@ -1042,7 +987,7 @@ test("codex status classifies initialize failures as handshake-failed and off-sc }); test("codex status reports permission-denied when connect fails with EACCES", { skip: process.platform === "win32" || process.getuid?.() === 0 }, async () => { - const home = mkdtempSync(join(tmpdir(), "gbot-codex-eacces-")); + const home = createCodexFixtureHome("gbot-codex-eacces-"); mkdirSync(join(home, "app-server-control")); const socketPath = join(home, "app-server-control", "app-server-control.sock"); const server = createTcpServer((sock) => sock.destroy()); diff --git a/test/codex-conversation.test.js b/test/codex-conversation.test.js index aec181e..a35ee9f 100644 --- a/test/codex-conversation.test.js +++ b/test/codex-conversation.test.js @@ -234,3 +234,21 @@ for (const failure of ['disconnect', 'reject']) { } }); } + +test('anchored collection excludes finals before steered user message', async () => { + const rows = [final, {id:'user',type:'userMessage',clientId:'steered'}, {...final,id:'after',text:'answer after steer'}]; + const fake = await fakeAppServer({...handlers,'thread/items/list':(_,ok)=>ok({data:rows.map(item=>({turnId:'turn-1',item})),nextCursor:null})}); + const c = await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try { + assert.equal((await c.wait({turnId:'turn-1',afterMessageId:'steered'})).reply.text,'answer after steer'); + const missing = await c.wait({turnId:'turn-1',afterMessageId:'missing'}); + assert.equal(missing.execution.state,'unknown'); assert.equal(missing.reply.text,''); assert.match(missing.execution.error,/anchor/); + assert.equal((await c.wait({turnId:'turn-1',afterMessageId:['steered','missing']})).execution.state,'unknown'); + } finally {await c.close();await fake.close();} +}); + +test('coalesced anchors select earliest actual user regardless of record order', async () => { + const rows = [final,{id:'u1',type:'userMessage',clientId:'first'},{...final,id:'between',text:'included'}, {id:'u2',type:'userMessage',clientId:'second'},{...final,id:'last',text:'last'}]; + const fake=await fakeAppServer({...handlers,'thread/items/list':(_,ok)=>ok({data:rows.map(item=>({turnId:'turn-1',item})),nextCursor:null})});const c=await openCodexConversation('thread-1',{env:{CODEX_HOME:fake.home}}); + try{assert.equal((await c.wait({turnId:'turn-1',afterMessageId:['second','first']})).reply.text,'included\nlast');}finally{await c.close();await fake.close();} +}); diff --git a/test/helpers/codex-server.js b/test/helpers/codex-server.js index 3d248b5..ed659e2 100644 --- a/test/helpers/codex-server.js +++ b/test/helpers/codex-server.js @@ -1,10 +1,17 @@ -import { mkdirSync, mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { decodeFrame, encodeFrame, websocketAccept } from "../../src/core/codex-bridge.js"; +export function createCodexFixtureHome(prefix = "gbot-codex-") { + // Darwin's sockaddr_un.sun_path holds 104 bytes including the NUL. Its + // per-user TMPDIR is often too long; retain mkdtemp isolation in a short root. + const candidate = join(tmpdir(), prefix + "XXXXXX", "app-server-control", "app-server-control.sock"); + const root = process.platform !== "win32" && Buffer.byteLength(candidate) >= 104 ? "/tmp" : tmpdir(); + return mkdtempSync(join(root, prefix)); +} export async function fakeAppServer(handlers) { - const home = mkdtempSync(join(tmpdir(), "gbot-codex-")); + const home = createCodexFixtureHome(); mkdirSync(join(home, "app-server-control")); const socketPath = join(home, "app-server-control", "app-server-control.sock"); const received = []; @@ -43,14 +50,19 @@ export async function fakeAppServer(handlers) { }); socket.on("error", () => {}); }); - await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + } catch (error) { rmSync(home, { recursive: true, force: true }); throw error; } return { home, received, disconnected, close: () => new Promise((resolve) => { for (const sock of sockets) sock.destroy(); - server.close(resolve); + server.close(() => { rmSync(home, { recursive: true, force: true }); resolve(); }); }), }; } diff --git a/test/relay-codex.test.js b/test/relay-codex.test.js new file mode 100644 index 0000000..9572ead --- /dev/null +++ b/test/relay-codex.test.js @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRelayCodex } from "../src/core/relay/codex.js"; + +test("failed session reconnect backs off with a bounded clock", async () => { + let calls = 0, + now = 0; + const c = createRelayCodex({ + env: {}, + read: () => ({ records: {} }), + clock: () => now, + openSession: async () => { + calls++; + throw new Error("offline"); + }, + }); + await assert.rejects(c.verify({ threadId: "t" })); + await assert.rejects(c.verify({ threadId: "t" })); + assert.equal(calls, 1); + now = 1000; + await assert.rejects(c.verify({ threadId: "t" })); + assert.equal(calls, 2); + await assert.rejects(c.verify({ threadId: "t" })); + assert.equal(calls, 2); + now = 3000; + await assert.rejects(c.verify({ threadId: "t" })); + assert.equal(calls, 3); + await c.close(); +}); +test("steer retries only definite stale guards and never falls back to turn/start", async () => { + let tries = 0; + const sends = []; + const session = { + client: { + closed: false, + onClose: () => {}, + close() {}, + request: async () => ({ + data: [{ id: "active", status: "inProgress" }], + nextCursor: null, + }), + }, + }; + const c = createRelayCodex({ + env: {}, + read: () => ({ records: {} }), + openSession: async () => session, + openConversation: async () => ({ + close() {}, + cwd: process.cwd(), + send: async (_, options) => { + sends.push(options); + tries++; + return { + delivery: "rejected", + reason: "rejected", + error: "expected turn guard mismatch", + }; + }, + }), + }); + const record = { + threadId: "thread", + expectedCwd: process.cwd(), + busyPolicy: "steer", + clientId: "client", + correlationId: "corr", + hop: 0, + maxHops: 4, + text: "message", + }; + assert.equal((await c.send(record)).delivery, "rejected"); + assert.equal(tries, 3); + assert.ok(sends.every((s) => s.whenBusy === "steer")); + await c.close(); +}); diff --git a/test/relay-completion.test.js b/test/relay-completion.test.js new file mode 100644 index 0000000..3b5ea10 --- /dev/null +++ b/test/relay-completion.test.js @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createCompletion } from "../src/core/relay/completion.js"; +function fixture(count, sameTurn = false) { + const data = { + records: Object.fromEntries( + Array.from({ length: count }, (_, i) => [ + String(i), + { + id: String(i), + kind: "codex", + submission: "accepted", + turnId: sameTurn ? "turn" : String(i), + targetId: "target", + threadId: "thread", + execution: "pending", + reason: null, + }, + ]), + ), + }, + seen = []; + const complete = createCompletion({ + state: { read: () => data }, + codex: { + wait: async (r) => { + seen.push(r.id); + return { execution: { state: "unknown" } }; + }, + }, + update: async (id, patch) => Object.assign(data.records[id], patch), + runnable: () => true, + }); + return { complete, seen, data }; +} +test("bounded completion scans rotate fairly through all pending turns", async () => { + const f = fixture(21); + await f.complete(); + await f.complete(); + assert.ok(f.seen.includes("20")); +}); +test("oversized coalesced turn pauses visibly before collector anchor limit", async () => { + const f = fixture(201, true); + await f.complete(); + assert.equal(f.seen.length, 0); + assert.ok( + Object.values(f.data.records).every( + (r) => r.execution === "paused" && r.reason === "capacity", + ), + ); +}); diff --git a/test/relay-engine.test.js b/test/relay-engine.test.js new file mode 100644 index 0000000..41d8067 --- /dev/null +++ b/test/relay-engine.test.js @@ -0,0 +1,566 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fakeAppServer } from "./helpers/codex-server.js"; + +async function fixture( + t, + { + active = false, + finalStatus = "completed", + finalText = "Codex answer", + disconnect = false, + } = {}, +) { + const dir = await mkdtemp(join(tmpdir(), "relay-engine-")); + const page = [], + sent = [], + items = []; + let state = active ? "inProgress" : finalStatus; + const fake = await fakeAppServer({ + initialize: (_, ok) => ok({}), + "thread/resume": (p, ok) => + ok({ + thread: { + id: p.threadId, + cwd: process.cwd(), + status: { type: active ? "active" : "idle" }, + }, + cwd: process.cwd(), + }), + "thread/turns/list": (_, ok) => + ok({ + data: items.length || active ? [{ id: "turn-1", status: state }] : [], + nextCursor: null, + }), + "thread/items/list": (_, ok) => + ok({ + data: items.map((item) => ({ turnId: "turn-1", item })), + nextCursor: null, + }), + "turn/start": (p, ok, err, send, socket) => { + items.push({ + id: "u" + items.length, + type: "userMessage", + clientId: p.clientUserMessageId, + }); + items.push({ + id: "a" + items.length, + type: "agentMessage", + phase: "final_answer", + text: finalText, + }); + if (disconnect) { + socket.destroy(); + return; + } + ok({ turn: { id: "turn-1", status: "inProgress" } }); + }, + "turn/steer": (p, ok) => { + items.push({ + id: "u" + items.length, + type: "userMessage", + clientId: p.clientUserMessageId, + }); + ok({ turnId: p.expectedTurnId }); + }, + }); + const gateway = { + resolve: async (ref) => ({ id: ref, name: "Fixture" }), + tail: async () => page.slice(-200), + send: async (targetId, text, extra) => { + sent.push({ targetId, text, ...extra }); + return { delivery: "unknown" }; + }, + }; + const { openRelayEngine } = await import("../src/core/relay/engine.js"); + let engine = await openRelayEngine({ + stateDir: dir, + profile: "fixture", + env: { CODEX_HOME: fake.home }, + gateway, + clock: () => 1000, + }); + t.after(async () => { + await engine.close(); + await fake.close(); + await rm(dir, { recursive: true, force: true }); + }); + return { + get engine() { + return engine; + }, + page, + sent, + items, + fake, + complete() { + state = "completed"; + items.push({ + id: "last", + type: "agentMessage", + phase: "final_answer", + text: "Combined answer", + }); + }, + async restart() { + await engine.close(); + engine = await openRelayEngine({ + stateDir: dir, + profile: "fixture", + env: { CODEX_HOME: fake.home }, + gateway, + clock: () => 1000, + }); + }, + }; +} +test("linked empty baseline forwards once, returns final and suppresses out-of-order own echo", async (t) => { + const f = await fixture(t); + await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + requestId: "link", + }); + f.page.push({ + id: "g1", + kind: "send-message", + requestId: "proactive", + message: { content: "Hello Codex" }, + }); + await f.engine.tick(); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/start").length, + 1, + ); + assert.equal(f.sent.length, 1); + assert.match(f.sent[0].text, /Codex answer/); + f.page.push( + { + id: "echo", + kind: "send-message", + requestId: "own", + message: { content: "reply echo" }, + }, + { + id: "outgoing", + kind: "user", + clientNonce: f.sent[0].clientNonce, + requestId: "own", + }, + ); + await f.engine.tick(); + await f.restart(); + await f.engine.tick(); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/start").length, + 1, + ); + assert.equal(f.sent.length, 1); +}); +test("tracked request uses actual requestId and never forwards another thread reply", async (t) => { + const f = await fixture(t); + const r = await f.engine.sendToGrok({ + grokTarget: "target", + codexThreadId: "thread", + message: "Ask Grok", + requestId: "ask", + }); + assert.equal(r.delivery, "unknown"); + assert.equal(f.sent.length, 1); + f.page.push( + { + id: "unrelated", + kind: "send-message", + requestId: "other", + text: "ignore", + }, + { + id: "answer", + kind: "send-message", + requestId: "request / opaque", + text: "The Grok answer", + }, + { + id: "user", + kind: "user", + clientNonce: f.sent[0].clientNonce, + requestId: "request / opaque", + }, + ); + await f.engine.tick(); + await f.engine.tick(); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/start").length, + 1, + ); + assert.equal(f.sent.length, 1); + assert.match( + f.fake.received.find((r) => r.method === "turn/start").params.input[0].text, + /The Grok answer/, + ); + await f.restart(); + await f.engine.sendToGrok({ + grokTarget: "target", + codexThreadId: "thread", + message: "Ask Grok", + requestId: "ask", + }); + assert.equal(f.sent.length, 1); +}); +test("missing cursor pauses and never replays reset snapshot", async (t) => { + const f = await fixture(t); + f.page.push({ id: "baseline", kind: "user" }); + await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + }); + f.page.splice(0, 1, { + id: "new", + kind: "send-message", + requestId: "r", + text: "lost coverage", + }); + await f.engine.tick(); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/start").length, + 0, + ); + assert.equal((await f.engine.status()).targets[0].reason, "gap"); +}); +test("active messages steer and coalesce a single anchored return", async (t) => { + const f = await fixture(t, { active: true }); + f.items.push({ + id: "before", + type: "agentMessage", + phase: "final_answer", + text: "Earlier final", + }); + await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + }); + f.page.push( + { id: "one", kind: "send-message", requestId: "r", text: "one" }, + { id: "two", kind: "send-message", requestId: "r", text: "two" }, + ); + await f.engine.tick(); + assert.equal(f.sent.length, 0); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/steer").length, + 2, + ); + f.complete(); + await f.engine.tick(); + assert.equal(f.sent.length, 1); + assert.match(f.sent[0].text, /Combined answer/); + assert.doesNotMatch(f.sent[0].text, /Earlier final/); +}); + +test("same request ID rejects changed inputs and stopped bindings cease intake", async (t) => { + const f = await fixture(t); + const b = await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + requestId: "bind", + }); + await f.engine.sendToGrok({ + bindingId: b.id, + message: "first", + requestId: "same", + }); + await assert.rejects( + f.engine.sendToGrok({ + bindingId: b.id, + message: "changed", + requestId: "same", + }), + /Idempotency/, + ); + assert.equal(f.sent.length, 1); + await f.engine.stopBinding({ bindingId: b.id }); + f.page.push({ + id: "after-stop", + kind: "send-message", + requestId: "r", + text: "stop", + }); + await f.engine.tick(); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/start").length, + 0, + ); +}); +test("uncertain Codex submission is adopted by clientId after restart, never resent", async (t) => { + const f = await fixture(t); + const r = await f.engine.sendToCodex({ + grokTarget: "target", + codexThreadId: "thread", + message: "direct", + requestId: "direct", + }); + await f.engine.close(); + const { openRelayState } = await import("../src/core/relay/state.js"); + const s = await openRelayState({ + dir: f.engine.stateDir, + profile: "fixture", + }); + const record = s.read().records[r.exchangeId]; + await s.commit([ + { + section: "records", + key: record.id, + value: { + ...record, + submission: "sending", + turnId: null, + messageId: null, + }, + }, + ]); + await s.close(); + await f.restart(); + await f.engine.tick(); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/start").length, + 1, + ); + assert.equal(f.sent.length, 1); + assert.equal( + f.engine.status().receipts.find((x) => x.exchangeId === r.exchangeId) + .delivery, + "accepted", + ); +}); +test("unknown gateway delivery without observed nonce never resends", async (t) => { + const f = await fixture(t); + await f.engine.sendToGrok({ + grokTarget: "target", + codexThreadId: "thread", + message: "request", + requestId: "unknown", + }); + await f.restart(); + await f.engine.tick(); + await f.engine.tick(); + assert.equal(f.sent.length, 1); + assert.equal(f.engine.status().receipts[0].delivery, "unknown"); +}); +test("malformed incoming text pauses safely at last checkpoint", async (t) => { + const f = await fixture(t); + await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + }); + f.page.push({ + id: "bad", + kind: "send-message", + requestId: "r", + text: "x".repeat(70000), + }); + await f.engine.tick(); + assert.equal(f.engine.status().targets[0].state, "paused"); + assert.equal(f.engine.status().targets[0].cursor, null); +}); +test("intake capacity retains old checkpoint and all pending records", async (t) => { + const f = await fixture(t); + await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + }); + await f.engine.close(); + const { openRelayEngine } = await import("../src/core/relay/engine.js"); + const limited = await openRelayEngine({ + stateDir: f.engine.stateDir, + profile: "fixture", + env: { CODEX_HOME: f.fake.home }, + gateway: { tail: async () => f.page }, + limits: { records: 1 }, + }); + t.after(() => limited.close()); + f.page.push( + { id: "one", kind: "send-message", requestId: "r", text: "one" }, + { id: "two", kind: "send-message", requestId: "r", text: "two" }, + ); + await limited.tick(); + assert.equal(limited.status().targets[0].reason, "capacity"); + assert.equal(limited.status().targets[0].cursor, null); + assert.equal(limited.status().receiptCount, 0); +}); + +test("hop-bound reply pauses visibly instead of creating a further delivery", async (t) => { + const f = await fixture(t); + await f.engine.sendToGrok({ + grokTarget: "target", + codexThreadId: "thread", + message: "last hop", + requestId: "hop", + hop: 3, + }); + f.page.push( + { + id: "user", + kind: "user", + clientNonce: f.sent[0].clientNonce, + requestId: "req", + }, + { id: "reply", kind: "send-message", requestId: "req", text: "reply" }, + ); + await f.engine.tick(); + assert.equal(f.engine.status().targets[0].reason, "hop-limit"); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/start").length, + 0, + ); +}); + +test("control request identity is independent of JSON property order", async (t) => { + const f = await fixture(t); + await f.engine.sendToGrok({ + grokTarget: "target", + codexThreadId: "thread", + message: "same", + requestId: "ordered", + }); + await f.engine.sendToGrok({ + requestId: "ordered", + message: "same", + codexThreadId: "thread", + grokTarget: "target", + }); + assert.equal(f.sent.length, 1); +}); + +for (const finalStatus of ["completed", "failed", "interrupted"]) + test(`empty ${finalStatus} result returns status once`, async (t) => { + const f = await fixture(t, { finalStatus, finalText: "" }); + await f.engine.sendToCodex({ + grokTarget: "target", + codexThreadId: "thread", + message: "direct", + }); + await f.engine.tick(); + await f.engine.tick(); + assert.equal(f.sent.length, 1); + assert.match(f.sent[0].text, new RegExp(`status ${finalStatus}`)); + }); +test("real socket disconnect after submission reconciles without replay", async (t) => { + const f = await fixture(t, { disconnect: true }); + const r = await f.engine.sendToCodex({ + grokTarget: "target", + codexThreadId: "thread", + message: "uncertain", + }); + assert.equal(r.delivery, "unknown"); + await f.restart(); + await f.engine.tick(); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/start").length, + 1, + ); + assert.equal(f.sent.length, 1); +}); +test("multiple tracked routes isolate requestIds across originating threads", async (t) => { + const f = await fixture(t); + await f.engine.sendToGrok({ + grokTarget: "target", + codexThreadId: "thread-a", + message: "a", + }); + await f.engine.sendToGrok({ + grokTarget: "target", + codexThreadId: "thread-b", + message: "b", + }); + f.page.push( + { + id: "ua", + kind: "user", + clientNonce: f.sent[0].clientNonce, + requestId: "a", + }, + { + id: "ub", + kind: "user", + clientNonce: f.sent[1].clientNonce, + requestId: "b", + }, + { id: "rb", kind: "send-message", requestId: "b", text: "answer b" }, + { id: "ra", kind: "send-message", requestId: "a", text: "answer a" }, + ); + await f.engine.tick(); + const sends = f.fake.received.filter((r) => r.method === "turn/start"); + assert.equal(sends.length, 2); + assert.ok( + sends + .find((r) => r.params.threadId === "thread-a") + .params.input[0].text.includes("answer a"), + ); + assert.ok( + sends + .find((r) => r.params.threadId === "thread-b") + .params.input[0].text.includes("answer b"), + ); +}); + +test("stopping the sole binding closes owned observation without interrupting the turn", async (t) => { + const f = await fixture(t, { active: true }); + const b = await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + }); + await f.engine.stopBinding({ bindingId: b.id }); + assert.equal(f.engine.status().generation, null); + assert.equal( + f.fake.received.filter((r) => r.method === "turn/interrupt").length, + 0, + ); +}); + +test("concurrent identical control requests claim and send exactly once", async (t) => { + const f = await fixture(t); + const input = { + grokTarget: "target", + codexThreadId: "thread", + message: "concurrent", + requestId: "race", + }; + const [a, b] = await Promise.all([ + f.engine.sendToGrok(input), + f.engine.sendToGrok({ ...input }), + ]); + assert.equal(a.exchangeId, b.exchangeId); + assert.equal(f.sent.length, 1); +}); +test("concurrent changed-input reuse is rejected without a second send", async (t) => { + const f = await fixture(t); + const input = { + grokTarget: "target", + codexThreadId: "thread", + message: "original", + requestId: "race", + }; + const results = await Promise.allSettled([ + f.engine.sendToGrok(input), + f.engine.sendToGrok({ ...input, message: "changed" }), + ]); + assert.equal(results[0].status, "fulfilled"); + assert.equal(results[1].status, "rejected"); + assert.match(results[1].reason.message, /Idempotency/); + assert.equal(f.sent.length, 1); +}); + +test("invalid Codex correlation is rejected before an outbound Grok submission", async (t) => { + const f = await fixture(t); + await assert.rejects( + f.engine.sendToGrok({ + grokTarget: "target", + codexThreadId: "thread", + message: "bad correlation", + correlationId: "not/a/codex/id", + }), + ); + assert.equal(f.sent.length, 0); +}); diff --git a/test/relay-interactions.test.js b/test/relay-interactions.test.js new file mode 100644 index 0000000..8f7bc0f --- /dev/null +++ b/test/relay-interactions.test.js @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { InteractionRegistry } from "../src/core/relay/interactions.js"; +function fixture() { + const replies = [], + state = { + bindings: { binding: { state: "running" } }, + records: { + own: { + id: "own", + kind: "codex", + threadId: "thread", + turnId: "turn", + submission: "accepted", + bindingId: "binding", + }, + }, + }; + const i = new InteractionRegistry(() => state); + i.reset("gen", { respond: (...args) => replies.push(args) }); + return { i, replies }; +} +const event = (method, params = {}) => ({ + kind: "interaction", + id: "ask", + method, + params: { threadId: "thread", turnId: "turn", ...params }, +}); +const response = (result) => ({ + interactionId: "gen:1", + generation: "gen", + threadId: "thread", + turnId: "turn", + bindingId: "binding", + result, +}); +test("strict response ownership, offered one-time decisions and consumed identity", () => { + const { i, replies } = fixture(); + i.observe( + event("item/commandExecution/requestApproval", { + availableDecisions: ["decline"], + }), + ); + for (const input of [ + { ...response({ decision: "decline" }), threadId: "foreign" }, + { ...response({ decision: "decline" }), generation: "old" }, + { ...response({ decision: "decline" }), bindingId: "foreign" }, + response({ decision: "acceptForSession" }), + response({ decision: "accept" }), + ]) + assert.throws(() => i.respond(input)); + assert.equal(replies.length, 0); + i.respond(response({ decision: "decline" })); + assert.equal(replies.length, 1); + assert.throws(() => i.respond(response({ decision: "decline" }))); +}); +test("questions require exact IDs, resolution and disconnect invalidate ownership", () => { + const { i, replies } = fixture(); + i.observe( + event("item/tool/requestUserInput", { + questions: [ + { id: "q", question: "choose", header: "Choice", isSecret: false }, + ], + }), + ); + assert.throws(() => + i.respond(response({ answers: { wrong: { answers: ["yes"] } } })), + ); + i.respond(response({ answers: { q: { answers: ["yes"] } } })); + assert.equal(replies.length, 1); + i.observe(event("item/commandExecution/requestApproval")); + i.observe({ method: "serverRequest/resolved", params: { requestId: "ask" } }); + assert.throws(() => i.respond(response({ decision: "accept" }))); + i.observe(event("item/commandExecution/requestApproval")); + i.reset("next", {}); + assert.equal(i.list().length, 0); +}); +test("file grantRoot and secret questions remain visible but require owning UI", () => { + const { i } = fixture(); + i.observe(event("item/fileChange/requestApproval", { grantRoot: "/" })); + assert.equal(i.list()[0].supported, false); + assert.throws( + () => i.respond(response({ decision: "accept" })), + /owning Codex UI/, + ); + i.observe( + event("item/tool/requestUserInput", { + questions: [{ id: "secret", isSecret: true, question: "token" }], + }), + ); + assert.equal(i.list()[0].supported, false); + assert.equal(i.list()[0].questions, undefined); +}); + +test("binding and exchange scopes must both match when supplied", () => { + const { i } = fixture(); + i.observe(event("item/commandExecution/requestApproval")); + assert.throws( + () => + i.respond({ ...response({ decision: "accept" }), exchangeId: "foreign" }), + /ownership/, + ); +}); + +test("stopped binding cannot answer an old pending interaction", () => { + const state = { + bindings: { binding: { state: "stopped" } }, + records: { + own: { + id: "own", + kind: "codex", + threadId: "thread", + turnId: "turn", + bindingId: "binding", + submission: "accepted", + }, + }, + }; + const i = new InteractionRegistry(() => state); + i.reset("gen", { + respond() { + assert.fail("must not send"); + }, + }); + i.observe(event("item/commandExecution/requestApproval")); + assert.equal(i.list().length, 0); + assert.throws(() => i.respond(response({ decision: "accept" })), /ownership/); +}); + +test("a reused upstream request ID cannot revive a resolved operator response", () => { + const { i } = fixture(); + i.observe(event("item/commandExecution/requestApproval")); + const old = i.list()[0].interactionId; + i.observe({ method: "serverRequest/resolved", params: { requestId: "ask" } }); + i.observe(event("item/commandExecution/requestApproval")); + assert.notEqual(i.list()[0].interactionId, old); +}); diff --git a/test/relay-state.test.js b/test/relay-state.test.js new file mode 100644 index 0000000..67a8a74 --- /dev/null +++ b/test/relay-state.test.js @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +const load = () => import("../src/core/relay/state.js"); +const target = { + id: "opaque / target", + cursor: null, + baseline: true, + state: "running", + reason: null, + nextPoll: 0, + failures: 0, +}; +test("state commits atomically, reopens without replay, and skips unchanged patches", async () => { + const { openRelayState } = await load(); + const dir = await mkdtemp(join(tmpdir(), "relay-state-")); + let s; + try { + s = await openRelayState({ dir, profile: "test" }); + await s.commit([{ section: "targets", key: target.id, value: target }]); + const before = await s.inspect(); + await s.commit([{ section: "targets", key: target.id, value: target }]); + assert.equal((await s.inspect()).headRevision, before.headRevision); + await s.close(); + s = await openRelayState({ dir, profile: "test" }); + assert.deepEqual(s.read().targets[target.id], target); + assert.equal((await stat(join(dir, "relay.sqlite"))).mode & 0o777, 0o600); + await s.close(); + s = null; + await assert.rejects(openRelayState({ dir, profile: "other" }), /profile/); + } finally { + await s?.close(); + await rm(dir, { recursive: true, force: true }); + } +}); +test("state refuses symlink database, corruption and capacity without resetting", async () => { + const { openRelayState } = await load(); + const dir = await mkdtemp(join(tmpdir(), "relay-state-")); + let s; + try { + const file = join(dir, "relay.sqlite"); + await symlink("/tmp", file); + await assert.rejects( + openRelayState({ dir, profile: "test" }), + /regular|symlink/, + ); + await rm(file); + await writeFile(file, "broken"); + await assert.rejects(openRelayState({ dir, profile: "test" })); + assert.equal((await stat(file)).size, 6); + await rm(file); + s = await openRelayState({ dir, profile: "test", limits: { targets: 1 } }); + await s.commit([{ section: "targets", key: target.id, value: target }]); + await assert.rejects( + s.commit([ + { + section: "targets", + key: "second", + value: { ...target, id: "second" }, + }, + ]), + /capacity/, + ); + assert.equal(Object.keys(s.read().targets).length, 1); + } finally { + await s?.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("journal compaction stays bounded and unsupported versions fail closed", async () => { + const { openRelayState } = await load(); + const dir = await mkdtemp(join(tmpdir(), "relay-state-")); + let s; + try { + s = await openRelayState({ dir, profile: "test" }); + for (let i = 0; i < 20; i++) + await s.commit([ + { + section: "targets", + key: target.id, + value: { ...target, nextPoll: i }, + }, + ]); + assert.ok((await s.inspect()).records < 8); + await s.close(); + s = null; + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(join(dir, "relay.sqlite")); + db.exec("UPDATE agent_state_meta SET schema_version = 999"); + db.close(); + await assert.rejects( + openRelayState({ dir, profile: "test" }), + /version|migration|schema/, + ); + } finally { + await s?.close(); + await rm(dir, { recursive: true, force: true }); + } +}); From d82b373d3e3800deb7f557499ca3c9c37475ff4e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 00:41:03 -0700 Subject: [PATCH 10/18] fix: preserve relay reconciliation and stop guarantees --- src/core/codex/conversation.js | 8 +- src/core/relay/codex.js | 73 ++++++---- src/core/relay/engine.js | 42 ++++-- test/relay-lifecycle.test.js | 248 +++++++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 37 deletions(-) create mode 100644 test/relay-lifecycle.test.js diff --git a/src/core/codex/conversation.js b/src/core/codex/conversation.js index d50c2e9..20ee9e7 100644 --- a/src/core/codex/conversation.js +++ b/src/core/codex/conversation.js @@ -204,10 +204,12 @@ export async function openCodexConversation(threadId, options = {}) { } return { threadId, cwd: resumed.cwd ?? resumed.thread.cwd, wait, watch, close, /** @param {string} text - * @param {{envelope?: object, whenBusy?: string, expectedTurnId?: string}} [options] */ - async send(text, { envelope = buildEnvelope({ env }), whenBusy = 'reject', expectedTurnId } = {}) { + * @param {{envelope?: object, whenBusy?: string, expectedTurnId?: string, signal?: AbortSignal}} [options] */ + async send(text, { envelope = buildEnvelope({ env }), whenBusy = 'reject', expectedTurnId, signal: sendSignal } = {}) { if (closed) return outcomeFromError(Object.assign(new Error('Conversation closed'), { delivery: 'rejected', reason: 'closed', threadId, envelope })); - const receipt = await sendToCodexThread(threadId, text, { env, envelope, whenBusy, expectedTurnId, session, expectedCwd, signal }); + if (sendSignal !== undefined && !(sendSignal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal'); + const submissionSignal = signal && sendSignal ? AbortSignal.any([signal, sendSignal]) : sendSignal ?? signal; + const receipt = await sendToCodexThread(threadId, text, { env, envelope, whenBusy, expectedTurnId, session, expectedCwd, signal: submissionSignal }); if (receipt.delivery === 'accepted' && receipt.turnId) { acceptedTurns.add(receipt.turnId); if (acceptedTurns.size > 100) acceptedTurns.delete(acceptedTurns.values().next().value); diff --git a/src/core/relay/codex.js b/src/core/relay/codex.js index 0a6946f..5869f14 100644 --- a/src/core/relay/codex.js +++ b/src/core/relay/codex.js @@ -94,38 +94,63 @@ export function createRelayCodex({ const c = await conversation({ threadId, expectedCwd }); return { threadId, cwd: realpathSync(c.cwd) }; }, - async send(record) { - const c = await conversation(record), + async send(record, { signal: deliverySignal } = {}) { + const submissionSignal = + signal && deliverySignal + ? AbortSignal.any([signal, deliverySignal]) + : (deliverySignal ?? signal); + const cancelled = () => ({ + delivery: "rejected", + reason: "cancelled", + threadId: record.threadId, + messageId: record.clientId, + }); + let c, s; + try { + c = await conversation(record); s = await connect(); + } catch (error) { + if (submissionSignal?.aborted) return cancelled(); + throw error; + } let receipt; for (let attempt = 0; attempt < 3; attempt++) { + if (submissionSignal?.aborted) return cancelled(); let active = null; - if (record.busyPolicy === "steer") - await visitCodexHistory( - s, - record.threadId, - "thread/turns/list", - {}, - (rows) => { - for (const turn of rows) { - if ( - !turn || - typeof turn.id !== "string" || - typeof turn.status !== "string" - ) - throw new Error("Invalid turn history"); - if (turn.status === "inProgress") { - active = turn.id; - return true; + if (record.busyPolicy === "steer") { + try { + await visitCodexHistory( + s, + record.threadId, + "thread/turns/list", + {}, + (rows) => { + for (const turn of rows) { + if ( + !turn || + typeof turn.id !== "string" || + typeof turn.status !== "string" + ) + throw new Error("Invalid turn history"); + if (turn.status === "inProgress") { + active = turn.id; + return true; + } } - } - return false; - }, - { signal }, - ); + return false; + }, + { signal: submissionSignal }, + ); + } catch (error) { + if (submissionSignal?.aborted) return cancelled(); + throw error; + } + } + if (submissionSignal?.aborted) return cancelled(); // Once a steer guard is rejected, retry only another observed guarded steer. if (attempt && !active) return receipt; receipt = await c.send(record.text, { + signal: submissionSignal, envelope: { messageId: record.clientId, correlationId: record.correlationId, diff --git a/src/core/relay/engine.js b/src/core/relay/engine.js index 5e7e0f0..380098e 100644 --- a/src/core/relay/engine.js +++ b/src/core/relay/engine.js @@ -51,7 +51,8 @@ export async function openRelayEngine({ const controller = new AbortController(); let closed = false, queue = Promise.resolve(), - lastError = null; + lastError = null, + reconcileOffset = 0; const codex = createRelayCodex({ env, read: state.read, @@ -61,6 +62,19 @@ export async function openRelayEngine({ openConversation, }); const stoppedBindings = new Set(); + const bindingControllers = new Map(); + function submissionSignal(record) { + if (!record.bindingId) return controller.signal; + if (!bindingControllers.has(record.bindingId)) + bindingControllers.set(record.bindingId, new AbortController()); + const scoped = bindingControllers.get(record.bindingId); + if ( + stoppedBindings.has(record.bindingId) || + state.read().bindings[record.bindingId]?.state !== "running" + ) + scoped.abort(); + return scoped.signal; + } const runnable = (r) => !closed && (!r.bindingId || @@ -138,7 +152,7 @@ export async function openRelayEngine({ try { result = r.kind === "codex" - ? await codex.send(r) + ? await codex.send(r, { signal: submissionSignal(r) }) : await gateway.send(r.targetId, r.text, { clientNonce: r.clientId, ...(r.sourceIds[0] ? { replyToId: r.sourceIds[0] } : {}), @@ -227,14 +241,21 @@ export async function openRelayEngine({ return receipt(await submit(id)); } async function reconcile() { - for (const r of records(state.read()) - .filter( - (r) => - r.kind === "codex" && - ["sending", "unknown"].includes(r.submission) && - runnable(r), - ) - .slice(0, 20)) { + const candidates = records(state.read()).filter( + (r) => + r.kind === "codex" && + ["sending", "unknown"].includes(r.submission) && + runnable(r), + ); + const selected = Array.from( + { length: Math.min(20, candidates.length) }, + (_, index) => candidates[(reconcileOffset + index) % candidates.length], + ); + reconcileOffset = candidates.length + ? (reconcileOffset + selected.length) % candidates.length + : 0; + for (const r of selected) { + if (!runnable(r)) continue; try { const observed = await codex.reconcile(r); if (observed) @@ -338,6 +359,7 @@ export async function openRelayEngine({ const b = state.read().bindings[bindingId]; if (!b) throw new Error("Unknown binding"); stoppedBindings.add(bindingId); + bindingControllers.get(bindingId)?.abort(); await change("bindings", { ...b, state: "stopped" }); if ( !Object.values(state.read().bindings).some( diff --git a/test/relay-lifecycle.test.js b/test/relay-lifecycle.test.js new file mode 100644 index 0000000..3810ce7 --- /dev/null +++ b/test/relay-lifecycle.test.js @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { openRelayEngine } from "../src/core/relay/engine.js"; +import { openRelayState } from "../src/core/relay/state.js"; +import { createRecordFactory, op } from "../src/core/relay/records.js"; +import { fakeAppServer } from "./helpers/codex-server.js"; + +const deferred = () => { + let resolve; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +}; +test("reconciliation visits the observable 21st record after 20 unresolved records", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "relay-reconcile-")); + let engine; + t.after(async () => { + await engine?.close(); + await rm(dir, { recursive: true, force: true }); + }); + const factory = createRecordFactory({ env: {}, clock: () => 1000 }); + const rows = Array.from({ length: 21 }, (_, i) => ({ + ...factory.newRecord( + "codex", + "record-" + String(i).padStart(2, "0"), + { + threadId: "thread", + targetId: "target", + expectedCwd: process.cwd(), + busyPolicy: "steer", + }, + "hello", + ), + submission: "unknown", + })); + const store = await openRelayState({ dir, profile: "fairness" }); + await store.commit(rows.map((r) => op("records", r))); + await store.close(); + let queries = 0; + const session = { + client: { + closed: false, + onClose() {}, + close() {}, + async request() { + queries++; + return { + data: [ + { + turnId: "turn-21", + item: { type: "userMessage", clientId: rows[20].clientId }, + }, + ], + nextCursor: null, + }; + }, + }, + }; + engine = await openRelayEngine({ + stateDir: dir, + profile: "fairness", + gateway: {}, + openSession: async () => session, + openConversation: async () => ({ + cwd: process.cwd(), + close() {}, + wait: async () => ({ execution: { state: "unknown" } }), + }), + }); + await engine.tick(); + assert.equal(queries, 20); + assert.equal( + engine.status().receipts.find((r) => r.exchangeId === rows[20].id).delivery, + "unknown", + ); + await engine.tick(); + assert.ok(queries <= 40); + const receipt = engine + .status() + .receipts.find((r) => r.exchangeId === rows[20].id); + assert.equal(receipt.delivery, "accepted"); + assert.equal(receipt.turnId, "turn-21"); + assert.equal( + engine.status().receipts.filter((r) => r.delivery === "unknown").length, + 20, + ); +}); + +for (const phase of [ + "history", + "idle-history", + "idle-resume", + "retry-history", + "send-resume", + "retry-resume", + "written", + "written-unknown", +]) + test( + `stopping one binding during ${phase} preserves other routes and delivery certainty`, + { timeout: 5000 }, + async (t) => { + const reached = deferred(), + release = deferred(); + let historyCalls = 0, + resumeCalls = 0, + steers = 0, + gateResume = false; + const hold = async (ok) => { + reached.resolve(); + await release.promise; + ok(); + }; + const idle = phase.startsWith("idle-"); + const turns = idle ? [] : [{ id: "turn", status: "inProgress" }]; + const submit = (p, ok, err) => { + const reply = () => + ok( + p.expectedTurnId + ? { + turnId: + phase === "written-unknown" && p.threadId === "thread-1" + ? "wrong-turn" + : "turn", + } + : { turn: { id: "turn", status: "inProgress" } }, + ); + if (p.threadId === "thread-1") { + steers++; + if (phase === "retry-history" || phase === "retry-resume") { + err({ code: -32600, message: "expected turn guard mismatch" }); + return; + } + if (phase.startsWith("written")) { + void hold(reply); + return; + } + } + reply(); + }; + const fake = await fakeAppServer({ + initialize: (_, ok) => ok({}), + "thread/resume": (p, ok) => { + const reply = () => + ok({ + thread: { + id: p.threadId, + cwd: process.cwd(), + status: { type: idle ? "idle" : "active" }, + }, + cwd: process.cwd(), + }); + if (p.threadId === "thread-1" && gateResume) { + resumeCalls++; + if ( + (["send-resume", "idle-resume"].includes(phase) && + resumeCalls === 1) || + (phase === "retry-resume" && resumeCalls === 2) + ) { + void hold(reply); + return; + } + } + reply(); + }, + "thread/turns/list": (p, ok) => { + if (p.threadId === "thread-1") { + historyCalls++; + gateResume = true; + if ( + (["history", "idle-history"].includes(phase) && + historyCalls === 1) || + (phase === "retry-history" && historyCalls === 2) + ) { + void hold(() => + ok({ + data: turns, + nextCursor: null, + }), + ); + return; + } + } + ok({ data: turns, nextCursor: null }); + }, + "turn/steer": submit, + "turn/start": submit, + }); + const dir = await mkdtemp(join(tmpdir(), "relay-stop-")); + const engine = await openRelayEngine({ + stateDir: dir, + profile: "stop", + env: { CODEX_HOME: fake.home }, + gateway: { resolve: async (id) => ({ id }), tail: async () => [] }, + }); + t.after(async () => { + release.resolve(); + await engine.close(); + await fake.close(); + await rm(dir, { recursive: true, force: true }); + }); + const first = await engine.startBinding({ + grokTarget: "target-1", + codexThreadId: "thread-1", + }); + const second = await engine.startBinding({ + grokTarget: "target-2", + codexThreadId: "thread-2", + }); + const pending = engine.sendToCodex({ + bindingId: first.id, + message: "stop me", + requestId: "stop-race", + }); + await reached.promise; + const before = steers; + await engine.stopBinding({ bindingId: first.id }); + release.resolve(); + const receipt = await pending; + assert.equal(steers, before, "no submission after stop returns"); + assert.equal( + receipt.delivery, + phase === "written" + ? "accepted" + : phase === "written-unknown" + ? "unknown" + : "rejected", + ); + if (!phase.startsWith("written")) + assert.equal(receipt.reason, "cancelled"); + assert.ok(engine.status().generation); + assert.equal( + ( + await engine.sendToCodex({ + bindingId: second.id, + message: "keep working", + }) + ).delivery, + "accepted", + ); + assert.equal( + fake.received.filter((r) => r.method === "turn/interrupt").length, + 0, + ); + }, + ); From b28c5a353c2b41d5b0d714ec0995dd0ec9860f69 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 00:44:27 -0700 Subject: [PATCH 11/18] docs: record packaged worker and native source identity proof --- docs/superpowers/plans/2026-09-15-managed-relay.md | 4 ++-- docs/superpowers/specs/2026-09-15-managed-relay-design.md | 4 ++-- docs/verification/2026-09-15-duplex.md | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-09-15-managed-relay.md b/docs/superpowers/plans/2026-09-15-managed-relay.md index 71000ab..5565b30 100644 --- a/docs/superpowers/plans/2026-09-15-managed-relay.md +++ b/docs/superpowers/plans/2026-09-15-managed-relay.md @@ -9,7 +9,7 @@ ### Task 1: Durable conversation relay engine -**Files:** Create `src/core/relay/state.js`, `src/core/relay/engine.js` and focused helper modules if needed, `test/relay-state.test.js`, `test/relay-engine.test.js`, optional `test/helpers/` fixtures. May add narrowly needed public conversation/history helpers in `src/core/codex/conversation.js` with tests; avoid duplicated protocol logic. Do not modify surface routes, runtime config or package files in this task. +**Files:** Create `src/core/relay/state.js`, `src/core/relay/engine.js` and focused helper modules if needed, `test/relay-state.test.js`, `test/relay-engine.test.js`, optional `test/helpers/` fixtures; narrowly update the existing `test/codex-bridge.test.js` fixture to reuse safe short Unix socket paths on macOS (same reproduced EINVAL as the new fixture). May add narrowly needed public conversation/history helpers in `src/core/codex/conversation.js` with tests; avoid duplicated protocol logic. Do not modify surface routes, runtime config or package files in this task. - [ ] Read exact spec and reviewed conversation interfaces; write failing state/engine tests for automatic request replies and explicit linked Grok→Codex→Grok return. - [ ] Implement a bounded, validated relay adapter over the existing Agent Bundle SQLite state kernel (do not duplicate its journal/transaction code) and intake/receipt/checkpoint transitions, scoped transcript correlation and echo prevention. @@ -20,7 +20,7 @@ ### Task 2: Managed worker and fluid plugin/MCP experience -**Files:** Create process/control modules in `src/core/relay/`, built worker entry `src/gbot-relay.ts` or equivalent; modify `agent-bundle.config.ts` only to package worker, `src/mcp/grok-bot/tools/gbot_send.tsx`, `codex_send.tsx`, new bridge start/status/stop/respond tools, shared TS adapter/schema module, CLI bridge routes and explicit gbot send auto-route options. Update installed skill, README, feature changeset and route/packed-worker tests. +**Files:** Create process/control modules in `src/core/relay/`, bundled worker entry `src/scripts/gbot-relay.ts` through the existing script pipeline (verified all-host emission); modify plugin description in `agent-bundle.config.ts`, `src/mcp/grok-bot/tools/gbot_send.tsx`, `codex_send.tsx`, new bridge start/status/stop/respond tools, shared TS adapter/schema module, CLI bridge routes and explicit gbot send auto-route options. Update installed skill, README, feature changeset and route/packed-worker tests. - [ ] Write failing generated MCP tests for native source identity automatic routing, source-unavailable manual receipt and explicit return route, plus worker survival after caller exits and concurrent starts. - [ ] Implement private bounded worker control protocol, verified startup/profile identity, stable mutation request IDs, concurrency-safe lifetime and foreground mode. Locate packaged worker correctly from CLI and generated MCP/host installations, including paths with spaces. diff --git a/docs/superpowers/specs/2026-09-15-managed-relay-design.md b/docs/superpowers/specs/2026-09-15-managed-relay-design.md index 28bbdf5..f1f994d 100644 --- a/docs/superpowers/specs/2026-09-15-managed-relay-design.md +++ b/docs/superpowers/specs/2026-09-15-managed-relay-design.md @@ -15,7 +15,7 @@ Two supported flows share the same durable relay engine: A binding identifies exact resolved Grok target ID, Codex thread ID, canonical expected cwd, endpoint/profile, busy policy and creation checkpoint. Resolve target names once; persist IDs. Resume verifies exact thread and cwd before any submission. Existing socket/thread allowlists apply. Do not attach unrelated threads or infer destinations from filesystem recency. -Source auto-detection uses `await agent()` request context: available host `codex`, available lineage with `source: native` and `resolution: native`, then `lineage.value.conversation`. Installed Agent Bundle 8e55ab832d derives this from MCP `_meta['x-codex-turn-metadata']` thread_id/session_id. Require exact source identity; do not treat Cursor tool-window inference, arbitrary session IDs, process-wide CODEX_THREAD_ID, or stale environment as a current Codex thread. Explicit `codexThreadId` works when the host omits identity. An existing explicit binding may be selected by bindingId. For an unidentifiable source, preserve ordinary gbot_send and return `replyRoute: {mode:'manual',reason:'source-unavailable'}` instead of claiming automatic delivery. `replyMode: 'manual'` explicitly selects old behavior. Once an automatic route is requested, do not silently downgrade a relay startup/storage/route failure into an untracked send. +Source auto-detection uses `await agent()` request context: available host normalized to `codex` with the runtime's `lineageHostFromClient` helper (a real daemon call reports `codex-mcp-client`, not literal `codex`), available lineage with `source: native` and `resolution: native`, then `lineage.value.conversation`. Installed Agent Bundle 8e55ab832d derives this from MCP `_meta['x-codex-turn-metadata']` thread_id/session_id. Require exact source identity; do not treat Cursor tool-window inference, arbitrary session IDs, process-wide CODEX_THREAD_ID, or stale environment as a current Codex thread. Explicit `codexThreadId` works when the host omits identity. An existing explicit binding may be selected by bindingId. For an unidentifiable source, preserve ordinary gbot_send and return `replyRoute: {mode:'manual',reason:'source-unavailable'}` instead of claiming automatic delivery. `replyMode: 'manual'` explicitly selects old behavior. Once an automatic route is requested, do not silently downgrade a relay startup/storage/route failure into an untracked send. Inbound text names its Grok sender and message identity and explains whether a normal final answer returns automatically. This is ordinary user-message provenance, not an authentication or system-instruction boundary. Actual correlation, reply destinations and hop ancestry are engine-owned records; models need not copy text headers. @@ -55,7 +55,7 @@ After reconnect, re-establish subscriptions, reconcile pending receipt IDs and t Start a background worker on demand for tracked sends or binding start. A successful MCP call must not depend on its render promise or stdio process staying alive. Prefer one local worker per endpoint/state directory with a private Unix-domain control socket; no unauthenticated public listener. Worker readiness is a successful version/profile handshake, not a pid or file. Concurrent starters must converge on the same owner. Never unlink a live listener or kill a PID based only on stale metadata. Verify endpoint ownership and protocol identity; close bounded requests/connections. Startup timeout is a visible failure before an untracked send. A worker disconnection never causes automatic re-execution of a control send without a durable idempotent request ID. -Ship the worker as an actual built/packed entry asset. Resolve it from the emitting package/plugin root, verify existence, and spawn with process.execPath plus argv arrays (no shell). Do not assume process.argv[1] is the CLI when invoked via MCP. Avoid copying credential text into command lines/logs/state. The worker may inherit already-authorized environment and refresh app auth through existing connectGateway logic. Profile mismatch between an existing worker and caller is an error, not credential replacement. Test installed artifact paths containing spaces. Windows should report the existing Codex Unix-socket limitation clearly. +Ship the worker as a conventional plain `src/scripts/gbot-relay.ts` entry with a top-level worker invocation, compiled to `scripts/gbot-relay.mjs` in every host artifact and npm dist. A parent prototype verified the script is declared in executables.scripts and files with a checksum for Claude/Codex/Cursor/portable, and executes from an artifact path containing spaces. `config.bin` alone is insufficient: it emitted the custom worker only in npm dist, absent from host artifacts and their manifest. Use the existing script pipeline, not a post-build copy or extra source-code runner. This plain bundled script has its own process lifetime, independent of CLI/MCP rendering budgets. Resolve it from the emitting package/plugin root, verify existence, and spawn with process.execPath plus argv arrays (no shell). Do not assume process.argv[1] is the CLI when invoked via MCP. Avoid copying credential text into command lines/logs/state. The worker may inherit already-authorized environment and refresh app auth through existing connectGateway logic. Profile mismatch between an existing worker and caller is an error, not credential replacement. The fingerprint includes effective endpoint and relevant gateway routing/auth override identity, thread allowlists, max-hop policy and test/local-gateway policy; a restricted caller must never reuse an unrestricted worker. Hash sensitive override values without retaining or printing them. Do not fingerprint unrelated host/plugin installation paths or ephemeral per-call thread IDs, since Codex and Cursor copies with the same policy should share a worker. Existing app-session refresh should keep its stable auth-source identity rather than treating every refreshed access token as a new profile. Test installed artifact paths containing spaces. Windows should report the existing Codex Unix-socket limitation clearly. Tools: - `gbot_send`: retain target/message, add optional replyMode(auto/manual), codexThreadId, expectedCwd, bindingId as needed; native Codex invocation auto-routes replies. Return existing submission receipt plus replyRoute and durable exchange ID. Explicit auto route first ensures worker and valid destination, then sends exactly once. diff --git a/docs/verification/2026-09-15-duplex.md b/docs/verification/2026-09-15-duplex.md index db98091..c50005e 100644 --- a/docs/verification/2026-09-15-duplex.md +++ b/docs/verification/2026-09-15-duplex.md @@ -26,6 +26,8 @@ Built CLI at conversation commit `0f86d73` ran `codex wait` against the already- A live two-client probe at `9d75150` accepted initial message `e6b71e1a-4c8a-4fd7-a9b6-426a51248031` and guarded active message `515df77a-5915-4bb2-b588-847f2288c495` into turn `01a0a8ff-ce3d-7132-92e9-977e6daf0152`. A second client saw ordered user/agent/completion events, and remained usable after the sender closed. The turn had one final_answer before the second user message and another after it; automatic reply selection therefore needs a message-ID anchor. Receipt: `/tmp/gbot-live-steer-receipt.json`. +A separate live Codex model turn called the generated read-only `path_probe` MCP tool successfully. It reported host `codex-mcp-client`, native lineage, and the exact originating thread `01a0a90e-1d4e-7640-82d0-93324b06b043` and turn `01a0a90e-2d34-7422-9241-53d7ceae2c6b`. `session` was unavailable, so reply routing must use lineage, not session fallback. Receipt: `/tmp/gbot-live-native-context-receipt.json`; the harness asserts successful tool execution and identity equality, not merely the model's completion text. The initial probe lacked readOnlyHint and was denied by inherited approvalPolicy never; accurately annotating this read-only inspection permitted it. Mutating messaging tools must retain honest annotations. + ## Relay acceptance Pending implementation and final verification. The protocol receipts above do not yet claim automatic Grok→Codex→Grok delivery, worker restart recovery, or Desktop app-tools parity. From ae5107830443459d79d3cf65372902fe92e4b310 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 01:20:56 -0700 Subject: [PATCH 12/18] feat: ship managed Grok Codex relay worker and tools --- .changeset/managed-conversation-relay.md | 5 + README.md | 69 +- agent-bundle.config.ts | 2 +- src/cli/codex/bridge/respond.tsx | 65 ++ src/cli/codex/bridge/run.tsx | 75 ++ src/cli/codex/bridge/start.tsx | 55 ++ src/cli/codex/bridge/status.tsx | 43 + src/cli/codex/bridge/stop.tsx | 46 + src/cli/codex/send.tsx | 149 ++- src/cli/send.tsx | 102 ++- src/core/codex-bridge.js | 3 +- src/core/relay/control.js | 76 ++ src/core/relay/managed.js | 124 +++ src/core/relay/ownership.js | 100 +++ src/core/relay/profile.js | 72 ++ src/core/relay/routes.ts | 222 +++++ src/core/relay/worker.js | 200 +++++ src/mcp/grok-bot/tools/codex_send.tsx | 159 ++-- src/mcp/grok-bot/tools/gbot_bridge_start.tsx | 49 + src/mcp/grok-bot/tools/gbot_bridge_status.tsx | 38 + src/mcp/grok-bot/tools/gbot_bridge_stop.tsx | 41 + src/mcp/grok-bot/tools/gbot_codex_respond.tsx | 60 ++ src/mcp/grok-bot/tools/gbot_send.tsx | 81 +- src/scripts/gbot-relay.ts | 34 + src/skills/talk-to-grok-bot/SKILL.md | 40 +- test/relay-surfaces.test.js | 847 ++++++++++++++++++ test/relay-worker.test.js | 210 +++++ tests/route-unit/tools.test.ts | 7 +- 28 files changed, 2812 insertions(+), 162 deletions(-) create mode 100644 .changeset/managed-conversation-relay.md create mode 100644 src/cli/codex/bridge/respond.tsx create mode 100644 src/cli/codex/bridge/run.tsx create mode 100644 src/cli/codex/bridge/start.tsx create mode 100644 src/cli/codex/bridge/status.tsx create mode 100644 src/cli/codex/bridge/stop.tsx create mode 100644 src/core/relay/control.js create mode 100644 src/core/relay/managed.js create mode 100644 src/core/relay/ownership.js create mode 100644 src/core/relay/profile.js create mode 100644 src/core/relay/routes.ts create mode 100644 src/core/relay/worker.js create mode 100644 src/mcp/grok-bot/tools/gbot_bridge_start.tsx create mode 100644 src/mcp/grok-bot/tools/gbot_bridge_status.tsx create mode 100644 src/mcp/grok-bot/tools/gbot_bridge_stop.tsx create mode 100644 src/mcp/grok-bot/tools/gbot_codex_respond.tsx create mode 100644 src/scripts/gbot-relay.ts create mode 100644 test/relay-surfaces.test.js create mode 100644 test/relay-worker.test.js diff --git a/.changeset/managed-conversation-relay.md b/.changeset/managed-conversation-relay.md new file mode 100644 index 0000000..98695d8 --- /dev/null +++ b/.changeset/managed-conversation-relay.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": minor +--- + +Add managed Grok/Codex conversation delivery with native Codex reply routing, explicit durable links, scoped operator responses, background worker lifecycle controls, and packaged foreground service entry. Preserve manual sends when native identity is unavailable and distinguish accepted submissions from execution and return delivery. diff --git a/README.md b/README.md index 101b934..f5c67d1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,71 @@ canonical JSON result; `send` and the `codex` commands also report failures as a JSON document on stdout with `exitCode` (see below), every other command prints the failure message on stderr and exits 1. +## Automatic Grok ↔ Codex replies + +In a native Codex invocation, `gbot_send` sends once and returns a durable exchange +receipt. Continue working: matching Grok replies arrive in the originating Codex +thread automatically. That thread's next answer is not sent back to Grok unless +you deliberately send again. The background worker survives the MCP caller exiting. + +When native identity is unavailable (including Cursor), supply `codexThreadId` on +the send, or make a one-time `gbot_bridge_start` binding with `grokTarget`, +`codexThreadId` and optional `expectedCwd`. New visible Grok bot messages then arrive +in that Codex thread, and its corresponding terminal answer returns to Grok. +Existing transcript history is not replayed. `codex_send` can also request a return +with `replyToGrok` or `bindingId`. Managed delivery defaults to guarded steering of +active work; `busyPolicy: "reject"` on a binding refuses busy threads. + +Without an identifiable native source or explicit route, `gbot_send` preserves the +ordinary send and returns `replyRoute: {mode: "manual", reason: "source-unavailable"}`; +read with `gbot_thread` later. `replyMode: "manual"` deliberately selects that flow. +An automatic-route startup failure never falls back to an untracked send. + +```sh +# CLI sends remain manual unless routing is explicitly requested. +gbot send --reply-mode auto --codex-thread-id THREAD_ID Researcher "Please investigate" +gbot codex bridge start --codex-thread-id THREAD_ID --expected-cwd /project Researcher --json +gbot codex bridge status --json +gbot codex send --reply-to-grok Researcher THREAD_ID "Investigate and return your final answer" +gbot codex bridge stop --binding-id BINDING_ID --json +# Explicitly stop the process; pending records remain for a later restart. +gbot codex bridge stop --worker --json +``` + +`gbot_bridge_status` separates worker health, binding coverage, submission, execution, +return delivery and pending operator interactions. `unknown` is not rejection: inspect +status before resending. Caller-supplied `requestId` (CLI `--request-id`) permits exact +replay of a tracked send without another submission; returned `controlRequestId` is +that control identity, separate from the gateway's transcript `requestId`. +Explicit chain `hop`/`correlationId` remain bounded. Automatic CLI routes own their +envelope; omit the legacy `--envelope` and `--reply-to` flags. +Paused gaps/capacity or unsupported interactions need attention; never guess a cursor +or automatically approve. `gbot_codex_respond` / `gbot codex bridge respond` requires +current `interactionId`, `generation`, `threadId`, `turnId` and binding/exchange scope. +Supply one-time `decision: accept|decline|cancel`, or `answersJson` containing exact +question IDs mapped to `{"answers":["answer"]}`. Session permissions and policy +amendments remain in the owning Codex UI. + +State lives in the user-owned `~/.grok-bot-cli/relay/` directory, or +`GROK_BOT_RELAY_DIR`, outside plugin caches. Profile mismatches fail visibly; gateway +overrides and thread restrictions cannot reuse a differently authorized worker. +No login service is installed. Network reconnects are automatic; a dead process +needs a new tracked send or bridge start to resume saved routes. A dead worker is +reported as stopped, not running. + +`gbot codex bridge run --lifetime-ms 60000` runs in the foreground for an explicit +bounded lifetime (default and maximum 23 hours), then closes cleanly before the CLI +renderer deadline. For an unlimited foreground service entry, run +`node /path/to/plugin/scripts/gbot-relay.mjs` (npm package: `dist/scripts/gbot-relay.mjs`) +with the same authorized environment. SIGINT/SIGTERM or explicit worker stop closes +observation without interrupting a Codex turn. Windows retains the Codex Unix-socket +limitation. + +Codex, Cursor, Claude and portable artifacts contain these tools and the worker. +Grok Bot participates through its existing gateway conversation; a native Grok plugin +loader or remote stdio tunnel has not been established. Artifact availability alone +does not mean a host has loaded the plugin. + ## Gateway URL policy By default `gbot` only sends credentials to expected hosts: @@ -91,7 +156,7 @@ Fail-open runs only before any stdin byte is consumed and no daemon payload was Residual risks, stated honestly: requests without both matching thread and turn IDs remain unanswered; a Codex client must handle those requests. A daemon that speaks framing-valid but semantically unexpected JSON-RPC (unknown methods, id-less responses) is treated as transport; pins are to app-server schema 0.154.0. The bridge trusts the local control socket; a malicious local daemon could hold the session up to the stated budgets, not past them. -Permanent tradeoff, stated plainly: Desktop's app-tools MCP (`-c` overrides on its spawn line) is not applied to the already-running managed daemon, and no config/`mcpServer`/`reload` path imports Desktop's `-c` flags — Desktop app-tools stay degraded while pointed at the shared daemon. Fully quit and relaunch ChatGPT.app after install (or login) so it inherits `CODEX_CLI_PATH`. `status` reads the macOS GUI-domain value via `launchctl getenv` (what Desktop actually inherits) alongside the calling shell's value. LaunchAgent persistence is macOS-first; elsewhere install still writes the wrapper and bridge but leaves `CODEX_CLI_PATH` for you to export. `~/.codex/bin` holds scripts only — there is no extra revert note to clean up; revert is `gbot codex desktop-shim uninstall` plus this section. +Current shim limitation: Desktop's spawn-time app-tools MCP `-c` overrides are not forwarded to the already-running managed daemon. No restoration path or app-tools parity has been demonstrated here; this is not a claim of permanent protocol impossibility. Fully quit and relaunch ChatGPT.app after install (or login) so it inherits `CODEX_CLI_PATH`. `status` reads the macOS GUI-domain value via `launchctl getenv` (what Desktop actually inherits) alongside the calling shell's value. LaunchAgent persistence is macOS-first; elsewhere install still writes the wrapper and bridge but leaves `CODEX_CLI_PATH` for you to export. `~/.codex/bin` holds scripts only — there is no extra revert note to clean up; revert is `gbot codex desktop-shim uninstall` plus this section. **Status contract (`gbot codex status --json`).** `reachable` is endpoint reachability only. `socketState` is `socket`, `absent`, `permission-denied`, or `not-a-socket`; `mode` is `daemon` for a usable daemon, otherwise the failure: `socket-absent`, `permission-denied` (the file or the connect refused this user), `not-a-socket`, `connect-failed` (socket present, nothing completed the WebSocket upgrade), `handshake-failed` (upgrade or `initialize` failed), `windows-unsupported`, or `bad-response` (reachable, but `initialize` returned something off-schema — `reachable` stays `true`). `schema.compatibility` is `exact` when the daemon reports the pinned version, `unverified` when it differs (methods usually survive upgrades, but the shapes are not re-checked), or `unknown`. `cliVersionProbe` reports whether `codex --version` answered (`ok`, `missing`, `timeout` after 3 s, `error`). The document is always written to stdout and includes `exitCode`; it is `0` only for a usable daemon. @@ -108,7 +173,7 @@ gbot codex send --correlation-id M --reply-to M --hop 1 "ack" # the a Sends at `hop >= GROK_BOT_MAX_HOPS` (default 4) are refused with `reason: "hop-limit"` before anything reaches the daemon, so two agents cannot acknowledge each other forever; `gbot` never auto-acknowledges. `--envelope` (implied by any envelope flag) prepends a one-line `[gbot msg=… corr=… reply-to=… hop=… from=user@host]` header so the receiving agent can quote the ids back. That header is caller-authored provenance for the reader, not authentication: the daemon authenticates the local user through the socket, nothing else. Private ChatGPT Desktop pipes and arbitrary ChatGPT chats stay out of scope; only Codex threads on a reachable app-server daemon are routes. -**Busy threads.** `send` reads the thread status on resume. Only `idle` and `notLoaded` threads start a turn. An `active` thread (a turn in progress, or waiting on approval / user input) is refused with `reason: "busy"`: in app-server 0.154.0 a `turn/start` on an active thread steers that turn rather than queueing behind it, and `gbot` never steers or interrupts work a human may be doing. Either wait for `list-threads` to show `idle` and resend, or pass `--when-busy queue` to hand the message to the daemon's own queue through Codex's experimental `thread/queue/add` — that needs `GROK_BOT_CODEX_EXPERIMENTAL=1`, returns `delivery: "queued"` with `queuedSubmissionId`, and `gbot codex queue ` shows what is still waiting. `systemError` threads are refused with `reason: "thread-error"`, statuses this version does not know with `reason: "unknown-status"`. Receipts distinguish `delivery: "accepted"` (turn started; `turnId`, `turnStatus`), `"queued"`, `"rejected"` (nothing was sent; see `reason`), and `"unknown"` (the request left but no acknowledgment came back — look for `messageId` in the thread or queue before resending). The decision record, with the schema evidence and a live probe of the queue API, is in [`docs/codex-busy-threads.md`](docs/codex-busy-threads.md). +**Busy threads.** `send` reads the thread status on resume. Only `idle` and `notLoaded` threads start a turn. An `active` thread (a turn in progress, or waiting on approval / user input) is refused with `reason: "busy"`: in app-server 0.154.0 a `turn/start` on an active thread steers that turn rather than queueing behind it, by default. Explicit guarded steering and managed bridge routes can deliver into active work; they never interrupt a turn. Either wait for `list-threads` to show `idle` and resend, or pass `--when-busy queue` to hand the message to the daemon's own queue through Codex's experimental `thread/queue/add` — that needs `GROK_BOT_CODEX_EXPERIMENTAL=1`, returns `delivery: "queued"` with `queuedSubmissionId`, and `gbot codex queue ` shows what is still waiting. `systemError` threads are refused with `reason: "thread-error"`, statuses this version does not know with `reason: "unknown-status"`. Receipts distinguish `delivery: "accepted"` (turn started; `turnId`, `turnStatus`), `"queued"`, `"rejected"` (nothing was sent; see `reason`), and `"unknown"` (the request left but no acknowledgment came back — look for `messageId` in the thread or queue before resending). The decision record, with the schema evidence and a live probe of the queue API, is in [`docs/codex-busy-threads.md`](docs/codex-busy-threads.md). **Failure modes.** Every `send` and `codex` outcome under `--json` is one document on stdout with `exitCode`; failures include `{ error, delivery, reason, messageId, correlationId, hop, exitCode: 1, … }` and the process exits 1. Framework argument/schema errors remain on stderr and exit 2. `--json` is reserved anywhere before `--`; put `--` before flag-like message text. `reason` values are stable: diff --git a/agent-bundle.config.ts b/agent-bundle.config.ts index 0684d21..4c7f5c8 100644 --- a/agent-bundle.config.ts +++ b/agent-bundle.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ output: { distPath: 'artifact' }, plugin: { description: - 'Message Grok Bot bots and groups and read their threads from Codex, Claude Code, and Cursor.', + 'Message Grok Bot from Codex, Claude Code, and Cursor, with managed automatic replies and explicit Codex conversation links.', // plugin.name is also the routed bin name: `dist/bin/gbot.mjs`. name: 'gbot', }, diff --git a/src/cli/codex/bridge/respond.tsx b/src/cli/codex/bridge/respond.tsx new file mode 100644 index 0000000..268c883 --- /dev/null +++ b/src/cli/codex/bridge/respond.tsx @@ -0,0 +1,65 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { + respondSchema as inputSchema, + relayResultSchema as resultSchema, + bridgeOperation, +} from '../../../core/relay/routes.js'; +import { outcomeFromError } from '../../../core/codex/contract.js'; +export { inputSchema, resultSchema }; +export const config = { + description: 'Managed Grok/Codex bridge respond.', + exitCode: 'result', + inputJsonSchema: { + type: 'object', + properties: { + interactionId: { + type: 'string', + }, + generation: { + type: 'string', + }, + threadId: { + type: 'string', + }, + turnId: { + type: 'string', + }, + bindingId: { + type: 'string', + }, + exchangeId: { + type: 'string', + }, + decision: { + type: 'string', + enum: ['accept', 'decline', 'cancel'], + }, + answersJson: { + type: 'string', + description: + 'JSON object mapping each advertised question ID to {"answers":["answer"]}; exact IDs required.', + }, + }, + required: ['interactionId', 'generation', 'threadId', 'turnId'], + additionalProperties: false, + }, +} satisfies CliRouteConfig; +export default async function route({ + input, +}: CliRouteProps) { + let out; + try { + out = { + ...(await bridgeOperation('respond', input, await agent())), + exitCode: 0, + }; + } catch (error) { + out = outcomeFromError(error); + } + return ( + + {JSON.stringify(out)} + + ); +} diff --git a/src/cli/codex/bridge/run.tsx b/src/cli/codex/bridge/run.tsx new file mode 100644 index 0000000..4317ab0 --- /dev/null +++ b/src/cli/codex/bridge/run.tsx @@ -0,0 +1,75 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { spawn } from 'node:child_process'; +import { z } from 'zod'; +import { resolveRelayWorker } from '../../../core/relay/managed.js'; +export const inputSchema = z + .object({ + lifetimeMs: z.number().int().min(100).max(82800000).default(82800000), + }) + .strict(); +export const resultSchema = z.object({ + state: z.string(), + exitCode: z.number(), +}); +export const config = { + description: + 'Run a bounded foreground relay (up to 23 hours). Use the packaged gbot-relay.mjs script for unlimited service lifetime.', + exitCode: 'result', + render: { maxElapsedMs: 86400000 }, + inputJsonSchema: { + type: 'object', + properties: { + lifetimeMs: { + type: 'number', + description: + 'Foreground lifetime in milliseconds (100..82800000; default 23h).', + }, + }, + additionalProperties: false, + }, +} satisfies CliRouteConfig; +export default async function route({ + signal, + input, +}: CliRouteProps) { + const context = await agent(); + const script = await resolveRelayWorker({ + pluginRoot: + context.plugin.state === 'available' + ? context.plugin.value.root + : undefined, + }); + // The renderer may terminate immediately on abort. The separate worker can finish + // asynchronous socket/ledger cleanup after the synchronous termination signal. + const child = spawn( + process.execPath, + [ + script, + '--lifetime-ms', + String(input.lifetimeMs), + '--parent-pid', + String(process.pid), + ], + { stdio: ['ignore', 'ignore', 'inherit'] }, + ); + const stop = () => { + child.kill('SIGTERM'); + }; + signal.addEventListener('abort', stop, { once: true }); + if (signal.aborted) stop(); + let exitCode; + try { + exitCode = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => resolve(code ?? 1)); + }); + } finally { + signal.removeEventListener('abort', stop); + } + return ( + + Relay worker stopped. + + ); +} diff --git a/src/cli/codex/bridge/start.tsx b/src/cli/codex/bridge/start.tsx new file mode 100644 index 0000000..8264bcf --- /dev/null +++ b/src/cli/codex/bridge/start.tsx @@ -0,0 +1,55 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { + bridgeStartSchema as inputSchema, + relayResultSchema as resultSchema, + bridgeOperation, +} from '../../../core/relay/routes.js'; +import { outcomeFromError } from '../../../core/codex/contract.js'; +export { inputSchema, resultSchema }; +export const config = { + description: 'Managed Grok/Codex bridge start.', + exitCode: 'result', + positionals: ['grokTarget'], + inputJsonSchema: { + type: 'object', + properties: { + grokTarget: { + type: 'string', + }, + codexThreadId: { + type: 'string', + }, + expectedCwd: { + type: 'string', + }, + busyPolicy: { + type: 'string', + enum: ['steer', 'reject'], + }, + requestId: { + type: 'string', + }, + }, + required: ['grokTarget'], + additionalProperties: false, + }, +} satisfies CliRouteConfig; +export default async function route({ + input, +}: CliRouteProps) { + let out; + try { + out = { + ...(await bridgeOperation('startBinding', input, await agent())), + exitCode: 0, + }; + } catch (error) { + out = outcomeFromError(error); + } + return ( + + {JSON.stringify(out)} + + ); +} diff --git a/src/cli/codex/bridge/status.tsx b/src/cli/codex/bridge/status.tsx new file mode 100644 index 0000000..d83aa2c --- /dev/null +++ b/src/cli/codex/bridge/status.tsx @@ -0,0 +1,43 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { + bridgeStatusSchema as inputSchema, + relayResultSchema as resultSchema, + bridgeOperation, +} from '../../../core/relay/routes.js'; +import { outcomeFromError } from '../../../core/codex/contract.js'; +export { inputSchema, resultSchema }; +export const config = { + description: 'Managed Grok/Codex bridge status.', + exitCode: 'result', + inputJsonSchema: { + type: 'object', + properties: { + bindingId: { + type: 'string', + }, + limit: { + type: 'number', + }, + }, + additionalProperties: false, + }, +} satisfies CliRouteConfig; +export default async function route({ + input, +}: CliRouteProps) { + let out; + try { + out = { + ...(await bridgeOperation('status', input, await agent())), + exitCode: 0, + }; + } catch (error) { + out = outcomeFromError(error); + } + return ( + + {JSON.stringify(out)} + + ); +} diff --git a/src/cli/codex/bridge/stop.tsx b/src/cli/codex/bridge/stop.tsx new file mode 100644 index 0000000..524e79a --- /dev/null +++ b/src/cli/codex/bridge/stop.tsx @@ -0,0 +1,46 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { + bridgeStopSchema as inputSchema, + relayResultSchema as resultSchema, + bridgeOperation, +} from '../../../core/relay/routes.js'; +import { outcomeFromError } from '../../../core/codex/contract.js'; +export { inputSchema, resultSchema }; +export const config = { + description: 'Managed Grok/Codex bridge stop.', + exitCode: 'result', + inputJsonSchema: { + type: 'object', + properties: { + bindingId: { + type: 'string', + }, + all: { + type: 'boolean', + }, + worker: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, +} satisfies CliRouteConfig; +export default async function route({ + input, +}: CliRouteProps) { + let out; + try { + out = { + ...(await bridgeOperation('stop', input, await agent())), + exitCode: 0, + }; + } catch (error) { + out = outcomeFromError(error); + } + return ( + + {JSON.stringify(out)} + + ); +} diff --git a/src/cli/codex/send.tsx b/src/cli/codex/send.tsx index 17be300..2d62b41 100644 --- a/src/cli/codex/send.tsx +++ b/src/cli/codex/send.tsx @@ -1,64 +1,121 @@ import { Agent, agent } from '@agent-bundle/runtime'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { sendFields, resultSchema, sendOperation, resultText } from '../../core/codex/routes.js'; -export { resultSchema }; -export const inputSchema = z.object({ ...sendFields, correlationId: z.string().min(1).optional(), replyTo: z.string().min(1).optional(), message: z.array(z.string()).min(1) }).strict(); +import { + sendFields, + resultSchema as plainResultSchema, + sendOperation, + resultText, +} from '../../core/codex/routes.js'; +import { + codexReturnOperation, + relayResultSchema, +} from '../../core/relay/routes.js'; +export const resultSchema = z.union([plainResultSchema, relayResultSchema]); +export const inputSchema = z + .object({ + ...sendFields, + whenBusy: z.enum(['reject', 'queue', 'steer']).optional(), + replyToGrok: z.string().min(1).optional(), + bindingId: z.string().min(1).optional(), + requestId: z.string().min(1).max(128).optional(), + correlationId: z.string().min(1).optional(), + replyTo: z.string().min(1).optional(), + message: z.array(z.string()).min(1), + }) + .strict(); export const config = { - description: 'Send to Codex; acceptance is distinct from completion. Options precede threadId.', - exitCode: 'result', render: { maxElapsedMs: 660000 }, positionals: ['threadId', 'message'], - inputJsonSchema: { type: 'object', additionalProperties: false, properties: { - "threadId": { - "type": "string" + description: + 'Send to Codex; acceptance is distinct from completion. Options precede threadId.', + exitCode: 'result', + render: { maxElapsedMs: 660000 }, + positionals: ['threadId', 'message'], + inputJsonSchema: { + type: 'object', + additionalProperties: false, + properties: { + replyToGrok: { type: 'string' }, + bindingId: { type: 'string' }, + requestId: { type: 'string' }, + threadId: { + type: 'string', }, - "expectedCwd": { - "type": "string" + expectedCwd: { + type: 'string', }, - "timeoutMs": { - "type": "number", - "description": "Observation timeout: 1-600000 milliseconds." + timeoutMs: { + type: 'number', + description: 'Observation timeout: 1-600000 milliseconds.', }, - "correlationId": { - "type": "string" + correlationId: { + type: 'string', }, - "envelope": { - "type": "boolean" + envelope: { + type: 'boolean', }, - "hop": { - "type": "number" + hop: { + type: 'number', }, - "replyTo": { - "type": "string" + replyTo: { + type: 'string', }, - "expectedTurnId": { - "type": "string", - "description": "Required active-turn guard for steer; stale guards reject." + expectedTurnId: { + type: 'string', + description: + 'Required active-turn guard for steer; stale guards reject.', }, - "wait": { - "type": "boolean" + wait: { + type: 'boolean', }, - "maxOutputBytes": { - "type": "number", - "description": "Reply budget: 1-4194304 bytes." + maxOutputBytes: { + type: 'number', + description: 'Reply budget: 1-4194304 bytes.', }, - "whenBusy": { - "type": "string", - "enum": [ - "reject", - "queue", - "steer" - ] + whenBusy: { + type: 'string', + enum: ['reject', 'queue', 'steer'], }, - "message": { - "type": "array", - "items": { - "type": "string" - } - } - }, required: ['threadId', 'message'] }, + message: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['threadId', 'message'], + }, } satisfies CliRouteConfig; -export default async function route({ input, signal }: CliRouteProps) { +export default async function route({ + input, + signal, +}: CliRouteProps) { const context = await agent(); - const out = await sendOperation({ ...input, message: input.message.join(' ').trim() }, signal, message => context.progress.report({ message }), true); - return {resultText(out)}; + if (input.replyToGrok || input.bindingId) { + const out = await codexReturnOperation( + { ...input, message: input.message.join(' ').trim() }, + context, + ); + return ( + + {`Delivery ${out.delivery}; terminal answer returns to Grok automatically.`} + + ); + } + const out = await sendOperation( + { + ...input, + whenBusy: input.whenBusy ?? 'reject', + message: input.message.join(' ').trim(), + }, + signal, + (message) => context.progress.report({ message }), + true, + ); + return ( + + {resultText(out)} + + ); } diff --git a/src/cli/send.tsx b/src/cli/send.tsx index 06472d3..7ea9a2b 100644 --- a/src/cli/send.tsx +++ b/src/cli/send.tsx @@ -1,7 +1,12 @@ -import { Agent } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; +import { + grokSendOperation, + routeFields, + relayResultSchema, +} from '../core/relay/routes.js'; import { buildEnvelope, withEnvelopeHeader } from '../core/codex-bridge.js'; import { outcomeFromError } from '../core/codex/contract.js'; import { saveHistory } from '../core/history.js'; @@ -17,16 +22,36 @@ export const config = { inputJsonSchema: { additionalProperties: false, properties: { - correlationId: { description: 'Stable correlation id for multi-hop replies', type: 'string' }, + replyMode: { type: 'string', enum: ['auto', 'manual'] }, + codexThreadId: { type: 'string' }, + bindingId: { type: 'string' }, + expectedCwd: { type: 'string' }, + requestId: { type: 'string' }, + correlationId: { + description: 'Stable correlation id for multi-hop replies', + type: 'string', + }, dir: { description: 'Agents directory for --files mode', type: 'string' }, envelope: { description: 'Prepend the [gbot …] header', type: 'boolean' }, files: { description: 'Force the on-disk agents store', type: 'boolean' }, gateway: { description: 'Force the live gateway', type: 'boolean' }, - historyDir: { description: 'Directory containing history.jsonl', type: 'string' }, - hop: { description: 'Hop count; refused at GROK_BOT_MAX_HOPS', type: 'number' }, + historyDir: { + description: 'Directory containing history.jsonl', + type: 'string', + }, + hop: { + description: 'Hop count; refused at GROK_BOT_MAX_HOPS', + type: 'number', + }, message: { items: { type: 'string' }, type: 'array' }, - noHistory: { description: 'Skip local history for this command', type: 'boolean' }, - replyTo: { description: 'Prior message id this send replies to', type: 'string' }, + noHistory: { + description: 'Skip local history for this command', + type: 'boolean', + }, + replyTo: { + description: 'Prior message id this send replies to', + type: 'string', + }, target: { type: 'string' }, }, required: ['target', 'message'], @@ -37,6 +62,8 @@ export const config = { export const inputSchema = backendFlagsSchema .extend({ + ...routeFields, + replyMode: z.enum(['auto', 'manual']).optional(), correlationId: z.string().min(1).optional(), envelope: z.boolean().default(false), historyDir: z.string().min(1).optional(), @@ -66,9 +93,15 @@ const receiptSchema = z .strict(); // Refusals and gateway failures are the same flat document `codex send` emits. -export const resultSchema = z.union([receiptSchema, failureDocumentSchema]); +export const resultSchema = z.union([ + receiptSchema, + failureDocumentSchema, + relayResultSchema, +]); -const deliver = async (input: z.infer): Promise> => { +const deliver = async ( + input: z.infer, +): Promise> => { const envelope = buildEnvelope({ correlationId: input.correlationId, envelope: input.envelope, @@ -77,7 +110,10 @@ const deliver = async (input: z.infer): Promise): Promise): Promise) { - let value: z.infer; +export default async function send({ + input, +}: CliRouteProps) { + let value: + z.infer | z.infer; try { + if (input.replyMode === 'auto' || input.codexThreadId || input.bindingId) { + if (input.envelope || input.replyTo) + throw Error( + 'Automatic routes own their envelope; omit --envelope and --reply-to. Use --hop and --correlation-id for explicit chains.', + ); + if (input.files) + throw Error('Automatic routes require the gateway backend'); + const out = await grokSendOperation( + { + target: input.target, + message: input.message.join(' ').trim(), + replyMode: input.replyMode ?? 'auto', + codexThreadId: input.codexThreadId, + bindingId: input.bindingId, + expectedCwd: input.expectedCwd, + requestId: input.requestId, + hop: input.hop, + correlationId: input.correlationId, + }, + await agent(), + ); + return ( + + {`Delivery ${out.delivery}; inspect codex bridge status for automatic reply delivery.`} + + ); + } value = await deliver(input); } catch (error) { value = outcomeFromError(error); } - const text = value.exitCode === 0 - ? `Sent to ${value.kind} ${value.name} (${value.id})${value.messageId ? ` message ${value.messageId}` : ''}; envelope ${value.envelopeId}` - : value.error; + const text = + value.exitCode === 0 + ? `Sent to ${value.kind} ${value.name} (${value.id})${value.messageId ? ` message ${value.messageId}` : ''}; envelope ${value.envelopeId}` + : value.error; return ( {text} diff --git a/src/core/codex-bridge.js b/src/core/codex-bridge.js index 08bfabf..61e0fbf 100644 --- a/src/core/codex-bridge.js +++ b/src/core/codex-bridge.js @@ -4,7 +4,7 @@ import { createConnection } from "node:net"; import { homedir, hostname } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; -import { createRequire } from "node:module"; +import pkg from "../../package.json" with { type: "json" }; import { outcomeFromError, outcomeFromReceipt, withStatusExitCode } from "./codex/contract.js"; import { desktopShimStatus } from "./desktop-shim.js"; @@ -28,7 +28,6 @@ export const WS_MAX_WRITE_BYTES = 8 * 1024 * 1024; export const CODEX_MAX_REQUESTS = 128; export const CODEX_MAX_LISTENERS = 128; const textDecoder = new TextDecoder("utf-8", { fatal: true }); -const pkg = createRequire(import.meta.url)("../../package.json"); export function codexSocketPath(env = process.env) { // Explicit socket wins so status/send/queue agree with a shim-installed or diff --git a/src/core/relay/control.js b/src/core/relay/control.js new file mode 100644 index 0000000..b056081 --- /dev/null +++ b/src/core/relay/control.js @@ -0,0 +1,76 @@ +import { StringDecoder } from "node:string_decoder"; +import { connect } from "node:net"; +import { randomUUID } from "node:crypto"; +import { relayLocation } from "./profile.js"; +export const RELAY_PROTOCOL = 1; +export const CONTROL_BYTES = 1024 * 1024; +export const CONTROL_TIMEOUT = 45000; +export function relayRequest( + options, + method, + input = {}, + { timeoutMs = CONTROL_TIMEOUT, signal, requestId = randomUUID() } = {}, +) { + const { socketPath, profile } = relayLocation(options); + return new Promise((resolve, reject) => { + const decoder = new StringDecoder("utf8"); + let buffer = "", + bytes = 0, + settled = false, + connected = false; + const socket = connect(socketPath); + const finish = (error, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + socket.destroy(); + error ? reject(error) : resolve(value); + }; + const uncertain = () => + Object.assign( + Error( + `Relay control connection lost; do not resend with a new requestId (${requestId}). Check bridge status.`, + ), + { requestId }, + ); + const abort = () => finish(uncertain()); + const timer = setTimeout(abort, timeoutMs); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) return abort(); + socket.on("error", (error) => finish(connected ? uncertain() : error)); + socket.on("close", () => finish(uncertain())); + socket.on("connect", () => { + connected = true; + const line = + JSON.stringify({ + version: RELAY_PROTOCOL, + profile, + requestId, + method, + input, + }) + "\n"; + if (Buffer.byteLength(line) > CONTROL_BYTES) + return finish(Error("Relay control request exceeds budget")); + socket.write(line); + }); + socket.on("data", (chunk) => { + bytes += chunk.length; + if (bytes > CONTROL_BYTES) + return finish(Error("Relay control response exceeds budget")); + buffer += decoder.write(chunk); + const end = buffer.indexOf("\n"); + if (end < 0) return; + try { + const reply = JSON.parse(buffer.slice(0, end)); + if (reply.version !== RELAY_PROTOCOL || reply.requestId !== requestId) + throw Error("Relay protocol identity mismatch"); + if (reply.error) throw Error(reply.error); + if (reply.profile !== profile) throw Error("Relay profile mismatch"); + finish(null, reply.result); + } catch (error) { + finish(error); + } + }); + }); +} diff --git a/src/core/relay/managed.js b/src/core/relay/managed.js new file mode 100644 index 0000000..4463c02 --- /dev/null +++ b/src/core/relay/managed.js @@ -0,0 +1,124 @@ +import { spawn } from "node:child_process"; +import { readFile, lstat, realpath } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; +import { digest, relayLocation } from "./profile.js"; +import { relayRequest } from "./control.js"; + +/** @param {{pluginRoot?: string, moduleUrl?: string}} options */ +export async function resolveRelayWorker({ + pluginRoot, + moduleUrl = import.meta.url, +} = {}) { + const roots = []; + if (pluginRoot) roots.push(pluginRoot); + let path = dirname(fileURLToPath(moduleUrl)); + for (let i = 0; i < 5; i++) { + roots.push(path); + path = dirname(path); + } + for (const candidate of new Set(roots)) { + let manifest; + try { + manifest = JSON.parse( + await readFile(join(candidate, "agent-bundle.manifest.json"), "utf8"), + ); + } catch (error) { + if (error.code === "ENOENT") continue; + throw error; + } + const relative = "scripts/gbot-relay.mjs"; + const file = manifest.files?.find((f) => f.path === relative); + if ( + manifest.application?.name !== "gbot" || + !file || + !manifest.executables?.scripts?.some((s) => s.path === relative) + ) + continue; + const root = await realpath(candidate), + worker = join(root, relative), + stat = await lstat(worker); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + (await realpath(worker)) !== worker + ) + throw Error("Relay worker must be a regular packaged script"); + if ( + stat.size !== file.bytes || + digest(await readFile(worker)) !== file.sha256 + ) + throw Error("Packaged relay worker checksum mismatch"); + return worker; + } + throw Error( + "Packaged relay worker not found; rebuild or reinstall the plugin", + ); +} +const unavailable = (error) => ["ENOENT", "ECONNREFUSED"].includes(error.code); +export async function ensureRelayWorker(options = {}) { + const location = relayLocation(options); + try { + return await relayRequest(location, "hello", {}, { timeoutMs: 1500 }); + } catch (error) { + if (!unavailable(error)) throw error; + } + const workerPath = await resolveRelayWorker(options); + const child = spawn(process.execPath, [workerPath], { + detached: true, + stdio: "ignore", + env: { ...location.env, GROK_BOT_RELAY_DIR: location.stateDir }, + cwd: dirname(workerPath), + }); + let spawnError; + child.on("error", (error) => { + spawnError = error; + }); + child.unref(); + const end = Date.now() + 10000; + while (Date.now() < end) { + if (spawnError) throw spawnError; + if (options.signal?.aborted) + throw Error("Relay startup cancelled before submission"); + try { + return await relayRequest( + location, + "hello", + {}, + { timeoutMs: 1000, signal: options.signal }, + ); + } catch (error) { + if (!unavailable(error)) throw error; + } + await new Promise((resolve) => setTimeout(resolve, 75)); + } + throw Error( + "Relay worker startup failed or timed out; no untracked send was attempted. Inspect bridge status and the relay owner lock.", + ); +} +export async function managedOperation(method, input, options = {}) { + const location = relayLocation(options); + const requestId = input.requestId ?? randomUUID(); + if (!["status", "stop", "respond"].includes(method)) + await ensureRelayWorker(options); + try { + return await relayRequest(location, method, input, { + signal: options.signal, + requestId, + }); + } catch (error) { + if (method === "status" && unavailable(error)) + return { + worker: { + state: "stopped", + profile: location.profile, + supervised: false, + }, + reason: + "Worker is not running. Start a route or tracked send to resume saved routes.", + }; + // Never retry a disconnected mutation. Its durable request ID is the recovery handle. + throw error; + } +} diff --git a/src/core/relay/ownership.js b/src/core/relay/ownership.js new file mode 100644 index 0000000..10a1b0d --- /dev/null +++ b/src/core/relay/ownership.js @@ -0,0 +1,100 @@ +import { lstat, open, chmod, unlink } from "node:fs/promises"; +import { createConnection } from "node:net"; +import { join, dirname } from "node:path"; +import { protectRelayDirectory } from "./state.js"; + +async function protectOwnershipFiles(dir) { + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + const path = join(dir, "worker.lock" + suffix); + let stat; + try { + stat = await lstat(path); + } catch (error) { + if (error.code === "ENOENT") continue; + throw error; + } + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.uid !== process.getuid() + ) + throw Error( + "Relay ownership storage must be an owned regular file, never a symlink", + ); + if (stat.size > 1024 * 1024) + throw Error("Relay ownership storage exceeds budget"); + await chmod(path, 0o600); + } +} +async function removeStaleSocket(socketPath) { + let stat; + try { + stat = await lstat(socketPath); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + if ( + !stat.isSocket() || + stat.isSymbolicLink() || + stat.uid !== process.getuid() + ) + throw Error("Relay control path must be an owned socket"); + const live = await new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + socket.setTimeout(1000, () => { + socket.destroy(); + reject(Error("Relay socket ownership uncertain")); + }); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", (error) => { + socket.destroy(); + ["ECONNREFUSED", "ENOENT"].includes(error.code) + ? resolve(false) + : reject(error); + }); + }); + if (live) throw Error("Relay control listener already running"); + await unlink(socketPath); +} + +/** + * A dedicated SQLite file supplies an OS-released lifetime lock, independent of + * the state driver's atomic commits. Never unlink or replace this inode: doing + * so could let another process lock a different file while this owner runs. + */ +export async function claimRelayOwner(location) { + await protectRelayDirectory(location.stateDir); + if (location.socketDir !== location.stateDir) { + await protectRelayDirectory(dirname(location.socketDir)); + await protectRelayDirectory(location.socketDir); + } + await protectOwnershipFiles(location.stateDir); + const path = join(location.stateDir, "worker.lock"); + const file = await open(path, "a", 0o600); + await file.close(); + const { DatabaseSync } = await import("node:sqlite"); + const database = new DatabaseSync(path); + let released = false; + const release = () => { + if (released) return; + released = true; + database.close(); + }; + try { + database.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE;"); + await protectOwnershipFiles(location.stateDir); + // Exclusive ownership comes before opening the relay engine or touching an + // abandoned listener. A live foreign listener is never removed. + await removeStaleSocket(location.socketPath); + return release; + } catch (error) { + release(); + if (/locked|busy/i.test(error.message)) + throw Error("Relay owner is running or locked"); + throw error; + } +} diff --git a/src/core/relay/profile.js b/src/core/relay/profile.js new file mode 100644 index 0000000..39a18be --- /dev/null +++ b/src/core/relay/profile.js @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { codexSocketPath } from "../codex-bridge.js"; +import { grokBotGatewayDescriptorPath } from "../app-session.js"; + +export const digest = (value) => + createHash("sha256").update(value).digest("hex"); +// Only explicit overrides are hashed. App-session token refresh retains its source identity. +export function relayProfile(env = process.env) { + const keys = [ + "GROK_BOT_GATEWAY_URL", + "SAND_HOST_GATEWAY_URL", + "SAND_HOST_PORT", + "GROK_BOT_GATEWAY_TOKEN", + "SAND_HOST_GATEWAY_TOKEN", + "SAND_GATEWAY_TOKEN", + "CURSOR_ACCESS_TOKEN", + "GROK_BOT_ACCESS_TOKEN", + "SAND_ACCESS_TOKEN", + "GROK_BOT_GATEWAY_HEADERS", + "SAND_BACKEND_URL", + "CURSOR_API_BASE_URL", + "SAND_BOX_NAMESPACE", + "SAND_CLIENT_VERSION", + "GROK_BOT_CODEX_THREADS", + "GROK_BOT_MAX_HOPS", + "GROK_BOT_CODEX_EXPERIMENTAL", + "GROK_BOT_ALLOW_LOCAL_GATEWAY", + "GROK_BOT_ALLOW_ANY_GATEWAY", + "GROK_BOT_TEST", + "NODE_ENV", + ]; + return digest( + JSON.stringify([ + resolve(codexSocketPath(env)), + grokBotGatewayDescriptorPath(homedir(), process.platform, env), + ...keys.map((key) => [key, env[key] ?? ""]), + ]), + ); +} +export function relayLocation({ + env = process.env, + stateDir, + profile = relayProfile(env), +} = {}) { + if (process.platform === "win32") + throw Error( + "Codex relay requires Unix-domain sockets; Windows is unsupported.", + ); + const dir = resolve( + stateDir ?? + env.GROK_BOT_RELAY_DIR ?? + join(homedir(), ".grok-bot-cli", "relay", profile.slice(0, 24)), + ); + // macOS Unix sockets cannot use arbitrarily long installation/state paths. + const socketDir = + Buffer.byteLength(join(dir, "control.sock")) < 100 + ? dir + : join( + "/tmp", + `gbot-relay-${process.getuid()}`, + digest(dir).slice(0, 24), + ); + return { + stateDir: dir, + profile, + env, + socketDir, + socketPath: join(socketDir, "control.sock"), + }; +} diff --git a/src/core/relay/routes.ts b/src/core/relay/routes.ts new file mode 100644 index 0000000..cfe0784 --- /dev/null +++ b/src/core/relay/routes.ts @@ -0,0 +1,222 @@ +import { lineageHostFromClient } from "@agent-bundle/runtime"; +import { agent } from "@agent-bundle/runtime"; +type AgentRequest = Awaited>; +import { z } from "zod"; +import { managedOperation } from "./managed.js"; +import { + connectGateway, + sendPrompt, + summarizeTarget, + withRedactedErrors, +} from "../../gbot.js"; + +import { buildEnvelope, withEnvelopeHeader } from "../codex-bridge.js"; + +const id = z.string().min(1).max(128); +export const routeFields = { + codexThreadId: id.optional(), + expectedCwd: z.string().min(1).max(4096).optional(), + bindingId: z.string().min(1).max(512).optional(), + requestId: id.optional(), +}; +export const grokSendSchema = z + .object({ + target: z.string().min(1), + message: z.string().min(1), + replyMode: z.enum(["auto", "manual"]).optional(), + hop: z.number().int().min(0).optional(), + correlationId: id.optional(), + ...routeFields, + }) + .strict(); +export const bridgeStartSchema = z + .object({ + grokTarget: z.string().min(1), + codexThreadId: id.optional(), + expectedCwd: z.string().min(1).max(4096).optional(), + busyPolicy: z.enum(["steer", "reject"]).optional(), + requestId: id.optional(), + }) + .strict(); +export const bridgeStatusSchema = z + .object({ + bindingId: z.string().min(1).max(512).optional(), + limit: z.number().int().min(1).max(100).optional(), + }) + .strict(); +export const bridgeStopSchema = z + .object({ + bindingId: z.string().min(1).max(512).optional(), + all: z.boolean().optional(), + worker: z.boolean().optional(), + }) + .strict() + .refine( + (x) => !!x.bindingId || x.all === true || x.worker === true, + "Specify bindingId, all or worker", + ); +export const respondSchema = z + .object({ + interactionId: z.string().min(1).max(512), + generation: id, + threadId: id, + turnId: id, + bindingId: z.string().min(1).max(512).optional(), + exchangeId: z.string().min(1).max(512).optional(), + decision: z.enum(["accept", "decline", "cancel"]).optional(), + answersJson: z.string().min(2).max(65536).optional(), + }) + .strict() + .refine( + (x) => !!x.bindingId || !!x.exchangeId, + "Binding or exchange scope required", + ) + .refine( + (x) => !!x.decision !== !!x.answersJson, + "Supply decision or answersJson", + ); +const answersSchema = z.record( + z.string().min(1).max(128), + z.object({ answers: z.array(z.string().max(8192)).max(20) }).strict(), +); +export const relayResultSchema = z.record(z.string(), z.json()); +export function nativeCodexThread(context: AgentRequest) { + return context.host.state === "available" && + lineageHostFromClient(context.host.value.name) === "codex" && + context.lineage.state === "available" && + context.lineage.source === "native" && + context.lineage.value.resolution === "native" + ? context.lineage.value.conversation + : undefined; +} +function options(context?: AgentRequest) { + return { + pluginRoot: + context?.plugin.state === "available" + ? context.plugin.value.root + : undefined, + signal: context?.signal, + }; +} +function destination( + input: { codexThreadId?: string; expectedCwd?: string }, + context?: AgentRequest, +) { + const codexThreadId = + input.codexThreadId ?? (context ? nativeCodexThread(context) : undefined); + // Native workspace evidence belongs to the invocation, not the plugin installation directory. + const workspace = context?.workspace; + const expectedCwd = + input.expectedCwd ?? + (workspace?.state === "available" && workspace.source === "native" + ? workspace.value.root + : undefined); + return { codexThreadId, expectedCwd }; +} +export async function grokSendOperation( + input: z.infer, + context?: AgentRequest, +) { + const route = destination(input, input.bindingId ? undefined : context); + if (input.replyMode !== "manual" && (input.bindingId || route.codexThreadId)) + return withRedactedErrors(() => + managedOperation( + "sendToGrok", + { + grokTarget: input.target, + message: input.message, + hop: input.hop, + correlationId: input.correlationId, + ...route, + ...(input.bindingId ? { bindingId: input.bindingId } : {}), + ...(input.requestId ? { requestId: input.requestId } : {}), + }, + options(context), + ), + ); + if (input.replyMode === "auto") + throw Error( + "Automatic reply delivery requires a native Codex source, codexThreadId or bindingId", + ); + const message = + input.hop !== undefined || input.correlationId !== undefined + ? withEnvelopeHeader(input.message, buildEnvelope(input)) + : input.message; + const sent = await withRedactedErrors(async () => + sendPrompt(await connectGateway(), input.target, message), + ); + return { + result: sent.result, + target: summarizeTarget(sent.target), + delivery: sent.delivery === "accepted" ? "accepted" : "unknown", + ...(sent.delivery === "accepted" && typeof sent.messageId === "string" + ? { messageId: sent.messageId } + : {}), + replyRoute: { + mode: "manual", + reason: input.replyMode === "manual" ? "requested" : "source-unavailable", + }, + }; +} +export async function bridgeOperation( + method: "startBinding" | "status" | "stop" | "respond", + input: Record, + context?: AgentRequest, +) { + let routed: Record = + method === "startBinding" + ? { ...input, ...destination(input, context) } + : input; + if (method === "respond") { + const { decision, answersJson, ...scope } = respondSchema.parse(input); + routed = { + ...scope, + result: decision + ? { decision } + : { answers: answersSchema.parse(JSON.parse(answersJson!)) }, + }; + } + if (method === "startBinding" && !routed.codexThreadId) + throw Error( + "Specify codexThreadId when native Codex source identity is unavailable", + ); + return withRedactedErrors(() => + managedOperation(method, routed, options(context)), + ); +} +export async function codexReturnOperation( + input: { + threadId: string; + message: string; + replyToGrok?: string; + bindingId?: string; + expectedCwd?: string; + requestId?: string; + whenBusy?: string; + hop?: number; + correlationId?: string; + }, + context?: AgentRequest, +) { + if (input.whenBusy === "queue") + throw Error( + "Managed relay supports steer or reject, not experimental queue", + ); + return withRedactedErrors(() => + managedOperation( + "sendToCodex", + { + grokTarget: input.replyToGrok, + codexThreadId: input.threadId, + expectedCwd: input.expectedCwd, + bindingId: input.bindingId, + message: input.message, + requestId: input.requestId, + busyPolicy: input.whenBusy ?? "steer", + hop: input.hop, + correlationId: input.correlationId, + }, + options(context), + ), + ); +} diff --git a/src/core/relay/worker.js b/src/core/relay/worker.js new file mode 100644 index 0000000..692e126 --- /dev/null +++ b/src/core/relay/worker.js @@ -0,0 +1,200 @@ +import { StringDecoder } from "node:string_decoder"; +import { createServer } from "node:net"; +import { chmod, unlink, realpath } from "node:fs/promises"; +import { openRelayEngine } from "./engine.js"; +import { relayLocation } from "./profile.js"; +import { claimRelayOwner } from "./ownership.js"; +import { CONTROL_BYTES, CONTROL_TIMEOUT, RELAY_PROTOCOL } from "./control.js"; +import { redactSecrets } from "../url-policy.js"; + +export async function runRelayWorker(options = {}) { + const location = relayLocation(options); + const release = await claimRelayOwner(location); + let engine, + server, + timer, + closing = false, + inFlight = 0, + ownsSocket = false; + const clients = new Set(); + let done; + const closed = new Promise((resolve) => { + done = resolve; + }); + async function close() { + if (closing) return closed; + closing = true; + clearTimeout(timer); + for (const s of clients) s.destroy(); + if (server?.listening) + await new Promise((resolve) => server.close(resolve)); + await engine?.close(); + if (ownsSocket) await unlink(location.socketPath).catch(() => {}); + await release(); + done(); + } + try { + engine = await (options.openEngine ?? openRelayEngine)({ + stateDir: location.stateDir, + profile: location.profile, + env: location.env, + }); + const health = () => ({ + state: closing ? "stopped" : "running", + pid: process.pid, + protocol: RELAY_PROTOCOL, + profile: location.profile, + supervised: false, + }); + async function dispatch(request) { + if (request.version !== RELAY_PROTOCOL) + throw Error("Relay protocol mismatch"); + if (request.profile !== location.profile) + throw Error("Relay profile mismatch"); + if ( + typeof request.requestId !== "string" || + request.requestId.length < 1 || + request.requestId.length > 128 + ) + throw Error("Invalid relay request identity"); + const input = request.input; + if (!input || typeof input !== "object" || Array.isArray(input)) + throw Error("Invalid relay input"); + switch (request.method) { + case "hello": + return { worker: health() }; + case "status": + return { ...engine.status(input), worker: health() }; + case "startBinding": + return { + binding: await engine.startBinding({ + ...input, + requestId: request.requestId, + }), + worker: health(), + }; + case "sendToGrok": + case "sendToCodex": + if (input.bindingId) { + const binding = engine.status({ bindingId: input.bindingId }) + .bindings[0]; + if (input.codexThreadId && input.codexThreadId !== binding.threadId) + throw Error("Binding belongs to a different Codex thread"); + if ( + input.expectedCwd && + (await realpath(input.expectedCwd)) !== binding.expectedCwd + ) + throw Error("Binding workspace does not match expectedCwd"); + } + return { + ...(await engine[request.method]({ + ...input, + requestId: request.requestId, + })), + controlRequestId: request.requestId, + worker: health(), + }; + case "stop": { + if (!input.bindingId && input.all !== true && input.worker !== true) + throw Error("Stop requires bindingId, all or worker"); + if (input.all) + for (const binding of engine.status().bindings) + await engine.stopBinding({ bindingId: binding.id }); + else if (input.bindingId) + await engine.stopBinding({ bindingId: input.bindingId }); + const result = { + ...engine.status(), + worker: { + ...health(), + state: input.worker ? "stopping" : "running", + }, + }; + return result; + } + case "respond": + return await engine.respond(input); + default: + throw Error("Unknown relay control method"); + } + } + server = createServer((socket) => { + if (clients.size >= 32 || inFlight >= 32) { + socket.destroy(); + return; + } + clients.add(socket); + socket.on("close", () => clients.delete(socket)); + socket.on("error", () => {}); + socket.setTimeout(CONTROL_TIMEOUT, () => socket.destroy()); + const decoder = new StringDecoder("utf8"); + let buffer = "", + bytes = 0, + received = false; + socket.on("data", async (chunk) => { + if (received) return; + bytes += chunk.length; + if (bytes > CONTROL_BYTES) { + socket.destroy(); + return; + } + buffer += decoder.write(chunk); + const end = buffer.indexOf("\n"); + if (end < 0) return; + received = true; + inFlight++; + let request; + let reply; + try { + request = JSON.parse(buffer.slice(0, end)); + reply = { result: await dispatch(request) }; + } catch (error) { + reply = { + error: redactSecrets(String(error.message)).slice(0, 2048), + }; + } + const out = + JSON.stringify({ + version: RELAY_PROTOCOL, + profile: location.profile, + requestId: request?.requestId, + ...reply, + }) + "\n"; + if (Buffer.byteLength(out) > CONTROL_BYTES) + socket.end( + JSON.stringify({ + version: RELAY_PROTOCOL, + profile: location.profile, + requestId: request?.requestId, + error: + "Relay status exceeds control budget; narrow bindingId or limit", + }) + "\n", + ); + else socket.end(out); + inFlight--; + if (!reply.error && request.method === "stop" && request.input.worker) + setImmediate(() => void close()); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(location.socketPath, () => { + ownsSocket = true; + resolve(); + }); + }); + await chmod(location.socketPath, 0o600); + async function tick() { + try { + await engine.tick(); + } catch { + /* Engine status retains bounded failure state. */ + } + if (!closing) timer = setTimeout(tick, 2000); + } + timer = setTimeout(tick, 2000); + return { close, closed, location }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/src/mcp/grok-bot/tools/codex_send.tsx b/src/mcp/grok-bot/tools/codex_send.tsx index 945d8a5..58d7bbd 100644 --- a/src/mcp/grok-bot/tools/codex_send.tsx +++ b/src/mcp/grok-bot/tools/codex_send.tsx @@ -1,59 +1,106 @@ import { Agent, agent } from '@agent-bundle/runtime'; import { defineTool } from 'agent-bundle/routes'; -import { sendSchema as inputSchema, resultSchema, sendOperation, resultText } from '../../../core/codex/routes.js'; -export { inputSchema }; -export default defineTool({ - description: 'Send to a Codex thread. Accepted means submitted, not finished; optional wait observes bounded completion. Guarded steer requires expectedTurnId.', title: 'Codex send', annotations: { readOnlyHint: false }, - render: { maxElapsedMs: 660000 }, - inputSchema, resultSchema, - inputJsonSchema: { type: 'object', additionalProperties: false, properties: { - "threadId": { - "type": "string" - }, - "expectedCwd": { - "type": "string" - }, - "timeoutMs": { - "type": "number", - "description": "Observation timeout: 1-600000 milliseconds." - }, - "correlationId": { - "type": "string" - }, - "envelope": { - "type": "boolean" - }, - "hop": { - "type": "number" - }, - "replyTo": { - "type": "string" - }, - "expectedTurnId": { - "type": "string", - "description": "Required active-turn guard for steer; stale guards reject." - }, - "wait": { - "type": "boolean" - }, - "maxOutputBytes": { - "type": "number", - "description": "Reply budget: 1-4194304 bytes." - }, - "whenBusy": { - "type": "string", - "enum": [ - "reject", - "queue", - "steer" - ] - }, - "message": { - "type": "string" - } - }, required: ['threadId', 'message'] }, -}, async input => { - const context = await agent(); - const out = await sendOperation(input, context.signal, message => context.progress.report({ message })); - return {resultText(out)}; +import { + sendSchema, + resultSchema as plainResultSchema, + sendOperation, + resultText, +} from '../../../core/codex/routes.js'; +import { z } from 'zod'; +import { + codexReturnOperation, + relayResultSchema, +} from '../../../core/relay/routes.js'; +export const inputSchema = sendSchema.extend({ + whenBusy: z.enum(['reject', 'queue', 'steer']).optional(), + replyToGrok: z.string().min(1).optional(), + bindingId: z.string().min(1).optional(), + requestId: z.string().min(1).max(128).optional(), }); +const resultSchema = z.union([plainResultSchema, relayResultSchema]); +export default defineTool( + { + description: + 'Send to Codex. With replyToGrok or bindingId, managed delivery returns the terminal answer to Grok automatically. Otherwise optional wait observes completion and explicit steer requires expectedTurnId. Acceptance is not completion.', + title: 'Codex send', + annotations: { readOnlyHint: false }, + render: { maxElapsedMs: 660000 }, + inputSchema, + resultSchema, + inputJsonSchema: { + type: 'object', + properties: { + expectedCwd: { + type: 'string', + }, + threadId: { + type: 'string', + }, + timeoutMs: { + type: 'number', + }, + correlationId: { + type: 'string', + }, + envelope: { + type: 'boolean', + }, + hop: { + type: 'number', + }, + replyTo: { + type: 'string', + }, + expectedTurnId: { + type: 'string', + }, + whenBusy: { + type: 'string', + enum: ['reject', 'queue', 'steer'], + }, + wait: { + default: false, + type: 'boolean', + }, + maxOutputBytes: { + type: 'number', + }, + message: { + type: 'string', + }, + replyToGrok: { + type: 'string', + }, + bindingId: { + type: 'string', + }, + requestId: { + type: 'string', + }, + }, + required: ['threadId', 'message'], + additionalProperties: false, + }, + }, + async (input) => { + const context = await agent(); + if (input.replyToGrok || input.bindingId) { + const out = await codexReturnOperation(input, context); + return ( + + {`Delivery ${out.delivery}; terminal answer returns to Grok automatically.`} + + ); + } + const out = await sendOperation( + { ...input, whenBusy: input.whenBusy ?? 'reject' }, + context.signal, + (message) => context.progress.report({ message }), + ); + return ( + + {resultText(out)} + + ); + }, +); diff --git a/src/mcp/grok-bot/tools/gbot_bridge_start.tsx b/src/mcp/grok-bot/tools/gbot_bridge_start.tsx new file mode 100644 index 0000000..7f9cb71 --- /dev/null +++ b/src/mcp/grok-bot/tools/gbot_bridge_start.tsx @@ -0,0 +1,49 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { + bridgeStartSchema as inputSchema, + relayResultSchema as resultSchema, + bridgeOperation, +} from '../../../core/relay/routes.js'; +export { inputSchema }; +export default defineTool( + { + description: + 'Link a Grok conversation to Codex once. New visible Grok messages arrive automatically and Codex final answers return to Grok.', + title: 'gbot_bridge_start', + annotations: { readOnlyHint: false }, + inputSchema, + resultSchema, + inputJsonSchema: { + type: 'object', + properties: { + grokTarget: { + type: 'string', + }, + codexThreadId: { + type: 'string', + }, + expectedCwd: { + type: 'string', + }, + busyPolicy: { + type: 'string', + enum: ['steer', 'reject'], + }, + requestId: { + type: 'string', + }, + }, + required: ['grokTarget'], + additionalProperties: false, + }, + }, + async (input) => { + const out = await bridgeOperation('startBinding', input, await agent()); + return ( + + {JSON.stringify(out)} + + ); + }, +); diff --git a/src/mcp/grok-bot/tools/gbot_bridge_status.tsx b/src/mcp/grok-bot/tools/gbot_bridge_status.tsx new file mode 100644 index 0000000..35c3f2a --- /dev/null +++ b/src/mcp/grok-bot/tools/gbot_bridge_status.tsx @@ -0,0 +1,38 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { + bridgeStatusSchema as inputSchema, + relayResultSchema as resultSchema, + bridgeOperation, +} from '../../../core/relay/routes.js'; +export { inputSchema }; +export default defineTool( + { + description: + 'Inspect worker health, route coverage, bounded receipts and scoped pending operator interactions.', + title: 'gbot_bridge_status', + annotations: { readOnlyHint: true }, + inputSchema, + resultSchema, + inputJsonSchema: { + type: 'object', + properties: { + bindingId: { + type: 'string', + }, + limit: { + type: 'number', + }, + }, + additionalProperties: false, + }, + }, + async (input) => { + const out = await bridgeOperation('status', input, await agent()); + return ( + + {JSON.stringify(out)} + + ); + }, +); diff --git a/src/mcp/grok-bot/tools/gbot_bridge_stop.tsx b/src/mcp/grok-bot/tools/gbot_bridge_stop.tsx new file mode 100644 index 0000000..fa67034 --- /dev/null +++ b/src/mcp/grok-bot/tools/gbot_bridge_stop.tsx @@ -0,0 +1,41 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { + bridgeStopSchema as inputSchema, + relayResultSchema as resultSchema, + bridgeOperation, +} from '../../../core/relay/routes.js'; +export { inputSchema }; +export default defineTool( + { + description: + 'Stop a binding without deleting receipts or interrupting Codex. all stops all bindings; worker explicitly shuts down the worker.', + title: 'gbot_bridge_stop', + annotations: { readOnlyHint: false }, + inputSchema, + resultSchema, + inputJsonSchema: { + type: 'object', + properties: { + bindingId: { + type: 'string', + }, + all: { + type: 'boolean', + }, + worker: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + }, + async (input) => { + const out = await bridgeOperation('stop', input, await agent()); + return ( + + {JSON.stringify(out)} + + ); + }, +); diff --git a/src/mcp/grok-bot/tools/gbot_codex_respond.tsx b/src/mcp/grok-bot/tools/gbot_codex_respond.tsx new file mode 100644 index 0000000..f83eb39 --- /dev/null +++ b/src/mcp/grok-bot/tools/gbot_codex_respond.tsx @@ -0,0 +1,60 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { defineTool } from 'agent-bundle/routes'; +import { + respondSchema as inputSchema, + relayResultSchema as resultSchema, + bridgeOperation, +} from '../../../core/relay/routes.js'; +export { inputSchema }; +export default defineTool( + { + description: + 'Explicit operator response to a current scoped Codex interaction. Supports only one-time accept/decline/cancel or exact question-ID answers. Never auto-approve.', + title: 'gbot_codex_respond', + annotations: { readOnlyHint: false }, + inputSchema, + resultSchema, + inputJsonSchema: { + type: 'object', + properties: { + interactionId: { + type: 'string', + }, + generation: { + type: 'string', + }, + threadId: { + type: 'string', + }, + turnId: { + type: 'string', + }, + bindingId: { + type: 'string', + }, + exchangeId: { + type: 'string', + }, + decision: { + type: 'string', + enum: ['accept', 'decline', 'cancel'], + }, + answersJson: { + type: 'string', + description: + 'JSON object mapping each advertised question ID to {"answers":["answer"]}; exact IDs required.', + }, + }, + required: ['interactionId', 'generation', 'threadId', 'turnId'], + additionalProperties: false, + }, + }, + async (input) => { + const out = await bridgeOperation('respond', input, await agent()); + return ( + + {JSON.stringify(out)} + + ); + }, +); diff --git a/src/mcp/grok-bot/tools/gbot_send.tsx b/src/mcp/grok-bot/tools/gbot_send.tsx index 025054e..676e4b4 100644 --- a/src/mcp/grok-bot/tools/gbot_send.tsx +++ b/src/mcp/grok-bot/tools/gbot_send.tsx @@ -1,48 +1,63 @@ -import { Agent } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import { defineTool } from 'agent-bundle/routes'; -import { z } from 'zod'; - -import { connectGateway, sendPrompt, summarizeTarget, targetSchema, withRedactedErrors } from '../../../gbot.js'; - -export const inputSchema = z.object({ message: z.string().min(1), target: z.string().min(1) }); - +import { + grokSendSchema as inputSchema, + relayResultSchema as resultSchema, + grokSendOperation, +} from '../../../core/relay/routes.js'; +export { inputSchema }; export default defineTool( { description: - 'Send a message to a Grok Bot bot or group by name or id, like `gbot send`. The bot answers asynchronously; read its reply later with gbot_thread.', + 'Send to Grok Bot. Native Codex calls automatically receive replies in their originating thread; send once and continue work. Without a native source, supply codexThreadId or use manual gbot_thread reading.', + title: 'Send a message to Grok Bot', + annotations: { readOnlyHint: false }, + inputSchema, + resultSchema, inputJsonSchema: { - additionalProperties: false, + type: 'object', properties: { - message: { description: 'Message text. Say who you are and what you need in the first line.', type: 'string' }, - target: { description: 'Bot or group name or id, for example "General".', type: 'string' }, + hop: { + type: 'number', + description: + 'Explicit chain hop count, refused at the configured bound.', + }, + correlationId: { type: 'string' }, + target: { + type: 'string', + }, + message: { + type: 'string', + }, + replyMode: { + type: 'string', + enum: ['auto', 'manual'], + }, + codexThreadId: { + type: 'string', + }, + expectedCwd: { + type: 'string', + }, + bindingId: { + type: 'string', + }, + requestId: { + type: 'string', + }, }, required: ['target', 'message'], - type: 'object', + additionalProperties: false, }, - inputSchema, - resultSchema: z.object({ - result: z.record(z.string(), z.json()), - target: targetSchema, - 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)); - // 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, - ...(messageId === undefined ? {} : { messageId }), - }; + async (input) => { + const out = await grokSendOperation(input, await agent()); return ( - + - {`Sent to ${value.target.kind} ${value.target.name} (${value.target.id})${messageId === undefined ? ' (no receipt; check the thread before resending)' : ` as ${messageId}`}. Read the reply with gbot_thread.`} + {out.replyRoute.mode === 'auto' + ? `Delivery ${out.delivery}; replies will arrive in Codex thread ${out.replyRoute.threadId}. Continue work; no polling needed.` + : `Delivery ${out.delivery}${out.delivery === 'unknown' ? ' (no receipt; check the thread before resending)' : ''}; read the reply with gbot_thread.`} ); diff --git a/src/scripts/gbot-relay.ts b/src/scripts/gbot-relay.ts new file mode 100644 index 0000000..bb7b3d1 --- /dev/null +++ b/src/scripts/gbot-relay.ts @@ -0,0 +1,34 @@ +import { runRelayWorker } from "../core/relay/worker.js"; + +const args = process.argv.slice(2); +let lifetimeMs: number | undefined, parentPid: number | undefined; +for (let i = 0; i < args.length; i += 2) { + const value = Number(args[i + 1]); + if (!Number.isSafeInteger(value) || value < 1) + throw Error("Invalid relay worker argument"); + if (args[i] === "--lifetime-ms" && value <= 82800000) lifetimeMs = value; + else if (args[i] === "--parent-pid") parentPid = value; + else throw Error("Unknown relay worker argument"); +} +if (parentPid !== undefined && process.ppid !== parentPid) + throw Error("Foreground relay parent has exited"); +const worker = await runRelayWorker(); +const stop = () => { + void worker.close(); +}; +for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, stop); +const lifetime = + lifetimeMs === undefined ? undefined : setTimeout(stop, lifetimeMs); +// Only a bounded foreground CLI child follows its parent's lifetime. Managed +// workers and direct service-manager invocations are independent by default. +const parent = + parentPid === undefined + ? undefined + : setInterval(() => { + if (process.ppid !== parentPid) stop(); + }, 250); +await worker.closed; +clearTimeout(lifetime); +clearInterval(parent); +for (const signal of ["SIGINT", "SIGTERM"] as const) + process.removeListener(signal, stop); diff --git a/src/skills/talk-to-grok-bot/SKILL.md b/src/skills/talk-to-grok-bot/SKILL.md index b1601ee..5f771c2 100644 --- a/src/skills/talk-to-grok-bot/SKILL.md +++ b/src/skills/talk-to-grok-bot/SKILL.md @@ -15,10 +15,32 @@ Do not ping a bot for work you can finish yourself. Replies are asynchronous — ## How -1. `gbot_send` with `target` (name or id) and `message` (first line: who you are + what you need). -2. Later, call `gbot_thread` with the same `target` (`limit` defaults to 40). The default receipt has only `summary`, `cursor`, `entryCount`, and `gapReset`; it never includes entries. -3. Poll with the previous `cursor` as `after`. This is exclusive and client-side: `entryCount: 0` means no change. -4. Pass `full: true` only when entry bodies are needed inline; it adds bounded `entries` to structured content, not to `Agent.Text`. If `gapReset` is true, repeat the same call with `full: true` to inspect the bounded reset snapshot. +1. Send once with `gbot_send` (`target`, `message`). Say who you are and what you need. +2. Read its `replyRoute`. Native Codex calls with proven native lineage get `mode: auto`; + continue work and receive the matching reply in that same thread. Do not poll or + hold a tool call open. That reply does not automatically send your next answer back. +3. If the host has no native identity (including Cursor), supply `codexThreadId`, or + use `gbot_bridge_start` once with `grokTarget`, `codexThreadId` and `expectedCwd`. + An explicit binding forwards new visible Grok bot messages and returns Codex's + corresponding terminal answer to Grok. Existing history is not replayed. +4. `mode: manual` with `reason: source-unavailable` preserves ordinary sending. + Read `gbot_thread` later, using `after` for a known cursor and `full: true` only + when entry bodies are needed. `replyMode: manual` explicitly requests this flow. + +Automatic routes start a durable background worker that survives the calling tool. +Use `gbot_bridge_status` to distinguish delivery from execution and return delivery, +and to inspect gaps, paused routes and pending interactions. Do not resend unknown +submissions under a new identity. A provided `requestId` safely replays identical +tracked input; `controlRequestId` is returned independently of the gateway request ID. +`gbot_bridge_stop` stops a `bindingId`; `worker: true` explicitly stops the process. +Neither deletes receipts nor interrupts a Codex turn. No login service is installed; +if the process dies, the next tracked send/start resumes saved routes. + +`gbot_codex_respond` is only for an explicit operator response to a current scoped +interaction. Copy the advertised interactionId, generation, threadId, turnId and +bindingId/exchangeId. Use one-time `decision: accept|decline|cancel`, or `answersJson` +with exact question IDs mapped to `{"answers":["answer"]}`. Never auto-approve, +change session permissions, or respond to an unsupported interaction; use its owning UI. Bot replies are `send-message` entries; yours are `message` with `role: user`. @@ -61,4 +83,12 @@ CLI equivalents are `gbot codex send --wait --timeout-ms 1000 THREAD_ID hello -- `gbot codex wait --timeout-ms 1000 THREAD_ID TURN_ID --json`, and `gbot codex watch --timeout-ms 1000 --max-events 20 THREAD_ID --json`. Use framework `--ndjson` for progress. Wait/watch are explicit diagnostics; automatic -background reply routing is not provided by this conversation slice. +background reply routing uses the managed tools above. `codex_send` with `replyToGrok` or `bindingId` returns its terminal answer automatically; without either it retains the explicit observation flow. Managed routes default to guarded steering; ordinary sends still reject busy work by default. + +`gbot codex bridge start/status/stop/respond` provide the same administration controls. +CLI auto-routing requires explicit `gbot send --reply-mode auto --codex-thread-id ID`. +`bridge run` is foreground and bounded (`--lifetime-ms`, default/maximum 23 hours). +The packaged `scripts/gbot-relay.mjs` is the unlimited foreground service entry. +Grok participation is through its gateway conversation, not an assumed native Grok +plugin loader or remote MCP tunnel. Never claim a host loaded a plugin from generated +configuration alone. diff --git a/test/relay-surfaces.test.js b/test/relay-surfaces.test.js new file mode 100644 index 0000000..11dee8a --- /dev/null +++ b/test/relay-surfaces.test.js @@ -0,0 +1,847 @@ +import assert from "node:assert/strict"; +import { spawn, execFile } from "node:child_process"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm, lstat, cp } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { fakeAppServer } from "./helpers/codex-server.js"; +import { relayRequest } from "../src/core/relay/control.js"; + +export async function fixture({ active = false, interaction } = {}) { + const calls = []; + const entries = []; + const gateway = createServer(async (req, res) => { + let body = ""; + for await (const c of req) body += c; + const value = JSON.parse(body); + calls.push({ path: req.url, value }); + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify( + req.url.endsWith("listAgents") + ? { agents: [{ id: "bot-1", name: "General" }] } + : req.url.endsWith("getAgentTranscriptTail") + ? { entries } + : req.url.endsWith("sendPrompt") + ? { messageId: "grok-1" } + : {}, + ), + ); + }); + gateway.listen(0, "127.0.0.1"); + await once(gateway, "listening"); + const fake = await fakeAppServer({ + initialize: (_, ok) => ok({}), + "thread/resume": (p, ok) => + ok({ + thread: { + id: p.threadId, + cwd: "/tmp", + status: { type: active ? "active" : "idle" }, + }, + }), + "thread/turns/list": (_, ok) => + ok({ + data: active ? [{ id: "turn-1", status: "inProgress" }] : [], + nextCursor: null, + }), + "thread/items/list": (_, ok) => ok({ data: [], nextCursor: null }), + "turn/start": (p, ok, _error, send) => { + ok({ turn: { id: "turn-1", status: "inProgress" } }); + if (interaction) + setTimeout( + () => + send({ + jsonrpc: "2.0", + id: "operator-1", + method: + interaction === "question" + ? "item/tool/requestUserInput" + : "item/commandExecution/requestApproval", + params: { + threadId: p.threadId, + turnId: "turn-1", + ...(interaction === "question" + ? { + questions: [ + { + id: "choice", + header: "Choice", + question: "Pick one", + isSecret: false, + }, + ], + } + : { + availableDecisions: ["decline"], + command: "echo fixture", + }), + }, + }), + 10, + ); + }, + "turn/steer": (p, ok) => ok({ turnId: p.expectedTurnId }), + }); + const dir = await mkdtemp("/tmp/relay packed state "); + const env = { + ...process.env, + GROK_BOT_TEST: "1", + CODEX_HOME: fake.home, + CODEX_APP_SERVER_SOCK: "", + GROK_BOT_CODEX_THREADS: "", + GROK_BOT_MAX_HOPS: "4", + GROK_BOT_RELAY_DIR: dir, + GROK_BOT_GATEWAY_URL: `http://127.0.0.1:${gateway.address().port}`, + GROK_BOT_GATEWAY_TOKEN: "fixture-token", + GROK_BOT_GATEWAY_HEADERS: "", + CODEX_THREAD_ID: "stale-env-thread", + }; + return { + env, + calls, + entries, + fake, + close: async () => { + await relayRequest({ env }, "stop", { worker: true }).catch(() => {}); + await new Promise((r) => setTimeout(r, 100)); + await fake.close(); + await new Promise((r) => gateway.close(r)); + await rm(dir, { recursive: true, force: true }); + }, + }; +} +export async function mcp( + root, + env, + name = "codex-mcp-client", + manifestPath = process.env.RELAY_MCP_MANIFEST ?? "mcp.json", +) { + const manifest = JSON.parse(await readFile(join(root, manifestPath), "utf8")); + const launch = manifest.mcpServers["grok-bot"]; + const expand = (value) => + value.replace( + /\$\{(?:PLUGIN_ROOT|CURSOR_PLUGIN_ROOT|CLAUDE_PLUGIN_ROOT)\}/g, + root, + ); + const child = spawn(process.execPath, launch.args.map(expand), { + cwd: resolve(root, expand(launch.cwd ?? root)), + env: { + ...env, + ...Object.fromEntries( + Object.entries(launch.env ?? {}).map(([key, value]) => [ + key, + expand(value), + ]), + ), + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let buf = "", + seq = 0, + stderr = ""; + const pending = new Map(); + child.stderr.on("data", (x) => (stderr += x)); + child.stdout.on("data", (x) => { + buf += x; + for (;;) { + const i = buf.indexOf("\n"); + if (i < 0) break; + const line = buf.slice(0, i); + buf = buf.slice(i + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + pending.get(msg.id)?.(msg); + pending.delete(msg.id); + } + }); + const rpc = (method, params) => + new Promise((res, rej) => { + const id = ++seq; + const timer = setTimeout(() => { + pending.delete(id); + rej(Error(`MCP timeout ${stderr}`)); + }, 20000); + pending.set(id, (msg) => { + clearTimeout(timer); + res(msg); + }); + child.stdin.write( + JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n", + ); + }); + await rpc("initialize", { + protocolVersion: "2024-11-05", + clientInfo: { name, version: "1" }, + capabilities: {}, + }); + child.stdin.write( + JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + + "\n", + ); + return { + rpc, + close: async () => { + child.kill(); + await once(child, "exit"); + }, + call: async (name, args, thread) => { + const r = await rpc("tools/call", { + name, + arguments: args, + ...(thread + ? { + _meta: { + "x-codex-turn-metadata": { + thread_id: thread, + session_id: thread, + turn_id: "native-turn", + }, + }, + } + : {}), + }); + assert.equal(r.result?.isError, undefined, JSON.stringify(r)); + return r.result.structuredContent; + }, + }; +} + +test( + "generated MCP native send survives caller; unavailable and inferred sources remain manual; explicit return routes use worker", + { timeout: 90000 }, + async () => { + const f = await fixture(); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const names = (await client.rpc("tools/list", {})).result.tools.map( + (t) => t.name, + ); + for (const name of [ + "gbot_bridge_start", + "gbot_bridge_status", + "gbot_bridge_stop", + "gbot_codex_respond", + ]) + assert.ok(names.includes(name), name); + const manual = await client.call("gbot_send", { + target: "General", + message: "manual", + }); + assert.deepEqual(manual.replyRoute, { + mode: "manual", + reason: "source-unavailable", + }); + assert.equal(manual.delivery, "accepted"); + const sent = await client.call( + "gbot_send", + { target: "General", message: "native" }, + "thread-native", + ); + assert.equal(sent.replyRoute.mode, "auto"); + assert.equal(sent.replyRoute.threadId, "thread-native"); + assert.equal(sent.execution, "pending"); + assert.equal(sent.target.id, "bot-1"); + assert.equal(sent.delivery, "accepted"); + assert.ok(sent.exchangeId); + const pid = sent.worker.pid; + await client.close(); + client = null; + const status = await relayRequest({ env: f.env }, "status", {}); + assert.equal(status.worker.pid, pid); + assert.equal(status.receipts.length, 1); + const nonce = f.calls.filter((x) => x.path.endsWith("sendPrompt")).at(-1) + .value.clientNonce; + f.entries.push( + { + id: "native-user", + kind: "message", + role: "user", + clientNonce: nonce, + requestId: "actual-native-request", + text: "native", + }, + { + id: "native-reply", + kind: "send-message", + requestId: "actual-native-request", + text: "reply after caller exit", + }, + ); + for ( + let i = 0; + i < 100 && !f.fake.received.some((x) => x.method === "turn/start"); + i++ + ) + await new Promise((r) => setTimeout(r, 50)); + const delivered = f.fake.received.find((x) => x.method === "turn/start"); + assert.equal(delivered?.params.threadId, "thread-native"); + assert.match(JSON.stringify(delivered), /reply after caller exit/); + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + "cursor", + ); + const inferred = await client.call( + "gbot_send", + { target: "General", message: "cursor" }, + "thread-inferred", + ); + assert.equal(inferred.replyRoute.mode, "manual"); + const bound = await client.call("gbot_bridge_start", { + grokTarget: "General", + codexThreadId: "thread-native", + expectedCwd: "/tmp", + }); + assert.equal(bound.binding.state, "running"); + assert.equal(bound.worker.pid, pid); + const returned = await client.call("codex_send", { + threadId: "thread-native", + message: "return please", + replyToGrok: "General", + expectedCwd: "/tmp", + }); + assert.equal(returned.delivery, "accepted"); + assert.equal(returned.replyRoute.mode, "auto"); + assert.equal(returned.execution, "pending"); + await client.call("gbot_bridge_stop", { bindingId: bound.binding.id }); + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 3, + ); + } finally { + await client?.close(); + await f.close(); + } + }, +); +const cli = (env, ...args) => + new Promise((resolveResult) => + execFile( + process.execPath, + [ + resolve(process.env.RELAY_CLI_ROOT ?? "dist", "bin/gbot.mjs"), + ...args, + "--json", + ], + { env }, + (error, out, err) => resolveResult({ code: error?.code ?? 0, out, err }), + ), + ); +test( + "CLI concurrent starts share one owner, durable request replay sends once, worker restart retains receipt", + { timeout: 90000 }, + async () => { + const f = await fixture(); + try { + const args = [ + "codex", + "bridge", + "start", + "--codex-thread-id", + "thread-cli", + "--expected-cwd", + "/tmp", + "--request-id", + "stable-binding", + "General", + ]; + const results = await Promise.all([ + cli(f.env, ...args), + cli(f.env, ...args), + ]); + for (const r of results) assert.equal(r.code, 0, r.err + r.out); + const [a, b] = results.map((r) => JSON.parse(r.out)); + assert.equal(a.worker.pid, b.worker.pid); + assert.equal(a.binding.id, b.binding.id); + const sendArgs = [ + "send", + "--reply-mode", + "auto", + "--binding-id", + a.binding.id, + "--request-id", + "stable-send", + "General", + "hello", + ]; + for (let i = 0; i < 2; i++) { + const r = await cli(f.env, ...sendArgs); + assert.equal(r.code, 0, r.err + r.out); + assert.equal(JSON.parse(r.out).delivery, "accepted"); + } + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 1, + ); + await relayRequest({ env: f.env }, "stop", { worker: true }); + await new Promise((r) => setTimeout(r, 150)); + const dead = await cli(f.env, "codex", "bridge", "status"); + assert.equal(JSON.parse(dead.out).worker.state, "stopped"); + const replay = await cli(f.env, ...sendArgs); + assert.equal(replay.code, 0, replay.err + replay.out); + assert.equal(JSON.parse(replay.out).delivery, "accepted"); + assert.notEqual(JSON.parse(replay.out).worker.pid, a.worker.pid); + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 1, + ); + } finally { + await f.close(); + } + }, +); +test( + "foreground CLI remains alive until chosen lifetime and releases socket ownership", + { timeout: 15000 }, + async () => { + const f = await fixture(); + const started = Date.now(); + try { + const result = await cli( + f.env, + "codex", + "bridge", + "run", + "--lifetime-ms", + "600", + ); + assert.equal(result.code, 0, result.out + result.err); + assert.ok(Date.now() - started >= 600); + assert.equal(JSON.parse(result.out).state, "stopped"); + await assert.rejects( + lstat(join(f.env.GROK_BOT_RELAY_DIR, "control.sock")), + (error) => error.code === "ENOENT", + ); + } finally { + await f.close(); + } + }, +); +test( + "plain packaged worker handles SIGTERM and crash recovery without losing durable receipts", + { timeout: 30000 }, + async () => { + const f = await fixture(); + let child; + try { + child = spawn( + process.execPath, + [ + resolve( + process.env.RELAY_ARTIFACT_ROOT ?? "artifact", + "scripts/gbot-relay.mjs", + ), + ], + { env: f.env, stdio: "ignore" }, + ); + let ready; + for (let i = 0; i < 100; i++) { + try { + ready = await relayRequest({ env: f.env }, "hello", {}); + break; + } catch { + await new Promise((r) => setTimeout(r, 25)); + } + } + assert.equal(ready?.worker.pid, child.pid); + const sent = await relayRequest( + { env: f.env }, + "sendToGrok", + { + grokTarget: "General", + codexThreadId: "thread-crash", + message: "once", + }, + { requestId: "crash-stable" }, + ); + assert.equal(sent.delivery, "accepted"); + child.kill("SIGKILL"); + await once(child, "exit"); + child = null; + const restart = await cli( + f.env, + "send", + "--reply-mode", + "auto", + "--codex-thread-id", + "thread-crash", + "--request-id", + "crash-stable", + "General", + "once", + ); + assert.equal(restart.code, 0, restart.err + restart.out); + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 1, + ); + const status = await relayRequest({ env: f.env }, "status", {}); + process.kill(status.worker.pid, "SIGTERM"); + for (let i = 0; i < 100; i++) { + try { + await lstat(join(f.env.GROK_BOT_RELAY_DIR, "control.sock")); + await new Promise((r) => setTimeout(r, 25)); + } catch { + break; + } + } + await assert.rejects( + lstat(join(f.env.GROK_BOT_RELAY_DIR, "control.sock")), + (error) => error.code === "ENOENT", + ); + } finally { + child?.kill(); + await f.close(); + } + }, +); +for (const signal of ["SIGINT", "SIGTERM"]) + test( + `foreground CLI ${signal} stops observation and releases ownership`, + { timeout: 15000 }, + async () => { + const f = await fixture(); + const child = spawn( + process.execPath, + [ + resolve(process.env.RELAY_CLI_ROOT ?? "dist", "bin/gbot.mjs"), + "codex", + "bridge", + "run", + "--lifetime-ms", + "10000", + "--json", + ], + { env: f.env, stdio: "ignore" }, + ); + try { + let ready; + for (let i = 0; i < 100; i++) { + try { + ready = await relayRequest({ env: f.env }, "hello", {}); + break; + } catch { + await new Promise((r) => setTimeout(r, 25)); + } + } + assert.ok(ready); + child.kill(signal); + await once(child, "exit"); + for (let i = 0; i < 100; i++) { + try { + await lstat(join(f.env.GROK_BOT_RELAY_DIR, "control.sock")); + await new Promise((r) => setTimeout(r, 25)); + } catch { + break; + } + } + await assert.rejects( + lstat(join(f.env.GROK_BOT_RELAY_DIR, "control.sock")), + (error) => error.code === "ENOENT", + ); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/interrupt").length, + 0, + ); + } finally { + child.kill(); + await f.close(); + } + }, + ); +test( + "requested automatic routes fail closed before send on missing source, wrong cwd or profile mismatch", + { timeout: 30000 }, + async () => { + const f = await fixture(); + let client, restricted; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + for (const args of [ + { target: "General", message: "missing source", replyMode: "auto" }, + { + target: "General", + message: "wrong cwd", + codexThreadId: "thread-1", + expectedCwd: "/not-the-thread-workspace", + }, + ]) { + const r = await client.rpc("tools/call", { + name: "gbot_send", + arguments: args, + }); + assert.equal(r.result.isError, true, JSON.stringify(r)); + } + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 0, + ); + restricted = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + { ...f.env, GROK_BOT_CODEX_THREADS: "other-thread" }, + ); + const mismatch = await restricted.rpc("tools/call", { + name: "gbot_send", + arguments: { + target: "General", + message: "profile mismatch", + codexThreadId: "thread-1", + }, + }); + assert.equal(mismatch.result.isError, true); + assert.match(JSON.stringify(mismatch), /profile mismatch/i); + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 0, + ); + const manual = await client.call( + "gbot_send", + { target: "General", message: "explicit manual", replyMode: "manual" }, + "native-thread", + ); + assert.deepEqual(manual.replyRoute, { + mode: "manual", + reason: "requested", + }); + } finally { + await restricted?.close(); + await client?.close(); + await f.close(); + } + }, +); +test( + "codex return refuses a binding belonging to another explicit thread", + { timeout: 30000 }, + async () => { + const f = await fixture(); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const bound = await client.call("gbot_bridge_start", { + grokTarget: "General", + codexThreadId: "thread-bound", + }); + const response = await client.rpc("tools/call", { + name: "codex_send", + arguments: { + threadId: "thread-other", + message: "do not misroute", + bindingId: bound.binding.id, + }, + }); + assert.equal(response.result.isError, true, JSON.stringify(response)); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/start").length, + 0, + ); + } finally { + await client?.close(); + await f.close(); + } + }, +); +test( + "copied host artifacts resolve a verified bundled worker without development package files", + { timeout: 45000 }, + async () => { + const root = await mkdtemp("/tmp/relay copied plugin "); + const f = await fixture(); + let client; + try { + await cp(process.env.RELAY_ARTIFACT_ROOT ?? resolve("artifact"), root, { + recursive: true, + }); + const manifest = JSON.parse( + await readFile(join(root, "agent-bundle.manifest.json"), "utf8"), + ); + const worker = manifest.executables.scripts.find( + (x) => x.path === "scripts/gbot-relay.mjs", + ); + assert.deepEqual([...worker.hosts].sort(), [ + "claude", + "codex", + "cursor", + "portable", + ]); + let pid; + for (const [host, path] of [ + ["codex-mcp-client", ".codex-plugin/mcp.json"], + ["cursor", ".cursor-plugin/mcp.json"], + ["claude-code", ".mcp.json"], + ["portable", "mcp.json"], + ]) { + client = await mcp(root, f.env, host, path); + const out = await client.call("gbot_send", { + target: "General", + message: "from " + host, + codexThreadId: "thread-copy", + }); + assert.equal(out.delivery, "accepted"); + assert.equal(out.replyRoute.threadId, "thread-copy"); + pid ??= out.worker.pid; + assert.equal(out.worker.pid, pid); + await client.close(); + client = null; + } + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 4, + ); + } finally { + await client?.close(); + await f.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); +test( + "managed codex_send defaults to guarded steering for an active thread", + { timeout: 15000 }, + async () => { + const f = await fixture({ active: true }); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const out = await client.call("codex_send", { + threadId: "thread-active", + message: "deliver while working", + replyToGrok: "General", + }); + assert.equal(out.delivery, "accepted"); + assert.equal( + f.fake.received.find((x) => x.method === "turn/steer")?.params + .expectedTurnId, + "turn-1", + ); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/start").length, + 0, + ); + } finally { + await client?.close(); + await f.close(); + } + }, +); +for (const interaction of ["approval", "question"]) + test( + `generated operator ${interaction} response is scoped, explicit and single-use`, + { timeout: 20000 }, + async () => { + const f = await fixture({ interaction }); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const sent = await client.call("codex_send", { + threadId: "thread-operator", + message: "request operator input", + replyToGrok: "General", + }); + let item; + for (let i = 0; i < 30 && !item; i++) { + const status = await client.call("gbot_bridge_status", {}); + item = status.interactions[0]; + if (!item) await new Promise((r) => setTimeout(r, 25)); + } + assert.ok(item); + assert.equal( + f.fake.received.filter((x) => x.id === "operator-1").length, + 0, + ); + const scope = { + interactionId: item.interactionId, + generation: item.generation, + threadId: item.threadId, + turnId: item.turnId, + exchangeId: sent.exchangeId, + }; + const invalid = + interaction === "approval" + ? { decision: "accept" } + : { answersJson: JSON.stringify({ wrong: { answers: ["yes"] } }) }; + const refused = await client.rpc("tools/call", { + name: "gbot_codex_respond", + arguments: { ...scope, ...invalid }, + }); + assert.equal(refused.result.isError, true); + assert.equal( + f.fake.received.filter((x) => x.id === "operator-1").length, + 0, + ); + const valid = + interaction === "approval" + ? { decision: "decline" } + : { answersJson: JSON.stringify({ choice: { answers: ["yes"] } }) }; + const accepted = await client.call("gbot_codex_respond", { + ...scope, + ...valid, + }); + assert.equal(accepted.resolved, true); + const again = await client.rpc("tools/call", { + name: "gbot_codex_respond", + arguments: { ...scope, ...valid }, + }); + assert.equal(again.result.isError, true); + const response = f.fake.received.filter((x) => x.id === "operator-1"); + assert.equal(response.length, 1); + assert.deepEqual( + response[0].result, + interaction === "approval" + ? { decision: "decline" } + : { answers: { choice: { answers: ["yes"] } } }, + ); + } finally { + await client?.close(); + await f.close(); + } + }, + ); +test( + "explicit CLI auto route preserves hop refusal before gateway submission", + { timeout: 15000 }, + async () => { + const f = await fixture(); + try { + const out = await cli( + f.env, + "send", + "--reply-mode", + "auto", + "--codex-thread-id", + "thread-chain", + "--hop", + "4", + "--correlation-id", + "chain", + "General", + "must not send", + ); + assert.equal(out.code, 1, out.out + out.err); + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 0, + ); + } finally { + await f.close(); + } + }, +); diff --git a/test/relay-worker.test.js b/test/relay-worker.test.js new file mode 100644 index 0000000..d00df70 --- /dev/null +++ b/test/relay-worker.test.js @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { relayProfile } from "../src/core/relay/profile.js"; +import { runRelayWorker } from "../src/core/relay/worker.js"; +import { relayRequest } from "../src/core/relay/control.js"; + +const env = { CODEX_HOME: "/tmp/test-codex", GROK_BOT_TEST: "1" }; +test("profile shares hosts but isolates endpoint, auth overrides and caller restrictions", () => { + const base = relayProfile(env); + assert.equal( + base, + relayProfile({ + ...env, + AGENT_BUNDLE_PLUGIN_ROOT: "/other host", + CODEX_THREAD_ID: "stale", + }), + ); + for (const extra of [ + { GROK_BOT_CODEX_THREADS: "thread-1" }, + { GROK_BOT_MAX_HOPS: "2" }, + { GROK_BOT_GATEWAY_TOKEN: "secret" }, + { GROK_BOT_GATEWAY_HEADERS: '{"x-route":"secret"}' }, + { CODEX_APP_SERVER_SOCK: "/other.sock" }, + { GROK_BOT_TEST: "0" }, + ]) + assert.notEqual(base, relayProfile({ ...env, ...extra })); + assert.ok( + !relayProfile({ ...env, GROK_BOT_GATEWAY_TOKEN: "secret" }).includes( + "secret", + ), + ); +}); +test("only socket owner opens engine, profile mismatch refuses, stop closes owner", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "relay-worker-")); + const profile = relayProfile(env); + let opened = 0, + closed = 0; + const openEngine = async () => { + opened++; + return { + status: () => ({ state: "running", bindings: [], receipts: [] }), + tick: async () => {}, + close: async () => { + closed++; + }, + }; + }; + const worker = await runRelayWorker({ stateDir, profile, env, openEngine }); + try { + assert.equal( + (await relayRequest({ stateDir, profile, env }, "hello", {})).worker + .state, + "running", + ); + await assert.rejects( + runRelayWorker({ stateDir, profile, env, openEngine }), + /owner|running|locked/i, + ); + assert.equal(opened, 1); + await assert.rejects( + relayRequest({ stateDir, profile: "other", env }, "hello", {}), + /profile/i, + ); + assert.equal( + (await relayRequest({ stateDir, profile, env }, "status", {})).worker + .supervised, + false, + ); + } finally { + await worker.close(); + await rm(stateDir, { recursive: true, force: true }); + } + assert.equal(closed, 1); +}); +test("worker rejects symlink lock before opening engine", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "relay-worker-")); + await symlink("/tmp/missing", join(stateDir, "worker.lock")); + try { + await assert.rejects( + runRelayWorker({ + stateDir, + profile: relayProfile(env), + env, + openEngine: () => { + throw Error("engine must not open"); + }, + }), + /symlink|regular/i, + ); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } +}); +test("live control listener without metadata is never unlinked or opened as another engine", async () => { + const { createServer } = await import("node:net"); + const { lstat } = await import("node:fs/promises"); + const { relayLocation } = await import("../src/core/relay/profile.js"); + const stateDir = await mkdtemp("/tmp/relay-owner-"); + const location = relayLocation({ stateDir, env }); + const server = createServer((s) => s.end()); + await new Promise((r) => server.listen(location.socketPath, r)); + try { + await assert.rejects( + runRelayWorker({ + ...location, + openEngine: () => { + throw Error("must not open"); + }, + }), + /listener already running/, + ); + assert.equal((await lstat(location.socketPath)).isSocket(), true); + } finally { + await new Promise((r) => server.close(r)); + await rm(stateDir, { recursive: true, force: true }); + } +}); +test("ownership keeps one lock inode across clean restarts and protects SQLite sidecars", async () => { + const { lstat } = await import("node:fs/promises"); + const stateDir = await mkdtemp("/tmp/relay-inode-"); + const profile = relayProfile(env); + const openEngine = async () => ({ + status: () => ({}), + tick: async () => {}, + close: async () => {}, + }); + let worker = await runRelayWorker({ stateDir, profile, env, openEngine }); + try { + const first = await lstat(join(stateDir, "worker.lock")); + await worker.close(); + assert.equal((await lstat(join(stateDir, "worker.lock"))).ino, first.ino); + worker = await runRelayWorker({ stateDir, profile, env, openEngine }); + assert.equal((await lstat(join(stateDir, "worker.lock"))).ino, first.ino); + await worker.close(); + await symlink("/tmp/foreign-owner", join(stateDir, "worker.lock-journal")); + await assert.rejects( + runRelayWorker({ stateDir, profile, env, openEngine }), + /symlink|regular/, + ); + } finally { + await worker.close(); + await rm(stateDir, { recursive: true, force: true }); + } +}); +test("private control protocol preserves UTF-8 text across socket chunks", async () => { + const { connect } = await import("node:net"); + const stateDir = await mkdtemp("/tmp/relay-utf8-"); + const profile = relayProfile(env); + const worker = await runRelayWorker({ + stateDir, + profile, + env, + openEngine: async () => ({ + tick: async () => {}, + close: async () => {}, + sendToGrok: async (input) => ({ text: input.message }), + }), + }); + try { + const text = "hello 🛰️ 世界"; + const request = Buffer.from( + JSON.stringify({ + version: 1, + profile, + requestId: "split-utf8", + method: "sendToGrok", + input: { message: text }, + }) + "\n", + ); + const split = request.indexOf(Buffer.from("🛰")) + 1; + const out = await new Promise((resolve, reject) => { + const socket = connect(worker.location.socketPath); + let buffer = ""; + socket.setEncoding("utf8"); + socket.on("error", reject); + socket.on("data", (c) => (buffer += c)); + socket.on("end", () => resolve(JSON.parse(buffer))); + socket.on("connect", () => { + socket.write(request.subarray(0, split)); + setTimeout(() => socket.end(request.subarray(split)), 20); + }); + }); + assert.equal(out.result.text, text); + } finally { + await worker.close(); + await rm(stateDir, { recursive: true, force: true }); + } +}); +test("short socket fallback refuses a symlinked parent directory", async () => { + const { mkdir } = await import("node:fs/promises"); + const { claimRelayOwner } = await import("../src/core/relay/ownership.js"); + const root = await mkdtemp("/tmp/relay-parent-"); + await mkdir(join(root, "actual")); + await symlink(join(root, "actual"), join(root, "alias")); + try { + await assert.rejects( + claimRelayOwner({ + stateDir: join(root, "state"), + socketDir: join(root, "alias", "child"), + socketPath: join(root, "alias", "child", "control.sock"), + }), + /symlink|owned/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/route-unit/tools.test.ts b/tests/route-unit/tools.test.ts index 92998aa..58b22bc 100644 --- a/tests/route-unit/tools.test.ts +++ b/tests/route-unit/tools.test.ts @@ -129,9 +129,9 @@ beforeEach(() => { }); describe('grok-bot MCP server', () => { - it('registers exactly the two gbot tools', async () => { + it('registers messaging, conversation and managed bridge tools', async () => { const surface = await listMcpSurface({ server: 'grok-bot' }); - expect([...surface.tools].sort()).toEqual(['codex_send', 'codex_threads', 'codex_wait', 'codex_watch', 'gbot_send', 'gbot_thread']); + expect([...surface.tools].sort()).toEqual(['codex_send', 'codex_threads', 'codex_wait', 'codex_watch', 'gbot_bridge_start', 'gbot_bridge_status', 'gbot_bridge_stop', 'gbot_codex_respond', 'gbot_send', 'gbot_thread']); }); it('gbot_send resolves the target by name and posts the prompt with the gateway token', async () => { @@ -142,6 +142,7 @@ describe('grok-bot MCP server', () => { expect(result.isError).toBe(false); expect(result.structuredContent).toEqual({ delivery: 'accepted', + replyRoute: { mode: 'manual', reason: 'source-unavailable' }, messageId: 'm-1', result: { messageId: 'm-1' }, target: { id: 'bot-1', kind: 'bot', name: 'General' }, @@ -319,6 +320,7 @@ describe('grok-bot MCP server', () => { expect(result.isError).toBe(false); expect(result.structuredContent).toEqual({ delivery: 'unknown', + replyRoute: { mode: 'manual', reason: 'source-unavailable' }, result: { ok: true }, target: { id: 'bot-5', kind: 'bot', name: 'Noreceipt' }, }); @@ -424,6 +426,7 @@ describe('grok-bot MCP server', () => { }); expect(local.structuredContent).toEqual({ delivery: 'accepted', + replyRoute: { mode: 'manual', reason: 'source-unavailable' }, messageId: 'm-1', result: { messageId: 'm-1' }, target: { id: 'bot-1', kind: 'bot', name: 'General' }, From 76cc59340c9e0cde6f7bb10dbb58e2a1128e3e6f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 01:32:41 -0700 Subject: [PATCH 13/18] fix: enforce explicit managed relay constraints --- README.md | 5 +- src/cli/send.tsx | 6 +- src/core/relay/engine.js | 5 + src/core/relay/routes.ts | 19 ++ src/skills/talk-to-grok-bot/SKILL.md | 5 + test/relay-engine.test.js | 27 +++ test/relay-surfaces.test.js | 279 ++++++++++++++++++++++++++- 7 files changed, 340 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f5c67d1..e18e278 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,10 @@ status before resending. Caller-supplied `requestId` (CLI `--request-id`) permit replay of a tracked send without another submission; returned `controlRequestId` is that control identity, separate from the gateway's transcript `requestId`. Explicit chain `hop`/`correlationId` remain bounded. Automatic CLI routes own their -envelope; omit the legacy `--envelope` and `--reply-to` flags. +envelope; omit the legacy `--envelope` and `--reply-to` flags. Managed Codex return +routes also reject explicit `expectedTurnId`/`--expected-turn-id`; use plain Codex +send when a caller-selected turn guard is required. If a binding and an explicit +Grok target are supplied together, the target must resolve to that binding's recipient. Paused gaps/capacity or unsupported interactions need attention; never guess a cursor or automatically approve. `gbot_codex_respond` / `gbot codex bridge respond` requires current `interactionId`, `generation`, `threadId`, `turnId` and binding/exchange scope. diff --git a/src/cli/send.tsx b/src/cli/send.tsx index 7ea9a2b..df26fd8 100644 --- a/src/cli/send.tsx +++ b/src/cli/send.tsx @@ -4,6 +4,7 @@ import { z } from 'zod'; import { grokSendOperation, + assertManagedSendOptions, routeFields, relayResultSchema, } from '../core/relay/routes.js'; @@ -146,10 +147,7 @@ export default async function send({ z.infer | z.infer; try { if (input.replyMode === 'auto' || input.codexThreadId || input.bindingId) { - if (input.envelope || input.replyTo) - throw Error( - 'Automatic routes own their envelope; omit --envelope and --reply-to. Use --hop and --correlation-id for explicit chains.', - ); + assertManagedSendOptions(input); if (input.files) throw Error('Automatic routes require the gateway backend'); const out = await grokSendOperation( diff --git a/src/core/relay/engine.js b/src/core/relay/engine.js index 380098e..c02e678 100644 --- a/src/core/relay/engine.js +++ b/src/core/relay/engine.js @@ -114,6 +114,11 @@ export async function openRelayEngine({ const b = state.read().bindings[input.bindingId]; if (!b || b.state !== "running") throw new Error("Unknown or stopped binding"); + if (input.grokTarget !== undefined) { + const target = await gateway.resolve(input.grokTarget); + if (relayId.parse(target.id) !== b.targetId) + throw new Error("Explicit Grok target does not match binding"); + } await codex.verify(b); return b; } diff --git a/src/core/relay/routes.ts b/src/core/relay/routes.ts index cfe0784..6ecdfa6 100644 --- a/src/core/relay/routes.ts +++ b/src/core/relay/routes.ts @@ -184,6 +184,21 @@ export async function bridgeOperation( managedOperation(method, routed, options(context)), ); } +export function assertManagedSendOptions(input: { + expectedTurnId?: string; + replyTo?: string; + envelope?: boolean; +}) { + if (input.expectedTurnId !== undefined) + throw Error( + "Managed relay does not support expectedTurnId; omit it to use managed guarded steering, or use a plain Codex send for an explicit turn guard", + ); + if (input.replyTo !== undefined || input.envelope === true) + throw Error( + "Managed relay does not support legacy replyTo/envelope options; omit them or use a plain send. Use hop/correlationId for explicit managed chains", + ); +} + export async function codexReturnOperation( input: { threadId: string; @@ -193,11 +208,15 @@ export async function codexReturnOperation( expectedCwd?: string; requestId?: string; whenBusy?: string; + expectedTurnId?: string; + replyTo?: string; + envelope?: boolean; hop?: number; correlationId?: string; }, context?: AgentRequest, ) { + assertManagedSendOptions(input); if (input.whenBusy === "queue") throw Error( "Managed relay supports steer or reject, not experimental queue", diff --git a/src/skills/talk-to-grok-bot/SKILL.md b/src/skills/talk-to-grok-bot/SKILL.md index 5f771c2..4c0532a 100644 --- a/src/skills/talk-to-grok-bot/SKILL.md +++ b/src/skills/talk-to-grok-bot/SKILL.md @@ -92,3 +92,8 @@ The packaged `scripts/gbot-relay.mjs` is the unlimited foreground service entry. Grok participation is through its gateway conversation, not an assumed native Grok plugin loader or remote MCP tunnel. Never claim a host loaded a plugin from generated configuration alone. + +Managed Codex return routes reject `expectedTurnId` and legacy `replyTo`/`envelope` +options before submission; use plain `codex_send` for a caller-selected turn guard. +An explicit Grok target supplied with `bindingId` must resolve to the binding's +recipient. A mismatch fails instead of selecting one destination silently. diff --git a/test/relay-engine.test.js b/test/relay-engine.test.js index 41d8067..3cc9e79 100644 --- a/test/relay-engine.test.js +++ b/test/relay-engine.test.js @@ -564,3 +564,30 @@ test("invalid Codex correlation is rejected before an outbound Grok submission", ); assert.equal(f.sent.length, 0); }); + +for (const method of ["sendToGrok", "sendToCodex"]) + test(`binding rejects conflicting explicit Grok target before ${method}`, async (t) => { + const f = await fixture(t); + const binding = await f.engine.startBinding({ + grokTarget: "bound-target", + codexThreadId: "thread", + requestId: "binding", + }); + await assert.rejects( + f.engine[method]({ + bindingId: binding.id, + grokTarget: "different-target", + message: "do not send", + requestId: "conflicting-send", + }), + /Grok target.*binding/i, + ); + assert.equal(f.sent.length, 0); + assert.equal( + f.fake.received.filter((x) => + ["turn/start", "turn/steer"].includes(x.method), + ).length, + 0, + ); + assert.equal(f.engine.status().receiptCount, 0); + }); diff --git a/test/relay-surfaces.test.js b/test/relay-surfaces.test.js index 11dee8a..e0fdc37 100644 --- a/test/relay-surfaces.test.js +++ b/test/relay-surfaces.test.js @@ -20,7 +20,12 @@ export async function fixture({ active = false, interaction } = {}) { res.end( JSON.stringify( req.url.endsWith("listAgents") - ? { agents: [{ id: "bot-1", name: "General" }] } + ? { + agents: [ + { id: "bot-1", name: "General" }, + { id: "bot-2", name: "Alice" }, + ], + } : req.url.endsWith("getAgentTranscriptTail") ? { entries } : req.url.endsWith("sendPrompt") @@ -845,3 +850,275 @@ test( } }, ); + +for (const surface of ["MCP", "CLI"]) + for (const direction of ["grok", "codex"]) { + test( + `managed constraints: ${surface} ${direction} refuses a conflicting explicit Grok target`, + { timeout: 20000 }, + async () => { + const f = await fixture(); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const { binding } = await client.call("gbot_bridge_start", { + grokTarget: "General", + codexThreadId: "thread-bound", + }); + if (surface === "MCP") { + const args = + direction === "grok" + ? { + target: "Alice", + bindingId: binding.id, + message: "must not reach General", + } + : { + threadId: "thread-bound", + replyToGrok: "Alice", + bindingId: binding.id, + message: "must not return to General", + }; + const response = await client.rpc("tools/call", { + name: direction === "grok" ? "gbot_send" : "codex_send", + arguments: args, + }); + assert.equal( + response.result.isError, + true, + JSON.stringify(response), + ); + assert.match(JSON.stringify(response), /Grok target.*binding/i); + } else { + const args = + direction === "grok" + ? [ + "send", + "--binding-id", + binding.id, + "Alice", + "must not reach General", + ] + : [ + "codex", + "send", + "--binding-id", + binding.id, + "--reply-to-grok", + "Alice", + "thread-bound", + "must not return to General", + ]; + const response = await cli(f.env, ...args); + assert.notEqual(response.code, 0, response.out + response.err); + assert.match(response.out + response.err, /Grok target.*binding/i); + } + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 0, + ); + assert.equal( + f.fake.received.filter((x) => + ["turn/start", "turn/steer"].includes(x.method), + ).length, + 0, + ); + } finally { + await client?.close(); + await f.close(); + } + }, + ); + } + +for (const surface of ["MCP", "CLI"]) + for (const option of ["expectedTurnId", "replyTo", "envelope"]) { + test( + `managed constraints: ${surface} codex refuses unsupported ${option} before submission`, + { timeout: 30000 }, + async () => { + const f = await fixture({ active: true }); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const { binding } = await client.call("gbot_bridge_start", { + grokTarget: "General", + codexThreadId: "thread-active", + }); + for (const route of [ + { replyToGrok: "General" }, + { bindingId: binding.id }, + ]) { + const extra = + option === "expectedTurnId" + ? { whenBusy: "steer", expectedTurnId: "stale-turn" } + : option === "replyTo" + ? { replyTo: "prior-message", correlationId: "chain" } + : { envelope: true }; + if (surface === "MCP") { + const response = await client.rpc("tools/call", { + name: "codex_send", + arguments: { + threadId: "thread-active", + message: "do not submit", + ...route, + ...extra, + }, + }); + assert.equal( + response.result.isError, + true, + JSON.stringify(response), + ); + assert.match(JSON.stringify(response), new RegExp(option)); + } else { + const routeArgs = route.bindingId + ? ["--binding-id", route.bindingId] + : ["--reply-to-grok", "General"]; + const optionArgs = + option === "expectedTurnId" + ? ["--when-busy", "steer", "--expected-turn-id", "stale-turn"] + : option === "replyTo" + ? [ + "--reply-to", + "prior-message", + "--correlation-id", + "chain", + ] + : ["--envelope"]; + const response = await cli( + f.env, + "codex", + "send", + ...routeArgs, + ...optionArgs, + "thread-active", + "do not submit", + ); + assert.notEqual(response.code, 0, response.out + response.err); + assert.match(response.out + response.err, new RegExp(option)); + } + } + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 0, + ); + assert.equal( + f.fake.received.filter((x) => + ["turn/start", "turn/steer"].includes(x.method), + ).length, + 0, + ); + } finally { + await client?.close(); + await f.close(); + } + }, + ); + } + +test( + "managed constraints: matching explicit Grok target and binding-only return remain supported", + { timeout: 20000 }, + async () => { + const f = await fixture(); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const { binding } = await client.call("gbot_bridge_start", { + grokTarget: "General", + codexThreadId: "thread-bound", + }); + const matched = await client.call("gbot_send", { + target: "General", + bindingId: binding.id, + message: "matching name", + }); + assert.equal(matched.delivery, "accepted"); + const returned = await client.call("codex_send", { + threadId: "thread-bound", + replyToGrok: "bot-1", + bindingId: binding.id, + message: "matching ID", + }); + assert.equal(returned.delivery, "accepted"); + const bindingOnly = await client.call("codex_send", { + threadId: "thread-bound", + bindingId: binding.id, + message: "binding-only return", + }); + assert.equal(bindingOnly.delivery, "accepted"); + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 1, + ); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/start").length, + 2, + ); + } finally { + await client?.close(); + await f.close(); + } + }, +); + +test( + "managed constraints: plain guarded sends retain legacy reply envelope options", + { timeout: 20000 }, + async () => { + const f = await fixture({ active: true }); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const out = await client.call("codex_send", { + threadId: "thread-active", + message: "plain MCP", + whenBusy: "steer", + expectedTurnId: "turn-1", + replyTo: "prior", + correlationId: "plain-chain", + envelope: true, + }); + assert.equal(out.delivery, "accepted"); + const cliOut = await cli( + f.env, + "codex", + "send", + "--when-busy", + "steer", + "--expected-turn-id", + "turn-1", + "--reply-to", + "prior", + "--correlation-id", + "plain-chain", + "--envelope", + "thread-active", + "plain CLI", + ); + assert.equal(cliOut.code, 0, cliOut.out + cliOut.err); + const sends = f.fake.received.filter((x) => x.method === "turn/steer"); + assert.equal(sends.length, 2); + for (const send of sends) { + assert.equal(send.params.expectedTurnId, "turn-1"); + assert.match(JSON.stringify(send.params), /reply-to=prior/); + } + } finally { + await client?.close(); + await f.close(); + } + }, +); From 6ee55dd64e4024a89a5640649eacf718594c7cec Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 08:34:25 +0000 Subject: [PATCH 14/18] refactor: remove legacy API paths --- .changeset/remove-legacy-apis.md | 10 +++ README.md | 14 ++-- agent-bundle.config.ts | 10 --- package.json | 2 +- scripts/run-unit-tests.mjs | 20 ------ src/cli/thread.tsx | 1 - src/core/app-session.js | 2 +- src/core/codex-bridge.js | 92 +++++++++++++++----------- src/core/codex/contract.js | 20 +----- src/core/commands.js | 4 +- src/core/desktop-shim.js | 18 ++--- src/core/format.js | 6 +- src/core/gateway.js | 34 +++------- src/core/history.js | 2 +- src/core/store.js | 15 ++--- src/core/transcript.js | 13 ++-- src/core/url-policy.js | 6 +- src/gbot.ts | 4 +- src/mcp/grok-bot/tools/gbot_thread.tsx | 2 +- src/skills/talk-to-grok-bot/SKILL.md | 4 +- test.env | 1 + test/codex-contract.test.js | 26 ++++---- test/connect-gateway.test.js | 34 +++++++--- test/doctor.test.js | 5 -- test/history.test.js | 17 +---- test/store.test.js | 23 +++++++ test/transcript.test.js | 5 +- test/url-policy.test.js | 2 +- tests/route-unit/tools.test.ts | 22 +++--- 29 files changed, 193 insertions(+), 221 deletions(-) create mode 100644 .changeset/remove-legacy-apis.md delete mode 100644 scripts/run-unit-tests.mjs create mode 100644 test.env diff --git a/.changeset/remove-legacy-apis.md b/.changeset/remove-legacy-apis.md new file mode 100644 index 0000000..08b2713 --- /dev/null +++ b/.changeset/remove-legacy-apis.md @@ -0,0 +1,10 @@ +--- +"grok-bot-cli": minor +--- + +Use `gbot thread`, `GROK_BOT_GATEWAY_URL` with `GROK_BOT_GATEWAY_TOKEN`, +`CURSOR_ACCESS_TOKEN`, `CURSOR_API_BASE_URL`, `GROK_BOT_AGENTS_DIR`, and `entries` +transcript containers; remove the `chat` alias, the `GROK_BOT_ACCESS_TOKEN` and +`SAND_ACCESS_TOKEN`, `SAND_BACKEND_URL`, `SAND_HOST_GATEWAY_*`, `SAND_GATEWAY_TOKEN`, +`SAND_HOST_PORT`, `SAND_AGENTS_DIR`, and `SAND_DATA_ROOT` compatibility variables, +implicit localhost gateway routing, and fallback transcript and entry-id shapes (#69). diff --git a/README.md b/README.md index 101b934..ddf3f5f 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,7 @@ snapshot with `gapReset: true`. The gateway request remains limit-only. Run `gbot --help` for every command. Options are command-local (for example, `gbot send --history-dir DIR ...`); -the former leading-global form is no longer accepted. `--json` prints the -canonical JSON result; `send` and the `codex` commands also report failures as +`--json` prints the canonical JSON result; `send` and the `codex` commands also report failures as a JSON document on stdout with `exitCode` (see below), every other command prints the failure message on stderr and exits 1. @@ -95,7 +94,7 @@ Permanent tradeoff, stated plainly: Desktop's app-tools MCP (`-c` overrides on i **Status contract (`gbot codex status --json`).** `reachable` is endpoint reachability only. `socketState` is `socket`, `absent`, `permission-denied`, or `not-a-socket`; `mode` is `daemon` for a usable daemon, otherwise the failure: `socket-absent`, `permission-denied` (the file or the connect refused this user), `not-a-socket`, `connect-failed` (socket present, nothing completed the WebSocket upgrade), `handshake-failed` (upgrade or `initialize` failed), `windows-unsupported`, or `bad-response` (reachable, but `initialize` returned something off-schema — `reachable` stays `true`). `schema.compatibility` is `exact` when the daemon reports the pinned version, `unverified` when it differs (methods usually survive upgrades, but the shapes are not re-checked), or `unknown`. `cliVersionProbe` reports whether `codex --version` answered (`ok`, `missing`, `timeout` after 3 s, `error`). The document is always written to stdout and includes `exitCode`; it is `0` only for a usable daemon. -`desktopShimConfigured` is true when the installed wrapper is selected by Desktop-facing `CODEX_CLI_PATH` (the GUI domain on macOS). This describes configuration for future launches; a running Desktop may not have inherited it, and the wrapper may have fallen back to stock Codex. `desktopAttached` is `"private-stdio"` when a Desktop-bundled app-server process is observed, otherwise `"unknown"`. That process observation and shim configuration can both be present. Neither a configured shim nor a reachable daemon proves Desktop is attached to that daemon. The former `"attached-shim"` value is no longer emitted; consumers checking shim setup should use `desktopShimConfigured`. Verify actual attachment with a controlled shared-thread interaction and matching thread/turn IDs. +`desktopShimConfigured` is true when the installed wrapper is selected by Desktop-facing `CODEX_CLI_PATH` (the GUI domain on macOS). This describes configuration for future launches; a running Desktop may not have inherited it, and the wrapper may have fallen back to stock Codex. `desktopAttached` is `"private-stdio"` when a Desktop-bundled app-server process is observed, otherwise `"unknown"`. That process observation and shim configuration can both be present. Neither a configured shim nor a reachable daemon proves Desktop is attached to that daemon. Verify actual attachment with a controlled shared-thread interaction and matching thread/turn IDs. **Thread discovery.** `list-threads --limit N` (1–200) pages with the opaque `--cursor` from the previous `nextCursor`; JSON keeps the cursor verbatim, text output prints a sanitized `more: --cursor …` hint. Text fields are stripped of terminal control sequences in both outputs (single-line fields also lose line breaks; `preview` keeps its newlines; a structured `source` such as `{ "custom": … }` passes through unchanged), `status` is one of `notLoaded | idle | active | systemError | unknown`, and non-numeric `updatedAt` becomes `null`. Unknown arguments are rejected before the socket is touched; a response that does not match the pinned schema (including an entry without a string `id`) fails with `reason: "bad-response"`. @@ -138,9 +137,8 @@ gbot-install install cursor gbot-install doctor ``` -Add `--replace` to an install command to overwrite an earlier copy. The bundle is -registered as `gbot`; if you installed the pre-0.4 `grok-bot` plugin from a source -checkout, uninstall it first so the two do not both register the `grok-bot` server. +Add `--replace` to an install command to overwrite an existing copy. The bundle is +registered as `gbot`. `gbot_thread` returns a small receipt by default: deterministic `summary`, opaque `cursor`, `entryCount`, and `gapReset`. @@ -157,6 +155,8 @@ A sandbox that blocks network egress or hides the home directory makes `gbot_sen fail with the gateway error; run `gbot doctor` inside the same sandbox to see which credential source is visible. +For on-disk `--files` mode, pass `--dir` or set `GROK_BOT_AGENTS_DIR`. + To route a repository's agents to a bot by default, add a note to its `AGENTS.md`: ```md @@ -171,7 +171,7 @@ for it. ## Local history Recording is **opt-in**. Set `GROK_BOT_HISTORY=on` (or `true`/`1`) to append successful -`send`/`thread`/`chat` observations as plaintext JSONL at +`send`/`thread` observations as plaintext JSONL at `~/.grok-bot-cli/history.jsonl`. Without that env, nothing is written. ```bash diff --git a/agent-bundle.config.ts b/agent-bundle.config.ts index 0684d21..e59b18c 100644 --- a/agent-bundle.config.ts +++ b/agent-bundle.config.ts @@ -1,15 +1,5 @@ import { defineConfig } from 'agent-bundle/config'; -/** - * One Agent Bundle project at repo root — same shape as cargo-hauler. - * Nested `plugin/` + hand `src/cli.js` are gone; hosts and npm share this tree. - * - * - `src/mcp/grok-bot/tools/*` → MCP tools - * - `src/cli/**` → npm CLI, including send/thread/history and Codex - * - `src/gbot-install.ts` → `gbot-install` host installer - * - `src/core/*` → domain (gateway, store, codex-bridge, …) - * - `src/skills/*` → installed skills - */ export default defineConfig({ bin: { 'gbot-install': './src/gbot-install.ts', diff --git a/package.json b/package.json index 4382b04..f1f59e0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "release": "changeset publish", "test": "npm run test:unit && npm run test:routes", "test:routes": "rstest --config rstest.route-unit.config.ts", - "test:unit": "node scripts/run-unit-tests.mjs", + "test:unit": "node --env-file=test.env --test \"test/*.test.js\"", "typecheck": "tsc -p tsconfig.json --noEmit", "validate": "agent-bundle validate", "validate:artifact": "agent-bundle validate --artifact artifact" diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs deleted file mode 100644 index 5e36f2e..0000000 --- a/scripts/run-unit-tests.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -// Enumerate test/*.test.js in JS so Windows cmd and Node 18/24 all work -// (shell globs do not expand on Windows; `node --test test` is not a directory walk). -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const dir = join(root, "test"); -const files = readdirSync(dir) - .filter((name) => name.endsWith(".test.js")) - .sort() - .map((name) => join("test", name)); -const result = spawnSync(process.execPath, ["--test", ...files], { - cwd: root, - // Loopback-only credential URLs (src/core/url-policy.js testMode): a test can never reach a live gateway. - env: { ...process.env, GROK_BOT_TEST: "1" }, - stdio: "inherit", -}); -process.exit(result.status === null ? 1 : result.status); diff --git a/src/cli/thread.tsx b/src/cli/thread.tsx index 3f95f8e..13ed64b 100644 --- a/src/cli/thread.tsx +++ b/src/cli/thread.tsx @@ -11,7 +11,6 @@ import { } from './_shared.js'; export const config = { - aliases: ['chat'], description: 'Read the most recent messages in a Grok Bot bot or group thread.', inputJsonSchema: { additionalProperties: false, diff --git a/src/core/app-session.js b/src/core/app-session.js index 23ca967..9cdde1d 100644 --- a/src/core/app-session.js +++ b/src/core/app-session.js @@ -13,7 +13,7 @@ const SAFE_STORAGE_PREFIX_V10_BUF = Buffer.from(SAFE_STORAGE_PREFIX_V10); const LINUX_BASIC_TEXT_PASSWORD = "peanuts"; const SUPPORTED_PLATFORMS = new Set(["darwin", "linux", "win32"]); -export class GrokBotGatewaySessionError extends Error { +class GrokBotGatewaySessionError extends Error { constructor(code, message) { super(message); this.name = "GrokBotGatewaySessionError"; diff --git a/src/core/codex-bridge.js b/src/core/codex-bridge.js index 9f2a38a..5548efc 100644 --- a/src/core/codex-bridge.js +++ b/src/core/codex-bridge.js @@ -12,8 +12,8 @@ import { desktopShimStatus } from "./desktop-shim.js"; // Method and param names below come from `codex app-server generate-json-schema` // of this Codex release. Newer daemons usually keep them; `gbot codex status` // reports the running daemon's version next to this one. -export const PINNED_CODEX_VERSION = "0.154.0"; -export const UPSTREAM_DESKTOP_ISSUES = [ +const PINNED_CODEX_VERSION = "0.154.0"; +const UPSTREAM_DESKTOP_ISSUES = [ "https://github.com/openai/codex/issues/41014", "https://github.com/openai/codex/issues/41112", ]; @@ -21,9 +21,9 @@ export const UPSTREAM_DESKTOP_ISSUES = [ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // Transport budgets: fail fast instead of buffering unbounded attacker-controlled bytes. // ponytail: raise these only with streaming/pagination support; the app-server sends small JSON-RPC frames. -export const WS_MAX_HEADER_BYTES = 16 * 1024; -export const WS_MAX_MESSAGE_BYTES = 4 * 1024 * 1024; -export const WS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; +const WS_MAX_HEADER_BYTES = 16 * 1024; +const WS_MAX_MESSAGE_BYTES = 4 * 1024 * 1024; +const WS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; const textDecoder = new TextDecoder("utf-8", { fatal: true }); const pkg = createRequire(import.meta.url)("../../package.json"); @@ -36,7 +36,7 @@ export function codexSocketPath(env = process.env) { } /** `socket` | `absent` | `permission-denied` | `not-a-socket`; permission failures are not absence. */ -export function socketState(path) { +function socketState(path) { try { return statSync(path).isSocket() ? "socket" : "not-a-socket"; } catch (err) { @@ -45,7 +45,7 @@ export function socketState(path) { } /** Strip ANSI/OSC sequences and C0/C1 controls (tab and newline stay) from server-supplied text. */ -export function stripTerminalControls(text) { +function stripTerminalControls(text) { return String(text) .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "") .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "") @@ -76,7 +76,7 @@ export function unreachableMessage(path, desktopAttached = "unknown") { ].join("\n"); } -export function windowsUnsupportedMessage() { +function windowsUnsupportedMessage() { return [ "gbot codex does not support native Windows yet.", "Codex's control socket is AF_UNIX; this CLI's Node client only dials Unix domain sockets.", @@ -164,7 +164,7 @@ export function websocketAccept(key) { return createHash("sha1").update(key + WS_GUID).digest("base64"); } -export class CodexRpcError extends Error { +class CodexRpcError extends Error { constructor(method, error) { super("Codex app-server rejected " + method + ": " + (error && error.message ? error.message : JSON.stringify(error))); this.name = "CodexRpcError"; @@ -173,21 +173,39 @@ export class CodexRpcError extends Error { } } -export class CodexSendError extends Error { - constructor(message, { delivery, threadId, turnId, refused, reason, envelope } = {}) { +class CodexSendError extends Error { + constructor(message, { + correlationId, + delivery, + hop, + messageId, + reason, + refused, + threadId, + turnId, + } = {}) { super(message); this.name = "CodexSendError"; this.delivery = delivery; + if (correlationId !== undefined) this.correlationId = correlationId; + if (hop !== undefined) this.hop = hop; + if (messageId !== undefined) this.messageId = messageId; if (reason !== undefined) this.reason = reason; + if (refused !== undefined) this.refused = refused; if (threadId !== undefined) this.threadId = threadId; if (turnId !== undefined) this.turnId = turnId; - if (refused !== undefined) this.refused = refused; - if (envelope !== undefined) this.envelope = envelope; + } +} + +function attachEnvelope(error, envelope) { + if (!error || typeof error !== "object") return; + for (const key of ["messageId", "correlationId", "hop"]) { + if (error[key] === undefined && envelope[key] !== undefined) error[key] = envelope[key]; } } /** The route to the app-server is unavailable; `mode` is a stable machine-readable state. */ -export class CodexRouteError extends Error { +class CodexRouteError extends Error { constructor(message, mode) { super(message); this.name = "CodexRouteError"; @@ -198,7 +216,7 @@ export class CodexRouteError extends Error { } /** The app-server answered with a shape this pinned schema does not describe. */ -export class CodexProtocolError extends Error { +class CodexProtocolError extends Error { constructor(method, detail) { super("Codex app-server returned an unexpected " + method + " response: " + detail + ". gbot is pinned to app-server schema " + PINNED_CODEX_VERSION + "; run `gbot codex status --json` to compare versions."); @@ -504,7 +522,7 @@ function appServerVersion(initResult) { } /** Throws CodexRouteError when the operator's socket cannot be used; the path comes only from CODEX_HOME. */ -export function assertRoute(path) { +function assertRoute(path) { if (process.platform === "win32") throw new CodexRouteError(windowsUnsupportedMessage(), "windows-unsupported"); const state = socketState(path); if (state === "socket") return; @@ -545,10 +563,10 @@ async function openSession(env = process.env, { experimental = false } = {}) { return { client, path, init }; } -export const CODEX_VERSION_PROBE_TIMEOUT_MS = 3000; +const CODEX_VERSION_PROBE_TIMEOUT_MS = 3000; /** Bounded `codex --version` probe: `{ version, probe }` where probe is ok | missing | timeout | error. */ -export function probeLocalCodexVersion(timeoutMs = CODEX_VERSION_PROBE_TIMEOUT_MS) { +function probeLocalCodexVersion(timeoutMs = CODEX_VERSION_PROBE_TIMEOUT_MS) { const out = spawnSync("codex", ["--version"], { encoding: "utf8", timeout: timeoutMs }); if (out.error) { if (out.error.code === "ENOENT") return { version: null, probe: "missing" }; @@ -560,9 +578,9 @@ export function probeLocalCodexVersion(timeoutMs = CODEX_VERSION_PROBE_TIMEOUT_M } /** Bounded `ps` snapshot for Desktop detection: process names only, never pipes or sockets. */ -export const DESKTOP_PROCESS_LIST_TIMEOUT_MS = 3000; +const DESKTOP_PROCESS_LIST_TIMEOUT_MS = 3000; -export function listDesktopProcesses(timeoutMs = DESKTOP_PROCESS_LIST_TIMEOUT_MS) { +function listDesktopProcesses(timeoutMs = DESKTOP_PROCESS_LIST_TIMEOUT_MS) { if (process.platform === "win32") return ""; try { const out = spawnSync("ps", ["-eo", "args"], { encoding: "utf8", timeout: timeoutMs }); @@ -677,7 +695,7 @@ function sourceField(value) { return typeof value === "string" ? singleLine(value) : value; } -export function summarizeThread(t) { +function summarizeThread(t) { if (!isObject(t) || typeof t.id !== "string" || !t.id) throw new CodexProtocolError("thread/list", "entry without a string `id`"); const type = isObject(t.status) && typeof t.status.type === "string" ? t.status.type : "unknown"; return { @@ -692,7 +710,7 @@ export function summarizeThread(t) { }; } -export const THREAD_LIST_MAX_LIMIT = 200; +const THREAD_LIST_MAX_LIMIT = 200; /** * @param {{ limit?: number, cursor?: string, env?: NodeJS.ProcessEnv }} [opts] @@ -734,7 +752,7 @@ function explainSendError(err, threadId) { return err; } -export const DEFAULT_MAX_HOPS = 4; +const DEFAULT_MAX_HOPS = 4; const ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; /** @@ -769,7 +787,7 @@ export function buildEnvelope({ correlationId, replyTo, hop, envelope = false, e throw new CodexSendError( "Refusing to send: hop " + hopCount + " reaches the relay bound " + bound + " (GROK_BOT_MAX_HOPS). " + "This message is an agent-to-agent relay that has already been forwarded too many times.", - { delivery: "rejected", reason: "hop-limit", envelope: out }, + { delivery: "rejected", reason: "hop-limit", ...out }, ); } return out; @@ -778,7 +796,7 @@ export function buildEnvelope({ correlationId, replyTo, hop, envelope = false, e /** One-line header a receiving agent can read to reply with `--reply-to` and `--hop N+1`. */ const identityToken = (value) => String(value ?? "").replace(/[^A-Za-z0-9_.:-]/g, "").slice(0, 64) || "unknown"; -export function envelopeHeader(envelope, env = process.env) { +function envelopeHeader(envelope, env = process.env) { const from = identityToken(env.USER || env.USERNAME) + "@" + hostnameSafe(); const parts = ["msg=" + envelope.messageId, "corr=" + envelope.correlationId]; if (envelope.replyTo) parts.push("reply-to=" + envelope.replyTo); @@ -799,7 +817,7 @@ export function withEnvelopeHeader(text, envelope, env = process.env) { } /** Operator-controlled destinations: GROK_BOT_CODEX_THREADS="id,id" restricts `codex send`. */ -export function assertThreadAllowed(threadId, env = process.env) { +function assertThreadAllowed(threadId, env = process.env) { const raw = env.GROK_BOT_CODEX_THREADS; if (raw == null || raw.trim() === "") return; const allowed = raw.split(",").map((s) => s.trim()).filter(Boolean); @@ -834,7 +852,7 @@ function threadState(resumed, threadId) { + PINNED_CODEX_VERSION + ") does not know; not sending.", { delivery: "rejected", reason: "unknown-status", threadId }); } -export function experimentalEnabled(env = process.env) { +function experimentalEnabled(env = process.env) { return /^(1|true|on)$/i.test(env.GROK_BOT_CODEX_EXPERIMENTAL || ""); } @@ -848,11 +866,11 @@ function requireExperimental(env, what) { function unsupportedOrRpc(err, method, threadId, envelope) { if (err instanceof CodexRpcError && err.rpc && err.rpc.code === -32601) { return new CodexSendError("Codex app-server does not offer " + method + " (daemon predates it, or experimentalApi was not granted). " - + "Upgrade Codex or send without --when-busy queue.", { delivery: "rejected", reason: "unsupported", threadId, envelope }); + + "Upgrade Codex or send without --when-busy queue.", { delivery: "rejected", reason: "unsupported", threadId, ...envelope }); } - if (err instanceof CodexRpcError) return new CodexSendError(err.message, { delivery: "rejected", reason: "rejected", threadId, envelope }); + if (err instanceof CodexRpcError) return new CodexSendError(err.message, { delivery: "rejected", reason: "rejected", threadId, ...envelope }); return new CodexSendError("Lost the Codex " + method + " response for thread " + threadId + ": " + ((err && err.message) || err) - + ". Delivery is unknown; list the queue before resending.", { delivery: (err && err.delivery) || "unknown", reason: "transport", threadId, envelope }); + + ". Delivery is unknown; list the queue before resending.", { delivery: (err && err.delivery) || "unknown", reason: "transport", threadId, ...envelope }); } /** Read the daemon's queue for one thread (experimental `thread/queue/list`). */ @@ -892,7 +910,7 @@ export async function sendToCodexThread(threadId, text, { env = process.env, env return outcomeFromReceipt(receipt); } catch (err) { // Every receipt names the message, including refusals that never reached the daemon. - if ((err instanceof CodexSendError || err instanceof CodexRouteError || err instanceof CodexProtocolError) && err.envelope === undefined) err.envelope = envelope; + if (err instanceof CodexSendError || err instanceof CodexRouteError || err instanceof CodexProtocolError) attachEnvelope(err, envelope); return outcomeFromError(err); } } @@ -914,7 +932,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy reason: err instanceof CodexRpcError ? (/no rollout found|thread not found/i.test(String(err.rpc && err.rpc.message)) ? "unknown-thread" : /active writer/i.test(String(err.rpc && err.rpc.message)) ? "external-owner" : "rejected") : "transport", threadId, - envelope, + ...envelope, }); } if (!isObject(resumed) || !isObject(resumed.thread) || typeof resumed.thread.id !== "string") { @@ -938,7 +956,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy throw new CodexSendError( "Codex thread " + threadId + " has an active turn" + flags + "; sending now would steer that turn. " + "Wait for it to go idle (`gbot codex list-threads`) and resend, or pass --when-busy queue.", - { delivery: "rejected", reason: "busy", threadId, envelope }, + { delivery: "rejected", reason: "busy", threadId, ...envelope }, ); } if (state.busy) { @@ -951,7 +969,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy const submission = isObject(queued) && isObject(queued.queuedSubmission) && typeof queued.queuedSubmission.id === "string" ? queued.queuedSubmission : null; if (!submission) { throw new CodexSendError("Codex app-server sent a malformed thread/queue/add acknowledgment for thread " + threadId - + ". Delivery is unknown; list the queue before resending.", { delivery: "unknown", reason: "bad-response", threadId, envelope }); + + ". Delivery is unknown; list the queue before resending.", { delivery: "unknown", reason: "bad-response", threadId, ...envelope }); } return { delivery: "queued", ...receiptBase, queuedSubmissionId: submission.id, activeFlags: state.flags }; } @@ -985,13 +1003,13 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy : "Lost the Codex turn/start response for thread " + threadId + ": " + ((err && err.message) || err) + ". Delivery is unknown; check the thread before resending."; // No blind retry: the receipt carries messageId so the caller can look for it before resending. - throw new CodexSendError(detail, { delivery, reason: delivery === "rejected" ? "rejected" : "transport", threadId, envelope }); + throw new CodexSendError(detail, { delivery, reason: delivery === "rejected" ? "rejected" : "transport", threadId, ...envelope }); } const turnId = turn && turn.turn && typeof turn.turn.id === "string" && turn.turn.id ? turn.turn.id : null; if (!turnId) { throw new CodexSendError( "Codex app-server sent a malformed turn/start acknowledgment for thread " + threadId + ". Delivery is unknown; check the thread before resending.", - { delivery: "unknown", reason: "bad-response", threadId, envelope }, + { delivery: "unknown", reason: "bad-response", threadId, ...envelope }, ); } client._adoptTurn(turnId); @@ -1011,7 +1029,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy throw new CodexSendError( "Turn " + turnId + " started on thread " + threadId + " but Codex asked for " + methods + ", which gbot refused. " + "Answer it in a Codex client, or set `approval_policy = \"never\"` in the daemon's config.toml for unattended sends.", - { delivery: "accepted", reason: "approval-refused", threadId, turnId, refused: freshRefused.map((r) => r.method), envelope }, + { delivery: "accepted", reason: "approval-refused", threadId, turnId, refused: freshRefused.map((r) => r.method), ...envelope }, ); } return { delivery: "accepted", ...receiptBase, turnId, turnStatus: turn.turn.status }; diff --git a/src/core/codex/contract.js b/src/core/codex/contract.js index 437375a..7dee301 100644 --- a/src/core/codex/contract.js +++ b/src/core/codex/contract.js @@ -36,7 +36,7 @@ import { redactSecrets } from "../url-policy.js"; * @param {{ reachable: boolean, mode: string }} status * @returns {0 | 1} */ -export function statusExitCode(status) { +function statusExitCode(status) { return status.reachable && status.mode === "daemon" ? 0 : 1; } @@ -59,7 +59,7 @@ export function withStatusExitCode(status) { * }} outcome * @returns {0 | 1} */ -export function sendExitCode(outcome) { +function sendExitCode(outcome) { if (outcome.error !== undefined) return 1; if (outcome.delivery === "accepted" && outcome.reason === "approval-refused") return 1; if (outcome.delivery === "rejected" || outcome.delivery === "unknown") return 1; @@ -68,8 +68,6 @@ export function sendExitCode(outcome) { /** * Flatten a thrown error into the #46 send/failure document (plus exitCode). - * Preserves the legacy failure-document fields, then adds exitCode for the - * generated CLI's `exitCode: 'result'` policy. * * @param {unknown} error * @param {{ isUsage?: (err: unknown) => boolean }} [opts] @@ -87,12 +85,6 @@ export function outcomeFromError(error, opts = {}) { if (error[key] !== undefined) out[key] = error[key]; } if (out.reason === undefined && isUsage(error)) out.reason = "usage"; - const envelope = /** @type {{ messageId?: string, correlationId?: string, hop?: number }} */ (error).envelope; - if (envelope && typeof envelope === "object") { - if (envelope.messageId !== undefined) out.messageId = envelope.messageId; - if (envelope.correlationId !== undefined) out.correlationId = envelope.correlationId; - if (envelope.hop !== undefined) out.hop = envelope.hop; - } } if (out.delivery === undefined) out.delivery = "rejected"; @@ -107,14 +99,6 @@ export function outcomeFromError(error, opts = {}) { export function outcomeFromReceipt(receipt) { const base = { ...receipt }; if (base.delivery === undefined) base.delivery = "accepted"; - // Flatten envelope onto the document when present (matches fail() + json print). - const envelope = base.envelope; - if (envelope && typeof envelope === "object") { - const e = /** @type {Record} */ (envelope); - if (base.messageId === undefined && e.messageId !== undefined) base.messageId = e.messageId; - if (base.correlationId === undefined && e.correlationId !== undefined) base.correlationId = e.correlationId; - if (base.hop === undefined && e.hop !== undefined) base.hop = e.hop; - } const exitCode = sendExitCode(/** @type {{ delivery: CodexDelivery, reason?: string, error?: string }} */ (base)); if (exitCode === 1 && base.error === undefined && base.reason === "approval-refused") { base.error = typeof base.message === "string" ? base.message : "Codex refused one or more approvals."; diff --git a/src/core/commands.js b/src/core/commands.js index 6b31509..10de4ea 100644 --- a/src/core/commands.js +++ b/src/core/commands.js @@ -1,7 +1,7 @@ import * as files from "./store.js"; import * as gw from "./gateway.js"; -export function chooseBackend(opts) { +function chooseBackend(opts) { const forceFiles = Boolean(opts.root) || opts.files; const forceGw = Boolean(opts.gateway); if (forceGw && forceFiles) { @@ -27,7 +27,7 @@ export async function openBackend(opts) { setGroupMembers: (group, members) => gw.setGroupMembers(session, group, members), addGroupMember: (group, bot) => gw.addGroupMember(session, group, bot), removeGroupMember: (group, bot) => gw.removeGroupMember(session, group, bot), - send: (ref, prompt, extra) => gw.sendPrompt(session, ref, prompt, extra), + send: (ref, prompt) => gw.sendPrompt(session, ref, prompt), transcript: (ref, limit) => gw.getTranscriptTail(session, ref, limit), thread: (ref, rootId) => gw.getThread(session, ref, rootId), }; diff --git a/src/core/desktop-shim.js b/src/core/desktop-shim.js index 64ab0c2..b6d7503 100644 --- a/src/core/desktop-shim.js +++ b/src/core/desktop-shim.js @@ -23,19 +23,19 @@ import { BRIDGE_SOURCE } from "./desktop-shim-bridge.js"; */ export const SHIM_LABEL = "com.zackjackson.codex-desktop-shared-daemon"; -export const WRAPPER_FILENAME = "codex-desktop-to-daemon"; -export const BRIDGE_FILENAME = "codex-stdio-to-daemon-ws.py"; -export const ENV_SCRIPT_FILENAME = "codex-desktop-shared-daemon-env.sh"; -export const BRIDGE_LOG_FILENAME = "codex-stdio-to-daemon-ws.log"; -export const WRAPPER_LOG_FILENAME = "codex-desktop-to-daemon.log"; -export const ENV_LOG_FILENAME = "codex-desktop-shared-daemon-env.log"; -export const STANDALONE_REAL_SUFFIX = join("packages", "standalone", "current", "bin", "codex"); +const WRAPPER_FILENAME = "codex-desktop-to-daemon"; +const BRIDGE_FILENAME = "codex-stdio-to-daemon-ws.py"; +const ENV_SCRIPT_FILENAME = "codex-desktop-shared-daemon-env.sh"; +const BRIDGE_LOG_FILENAME = "codex-stdio-to-daemon-ws.log"; +const WRAPPER_LOG_FILENAME = "codex-desktop-to-daemon.log"; +const ENV_LOG_FILENAME = "codex-desktop-shared-daemon-env.log"; +const STANDALONE_REAL_SUFFIX = join("packages", "standalone", "current", "bin", "codex"); -export function codexHomeDir(env = process.env) { +function codexHomeDir(env = process.env) { return env.CODEX_HOME || join(env.HOME || homedir(), ".codex"); } -export function userHomeDir(env = process.env) { +function userHomeDir(env = process.env) { return env.HOME || homedir(); } diff --git a/src/core/format.js b/src/core/format.js index d971f46..720e03e 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -1,7 +1,7 @@ import { entryText, transcriptEntries } from "./transcript.js"; /** Strip CSI/OSC and other C0/C1 controls so thread fields cannot drive the terminal. */ -export function stripTerminalControls(text) { +function stripTerminalControls(text) { return String(text) .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "") .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "") @@ -57,7 +57,7 @@ export function formatTranscript(out, { full = false } = {}) { for (const e of entries) { const role = e.role || e.kind || e.sender || e.type || "msg"; const text = entryText(e); - const id = e.id || e.messageId || ""; + const id = e.id || ""; lines.push("[" + role + (id ? " " + id : "") + "] " + (full ? text : truncateCliText(text))); } return lines.join("\n"); @@ -108,7 +108,7 @@ export function formatCodexThread(t) { } /** Shell-safe single-quoting for copy-pasteable export lines (spaces, quotes, $). */ -export function shellQuote(value) { +function shellQuote(value) { return "'" + String(value).replace(/'/g, "'\\''") + "'"; } diff --git a/src/core/gateway.js b/src/core/gateway.js index d0da4a3..c55bdff 100644 --- a/src/core/gateway.js +++ b/src/core/gateway.js @@ -4,7 +4,7 @@ import { hasGrokBotGatewaySession, loadGrokBotGatewaySession } from "./app-sessi import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS } from "./store.js"; import { assertAllowedCredentialUrl, redactSecrets } from "./url-policy.js"; -export class GatewayError extends Error { +class GatewayError extends Error { constructor(message, { status, method } = {}) { super(message); this.name = "GatewayError"; @@ -14,42 +14,27 @@ export class GatewayError extends Error { } // ponytail: fixed 30 s deadline and buffered byte cap; upgrade path is per-method budgets plus streaming reads. -export const GATEWAY_TIMEOUT_MS = 30000; +const GATEWAY_TIMEOUT_MS = 30000; export const GATEWAY_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; function backendBase() { return ( - process.env.SAND_BACKEND_URL || process.env.CURSOR_API_BASE_URL || "https://api2.cursor.sh" ).replace(/\/$/, ""); } function accessTokenFromEnv() { - return ( - process.env.CURSOR_ACCESS_TOKEN || - process.env.GROK_BOT_ACCESS_TOKEN || - process.env.SAND_ACCESS_TOKEN || - "" - ).trim(); + return (process.env.CURSOR_ACCESS_TOKEN || "").trim(); } function gatewayTokenFromEnv() { - return ( - process.env.GROK_BOT_GATEWAY_TOKEN || - process.env.SAND_HOST_GATEWAY_TOKEN || - process.env.SAND_GATEWAY_TOKEN || - "" - ).trim(); + return (process.env.GROK_BOT_GATEWAY_TOKEN || "").trim(); } function gatewayOverride() { const token = gatewayTokenFromEnv(); - const explicitUrl = (process.env.GROK_BOT_GATEWAY_URL || process.env.SAND_HOST_GATEWAY_URL || "").trim(); - const localUrl = token - ? "http://127.0.0.1:" + (process.env.SAND_HOST_PORT || "1340") - : ""; - const url = explicitUrl || localUrl; + const url = (process.env.GROK_BOT_GATEWAY_URL || "").trim(); if (url && token) { return { gatewayUrl: assertAllowedCredentialUrl(url.replace(/\/$/, ""), { kind: "gateway" }), @@ -124,7 +109,7 @@ function pick(obj, ...keys) { return undefined; } -export async function ensureSandbox(accessToken) { +async function ensureSandbox(accessToken) { const url = assertAllowedCredentialUrl(backendBase(), { kind: "backend" }) + "/aiserver.v1.GrokBotService/EnsureSandBox"; const res = await fetch(url, { method: "POST", @@ -158,7 +143,7 @@ export async function connectGateway() { return ensureSandbox(token); } -export async function gatewayCall(session, method, body = {}) { +async function gatewayCall(session, method, body = {}) { const base = assertAllowedCredentialUrl(session.gatewayUrl, { kind: "gateway" }); const url = base + "/api/" + method; const res = await fetch(url, { @@ -341,14 +326,13 @@ export async function removeGroupMember(session, groupRef, memberRef) { return setGroupMembers(session, group.id, next); } -export async function sendPrompt(session, ref, prompt, extra = {}) { +export async function sendPrompt(session, ref, prompt) { const rec = await resolveRef(session, ref); const body = { agentId: rec.id, prompt, - clientNonce: extra.clientNonce || randomUUID(), + clientNonce: randomUUID(), }; - if (extra.replyToId) body.replyToId = extra.replyToId; let data; try { data = await gatewayCall(session, "sendPrompt", body); diff --git a/src/core/history.js b/src/core/history.js index b101268..5ce0327 100644 --- a/src/core/history.js +++ b/src/core/history.js @@ -24,7 +24,7 @@ export function saveHistory(out, { dir, disabled, event, prompt, rootId } = {}) target, ...(rootId ? { rootId } : {}), role: String(entry.role || entry.kind || entry.sender || entry.type || "msg"), - ...(entry.id || entry.messageId ? { messageId: String(entry.id || entry.messageId) } : {}), + ...(entry.id ? { messageId: String(entry.id) } : {}), ...(entry.timestamp || entry.createdAt ? { timestamp: String(entry.timestamp || entry.createdAt) } : {}), text: entryText(entry), })); diff --git a/src/core/store.js b/src/core/store.js index 12be6a2..909cfbc 100644 --- a/src/core/store.js +++ b/src/core/store.js @@ -11,11 +11,11 @@ import { import { homedir } from "node:os"; import { join } from "node:path"; -export const GROUP_JSON_VERSION = 1; +const GROUP_JSON_VERSION = 1; export const MAX_GROUP_MEMBERS = 6; -export const PROFILE_FILE = "profile.json"; -export const GROUP_FILE = "group.json"; -export const SETTINGS_FILE = "settings.json"; +const PROFILE_FILE = "profile.json"; +const GROUP_FILE = "group.json"; +const SETTINGS_FILE = "settings.json"; export const AVATAR_SHAPES = [ "blob", @@ -76,12 +76,7 @@ function isUuid(value) { export function defaultCandidateRoots() { const home = homedir(); - const env = [ - process.env.GROK_BOT_AGENTS_DIR, - process.env.SAND_AGENTS_DIR, - process.env.SAND_DATA_ROOT && join(process.env.SAND_DATA_ROOT, "agents"), - process.env.SAND_DATA_ROOT && join(process.env.SAND_DATA_ROOT, "agent-data", "agents"), - ].filter(Boolean); + const env = [process.env.GROK_BOT_AGENTS_DIR].filter(Boolean); return [ ...env, diff --git a/src/core/transcript.js b/src/core/transcript.js index c771f9d..76a44e7 100644 --- a/src/core/transcript.js +++ b/src/core/transcript.js @@ -1,7 +1,5 @@ -// Shared by `gbot thread` and the grok-bot plugin's gbot_thread tool. - /** Coerce anything to a string without throwing (numbers, BigInt, unserializable objects). */ -export function toSafeText(value) { +function toSafeText(value) { if (typeof value === "string") return value; if (value == null) return ""; if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); @@ -14,7 +12,7 @@ export function toSafeText(value) { } /** Coerce to string and replace lone surrogates so downstream slicing/JSON never breaks. */ -export function normalizeText(value) { +function normalizeText(value) { return toSafeText(value).replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?(run: () => Promise): Promise => { try { return await run(); @@ -82,7 +82,7 @@ const metaLength = (entry: Entry): number => entry.id.length + entry.kind.length export const transcriptEntries = (transcript: unknown): Entry[] => { const rows = unwrapEntries(transcript); let remaining = TRANSCRIPT_TOTAL_MAX; - return rows.map((raw) => { + return rows.map((raw: unknown) => { const entry = threadEntry(raw); const allowText = Math.max(0, Math.min(entry.text.length, remaining - metaLength(entry))); if (allowText < entry.text.length) { diff --git a/src/mcp/grok-bot/tools/gbot_thread.tsx b/src/mcp/grok-bot/tools/gbot_thread.tsx index d48b3db..68f08ed 100644 --- a/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -62,7 +62,7 @@ export default defineTool( async ({ after, limit, target, full }) => { const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit)); const delta = transcriptDelta(tail.transcript, { after, limit }); - const entries = transcriptEntries(delta.entries); + const entries = transcriptEntries({ entries: delta.entries }); const summary = delta.gapReset ? `${delta.entryCount} entries; gap reset` : after === undefined diff --git a/src/skills/talk-to-grok-bot/SKILL.md b/src/skills/talk-to-grok-bot/SKILL.md index d4e156e..e1a2db5 100644 --- a/src/skills/talk-to-grok-bot/SKILL.md +++ b/src/skills/talk-to-grok-bot/SKILL.md @@ -34,4 +34,6 @@ Framework argument/schema errors use stderr and exit 2. `--json` is reserved bef ## Auth -Same order as `gbot`: explicit `GROK_BOT_GATEWAY_*`, else Grok Bot app session, else `CURSOR_ACCESS_TOKEN`. `gbot doctor` shows which source is present. +Same order as `gbot`: `GROK_BOT_GATEWAY_URL` plus `GROK_BOT_GATEWAY_TOKEN`, +else the Grok Bot app session, else `CURSOR_ACCESS_TOKEN`. `gbot doctor` shows +which source is present. diff --git a/test.env b/test.env new file mode 100644 index 0000000..e26f84e --- /dev/null +++ b/test.env @@ -0,0 +1 @@ +GROK_BOT_TEST=1 diff --git a/test/codex-contract.test.js b/test/codex-contract.test.js index 253b073..cab3c88 100644 --- a/test/codex-contract.test.js +++ b/test/codex-contract.test.js @@ -4,17 +4,15 @@ import { describe, it } from "node:test"; import { outcomeFromError, outcomeFromReceipt, - sendExitCode, - statusExitCode, withStatusExitCode, } from "../src/core/codex/contract.js"; describe("codex contract — status exit", () => { it("exit 0 only for reachable daemon", () => { - assert.equal(statusExitCode({ reachable: true, mode: "daemon" }), 0); - assert.equal(statusExitCode({ reachable: false, mode: "socket-absent" }), 1); - assert.equal(statusExitCode({ reachable: true, mode: "bad-response" }), 1); - assert.equal(statusExitCode({ reachable: false, mode: "daemon" }), 1); + assert.equal(withStatusExitCode({ reachable: true, mode: "daemon" }).exitCode, 0); + assert.equal(withStatusExitCode({ reachable: false, mode: "socket-absent" }).exitCode, 1); + assert.equal(withStatusExitCode({ reachable: true, mode: "bad-response" }).exitCode, 1); + assert.equal(withStatusExitCode({ reachable: false, mode: "daemon" }).exitCode, 1); }); it("withStatusExitCode attaches exitCode without mutating", () => { @@ -27,18 +25,18 @@ describe("codex contract — status exit", () => { describe("codex contract — send exit", () => { it("accepted and queued succeed", () => { - assert.equal(sendExitCode({ delivery: "accepted" }), 0); - assert.equal(sendExitCode({ delivery: "queued" }), 0); + assert.equal(outcomeFromReceipt({ delivery: "accepted" }).exitCode, 0); + assert.equal(outcomeFromReceipt({ delivery: "queued" }).exitCode, 0); }); it("rejected, unknown, and error fail", () => { - assert.equal(sendExitCode({ delivery: "rejected", error: "nope" }), 1); - assert.equal(sendExitCode({ delivery: "unknown", error: "maybe" }), 1); - assert.equal(sendExitCode({ delivery: "accepted", error: "x" }), 1); + assert.equal(outcomeFromReceipt({ delivery: "rejected", error: "nope" }).exitCode, 1); + assert.equal(outcomeFromReceipt({ delivery: "unknown", error: "maybe" }).exitCode, 1); + assert.equal(outcomeFromReceipt({ delivery: "accepted", error: "x" }).exitCode, 1); }); it("approval-refused is accepted delivery with exit 1", () => { - assert.equal(sendExitCode({ delivery: "accepted", reason: "approval-refused" }), 1); + assert.equal(outcomeFromReceipt({ delivery: "accepted", reason: "approval-refused" }).exitCode, 1); }); }); @@ -49,7 +47,9 @@ describe("codex contract — outcomeFromError", () => { delivery: "rejected", reason: "busy", threadId: "thr_1", - envelope: { messageId: "m_1", correlationId: "c_1", hop: 2 }, + messageId: "m_1", + correlationId: "c_1", + hop: 2, }); const out = outcomeFromError(err); assert.equal(out.error, "busy thread"); diff --git a/test/connect-gateway.test.js b/test/connect-gateway.test.js index a8c3ff3..31a998b 100644 --- a/test/connect-gateway.test.js +++ b/test/connect-gateway.test.js @@ -5,7 +5,7 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { grokBotGatewayDescriptorPath } from "../src/core/app-session.js"; -import { connectGateway } from "../src/core/gateway.js"; +import { connectGateway, hasGatewayAuth } from "../src/core/gateway.js"; function withEnv(values, fn) { const prev = {}; @@ -69,13 +69,9 @@ test("unusable app session falls through to CURSOR_ACCESS_TOKEN EnsureSandBox", USERPROFILE: home, CURSOR_ACCESS_TOKEN: "cursor-access-token", CURSOR_API_BASE_URL: "http://127.0.0.1:1340", + SAND_BACKEND_URL: "https://ignored.invalid", GROK_BOT_GATEWAY_URL: null, GROK_BOT_GATEWAY_TOKEN: null, - SAND_HOST_GATEWAY_URL: null, - SAND_HOST_GATEWAY_TOKEN: null, - SAND_GATEWAY_TOKEN: null, - GROK_BOT_ACCESS_TOKEN: null, - SAND_ACCESS_TOKEN: null, GROK_BOT_ALLOW_ANY_GATEWAY: null, GROK_BOT_ALLOW_LOCAL_GATEWAY: null, ...env, @@ -90,6 +86,27 @@ test("unusable app session falls through to CURSOR_ACCESS_TOKEN EnsureSandBox", ); }); +test("removed token and gateway env aliases do not select gateway auth", () => { + const home = mkdtempSync(join(tmpdir(), "gbot-connect-alias-home-")); + withEnv( + { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + APPDATA: join(home, "AppData/Roaming"), + CURSOR_ACCESS_TOKEN: null, + GROK_BOT_GATEWAY_URL: null, + GROK_BOT_GATEWAY_TOKEN: null, + GROK_BOT_ACCESS_TOKEN: "removed", + SAND_ACCESS_TOKEN: "removed", + SAND_HOST_GATEWAY_URL: "http://127.0.0.1:1340", + SAND_HOST_GATEWAY_TOKEN: "removed", + SAND_GATEWAY_TOKEN: "removed", + }, + () => assert.equal(hasGatewayAuth(), false), + ); +}); + test("unusable app session without access token surfaces the session error", async (t) => { if (!["darwin", "linux", "win32"].includes(process.platform)) { t.skip("app session platforms only"); @@ -102,13 +119,8 @@ test("unusable app session without access token surfaces the session error", asy HOME: home, USERPROFILE: home, CURSOR_ACCESS_TOKEN: null, - GROK_BOT_ACCESS_TOKEN: null, - SAND_ACCESS_TOKEN: null, GROK_BOT_GATEWAY_URL: null, GROK_BOT_GATEWAY_TOKEN: null, - SAND_HOST_GATEWAY_URL: null, - SAND_HOST_GATEWAY_TOKEN: null, - SAND_GATEWAY_TOKEN: null, ...env, }, async () => { diff --git a/test/doctor.test.js b/test/doctor.test.js index c9da0c7..66d827e 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -21,13 +21,8 @@ test("doctor reports a present but unusable Grok Bot app session", { for (const name of [ "XDG_CONFIG_HOME", "CURSOR_ACCESS_TOKEN", - "GROK_BOT_ACCESS_TOKEN", "GROK_BOT_GATEWAY_URL", "GROK_BOT_GATEWAY_TOKEN", - "SAND_ACCESS_TOKEN", - "SAND_HOST_GATEWAY_URL", - "SAND_HOST_GATEWAY_TOKEN", - "SAND_GATEWAY_TOKEN", ]) delete env[name]; const result = spawnSync(process.execPath, [CLI, "doctor", "--json"], { diff --git a/test/history.test.js b/test/history.test.js index 9d65d20..1f4ec08 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -77,11 +77,11 @@ test("send persists a full multiline prompt across processes, searchable offline } }); -test("thread and chat preserve full replies, group and root metadata, and repeated observations", async (t) => { +test("thread preserves full replies, group and root metadata, and repeated observations", async (t) => { const f = await fixture(t); await f.run(["thread", "Researcher", "--limit", "80"]); assert.deepEqual(f.calls.at(-1), { method: "/api/getAgentTranscriptTail", body: { id: target.id, limit: 80 } }); - await f.run(["chat", "Launch", "--root", "root-1", "--json"]); + await f.run(["thread", "Launch", "--root", "root-1", "--json"]); assert.deepEqual(f.calls.at(-1), { method: "/api/getAgentThread", body: { id: group.id, rootId: "root-1" } }); const rows = f.rows(); assert.equal(rows.length, 2); @@ -97,19 +97,6 @@ test("thread and chat preserve full replies, group and root metadata, and repeat assert.equal(f.rows().length, 3); }); -test("supports all transcript envelopes and text fields already displayed by the CLI", async (t) => { - const f = await fixture(t); - for (const [key, entry] of [ - ["messages", { messageId: "m1", sender: "assistant", message: "message text" }], - ["items", { kind: "user", prompt: "prompt text" }], - [null, { type: "assistant", content: "content text" }], - ]) { - f.state.payload = key ? { [key]: [entry] } : [entry]; - await f.run(["thread", "Researcher"]); - } - assert.deepEqual(f.rows().map((r) => r.text), ["message text", "prompt text", "content text"]); -}); - test("thread --after returns exclusive deltas, no-op receipts, and bounded gap resets", async (t) => { const f = await fixture(t); const entries = Array.from({ length: 45 }, (_, index) => ({ id: `m${index + 1}`, text: `message ${index + 1}` })); diff --git a/test/store.test.js b/test/store.test.js index 9fc84eb..3b9fad0 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -7,6 +7,7 @@ import { addGroupMember, createAgent, createGroup, + defaultCandidateRoots, deleteAgent, listRecords, removeGroupMember, @@ -21,6 +22,28 @@ function withRoot(fn) { .finally(() => rmSync(root, { recursive: true, force: true })); } +test("agent root discovery ignores removed sand env aliases", () => { + const previousAgentsDir = process.env.GROK_BOT_AGENTS_DIR; + const previousSandAgentsDir = process.env.SAND_AGENTS_DIR; + const previousSandDataRoot = process.env.SAND_DATA_ROOT; + try { + process.env.GROK_BOT_AGENTS_DIR = "/canonical"; + process.env.SAND_AGENTS_DIR = "/removed-agents"; + process.env.SAND_DATA_ROOT = "/removed-data"; + const candidates = defaultCandidateRoots(); + assert.equal(candidates[0], "/canonical"); + assert.equal(candidates.includes("/removed-agents"), false); + assert.equal(candidates.some((path) => path.startsWith("/removed-data")), false); + } finally { + if (previousAgentsDir === undefined) delete process.env.GROK_BOT_AGENTS_DIR; + else process.env.GROK_BOT_AGENTS_DIR = previousAgentsDir; + if (previousSandAgentsDir === undefined) delete process.env.SAND_AGENTS_DIR; + else process.env.SAND_AGENTS_DIR = previousSandAgentsDir; + if (previousSandDataRoot === undefined) delete process.env.SAND_DATA_ROOT; + else process.env.SAND_DATA_ROOT = previousSandDataRoot; + } +}); + test("create list delete bots", async () => { await withRoot((root) => { const a = createAgent(root, { name: "Oncall", description: "pages" }); diff --git a/test/transcript.test.js b/test/transcript.test.js index 8aced79..4716a20 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -19,11 +19,8 @@ test("direct string keys win over content, then content parts join", () => { assert.equal(entryText({ preview: "preview only" }), "preview only"); }); -test("transcript containers unwrap to an entry list", () => { +test("transcript containers expose their canonical entries list", () => { assert.deepEqual(transcriptEntries({ entries: [1], nextBeforeSeq: 2 }), [1]); - assert.deepEqual(transcriptEntries({ messages: [2] }), [2]); - assert.deepEqual(transcriptEntries({ items: [3] }), [3]); - assert.deepEqual(transcriptEntries([4]), [4]); assert.deepEqual(transcriptEntries({ nextBeforeSeq: 2 }), []); assert.deepEqual(transcriptEntries(null), []); }); diff --git a/test/url-policy.test.js b/test/url-policy.test.js index 9986af8..d55363b 100644 --- a/test/url-policy.test.js +++ b/test/url-policy.test.js @@ -6,7 +6,7 @@ import { resetPolicyWarnings, } from "../src/core/url-policy.js"; -// Tests run with GROK_BOT_TEST=1 (scripts/run-unit-tests.mjs); production-policy +// Unit tests run with GROK_BOT_TEST=1; production-policy // cases opt out explicitly so the assertions below describe the real CLI. function withEnv(values, fn) { values = { GROK_BOT_TEST: null, NODE_ENV: null, ...values }; diff --git a/tests/route-unit/tools.test.ts b/tests/route-unit/tools.test.ts index 9f1a5c0..9d72562 100644 --- a/tests/route-unit/tools.test.ts +++ b/tests/route-unit/tools.test.ts @@ -20,7 +20,7 @@ const roster = { agents: [ { id: 'bot-1', isGroup: false, name: 'General' }, { id: 'grp-1', memberAgentIds: ['bot-1'], name: 'Launch' }, - { id: 'bot-2', isGroup: false, name: 'Legacy' }, + { id: 'bot-2', isGroup: false, name: 'Varied' }, { id: 'bot-3', isGroup: false, name: 'Proxy' }, { id: 'bot-4', isGroup: false, name: 'Odd' }, { id: 'bot-5', isGroup: false, name: 'Noreceipt' }, @@ -39,7 +39,7 @@ const transcripts: Record = { })), }, 'bot-2': { - messages: [ + entries: [ { content: 'ignored when text is set', id: 'l1', text: 'direct text' }, { content: [{ text: 'part one' }, 'part two', { content: 'part three' }], id: 'l2', kind: 'note' }, { id: 'l3', message: 'plain message' }, @@ -191,7 +191,7 @@ describe('grok-bot MCP server', () => { expect(contentText(full.content)).toBe('3 entries'); }); - it('gbot_thread defaults the limit to 40 like the CLI and reads the other transcript shapes', async () => { + it('gbot_thread defaults the limit to 40 like the CLI and summarizes varied entry shapes', async () => { const empty = await invokeMcpTool('gbot_thread', { input: { target: 'General' }, server: 'grok-bot' }); expect(calls[1]?.body).toEqual({ id: 'bot-1', limit: 40 }); expect(empty.structuredContent).toEqual({ @@ -202,23 +202,23 @@ describe('grok-bot MCP server', () => { }); expect(contentText(empty.content)).toBe('0 entries'); - const legacy = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); - expect(legacy.structuredContent).toEqual({ + const varied = await invokeMcpTool('gbot_thread', { input: { target: 'Varied' }, server: 'grok-bot' }); + expect(varied.structuredContent).toEqual({ cursor: 'l4', entryCount: 4, gapReset: false, summary: '4 entries', }); - const legacySummary = contentText(legacy.content); - expect(legacySummary).toBe('4 entries'); - expect(legacySummary).not.toContain('direct text'); - expect(legacySummary).not.toContain('…'); - expect(legacySummary).not.toContain('x'.repeat(450)); + const variedSummary = contentText(varied.content); + expect(variedSummary).toBe('4 entries'); + expect(variedSummary).not.toContain('direct text'); + expect(variedSummary).not.toContain('…'); + expect(variedSummary).not.toContain('x'.repeat(450)); }); it('gbot_thread recovers a complete long reply with full:true and normalizes malformed entries', async () => { const full = await invokeMcpTool('gbot_thread', { - input: { full: true, target: 'Legacy' }, + input: { full: true, target: 'Varied' }, server: 'grok-bot', }); expect(full.isError).toBe(false); From 18047d1910537f667fbd68c8f0210413a41d9fdd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 01:35:57 -0700 Subject: [PATCH 15/18] docs: record native messaging and worker recovery proof --- .../specs/2026-09-15-managed-relay-design.md | 4 ++-- docs/verification/2026-09-15-duplex.md | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-15-managed-relay-design.md b/docs/superpowers/specs/2026-09-15-managed-relay-design.md index f1f994d..83a222d 100644 --- a/docs/superpowers/specs/2026-09-15-managed-relay-design.md +++ b/docs/superpowers/specs/2026-09-15-managed-relay-design.md @@ -25,7 +25,7 @@ For auto-routed sends without an explicit expectedCwd, verify the source via res Create a small relay core under `src/core/relay/`, separating state storage, engine transitions and process control. Public engine operations should cover `startBinding`, `stopBinding`, `status`, `sendToGrok`, `sendToCodex`, `tick` or a cancellable `run`, and `close`. Exact function signatures may fit the existing code, but route adapters must share them; never duplicate network/delivery policy in MCP and CLI. Test seams inject the gateway and conversation/session interfaces, clock and state directory. -Reuse the already-installed Agent Bundle state kernel (`@agent-bundle/runtime/state` and its `/sqlite` durable driver) for versioned JSON-safe state, schema validation, atomic commits and idempotent state events. It supports the minimum Node22.19.0; no new dependency or custom database engine is needed. Wrap it in the relay state module, rather than reimplementing its transaction/journal machinery. Persist in a user-owned relay directory, default `~/.grok-bot-cli/relay/` with explicit `GROK_BOT_RELAY_DIR` override. Do not place state in a versioned plugin cache. A single worker owns mutation, enforced by an exclusive lock and control endpoint. Use the kernel's atomic durable commits and bounded state/journal policies, with bounded domain events and a pure reducer. Avoid journaling the entire accumulated state for every poll/checkpoint; unchanged polls commit nothing. Load the optional SQLite driver only when durable relay state is opened so ordinary stateless commands keep their existing runtime behavior. Directories mode0700, files0600; inspect the database, WAL/SHM and lock paths and reject symlink/nonregular targets before opening. A corrupt, oversized or unsupported-version ledger fails visibly and does not reset or resend. Keep service ownership separate from the state driver's SQLite locking; an atomic state commit does not prevent two workers from sending the same prepared record. Store only routing, necessary bounded message text, delivery receipts and dedupe state; never credentials, raw gateway results or unrelated transcripts. Persisted text is an inherent part of the requested durable relay, independent of opt-in general history. +Reuse the already-installed Agent Bundle state kernel (`@agent-bundle/runtime/state` and its `/sqlite` durable driver) for versioned JSON-safe state, schema validation, atomic commits and idempotent state events. It supports the minimum Node22.19.0; no new dependency or custom database engine is needed. Wrap it in the relay state module, rather than reimplementing its transaction/journal machinery. Persist in a user-owned relay directory, default `~/.grok-bot-cli/relay/` with explicit `GROK_BOT_RELAY_DIR` override. Do not place state in a versioned plugin cache. A single worker owns mutation, enforced by an exclusive lock and control endpoint. Use the kernel's atomic durable commits and bounded state/journal policies, with bounded domain events and a pure reducer. Avoid journaling the entire accumulated state for every poll/checkpoint; unchanged polls commit nothing. Load the optional SQLite driver only when durable relay state is opened so ordinary stateless commands keep their existing runtime behavior. Directories mode0700, files0600; inspect the database, WAL/SHM and lock paths and reject symlink/nonregular targets before opening. A corrupt, oversized or unsupported-version ledger fails visibly and does not reset or resend. Keep service ownership separate from the state driver's SQLite locking; an atomic state commit does not prevent two workers from sending the same prepared record. A dedicated ownership SQLite database holds an exclusive transaction for the entire worker lifetime, acquired before opening the state engine. The OS releases this ownership on process death. Validate its database and journal/WAL/SHM paths too, and never remove or replace a lock database another worker may hold. This avoids a stale recovery-gate file that could strand startup after a crash. Store only routing, necessary bounded message text, delivery receipts and dedupe state; never credentials, raw gateway results or unrelated transcripts. Persisted text is an inherent part of the requested durable relay, independent of opt-in general history. Record source entry IDs, clientNonce/clientUserMessageId, correlation/replyTo/hop, target/thread/turn IDs, submission state, execution state, reply delivery and checkpoints separately. States include prepared (definitely not submitted), sending (uncertain after crash), accepted, rejected, unknown, completed, needs-input, paused. Write intent before network submission and acknowledgment after. A missing ack cannot become accepted; an accepted submission cannot become rejected because waiting timed out. A client ID is correlation evidence, not an assumed upstream idempotency guarantee. @@ -63,7 +63,7 @@ Tools: - `gbot_bridge_status`: optional bindingId, bounded receipts and current pending interactions. Distinguish worker health, binding coverage, submission, execution and return delivery; no raw transcript dump. - `gbot_bridge_stop`: bindingId, preserving ledger. Provide optional all/worker shutdown only through explicit input. - Extend `codex_send` with optional replyToGrok target/binding so the same worker returns its answer automatically. Without a return route, retain the conversation tool's immediate/explicit-wait behavior. -- CLI `gbot codex bridge start/status/stop/run` and existing gbot send equivalent auto-route flags when explicitly requested. All adapters share core behavior. `run` is the foreground worker entry for service managers; ordinary plugin users need no terminal process. +- CLI `gbot codex bridge start/status/stop/run` and existing gbot send equivalent auto-route flags when explicitly requested. All adapters share core behavior. `run` is an explicitly bounded foreground session that shuts down before the Agent Bundle renderer's 24-hour hard ceiling; an explicit duration option allows short lifecycle verification. The plain packaged `scripts/gbot-relay.mjs` is the unlimited service-manager entry. Ordinary plugin users need no terminal process and the on-demand background worker has no render lifetime ceiling. Correct the existing README claim of a permanent Desktop app-tools impossibility: the current shim does not forward spawn-time overrides, and no restoration path has been demonstrated here; do not claim a permanent protocol impossibility or app-tools parity. diff --git a/docs/verification/2026-09-15-duplex.md b/docs/verification/2026-09-15-duplex.md index c50005e..88a0b2e 100644 --- a/docs/verification/2026-09-15-duplex.md +++ b/docs/verification/2026-09-15-duplex.md @@ -31,3 +31,23 @@ A separate live Codex model turn called the generated read-only `path_probe` MCP ## Relay acceptance Pending implementation and final verification. The protocol receipts above do not yet claim automatic Grok→Codex→Grok delivery, worker restart recovery, or Desktop app-tools parity. + +## Live durable relay core (2026-09-16) + +At reviewed core `d82b373`, a private temporary state directory and the dedicated verification bot/thread completed Grok → Codex → Grok without model polling. Grok source `t1s0` produced accepted Codex client message `d59c0352-1cd9-413f-983d-c6a95ba6f65a`, turn `01a0a92e-435c-77f3-b041-212ed849ac11`, and completed final `GBOT_CORE_DUPLEX_OK_20260916`. The gateway send initially had unknown delivery; history nonce reconciliation confirmed returned user message `t2u` with request ID `be96425b-f2a0-4794-a92b-f09d67d2b683`. No blind retry occurred. + +Closing and reopening the engine against the same persisted state preserved the two exchange IDs/client IDs/turn IDs and did not forward the bot's acknowledgment as another Codex request. The verification binding was then stopped. Receipt `/tmp/gbot-live-relay-core-receipt.json`, harness `/tmp/gbot-live-relay-core.mjs`. This proves the core policy and durable recovery; independent packaged worker/MCP lifecycle proof is still required. + +The reverse tracked-request path also passed against real services. A Codex-origin route sent one Grok request, closed/reopened the engine immediately, and replayed the identical control request ID. Transcript history showed exactly one outbound nonce (`t3u`); the matching Grok reply `t3s0` reached the original Codex thread and completed turn `01a0a932-6613-7572-9d5a-ed2365499a27` with exact final `GBOT_TRACKED_RESTART_OK_20260916`. No automatic Grok return was created for this tracked reply, preventing a ping-pong loop. Receipt `/tmp/gbot-live-tracked-core-receipt.json`, harness `/tmp/gbot-live-tracked-core.mjs`. The engine was closed after verification. + +## Live generated MCP automatic reply (2026-09-16) + +Committed worker/tool implementation `ae51078` passed an actual Codex model invocation using the independently copied plugin artifact (path containing spaces, no development node_modules). The model called `gbot_send` once with only target/message: no thread ID, binding or reply-mode hint. Native host lineage selected exact thread `01a0a94f-616a-7f70-b322-e9cbb2a368ac`; submission exchange `exchange:3541376a8e960677068c0d3980816e0a4ca1f20866df6db49a3e5dd261bf2a89` returned an automatic reply route. The Grok response arrived as a correlated user message in that thread, and the model produced final `GBOT_NATIVE_RELAY_RECEIVED_20260916` without polling or another tool call. The harness verifies actual successful tool execution, worker ledger client ID, and the matching user item in native Codex history rather than trusting a model's success statement. + +Receipt `/tmp/gbot-live-native-relay-receipt.json`; harness `/tmp/gbot-live-native-relay.mjs`. A new dedicated test thread used a scoped temporary MCP server with only send/status/stop exposed and explicit per-tool settings for these authorized fixture operations. No global permissions/model settings changed. The worker was explicitly stopped through its MCP control tool afterward. + +## Live packaged worker survival and restart (2026-09-16) + +The copied artifact also completed two real Grok → Codex → Grok rounds through `gbot_bridge_start/status/stop`. The MCP caller exited before each incoming message. Worker PID23390 delivered the first exact final to Grok `t6u`; an explicit worker shutdown preserved the running binding, and a new tool invocation resumed that same binding in PID24149. The second exact final returned as `t8u`. Status contained exactly two inbound exchanges and two automatic returns after round two, with all first-round identities unchanged. Bot acknowledgments did not echo into Codex. Both the test binding and worker were stopped afterward, with stopped state verified separately from the shutdown acknowledgment. + +Receipt `/tmp/gbot-live-worker-restart-receipt.json`; harness `/tmp/gbot-live-worker-restart.mjs`. This exercises real background process survival, persistent routes, fresh-process recovery and exact return delivery using the standalone copied plugin, beyond the earlier in-process core reopen proof. From 0c192535a8803e876c99275d8ebaaa48bf2bbee7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 02:07:10 -0700 Subject: [PATCH 16/18] fix: preserve relay send policy cancellation and recovery --- README.md | 8 +- src/core/gateway.js | 81 +++++--- src/core/relay/engine.js | 41 ++-- src/core/relay/intake.js | 8 +- src/core/relay/routes.ts | 2 +- test/relay-auth-recovery.test.js | 270 +++++++++++++++++++++++++++ test/relay-engine.test.js | 56 ++++++ test/relay-gateway-lifecycle.test.js | 253 +++++++++++++++++++++++++ test/relay-surfaces.test.js | 149 ++++++++++++++- 9 files changed, 827 insertions(+), 41 deletions(-) create mode 100644 test/relay-auth-recovery.test.js create mode 100644 test/relay-gateway-lifecycle.test.js diff --git a/README.md b/README.md index e18e278..cf9976a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,9 @@ the send, or make a one-time `gbot_bridge_start` binding with `grokTarget`, in that Codex thread, and its corresponding terminal answer returns to Grok. Existing transcript history is not replayed. `codex_send` can also request a return with `replyToGrok` or `bindingId`. Managed delivery defaults to guarded steering of -active work; `busyPolicy: "reject"` on a binding refuses busy threads. +active work; `busyPolicy: "reject"` on a binding refuses busy threads. A managed +`codex_send` can select `whenBusy` for that one delivery; omitting it uses the +binding's stored policy. An override does not change subsequent linked traffic. Without an identifiable native source or explicit route, `gbot_send` preserves the ordinary send and returns `replyRoute: {mode: "manual", reason: "source-unavailable"}`; @@ -99,6 +101,8 @@ amendments remain in the owning Codex UI. State lives in the user-owned `~/.grok-bot-cli/relay/` directory, or `GROK_BOT_RELAY_DIR`, outside plugin caches. Profile mismatches fail visibly; gateway overrides and thread restrictions cannot reuse a differently authorized worker. +Authentication failures use bounded retries (1..30 seconds) from the saved cursor; +restored credentials do not reset coverage or resend uncertain submissions. No login service is installed. Network reconnects are automatic; a dead process needs a new tracked send or bridge start to resume saved routes. A dead worker is reported as stopped, not running. @@ -141,7 +145,7 @@ gbot codex send "Grok here: the build is green, please continue." `send` resumes the thread, starts a turn with your text, prints the turn id, and returns; Codex keeps working after `gbot` disconnects. Every command accepts `--json`. -**Which Codex you reach.** `gbot` connects to `$CODEX_HOME/app-server-control/app-server-control.sock` (default `~/.codex/...`) with a built-in WebSocket client. The daemon must be started by `codex app-server daemon start`. `list-threads` shows the threads recorded under `CODEX_HOME` (CLI, TUI, VS Code); `send` works on any of them that no other client currently holds open. Method and parameter names are pinned to the Codex release recorded in `src/core/codex-bridge.js` (`codex app-server generate-json-schema`); `status` prints the daemon and CLI versions so a stale daemon is visible, and `codex app-server daemon restart` picks up the installed CLI. Native Windows is not supported yet (AF_UNIX control socket); use WSL, Linux, or macOS. +**Which Codex you reach.** `gbot` connects to `$CODEX_HOME/app-server-control/app-server-control.sock` (default `~/.codex/...`) with a built-in WebSocket client. The daemon must be started by `codex app-server daemon start`. `list-threads` shows the threads recorded under `CODEX_HOME` (CLI, TUI, VS Code); `send` uses the resumed thread state and selected busy policy: ordinary sends reject active work, while explicitly selected guarded steering can deliver into the active turn. Method and parameter names are pinned to the Codex release recorded in `src/core/codex-bridge.js` (`codex app-server generate-json-schema`); `status` prints the daemon and CLI versions so a stale daemon is visible, and `codex app-server daemon restart` picks up the installed CLI. Native Windows is not supported yet (AF_UNIX control socket); use WSL, Linux, or macOS. **ChatGPT Desktop limitation.** Desktop runs its own private stdio app-server and does not publish the shared control socket, so external clients cannot reach live Desktop tasks. When the socket is absent, `gbot codex status` exits 1 and says so, naming the upstream issues: [openai/codex#41014](https://github.com/openai/codex/issues/41014) and [openai/codex#41112](https://github.com/openai/codex/issues/41112). `gbot` never reads Desktop's temporary `CODEX_APP_TOOLS_PIPE_PATH` sockets under `/tmp/codex-browser-use/`; that channel is private to Desktop. diff --git a/src/core/gateway.js b/src/core/gateway.js index d0da4a3..0918467 100644 --- a/src/core/gateway.js +++ b/src/core/gateway.js @@ -17,6 +17,20 @@ export class GatewayError extends Error { export const GATEWAY_TIMEOUT_MS = 30000; export const GATEWAY_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +function assertGatewayActive(signal) { + if (signal?.aborted) { + const error = new GatewayError("Gateway submission cancelled before transmission"); + error.delivery = "rejected"; + error.reason = "cancelled"; + throw error; + } +} + +function gatewayDeadline(signal) { + const timeout = AbortSignal.timeout(GATEWAY_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + function backendBase() { return ( process.env.SAND_BACKEND_URL || @@ -124,16 +138,24 @@ function pick(obj, ...keys) { return undefined; } -export async function ensureSandbox(accessToken) { +export async function ensureSandbox(accessToken, { signal } = {}) { + assertGatewayActive(signal); const url = assertAllowedCredentialUrl(backendBase(), { kind: "backend" }) + "/aiserver.v1.GrokBotService/EnsureSandBox"; - const res = await fetch(url, { - method: "POST", - redirect: "error", - signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS), - headers: ensureSandboxHeaders(accessToken), - body: "{}", - }); - const body = await readJson(res); + let res, body; + try { + res = await fetch(url, { + method: "POST", + redirect: "error", + signal: gatewayDeadline(signal), + headers: ensureSandboxHeaders(accessToken), + body: "{}", + }); + body = await readJson(res); + } catch (error) { + assertGatewayActive(signal); + throw error; + } + assertGatewayActive(signal); if (!res.ok) { const detail = body.message || body.error || body.raw || res.statusText; throw new GatewayError("EnsureSandBox failed: " + res.status + " " + redactSecrets(detail), { status: res.status, method: "EnsureSandBox" }); @@ -146,7 +168,8 @@ export async function ensureSandbox(accessToken) { return { gatewayUrl: assertAllowedCredentialUrl(String(gatewayUrl).replace(/\/$/, ""), { kind: "gateway" }), gatewayToken: String(gatewayToken), gatewayHeaders: mergeGatewayHeaders(headersFromEnsureSandbox(body), headersFromEnv()) }; } -export async function connectGateway() { +export async function connectGateway({ signal } = {}) { + assertGatewayActive(signal); const override = gatewayOverride(); if (override) return override; const fromApp = sessionFromApp(); @@ -155,20 +178,33 @@ export async function connectGateway() { if (!token) { throw new GatewayError("Set CURSOR_ACCESS_TOKEN, or GROK_BOT_GATEWAY_URL + GROK_BOT_GATEWAY_TOKEN. Do not use a Cursor dashboard API key."); } - return ensureSandbox(token); + return ensureSandbox(token, { signal }); } -export async function gatewayCall(session, method, body = {}) { +export async function gatewayCall(session, method, body = {}, { signal } = {}) { + assertGatewayActive(signal); const base = assertAllowedCredentialUrl(session.gatewayUrl, { kind: "gateway" }); const url = base + "/api/" + method; - const res = await fetch(url, { + // Cancellation can abort read/auth preflights. Once a prompt request starts, + // observe its actual receipt (or timeout) instead of losing certainty on stop. + const prompt = method === "sendPrompt"; + const options = { method: "POST", redirect: "error", - signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS), + signal: gatewayDeadline(prompt ? undefined : signal), headers: requestHeaders(session), body: JSON.stringify(body), - }); - const data = await readJson(res); + }; + assertGatewayActive(signal); // Final synchronous boundary before transmission. + let res, data; + try { + res = await fetch(url, options); + data = await readJson(res); + } catch (error) { + if (!prompt) assertGatewayActive(signal); + throw error; + } + if (!prompt) assertGatewayActive(signal); if (!res.ok) { const detail = data.message || data.error || data.raw || res.statusText; throw new GatewayError(method + " failed: " + res.status + " " + redactSecrets(String(detail).slice(0, 300)), { status: res.status, method }); @@ -216,8 +252,8 @@ function unwrapOne(data) { return asRecord(data.agent || data); } -export async function listAgents(session) { - const data = await gatewayCall(session, "listAgents", {}); +export async function listAgents(session, { signal } = {}) { + const data = await gatewayCall(session, "listAgents", {}, { signal }); return unwrapList(data).map(asRecord).filter((r) => r && r.id); } @@ -231,8 +267,8 @@ function resolveFromList(records, ref) { throw new GatewayError("Ambiguous name \"" + ref + "\""); } -export async function resolveRef(session, ref) { - return resolveFromList(await listAgents(session), ref); +export async function resolveRef(session, ref, { signal } = {}) { + return resolveFromList(await listAgents(session, { signal }), ref); } export async function createAgent(session, input) { @@ -342,7 +378,8 @@ export async function removeGroupMember(session, groupRef, memberRef) { } export async function sendPrompt(session, ref, prompt, extra = {}) { - const rec = await resolveRef(session, ref); + assertGatewayActive(extra.signal); + const rec = await resolveRef(session, ref, { signal: extra.signal }); const body = { agentId: rec.id, prompt, @@ -351,7 +388,7 @@ export async function sendPrompt(session, ref, prompt, extra = {}) { if (extra.replyToId) body.replyToId = extra.replyToId; let data; try { - data = await gatewayCall(session, "sendPrompt", body); + data = await gatewayCall(session, "sendPrompt", body, { signal: extra.signal }); } catch (err) { // Delivery states: the server answered no (rejected) vs the request may have landed (unknown). // Never retry an unknown delivery blindly; read the thread first. diff --git a/src/core/relay/engine.js b/src/core/relay/engine.js index c02e678..8e4ff54 100644 --- a/src/core/relay/engine.js +++ b/src/core/relay/engine.js @@ -25,7 +25,12 @@ function createGateway() { resolve: async (ref) => resolveRef(await connectGateway(), ref), tail: async (id) => getTranscriptTail(await connectGateway(), id, 200), send: async (id, text, extra) => - sendPrompt(await connectGateway(), id, text, extra), + sendPrompt( + await connectGateway({ signal: extra.signal }), + id, + text, + extra, + ), }; } /** Core owns policy and durable intents; its caller must hold the single-worker lock. */ @@ -73,7 +78,7 @@ export async function openRelayEngine({ state.read().bindings[record.bindingId]?.state !== "running" ) scoped.abort(); - return scoped.signal; + return AbortSignal.any([controller.signal, scoped.signal]); } const runnable = (r) => !closed && @@ -110,6 +115,11 @@ export async function openRelayEngine({ return work; } async function route(input) { + if ( + input.busyPolicy !== undefined && + !["steer", "reject"].includes(input.busyPolicy) + ) + throw new Error("Unsupported busy policy"); if (input.bindingId) { const b = state.read().bindings[input.bindingId]; if (!b || b.state !== "running") @@ -120,7 +130,7 @@ export async function openRelayEngine({ throw new Error("Explicit Grok target does not match binding"); } await codex.verify(b); - return b; + return { ...b, busyPolicy: input.busyPolicy ?? b.busyPolicy }; } const threadId = relayId.parse(input.codexThreadId), target = await gateway.resolve(input.grokTarget), @@ -130,8 +140,6 @@ export async function openRelayEngine({ expectedCwd: input.expectedCwd, }); const busyPolicy = input.busyPolicy ?? "steer"; - if (!["steer", "reject"].includes(busyPolicy)) - throw new Error("Unsupported busy policy"); return { targetId, threadId, expectedCwd: verified.cwd, busyPolicy }; } async function submit(id) { @@ -155,19 +163,26 @@ export async function openRelayEngine({ r = state.read().records[id]; let result; try { - result = - r.kind === "codex" - ? await codex.send(r, { signal: submissionSignal(r) }) - : await gateway.send(r.targetId, r.text, { - clientNonce: r.clientId, - ...(r.sourceIds[0] ? { replyToId: r.sourceIds[0] } : {}), - }); + const signal = submissionSignal(r); + if (!runnable(r) || signal.aborted) { + result = { delivery: "rejected", reason: "cancelled" }; + } else if (r.kind === "codex") { + result = await codex.send(r, { signal }); + } else { + result = await gateway.send(r.targetId, r.text, { + signal, + clientNonce: r.clientId, + ...(r.sourceIds[0] ? { replyToId: r.sourceIds[0] } : {}), + }); + } } catch (error) { result = { delivery: error.delivery === "rejected" ? "rejected" : "unknown", reason: error.delivery === "rejected" - ? "submission-rejected" + ? error.reason === "cancelled" + ? "cancelled" + : "submission-rejected" : "transport-uncertain", }; } diff --git a/src/core/relay/intake.js b/src/core/relay/intake.js index 34566a9..b471131 100644 --- a/src/core/relay/intake.js +++ b/src/core/relay/intake.js @@ -25,7 +25,11 @@ export function createIntake({ } async function poll(targetId, force = false) { let target = state.read().targets[targetId]; - if (target.state === "paused" || (!force && target.nextPoll > clock())) + const retryAuth = target.reason === "auth"; + if ( + (target.state === "paused" && !retryAuth) || + ((!force || retryAuth) && target.nextPoll > clock()) + ) return; let page; try { @@ -36,7 +40,7 @@ export function createIntake({ auth = [401, 403].includes(error.status); await change("targets", { ...target, - state: coverage || auth ? "paused" : "backoff", + state: coverage ? "paused" : "backoff", reason: coverage ? "invalid-coverage" : auth diff --git a/src/core/relay/routes.ts b/src/core/relay/routes.ts index 6ecdfa6..e8d5829 100644 --- a/src/core/relay/routes.ts +++ b/src/core/relay/routes.ts @@ -231,7 +231,7 @@ export async function codexReturnOperation( bindingId: input.bindingId, message: input.message, requestId: input.requestId, - busyPolicy: input.whenBusy ?? "steer", + busyPolicy: input.whenBusy, hop: input.hop, correlationId: input.correlationId, }, diff --git a/test/relay-auth-recovery.test.js b/test/relay-auth-recovery.test.js new file mode 100644 index 0000000..1efacb3 --- /dev/null +++ b/test/relay-auth-recovery.test.js @@ -0,0 +1,270 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { openRelayEngine } from "../src/core/relay/engine.js"; +import { openRelayState } from "../src/core/relay/state.js"; +import { createIntake } from "../src/core/relay/intake.js"; +import { createRecordFactory, op } from "../src/core/relay/records.js"; +import { fakeAppServer } from "./helpers/codex-server.js"; + +async function fixture(t) { + const dir = await mkdtemp("/tmp/relay-auth-recovery-"); + let now = 1000, + auth = null, + reads = 0, + engine; + const baseline = { id: "baseline", kind: "note", text: "old" }; + let page = [baseline]; + const sends = [], + items = []; + const fake = await fakeAppServer({ + initialize: (_, ok) => ok({}), + "thread/resume": (p, ok) => + ok({ thread: { id: p.threadId, cwd: "/tmp", status: { type: "idle" } } }), + "thread/turns/list": (_, ok) => + ok({ + data: items.length ? [{ id: "turn", status: "completed" }] : [], + nextCursor: null, + }), + "thread/items/list": (_, ok) => + ok({ + data: items.map((item) => ({ turnId: "turn", item })), + nextCursor: null, + }), + "turn/start": (p, ok) => { + items.push( + { id: "user", type: "userMessage", clientId: p.clientUserMessageId }, + { + id: "answer", + type: "agentMessage", + phase: "final_answer", + text: "final", + }, + ); + ok({ turn: { id: "turn", status: "inProgress" } }); + }, + }); + const gateway = { + resolve: async (id) => ({ id }), + tail: async (id) => { + if (id === "target") reads++; + if (auth) + throw Object.assign(new Error("authentication rejected"), { + status: auth, + }); + return id === "target" ? page : [baseline]; + }, + send: async (id, text, extra) => { + sends.push({ id, text, ...extra }); + return { delivery: "unknown" }; + }, + }; + const clock = () => now, + options = { + stateDir: dir, + profile: "auth-fixture", + env: { CODEX_HOME: fake.home }, + gateway, + clock, + }; + engine = await openRelayEngine(options); + t.after(async () => { + await engine?.close(); + await fake.close(); + await rm(dir, { recursive: true, force: true }); + }); + const unknown = await engine.sendToGrok({ + grokTarget: "uncertain-target", + codexThreadId: "source", + message: "uncertain", + requestId: "uncertain", + }); + await engine.startBinding({ grokTarget: "target", codexThreadId: "linked" }); + return { + get engine() { + return engine; + }, + get reads() { + return reads; + }, + sends, + fake, + unknown, + baseline, + set time(value) { + now = value; + }, + set auth(value) { + auth = value; + }, + set page(value) { + page = value; + }, + async restart({ legacy = false, forceBeforeDeadline = false } = {}) { + await engine.close(); + if (legacy || forceBeforeDeadline) { + const state = await openRelayState({ dir, profile: "auth-fixture" }); + if (legacy) + await state.commit([ + op("targets", { + ...state.read().targets.target, + state: "paused", + reason: "auth", + }), + ]); + if (forceBeforeDeadline) { + const intake = createIntake({ + state, + gateway, + clock, + newRecord: createRecordFactory({ env: {}, clock }).newRecord, + stoppedBindings: new Set(), + }); + await intake.poll("target", true); + } + await state.close(); + } + engine = await openRelayEngine(options); + }, + }; +} + +for (const status of [401, 403]) + for (const legacy of [false, true]) + test( + `auth ${status} recovers ${legacy ? "persisted paused" : "backoff"} target on deadline without duplicate intake or uncertain resend`, + { timeout: 10000 }, + async (t) => { + const f = await fixture(t); + f.auth = status; + await f.engine.tick(); + let target = f.engine + .status() + .targets.find((target) => target.id === "target"); + if (!legacy) assert.equal(target.state, "backoff"); + assert.equal(target.reason, "auth"); + assert.equal(target.cursor, "baseline"); + assert.equal(target.nextPoll, 2000); + f.time = 1999; + let reads = f.reads; + await f.restart({ legacy, forceBeforeDeadline: true }); + await f.engine.tick(); + assert.equal(f.reads, reads); + for (let failure = 1; failure <= 6; failure++) { + f.time = target.nextPoll; + const attemptedAt = target.nextPoll; + await f.engine.tick(); + target = f.engine + .status() + .targets.find((target) => target.id === "target"); + assert.equal(target.state, "backoff"); + assert.equal(target.reason, "auth"); + assert.equal(target.cursor, "baseline"); + assert.equal( + target.nextPoll - attemptedAt, + Math.min(30000, 1000 * 2 ** failure), + ); + reads = f.reads; + f.time = target.nextPoll - 1; + await f.engine.tick(); + assert.equal(f.reads, reads); + } + f.auth = null; + f.page = [ + f.baseline, + { + id: "fresh", + kind: "send-message", + requestId: "new-request", + text: "new message after auth restore", + }, + ]; + f.time = target.nextPoll; + await f.engine.tick(); + assert.equal( + f.engine.status().targets.find((target) => target.id === "target") + .state, + "running", + ); + assert.equal( + f.engine.status().targets.find((target) => target.id === "target") + .cursor, + "fresh", + ); + assert.equal( + f.engine.status().targets.find((target) => target.id === "target") + .reason, + null, + ); + await f.engine.tick(); + await f.restart(); + await f.engine.tick(); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/start").length, + 1, + ); + assert.equal( + f.engine + .status() + .receipts.find((x) => x.exchangeId === f.unknown.exchangeId) + .delivery, + "unknown", + ); + assert.equal( + f.sends.filter((x) => x.clientNonce === f.unknown.clientId).length, + 1, + ); + }, + ); +for (const status of [401, 403]) + test( + `auth ${status} restore with missing cursor pauses as gap and never resets coverage`, + { timeout: 10000 }, + async (t) => { + const f = await fixture(t); + f.auth = status; + await f.engine.tick(); + f.time = f.engine + .status() + .targets.find((target) => target.id === "target").nextPoll; + f.auth = null; + f.page = [ + { + id: "uncovered", + kind: "send-message", + requestId: "gap-request", + text: "must not replay", + }, + ]; + await f.engine.tick(); + const target = f.engine + .status() + .targets.find((target) => target.id === "target"); + assert.equal(target.state, "paused"); + assert.equal(target.reason, "gap"); + assert.equal(target.cursor, "baseline"); + assert.equal(target.observedCursor, "uncovered"); + const reads = f.reads; + f.page = [ + f.baseline, + { + id: "later", + kind: "send-message", + requestId: "later-request", + text: "still paused", + }, + ]; + f.time += 60000; + await f.restart(); + await f.engine.tick(); + assert.equal(f.reads, reads); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/start").length, + 0, + ); + assert.equal( + f.sends.filter((x) => x.clientNonce === f.unknown.clientId).length, + 1, + ); + }, + ); diff --git a/test/relay-engine.test.js b/test/relay-engine.test.js index 3cc9e79..ce1ed6f 100644 --- a/test/relay-engine.test.js +++ b/test/relay-engine.test.js @@ -591,3 +591,59 @@ for (const method of ["sendToGrok", "sendToCodex"]) ); assert.equal(f.engine.status().receiptCount, 0); }); + +for (const bindingPolicy of ["steer", "reject"]) + test(`per-send busy policy overrides ${bindingPolicy} binding without changing its default`, async (t) => { + const f = await fixture(t, { active: true }); + const binding = await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + busyPolicy: bindingPolicy, + }); + for (const policy of [undefined, "steer", "reject"]) { + const before = f.fake.received.filter( + (x) => x.method === "turn/steer", + ).length; + const out = await f.engine.sendToCodex({ + bindingId: binding.id, + message: "policy test", + ...(policy === undefined ? {} : { busyPolicy: policy }), + }); + const expected = + (policy ?? bindingPolicy) === "reject" ? "rejected" : "accepted"; + assert.equal( + out.delivery, + expected, + `binding=${bindingPolicy} override=${policy}`, + ); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/steer").length - + before, + expected === "accepted" ? 1 : 0, + ); + assert.equal(f.engine.status().bindings[0].busyPolicy, bindingPolicy); + } + }); +test("invalid per-send binding busy policies reject before recording or submitting", async (t) => { + const f = await fixture(t, { active: true }); + const binding = await f.engine.startBinding({ + grokTarget: "target", + codexThreadId: "thread", + }); + for (const busyPolicy of ["queue", "invalid", null, 0]) + await assert.rejects( + f.engine.sendToCodex({ + bindingId: binding.id, + message: "invalid", + busyPolicy, + }), + /busy policy/i, + ); + assert.equal(f.engine.status().receiptCount, 0); + assert.equal( + f.fake.received.filter((x) => + ["turn/start", "turn/steer"].includes(x.method), + ).length, + 0, + ); +}); diff --git a/test/relay-gateway-lifecycle.test.js b/test/relay-gateway-lifecycle.test.js new file mode 100644 index 0000000..8ecedab --- /dev/null +++ b/test/relay-gateway-lifecycle.test.js @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { join } from "node:path"; +import { openRelayEngine } from "../src/core/relay/engine.js"; +import { fakeAppServer } from "./helpers/codex-server.js"; + +const deferred = () => { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; +async function fixture(t, kind, phase) { + const dir = await fs.mkdtemp("/tmp/relay-gateway-cancel-"); + const entered = deferred(), + release = deferred(); + const prompts = [], + items = new Map(); + let engine, + armed = false, + held = false; + const sending = () => + armed && + engine + .status() + .receipts.some((r) => r.kind === kind && r.delivery === "sending"); + async function hold() { + held = true; + entered.resolve(); + await release.promise; + } + const server = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + let response; + if (req.url.endsWith("/EnsureSandBox")) { + if (!held && phase === "auth" && sending()) await hold(); + response = { + gatewayUrl: `http://127.0.0.1:${server.address().port}`, + gatewayToken: "fixture-gateway-token", + }; + } else if (req.url === "/api/listAgents") { + if (!held && phase === "resolve" && sending()) await hold(); + response = { + agents: [ + { id: "target-a", name: "A" }, + { id: "target-b", name: "B" }, + ], + }; + } else if (req.url === "/api/sendPrompt") { + prompts.push(body); + if (!held && phase.startsWith("written") && sending()) await hold(); + response = + phase === "written-unknown" && body.agentId === "target-a" + ? {} + : { messageId: `message-${prompts.length}` }; + } else response = { entries: [] }; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(response)); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const fake = await fakeAppServer({ + initialize: (_, ok) => ok({}), + "thread/resume": (p, ok) => + ok({ thread: { id: p.threadId, cwd: "/tmp", status: { type: "idle" } } }), + "thread/turns/list": (p, ok) => + ok({ + data: items.has(p.threadId) + ? [{ id: "turn-" + p.threadId, status: "completed" }] + : [], + nextCursor: null, + }), + "thread/items/list": (p, ok) => + ok({ + data: (items.get(p.threadId) ?? []).map((item) => ({ + turnId: "turn-" + p.threadId, + item, + })), + nextCursor: null, + }), + "turn/start": (p, ok) => { + items.set(p.threadId, [ + { + id: "user-" + p.threadId, + type: "userMessage", + clientId: p.clientUserMessageId, + }, + { + id: "answer-" + p.threadId, + type: "agentMessage", + phase: "final_answer", + text: "fixture final", + }, + ]); + ok({ turn: { id: "turn-" + p.threadId, status: "inProgress" } }); + }, + }); + const url = `http://127.0.0.1:${server.address().port}`; + const env = { + HOME: join(dir, "private-home"), + USERPROFILE: join(dir, "private-home"), + XDG_CONFIG_HOME: join(dir, "private-home", ".config"), + APPDATA: join(dir, "private-home", "AppData"), + GROK_BOT_TEST: "1", + NODE_ENV: "test", + GROK_BOT_GATEWAY_URL: url, + GROK_BOT_GATEWAY_TOKEN: "fixture-gateway-token", + GROK_BOT_GATEWAY_HEADERS: "", + CURSOR_API_BASE_URL: url, + SAND_BACKEND_URL: url, + CURSOR_ACCESS_TOKEN: "fixture-access-token", + SAND_HOST_GATEWAY_URL: "", + SAND_HOST_GATEWAY_TOKEN: "", + SAND_GATEWAY_TOKEN: "", + GROK_BOT_ACCESS_TOKEN: "", + SAND_ACCESS_TOKEN: "", + CODEX_HOME: fake.home, + CODEX_APP_SERVER_SOCK: "", + GROK_BOT_CODEX_THREADS: "", + GROK_BOT_MAX_HOPS: "4", + }; + const previous = Object.fromEntries( + Object.keys(env).map((key) => [key, process.env[key]]), + ); + Object.assign(process.env, env); + const originalChmod = fs.chmod; + t.after(async () => { + release.resolve(); + fs.chmod = originalChmod; + syncBuiltinESMExports(); + await engine?.close(); + await fake.close(); + server.closeAllConnections(); + await new Promise((r) => server.close(r)); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await fs.rm(dir, { recursive: true, force: true }); + }); + const options = { + stateDir: join(dir, "state"), + profile: "gateway-cancel-fixture", + }; + engine = await openRelayEngine(options); + const a = await engine.startBinding({ + grokTarget: "A", + codexThreadId: "thread-a", + }); + const b = await engine.startBinding({ + grokTarget: "B", + codexThreadId: "thread-b", + }); + fs.chmod = async (...args) => { + await originalChmod(...args); + if (!held && phase === "persist" && sending()) await hold(); + }; + syncBuiltinESMExports(); + return { + get engine() { + return engine; + }, + a, + b, + prompts, + fake, + entered, + release, + async start() { + if (kind === "grok-return") + await engine.sendToCodex({ + bindingId: a.id, + message: "produce final", + requestId: "return-source", + }); + if (phase === "auth") process.env.GROK_BOT_GATEWAY_TOKEN = ""; + armed = true; + return kind === "grok-request" + ? engine.sendToGrok({ + bindingId: a.id, + message: "stop this request", + requestId: "cancelled-request", + }) + : engine.tick(); + }, + async reopen() { + engine = await openRelayEngine(options); + }, + }; +} + +for (const kind of ["grok-request", "grok-return"]) + for (const stop of ["binding", "engine"]) + for (const phase of [ + "persist", + "auth", + "resolve", + "written-accepted", + "written-unknown", + ]) { + test( + `${kind}: ${stop} cancellation during ${phase} preserves transmission certainty and another binding`, + { timeout: 10000 }, + async (t) => { + const f = await fixture(t, kind, phase); + const pending = f.start(); + pending.catch(() => {}); + await f.entered.promise; + const stopping = + stop === "binding" + ? f.engine.stopBinding({ bindingId: f.a.id }) + : f.engine.close(); + f.release.resolve(); + await Promise.all([stopping, pending]); + const receipt = f.engine + .status() + .receipts.find((r) => r.kind === kind); + const expected = + phase === "written-accepted" + ? "accepted" + : phase === "written-unknown" + ? "unknown" + : "rejected"; + assert.equal(receipt?.delivery, expected); + if (!phase.startsWith("written")) + assert.equal(receipt.reason, "cancelled"); + assert.equal(f.prompts.length, phase.startsWith("written") ? 1 : 0); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/interrupt").length, + 0, + ); + if (stop === "engine") await f.reopen(); + const persisted = f.engine + .status() + .receipts.find((r) => r.exchangeId === receipt.exchangeId); + assert.equal(persisted.delivery, expected); + const other = await f.engine.sendToGrok({ + bindingId: f.b.id, + message: "other binding remains usable", + requestId: "other-send", + }); + assert.equal(other.delivery, "accepted"); + assert.equal(f.prompts.at(-1).agentId, "target-b"); + }, + ); + } diff --git a/test/relay-surfaces.test.js b/test/relay-surfaces.test.js index e0fdc37..535fd30 100644 --- a/test/relay-surfaces.test.js +++ b/test/relay-surfaces.test.js @@ -8,7 +8,7 @@ import test from "node:test"; import { fakeAppServer } from "./helpers/codex-server.js"; import { relayRequest } from "../src/core/relay/control.js"; -export async function fixture({ active = false, interaction } = {}) { +export async function fixture({ active = false, interaction, onGateway } = {}) { const calls = []; const entries = []; const gateway = createServer(async (req, res) => { @@ -16,6 +16,7 @@ export async function fixture({ active = false, interaction } = {}) { for await (const c of req) body += c; const value = JSON.parse(body); calls.push({ path: req.url, value }); + await onGateway?.(req.url, value); res.setHeader("content-type", "application/json"); res.end( JSON.stringify( @@ -1122,3 +1123,149 @@ test( } }, ); + +for (const surface of ["MCP", "CLI"]) + test( + `per-send busy policy: ${surface} honors explicit and omitted policy without changing binding defaults`, + { timeout: 30000 }, + async () => { + const f = await fixture({ active: true }); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + for (const busyPolicy of ["steer", "reject"]) { + const { binding } = await client.call("gbot_bridge_start", { + grokTarget: busyPolicy === "steer" ? "General" : "Alice", + codexThreadId: "thread-active", + busyPolicy, + }); + for (const whenBusy of ["reject", "steer", undefined]) { + const before = f.fake.received.filter( + (x) => x.method === "turn/steer", + ).length; + const out = + surface === "MCP" + ? await client.call("codex_send", { + threadId: "thread-active", + bindingId: binding.id, + message: "per-send policy", + ...(whenBusy === undefined ? {} : { whenBusy }), + }) + : JSON.parse( + ( + await cli( + f.env, + "codex", + "send", + "--binding-id", + binding.id, + ...(whenBusy === undefined + ? [] + : ["--when-busy", whenBusy]), + "thread-active", + "per-send policy", + ) + ).out, + ); + const expected = + (whenBusy ?? busyPolicy) === "reject" ? "rejected" : "accepted"; + assert.equal( + out.delivery, + expected, + `${surface} binding=${busyPolicy} override=${whenBusy}`, + ); + assert.equal( + f.fake.received.filter((x) => x.method === "turn/steer").length - + before, + expected === "accepted" ? 1 : 0, + ); + const status = await client.call("gbot_bridge_status", { + bindingId: binding.id, + }); + assert.equal(status.bindings[0].busyPolicy, busyPolicy); + } + } + } finally { + await client?.close(); + await f.close(); + } + }, + ); + +test( + "managed gateway cancellation: generated binding stop prevents held recipient preflight from sending", + { timeout: 20000 }, + async () => { + let armed = false, + lookups = 0, + entered, + release; + const reached = new Promise((r) => { + entered = r; + }), + gate = new Promise((r) => { + release = r; + }); + const f = await fixture({ + onGateway: async (path) => { + if (armed && path === "/api/listAgents" && ++lookups === 2) { + entered(); + await gate; + } + }, + }); + let client; + try { + client = await mcp( + resolve(process.env.RELAY_ARTIFACT_ROOT ?? "artifact"), + f.env, + ); + const { binding: a } = await client.call("gbot_bridge_start", { + grokTarget: "General", + codexThreadId: "thread-a", + }); + const { binding: b } = await client.call("gbot_bridge_start", { + grokTarget: "Alice", + codexThreadId: "thread-b", + }); + armed = true; + const sending = client.call("gbot_send", { + target: "General", + bindingId: a.id, + message: "cancel before prompt", + }); + sending.catch(() => {}); + await reached; + const status = await client.call("gbot_bridge_status", { + bindingId: a.id, + }); + assert.equal(status.receipts[0].delivery, "sending"); + await client.call("gbot_bridge_stop", { bindingId: a.id }); + const receipt = await sending; + assert.equal(receipt.delivery, "rejected"); + assert.equal(receipt.reason, "cancelled"); + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 0, + ); + release(); + const other = await client.call("gbot_send", { + target: "Alice", + bindingId: b.id, + message: "other binding", + }); + assert.equal(other.delivery, "accepted"); + assert.equal( + f.calls.filter((x) => x.path.endsWith("sendPrompt")).length, + 1, + ); + } finally { + release(); + await client?.close(); + await f.close(); + } + }, +); From 239f36d1dab3bd5b02df98ecdaa8e310fd618f9d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 02:09:57 -0700 Subject: [PATCH 17/18] docs: record reviewed relay behavior and release evidence --- README.md | 15 +++++++---- .../specs/2026-09-15-managed-relay-design.md | 2 +- docs/verification/2026-09-15-duplex.md | 26 ++++++++++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cf9976a..bbb32c6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ prints the failure message on stderr and exits 1. In a native Codex invocation, `gbot_send` sends once and returns a durable exchange receipt. Continue working: matching Grok replies arrive in the originating Codex thread automatically. That thread's next answer is not sent back to Grok unless -you deliberately send again. The background worker survives the MCP caller exiting. +you deliberately send again. The background worker survives the MCP caller exiting. Outgoing MCP tool calls +still follow the host's tool-approval policy; the bridge does not change it. When native identity is unavailable (including Cursor), supply `codexThreadId` on the send, or make a one-time `gbot_bridge_start` binding with `grokTarget`, @@ -196,10 +197,14 @@ Sends at `hop >= GROK_BOT_MAX_HOPS` (default 4) are refused with `reason: "hop-l ## Talking to Grok Bot from Codex The npm package is also an [Agent Bundle](https://scriptedalchemy.github.io/agent-bundle/) plugin -that gives Codex, Claude Code, and Cursor two MCP tools on a `grok-bot` server, -`gbot_send` and `gbot_thread`, plus a `talk-to-grok-bot` skill that tells the agent -when to ping a bot and how to word the message. The tools bundle this repository's -gateway client, so the installed plugin does not need `gbot` on `PATH`. +that gives Codex, Claude Code, and Cursor a `grok-bot` MCP server with messaging, +Codex conversation, and managed bridge tools, plus a `talk-to-grok-bot` skill. +`gbot_send` and `gbot_thread` handle Grok conversations; `codex_threads`, +`codex_send`, `codex_wait`, and `codex_watch` handle Codex conversations. +`gbot_bridge_start`, `gbot_bridge_status`, `gbot_bridge_stop`, and +`gbot_codex_respond` manage automatic delivery and scoped operator responses. +The tools bundle this repository's gateway client and worker, so the installed +plugin does not need `gbot` on `PATH`. Install the bundled host projections from the same npm package: diff --git a/docs/superpowers/specs/2026-09-15-managed-relay-design.md b/docs/superpowers/specs/2026-09-15-managed-relay-design.md index 83a222d..64c2d8d 100644 --- a/docs/superpowers/specs/2026-09-15-managed-relay-design.md +++ b/docs/superpowers/specs/2026-09-15-managed-relay-design.md @@ -49,7 +49,7 @@ Incoming messages should reach active work. The managed route's default busy pol Each accepted message is associated with the returned turn ID. Multiple messages steered into the same turn may share its terminal answer: coalesce the automatic Grok return per target+thread+turn and include the source message IDs/correlation records, rather than posting the same final repeatedly. A live steer probe confirmed that an active turn can contain an already-emitted final_answer before the steered user message and another final_answer afterward. Anchor automatic returns to the earliest associated clientUserMessageId in that turn and exclude agent items before its userMessage.clientId. Extend the shared collector with optional `afterMessageId` (or an equivalent narrow reply-selection helper) and test this actual ordering; a plain turn-level wait can keep returning all final items. Missing anchor/coverage must be explicit, never forward earlier unrelated content as the reply. The anchor bounds history selection; a shared active turn can still combine subsequent inputs, which should be described accurately. Use the collector's completed final_answer items or terminal phase-null fallback; never forward commentary, reasoning, tool logs or unrelated turns. Preserve failed/interrupted status and empty successful replies. Default outbound text at most64KiB, explicit truncation. Send status once if no final text is present. A needs-input turn is visible and remains resumable; timeout does not interrupt it. No automatic approvals/refusals from observers. -After reconnect, re-establish subscriptions, reconcile pending receipt IDs and turn completion, then resume new intake. Backoff bounded1..30s; no tight reconnect loop. Auth failures and gaps are visible route states. Stop cancels relay observation/submissions and closes owned sockets but never cancels another client's Codex turn or deletes pending records. Restart resumes from safe checkpoints. A worker reconnects lost network/app-server connections on its own. Distinguish this from process/OS supervision: if login persistence is not installed, report that a stopped/crashed process needs restart, and ensure the next tool-driven worker start resumes saved routes. Never report a dead pid as a running route. +After reconnect, re-establish subscriptions, reconcile pending receipt IDs and turn completion, then resume new intake. Backoff bounded1..30s; no tight reconnect loop. Auth failures use a visible auth reason with bounded retry backoff; after credentials are restored, resume transcript reads from the unchanged checkpoint. Persisted auth-paused state can retry under the same nextPoll bound. Missing cursor coverage still pauses as a gap, and no auth recovery resets a cursor or resends an unknown submission. Other paused states remain paused. Stop cancels relay observation/submissions and closes owned sockets but never cancels another client's Codex turn or deletes pending records. Restart resumes from safe checkpoints. A worker reconnects lost network/app-server connections on its own. Distinguish this from process/OS supervision: if login persistence is not installed, report that a stopped/crashed process needs restart, and ensure the next tool-driven worker start resumes saved routes. Never report a dead pid as a running route. ## Managed process and tools diff --git a/docs/verification/2026-09-15-duplex.md b/docs/verification/2026-09-15-duplex.md index 88a0b2e..2a7c309 100644 --- a/docs/verification/2026-09-15-duplex.md +++ b/docs/verification/2026-09-15-duplex.md @@ -18,7 +18,7 @@ Local raw receipts: `/tmp/gbot-live-protocol-receipt.json`, `/tmp/gbot-live-hist The current supported plugin host flow is Codex/Cursor/Claude spawning local stdio MCP, whose gateway client communicates with Grok Bot over HTTPS. Grok Bot Computers local execution is a separate facility. Native Grok Bot loading of this generated plugin or a remote stdio MCP tunnel has not been established. The managed relay uses Grok gateway conversations for Grok-origin messages. -At this check, live gbot plugin copies in Codex and Cursor were 0.4.2 while source was 0.4.4. A generated artifact or installer receipt alone is not proof a live host loaded new tools. Hosted releases remained pending and npm latest was 0.4.2; publication and local activation require their own final receipts. +The initial inventory found live gbot plugin copies in Codex and Cursor at 0.4.2 while source was 0.4.4. A generated artifact or installer receipt alone is not proof a live host loaded new tools. Hosted releases remained pending and npm latest was 0.4.2; publication and local activation require their own final receipts. ## Conversation implementation receipt @@ -30,13 +30,13 @@ A separate live Codex model turn called the generated read-only `path_probe` MCP ## Relay acceptance -Pending implementation and final verification. The protocol receipts above do not yet claim automatic Grok→Codex→Grok delivery, worker restart recovery, or Desktop app-tools parity. +The live receipts below establish automatic Grok→Codex→Grok delivery, native MCP source routing, caller-exit survival and worker restart recovery. Final installed-host activation remains a separate rollout check. Desktop app-tools parity has not been established. ## Live durable relay core (2026-09-16) At reviewed core `d82b373`, a private temporary state directory and the dedicated verification bot/thread completed Grok → Codex → Grok without model polling. Grok source `t1s0` produced accepted Codex client message `d59c0352-1cd9-413f-983d-c6a95ba6f65a`, turn `01a0a92e-435c-77f3-b041-212ed849ac11`, and completed final `GBOT_CORE_DUPLEX_OK_20260916`. The gateway send initially had unknown delivery; history nonce reconciliation confirmed returned user message `t2u` with request ID `be96425b-f2a0-4794-a92b-f09d67d2b683`. No blind retry occurred. -Closing and reopening the engine against the same persisted state preserved the two exchange IDs/client IDs/turn IDs and did not forward the bot's acknowledgment as another Codex request. The verification binding was then stopped. Receipt `/tmp/gbot-live-relay-core-receipt.json`, harness `/tmp/gbot-live-relay-core.mjs`. This proves the core policy and durable recovery; independent packaged worker/MCP lifecycle proof is still required. +Closing and reopening the engine against the same persisted state preserved the two exchange IDs/client IDs/turn IDs and did not forward the bot's acknowledgment as another Codex request. The verification binding was then stopped. Receipt `/tmp/gbot-live-relay-core-receipt.json`, harness `/tmp/gbot-live-relay-core.mjs`. This proves the core policy and durable recovery; independent packaged worker/MCP lifecycle proof follows below. The reverse tracked-request path also passed against real services. A Codex-origin route sent one Grok request, closed/reopened the engine immediately, and replayed the identical control request ID. Transcript history showed exactly one outbound nonce (`t3u`); the matching Grok reply `t3s0` reached the original Codex thread and completed turn `01a0a932-6613-7572-9d5a-ed2365499a27` with exact final `GBOT_TRACKED_RESTART_OK_20260916`. No automatic Grok return was created for this tracked reply, preventing a ping-pong loop. Receipt `/tmp/gbot-live-tracked-core-receipt.json`, harness `/tmp/gbot-live-tracked-core.mjs`. The engine was closed after verification. @@ -51,3 +51,23 @@ Receipt `/tmp/gbot-live-native-relay-receipt.json`; harness `/tmp/gbot-live-nati The copied artifact also completed two real Grok → Codex → Grok rounds through `gbot_bridge_start/status/stop`. The MCP caller exited before each incoming message. Worker PID23390 delivered the first exact final to Grok `t6u`; an explicit worker shutdown preserved the running binding, and a new tool invocation resumed that same binding in PID24149. The second exact final returned as `t8u`. Status contained exactly two inbound exchanges and two automatic returns after round two, with all first-round identities unchanged. Bot acknowledgments did not echo into Codex. Both the test binding and worker were stopped afterward, with stopped state verified separately from the shutdown acknowledgment. Receipt `/tmp/gbot-live-worker-restart-receipt.json`; harness `/tmp/gbot-live-worker-restart.mjs`. This exercises real background process survival, persistent routes, fresh-process recovery and exact return delivery using the standalone copied plugin, beyond the earlier in-process core reopen proof. + +## Existing installed-plugin approval baseline + +The existing `gbot@gbot-marketplace` installation exposed `gbot_send` and `gbot_thread` in a new Codex thread. A model that was allowed to discover the tool attempted exactly one controlled send, which the inherited `approvalPolicy: never` rejected with `MCP tool call requires approval, but approval policy is never`. No message was sent and the completed fixture thread was archived. Receipt: `/tmp/gbot-live-installed-discovery-receipt.json`, thread `01a0a974-947d-7a32-b4c6-daf67e152bef`. The earlier discovery-forbidden probe made no tool call and does not establish an approval outcome. + +The successful native automatic-reply proof above used an explicit per-thread test-server approval for the authorized fixture send. It proves native routing and delivery, not an override of installed-host permission policy. Ordinary outgoing MCP operations still require the host's authorization; the worker does not approve tools or change global permissions. + +## Contribution release receipt + +The reviewed 0.4.5 release completed in [GitHub Actions run 35070689066](https://github.com/ScriptedAlchemy/grok-bot-cli/actions/runs/35070689066), including registry resolution and packed executable verification. npm resolved `grok-bot-cli@0.4.5` with SHA1 `0ab5a5ed76ba6372de85f2502afe2083c90d97e5`. This release contains the earlier reviewed contributions; the automatic relay feature is awaiting its own merge and release. + +## Final reviewed relay candidate + +The final fix commit `0c192535a8803e876c99275d8ebaaa48bf2bbee7` closes the explicit per-send busy-policy, stop-during-Grok-preflight, and authentication-recovery findings. Focused re-review found no remaining code blocker. The integrated `npm run check` passed **369 unit tests and 14 route tests**, source/artifact validation, build, and typecheck; log `/tmp/gbot-duplex-final-check.log`. The fix also passed **178 affected tests** and **54 tests against copied/extracted plugin and npm artifacts on Node 22.19**, including real gateway transmission boundaries; logs `/tmp/relay-final-affected.log` and `/tmp/relay-final-packed22.log`. + +Three implementation decisions resolve observed runtime constraints: + +- The generated CLI foreground command has an explicit bounded lifetime; the packaged plain script supports unlimited service-manager lifetime. This avoids the renderer's 24-hour ceiling. Administrators must use the plain script for an unlimited foreground service. +- A dedicated SQLite database holds lifetime ownership separately from relay-state transactions. OS release on process exit avoids stale file-lock recovery windows, at the cost of a small additional database per profile. +- Authentication failures retry from the saved cursor with a 1–30 second backoff, including previously persisted auth-paused state. This permits recovery after reauthentication, at the cost of bounded periodic reads while credentials remain invalid; missing coverage still pauses as a gap and uncertain sends are never retried. From ff76386d07deaebe29afca715cbe286ba9f44217 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 02:10:36 -0700 Subject: [PATCH 18/18] fix: force test mode before loading unit tests --- package.json | 2 +- scripts/run-unit-tests.mjs | 19 ++++++++++++++++++ test.env | 1 - test/test-runner.test.js | 41 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 scripts/run-unit-tests.mjs delete mode 100644 test.env create mode 100644 test/test-runner.test.js diff --git a/package.json b/package.json index f1f59e0..4382b04 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "release": "changeset publish", "test": "npm run test:unit && npm run test:routes", "test:routes": "rstest --config rstest.route-unit.config.ts", - "test:unit": "node --env-file=test.env --test \"test/*.test.js\"", + "test:unit": "node scripts/run-unit-tests.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "validate": "agent-bundle validate", "validate:artifact": "agent-bundle validate --artifact artifact" diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs new file mode 100644 index 0000000..16ad144 --- /dev/null +++ b/scripts/run-unit-tests.mjs @@ -0,0 +1,19 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Enumerate tests portably without relying on shell glob expansion. +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const dir = join(root, "test"); +const files = readdirSync(dir) + .filter((name) => name.endsWith(".test.js")) + .sort() + .map((name) => join("test", name)); +const result = spawnSync(process.execPath, ["--test", ...files], { + cwd: root, + // Loopback-only credential URLs (src/core/url-policy.js testMode): a test can never reach a live gateway. + env: { ...process.env, GROK_BOT_TEST: "1" }, + stdio: "inherit", +}); +process.exit(result.status === null ? 1 : result.status); diff --git a/test.env b/test.env deleted file mode 100644 index e26f84e..0000000 --- a/test.env +++ /dev/null @@ -1 +0,0 @@ -GROK_BOT_TEST=1 diff --git a/test/test-runner.test.js b/test/test-runner.test.js new file mode 100644 index 0000000..12fc81c --- /dev/null +++ b/test/test-runner.test.js @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const root = new URL("../", import.meta.url); +const config = JSON.parse(readFileSync(new URL("package.json", root), "utf8")); +for (const inherited of ["0", ""]) { + test(`unit bootstrap overrides inherited GROK_BOT_TEST=${JSON.stringify(inherited)} before loading tests`, () => { + const fixture = mkdtempSync(join(tmpdir(), "gbot-test-bootstrap-")); + try { + mkdirSync(join(fixture, "test")); + writeFileSync(join(fixture, "package.json"), JSON.stringify({ type: "module", scripts: { "test:unit": config.scripts["test:unit"] } })); + for (const path of ["scripts", "test.env"]) { + if (existsSync(new URL(path, root))) cpSync(new URL(path, root), join(fixture, path), { recursive: true }); + } + writeFileSync(join(fixture, "test", "guard.test.js"), ` +import assert from "node:assert/strict"; +import { assertAllowedCredentialUrl } from ${JSON.stringify(new URL("src/core/url-policy.js", root).href)}; +globalThis.fetch = () => { throw new Error("No network allowed in bootstrap regression"); }; +assert.throws(() => assertAllowedCredentialUrl("https://api2.cursor.sh", { kind: "backend" }), /test mode.*loopback/); +assert.equal(process.env.GROK_BOT_TEST, "1"); +console.log("bootstrap-policy-verified"); +`); + const childEnv = { ...process.env, GROK_BOT_TEST: inherited, NODE_ENV: "production" }; + delete childEnv.NODE_TEST_CONTEXT; + const result = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "test:unit"], { + cwd: fixture, + env: childEnv, + encoding: "utf8", + timeout: 15_000, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /bootstrap-policy-verified/); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + }); +}