From 5395aa73d21fdadcb72ebad878f0f23dcd2e02e4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 05:15:28 +0000 Subject: [PATCH 1/2] test: refuse non-loopback gateways in test mode; verify publish; require changesets --- .changeset/config.json | 10 ++++- .changeset/test-mode-loopback-only.md | 5 +++ .github/workflows/changeset.yml | 54 +++++++++++++++++++++++++++ .github/workflows/release.yml | 21 +++++++++++ rstest.route-unit.config.ts | 3 ++ scripts/run-unit-tests.mjs | 2 + src/core/url-policy.js | 16 ++++++++ test/connect-gateway.test.js | 5 ++- test/gateway-groups.test.js | 2 +- test/gateway-send.test.js | 11 +++++- test/history.test.js | 1 + test/url-policy.test.js | 16 ++++++++ tests/route-unit/tools.test.ts | 2 + 13 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 .changeset/test-mode-loopback-only.md create mode 100644 .github/workflows/changeset.yml diff --git a/.changeset/config.json b/.changeset/config.json index ec98e35..b865dcf 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,13 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "changedFilePatterns": [ + "src/**", + "package.json", + "agent-bundle.config.ts", + "tsconfig.json", + "README.md", + "LICENSE" + ] } diff --git a/.changeset/test-mode-loopback-only.md b/.changeset/test-mode-loopback-only.md new file mode 100644 index 0000000..b123f93 --- /dev/null +++ b/.changeset/test-mode-loopback-only.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Refuse every non-loopback gateway or backend URL when `GROK_BOT_TEST=1` or `NODE_ENV=test`, ignoring `GROK_BOT_ALLOW_ANY_GATEWAY`, so the test suites can never send a prompt to a live thread. The unit and route-unit runners set `GROK_BOT_TEST=1`. diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml new file mode 100644 index 0000000..b1fb76d --- /dev/null +++ b/.github/workflows/changeset.yml @@ -0,0 +1,54 @@ +name: Changeset + +# Every pull request that changes the shipped package must carry a +# `.changeset/*.md` entry. Separate from ci.yml so toggling the +# `skip-changeset` label re-evaluates only this check. +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +concurrency: + group: changeset-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + changeset: + name: Changeset present + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # `changeset status --since=origin/main` diffs against the merge-base. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "24" + - run: npm ci + - name: Require a changeset for shipped-package changes + env: + SKIP_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changeset') }} + # Only the machine-owned release branch of this repository is exempt. + IS_RELEASE_BRANCH: >- + ${{ github.event.pull_request.head.ref == 'changeset-release/main' && + github.event.pull_request.head.repo.full_name == github.repository }} + run: | + set -euo pipefail + if [ "$SKIP_LABEL" = "true" ]; then + echo "::notice::skip-changeset label present; not requiring a changeset." + exit 0 + fi + if [ "$IS_RELEASE_BRANCH" = "true" ]; then + echo "::notice::Release branch; changesets are consumed here, not added." + exit 0 + fi + # Exit 1 when a shipped file changed (changedFilePatterns in + # .changeset/config.json) and this PR adds no .changeset/*.md. + if ! npx changeset status --since=origin/main --verbose; then + echo "::error::This PR changes grok-bot-cli's shipped surface without a changeset. Run 'npx changeset' (or add .changeset/.md). For a genuinely no-op change, apply the 'skip-changeset' label." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81f103a..d7343bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,3 +65,24 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} publish-script: npm run release pr-title: Release packages + + # Green must mean published. Once no changesets are pending, the version + # in package.json is the one that should be on npm, whether this run + # published it or an earlier one did; a silent publish failure is red. + - name: Verify package.json version resolves on npm + if: steps.changesets.outputs.has-changesets == 'false' + shell: bash + run: | + set -euo pipefail + name=$(node -p "require('./package.json').name") + version=$(node -p "require('./package.json').version") + for attempt in 1 2 3 4 5 6 7 8; do + if [ "$(npm view "$name@$version" version 2>/dev/null || true)" = "$version" ]; then + echo "registry $name@$version" + exit 0 + fi + echo "attempt $attempt: $name@$version not on npm yet" + sleep 15 + done + echo "::error::$name@$version is not on npm; the release did not publish." + exit 1 diff --git a/rstest.route-unit.config.ts b/rstest.route-unit.config.ts index d4adc77..4b0c4d5 100644 --- a/rstest.route-unit.config.ts +++ b/rstest.route-unit.config.ts @@ -1,4 +1,7 @@ import { defineConfig } from '@rstest/core'; import { agentBundleRstest } from 'agent-bundle/rstest'; +// Loopback-only credential URLs (src/core/url-policy.js testMode); workers inherit the env. +process.env.GROK_BOT_TEST = '1'; + export default defineConfig(await agentBundleRstest()); diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index f02637b..5e36f2e 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -13,6 +13,8 @@ const files = readdirSync(dir) .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/core/url-policy.js b/src/core/url-policy.js index 977c98c..418d6ae 100644 --- a/src/core/url-policy.js +++ b/src/core/url-policy.js @@ -7,6 +7,8 @@ * never *.cursorvm.com, so a CURSOR_ACCESS_TOKEN cannot be pointed at a box host. * Local/dev gateways: http(s)://127.0.0.1|localhost|::1 when GROK_BOT_ALLOW_LOCAL_GATEWAY=1. * Escape hatch: GROK_BOT_ALLOW_ANY_GATEWAY=1 (unsafe; disables host checks; warns once). + * Test mode (GROK_BOT_TEST=1 or NODE_ENV=test): loopback only, for gateway and backend + * alike, and the escape hatches are ignored — a test can never reach a live thread. */ function truthyEnv(name) { @@ -22,6 +24,10 @@ export function allowLocalGateway() { return truthyEnv("GROK_BOT_ALLOW_LOCAL_GATEWAY"); } +export function testMode() { + return truthyEnv("GROK_BOT_TEST") || process.env.NODE_ENV === "test"; +} + const warned = new Set(); function warnOnce(key, message) { @@ -71,6 +77,16 @@ export function assertAllowedCredentialUrl(rawUrl, opts = {}) { throw new Error("Rejected " + label + ": userinfo is not allowed."); } + if (testMode()) { + if (!isLocalHostname(parsed.hostname) || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { + throw new Error( + "Rejected " + label + " host \"" + parsed.hostname + + "\": test mode (GROK_BOT_TEST / NODE_ENV=test) only allows http(s) loopback gateways.", + ); + } + return String(rawUrl).replace(/\/$/, ""); + } + if (allowAnyGateway()) { warnOnce( "ALLOW_ANY", diff --git a/test/connect-gateway.test.js b/test/connect-gateway.test.js index 8da1e6d..a8c3ff3 100644 --- a/test/connect-gateway.test.js +++ b/test/connect-gateway.test.js @@ -56,7 +56,7 @@ test("unusable app session falls through to CURSOR_ACCESS_TOKEN EnsureSandBox", calls.push({ url: String(url), body: options.body }); return new Response( JSON.stringify({ - gatewayUrl: "https://box.cursor.sh", + gatewayUrl: "http://127.0.0.1:1341", gatewayToken: "from-ensure", }), { status: 200 }, @@ -68,6 +68,7 @@ test("unusable app session falls through to CURSOR_ACCESS_TOKEN EnsureSandBox", HOME: home, USERPROFILE: home, CURSOR_ACCESS_TOKEN: "cursor-access-token", + CURSOR_API_BASE_URL: "http://127.0.0.1:1340", GROK_BOT_GATEWAY_URL: null, GROK_BOT_GATEWAY_TOKEN: null, SAND_HOST_GATEWAY_URL: null, @@ -81,7 +82,7 @@ test("unusable app session falls through to CURSOR_ACCESS_TOKEN EnsureSandBox", }, async () => { const session = await connectGateway(); - assert.equal(session.gatewayUrl, "https://box.cursor.sh"); + assert.equal(session.gatewayUrl, "http://127.0.0.1:1341"); assert.equal(session.gatewayToken, "from-ensure"); assert.equal(calls.length, 1); assert.match(calls[0].url, /EnsureSandBox/); diff --git a/test/gateway-groups.test.js b/test/gateway-groups.test.js index 681b7c4..932ef8f 100644 --- a/test/gateway-groups.test.js +++ b/test/gateway-groups.test.js @@ -8,7 +8,7 @@ import { } from "../src/core/gateway.js"; import { MAX_GROUP_MEMBERS } from "../src/core/store.js"; -const session = { gatewayUrl: "https://box.cursor.sh", gatewayToken: "test-token" }; +const session = { gatewayUrl: "http://127.0.0.1:1340", gatewayToken: "test-token" }; const bots = Array.from({ length: MAX_GROUP_MEMBERS + 1 }, (_, i) => ({ id: `bot-${i + 1}`, name: `Bot ${i + 1}`, diff --git a/test/gateway-send.test.js b/test/gateway-send.test.js index 79cabd0..a2b86d8 100644 --- a/test/gateway-send.test.js +++ b/test/gateway-send.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { GATEWAY_MAX_RESPONSE_BYTES, getTranscriptTail, sendPrompt } from "../src/core/gateway.js"; -const session = { gatewayUrl: "https://box.cursor.sh", gatewayToken: "t" }; +const session = { gatewayUrl: "http://127.0.0.1:1340", gatewayToken: "t" }; const roster = { agents: [{ id: "bot-1", name: "General" }] }; function mockGateway(t, send) { @@ -13,6 +13,15 @@ function mockGateway(t, send) { }); } +test("sendPrompt refuses a live gateway host in test mode before any request", async (t) => { + const fetchMock = t.mock.method(globalThis, "fetch", async () => new Response("{}", { status: 200 })); + await assert.rejects( + sendPrompt({ gatewayUrl: "https://box.cursor.sh", gatewayToken: "t" }, "General", "hi"), + /test mode/i, + ); + assert.equal(fetchMock.mock.callCount(), 0); +}); + test("sendPrompt accepts only a confirmed messageId receipt", async (t) => { mockGateway(t, () => new Response(JSON.stringify({ messageId: "m-1" }), { status: 200 })); const out = await sendPrompt(session, "General", "hi"); diff --git a/test/history.test.js b/test/history.test.js index edd91e1..9d65d20 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -22,6 +22,7 @@ async function fixture(t) { for (const key of Object.keys(env)) { if (/^(GROK_BOT_|CURSOR_|SAND_)/.test(key)) delete env[key]; } + env.GROK_BOT_TEST = "1"; env.GROK_BOT_HISTORY = "on"; env.GROK_BOT_ALLOW_LOCAL_GATEWAY = "1"; const calls = []; diff --git a/test/url-policy.test.js b/test/url-policy.test.js index 9644529..a37392c 100644 --- a/test/url-policy.test.js +++ b/test/url-policy.test.js @@ -6,7 +6,10 @@ import { resetPolicyWarnings, } from "../src/core/url-policy.js"; +// Tests run with GROK_BOT_TEST=1 (scripts/run-unit-tests.mjs); 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 }; const prev = {}; for (const key of Object.keys(values)) { prev[key] = process.env[key]; @@ -100,6 +103,19 @@ test("ALLOW_ANY_GATEWAY bypasses host checks", () => { }); }); +test("test mode allows only loopback, for gateway and backend, and ignores escape hatches", () => { + for (const env of [{ GROK_BOT_TEST: "1" }, { NODE_ENV: "test" }]) { + withEnv({ GROK_BOT_ALLOW_ANY_GATEWAY: "1", GROK_BOT_ALLOW_LOCAL_GATEWAY: null, ...env }, () => { + assert.equal(assertAllowedCredentialUrl("http://127.0.0.1:1340/"), "http://127.0.0.1:1340"); + assert.equal(assertAllowedCredentialUrl("http://localhost:1340", { kind: "backend" }), "http://localhost:1340"); + assert.throws(() => assertAllowedCredentialUrl("https://box.cursor.sh"), /test mode/i); + assert.throws(() => assertAllowedCredentialUrl("https://api2.cursor.sh", { kind: "backend" }), /test mode/i); + assert.throws(() => assertAllowedCredentialUrl("https://evil.example"), /test mode/i); + assert.throws(() => assertAllowedCredentialUrl("ws://127.0.0.1:1340"), /test mode/i); + }); + } +}); + test("redacts bearer, basic, cookie, and token-like fields", () => { const out = redactSecrets( 'EnsureSandBox failed: 401 {"gatewayToken":"supersecret","x-anyrun-network-token":"route"} ' + diff --git a/tests/route-unit/tools.test.ts b/tests/route-unit/tools.test.ts index cbeefb6..76042b7 100644 --- a/tests/route-unit/tools.test.ts +++ b/tests/route-unit/tools.test.ts @@ -111,6 +111,8 @@ beforeAll(async () => { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address() as AddressInfo; for (const key of envKeys) savedEnv[key] = process.env[key]; + // rstest.route-unit.config.ts sets this; url-policy then refuses every non-loopback gateway. + expect(process.env.GROK_BOT_TEST).toBe('1'); process.env.GROK_BOT_GATEWAY_URL = `http://127.0.0.1:${port}`; process.env.GROK_BOT_GATEWAY_TOKEN = 'test-token'; process.env.GROK_BOT_ALLOW_LOCAL_GATEWAY = '1'; From 744525e95bbf8b537a4a45c0eac980c9c96b069d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 05:34:56 +0000 Subject: [PATCH 2/2] changesets: CHANGELOG.md is a shipped file --- .changeset/config.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/config.json b/.changeset/config.json index b865dcf..eead04f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,6 +14,7 @@ "agent-bundle.config.ts", "tsconfig.json", "README.md", + "CHANGELOG.md", "LICENSE" ] }