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);