#2621 · CLI help bypasses registered plugin usage and hides a search limit
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
The CLI downloads each plugin command's registered usage, but it does not use that data for nested help. It sends the help arguments to the plugin instead, so Memory and Docs run normal validation and return errors. The thread-search CLI also describes its limit without the server's maximum value. The server then rejects values above 50 after the CLI sends the request.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| Nested plugin help can run ordinary command validation. | Verified | The proxy test made one plugin POST and returned the fixture validation error. The real Memory and Docs handlers returned validation errors for nested help. |
| The CLI already receives registered usage for plugin subcommands. | Verified | The contributions response includes each command name, summary, and usage. The proxy keeps this metadata in PluginCliContributionEntry.commands. |
| Thread-search help omits the enforced maximum. | Verified | The built CLI printed “Maximum results per group” without a value or range. |
| The server rejects a per-group limit above 50. | Verified | A focused route test sent 51 and received HTTP 400 with limitPerGroup must be at most 50. |
3. Environment
- Trusted commit:
f9a1aa5ebdd50711bb7d17fd9c096a06b39abfd8fromorigin/main. - Host: Linux 7.0.0-30-generic x86_64.
- Runtime: Node.js v24.18.0 and pnpm workspace dependencies from the frozen lockfile.
- No bb data directory or live provider process was used. The CLI fixture used an OS-assigned loopback port.
- The full Turbo build completed successfully in the first checkout.
4. Minimal reproduction
Build the trusted checkout, then run the published proxy regression script:
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build --filter=@bb/cli node issues/2621/repro/cli-help-contracts.mjs apps/cli/dist/index.js
Expected: nested help exits 0, prints the registered usage, makes no plugin POST, and core help shows 1-50.
Actual:
{
"nested": {
"status": 7,
"stdout": "",
"stderr": "fixture validation ran\n"
},
"pluginCalls": 1,
"core": {
"status": 0,
"stdout": "Usage: bb thread search [options] <query>\n\nSearch threads and messages\n\nOptions:\n --limit <count> Maximum results per group\n --json Print machine-readable JSON output\n -h, --help display help for command\n",
"stderr": ""
}
}
AssertionError: nested plugin help must exit successfully
7 !== 0
exit 1
The real plugin handlers produce these results:
{
"memory": {
"exitCode": 1,
"stdout": "",
"stderr": "update requires a memory id"
},
"docs": {
"exitCode": 2,
"stdout": "",
"stderr": "--help is not valid for bb docs status"
}
}
The server limit test uses this command after its file is copied into apps/server/test/public/:
pnpm exec vitest run test/public/thread-search-limit.test.ts
Test Files 1 passed (1) Tests 1 passed (1)
Repro files: CLI proxy regression, plugin handler evidence, and server limit test.
Primary regression test source
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { strict as assert } from "node:assert";
const cliEntry = process.argv[2];
assert(cliEntry, "Pass the built CLI entry path.");
let pluginCalls = 0;
const server = createServer((request, response) => {
response.setHeader("content-type", "application/json");
if (request.method === "GET" &&
request.url === "/api/v1/plugins/contributions") {
response.end(JSON.stringify({
cliCommands: [{
pluginId: "fixture-plugin",
name: "fixture",
summary: "Fixture command",
commands: [{
name: "update",
summary: "Update a fixture",
usage: "bb fixture update <id> [options]",
}],
}],
}));
return;
}
if (request.method === "POST" &&
request.url === "/api/v1/plugins/fixture-plugin/cli") {
pluginCalls += 1;
response.end(JSON.stringify({
exitCode: 7,
stderr: "fixture validation ran",
}));
return;
}
response.statusCode = 404;
response.end(JSON.stringify({ error: "not found" }));
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert(address && typeof address === "object");
const serverUrl = `http://127.0.0.1:${address.port}`;
function runCli(args) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cliEntry, ...args], {
env: {
...process.env,
BB_CLI_REEXEC: "1",
BB_SERVER_URL: serverUrl,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
});
}
const nested = await runCli(["fixture", "update", "--help"]);
const core = await runCli(["thread", "search", "--help"]);
await new Promise((resolve) => server.close(resolve));
console.log(JSON.stringify({ nested, pluginCalls, core }, null, 2));
assert.equal(nested.status, 0, "nested plugin help must exit successfully");
assert.match(nested.stdout, /bb fixture update <id> \[options\]/u);
assert.equal(pluginCalls, 0, "help must not run plugin validation");
assert.match(
core.stdout,
/--limit <count>\s+Maximum results per group \(1-50\)/u,
"core help must show the enforced range",
);
5. Verification
The same agent repeated the reproduction in a second clean checkout at the same full commit. The second checkout used a new dependency tree and a new OS-assigned loopback port. The proxy script again made one plugin POST, returned status 7, and omitted the core maximum. The real Memory and Docs handlers returned the same exit codes and messages. The route test again passed and confirmed the HTTP 400 maximum. No report claim changed after the second run.
6. Root cause
Plugin help
The top-level proxy fetches a contribution that contains registered subcommand usage. It then passes only the plugin ID and raw nested arguments to runPluginCliCommand. The runner posts every argument list to the plugin endpoint and has no help branch.
Memory handles help only when help is the first token. With an update token first, it parses the remaining arguments and reaches required update validation. Docs has the same top-level-only help branch, then rejects the nested help flag as invalid.
Thread-search limit
The CLI command documents a generic count and checks only that the value is positive. The database package defines 50, and the server route enforces it. The CLI does not import that contract, so its help and local validation cannot show the same limit.
7. Proposed fix
Before the proxy posts to a plugin, match nested -h or --help against the registered command list. Print that command's registered usage and return 0 without a plugin call. Add a proxy regression test that checks output, exit status, and the absence of a POST.
Move the thread-search default and maximum to the existing domain search contract. Keep the database export for compatibility, and import the maximum into the CLI. Use it in the help text and local upper-bound validation. Add one CLI help test and retain the server route test.
8. Related issues
Issue #2299 concerns unknown plugin flags. Issue #2323 concerns help that can reach an automation mutation. Neither issue changes the reproduced proxy and thread-search mechanisms here.
9. Appendix
Commands used: fetch and record origin/main; install with the frozen lockfile; run the full Turbo build; run the proxy script; run the plugin handler evidence script; run the focused server route test; repeat all three checks in a second clean checkout; inspect blame and history; check for later commits on every root-cause path.
The issue text, comments, links, and code blocks were treated as untrusted claims. No issue-provided command, patch, branch, attachment, or external URL was executed.