Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,14 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [],
"changedFilePatterns": [
"src/**",
"package.json",
"agent-bundle.config.ts",
"tsconfig.json",
"README.md",
"CHANGELOG.md",
"LICENSE"
]
}
5 changes: 5 additions & 0 deletions .changeset/test-mode-loopback-only.md
Original file line number Diff line number Diff line change
@@ -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`.
54 changes: 54 additions & 0 deletions .github/workflows/changeset.yml
Original file line number Diff line number Diff line change
@@ -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/<slug>.md). For a genuinely no-op change, apply the 'skip-changeset' label."
exit 1
fi
21 changes: 21 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions rstest.route-unit.config.ts
Original file line number Diff line number Diff line change
@@ -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());
2 changes: 2 additions & 0 deletions scripts/run-unit-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
16 changes: 16 additions & 0 deletions src/core/url-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions test/connect-gateway.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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,
Expand All @@ -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/);
Expand Down
2 changes: 1 addition & 1 deletion test/gateway-groups.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
11 changes: 10 additions & 1 deletion test/gateway-send.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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");
Expand Down
1 change: 1 addition & 0 deletions test/history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
16 changes: 16 additions & 0 deletions test/url-policy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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"} ' +
Expand Down
2 changes: 2 additions & 0 deletions tests/route-unit/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ beforeAll(async () => {
await new Promise<void>((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';
Expand Down