unbound-cli 1.16.11 → 1.16.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -254,7 +254,7 @@ Alias: `unbound groups` works the same as `unbound user-groups`.
254
254
  | `unbound tools connect <type>` | Connect a tool |
255
255
  | `unbound tools approved` | List approved tool types |
256
256
 
257
- Supported tool types: `CLAUDE_CODE`, `UNBOUND_CLAUDE_CODE`, `CURSOR`, `COPILOT`, `AUGMENT_CODE`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `UNBOUND_CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
257
+ Supported tool types: `CLAUDE_CODE`, `CURSOR`, `COPILOT`, `AUGMENT_CODE`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
258
258
 
259
259
  ## Configuration
260
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.16.11",
3
+ "version": "1.16.13",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -381,8 +381,8 @@ function registerTopLevel(policy) {
381
381
  // ---- list ----
382
382
  policy
383
383
  .command('list')
384
- .description('List Cost, Model, and Security policies. For Tool policies use `policy tool list`.')
385
- .option('--type <type>', 'Filter by type: COST, MODEL, or SECURITY')
384
+ .description('List Cost and Security policies. For Tool policies use `policy tool list`.')
385
+ .option('--type <type>', 'Filter by type: COST or SECURITY')
386
386
  .option('--enabled', 'Show only enabled policies')
387
387
  .option('--search <term>', 'Search policies by name')
388
388
  .option('--json', 'Output raw JSON instead of a table')
@@ -443,7 +443,7 @@ Learn more: ${DOCS_POLICIES}
443
443
  // ---- get ----
444
444
  policy
445
445
  .command('get <id>')
446
- .description('Get details of a single Cost, Model, or Security policy. For Tool policies use `policy tool get`.')
446
+ .description('Get details of a single Cost or Security policy. For Tool policies use `policy tool get`.')
447
447
  .option('--json', 'Output raw JSON')
448
448
  .addHelpText('after', `
449
449
  Examples:
@@ -468,7 +468,7 @@ Examples:
468
468
  // ---- delete ----
469
469
  policy
470
470
  .command('delete <id>')
471
- .description('Delete a Cost, Model, or Security policy. For Tool policies use `policy tool delete`.')
471
+ .description('Delete a Cost or Security policy. For Tool policies use `policy tool delete`.')
472
472
  .option('--yes', 'Skip confirmation prompt')
473
473
  .addHelpText('after', `
474
474
  Examples:
@@ -881,8 +881,10 @@ Learn more: ${DOCS_COST}
881
881
  // ============================================================================
882
882
 
883
883
  function registerModel(policy) {
884
+ // Model policies only take effect on the paused LLM-gateway path, so they are hidden
885
+ // from help. The commands stay functional so existing scripts keep working.
884
886
  const model = policy
885
- .command('model')
887
+ .command('model', { hidden: true })
886
888
  .description('Manage Model policies. Control which AI models are available to a user group.')
887
889
  .addHelpText('after', `
888
890
  Model policies restrict which AI models users can invoke. Choose one of:
@@ -899,7 +901,7 @@ Subcommands:
899
901
  `);
900
902
 
901
903
  model
902
- .command('list')
904
+ .command('list', { hidden: true })
903
905
  .description('List all Model policies.')
904
906
  .option('--json', 'Output raw JSON')
905
907
  .action(async (opts) => {
@@ -930,7 +932,7 @@ Subcommands:
930
932
  });
931
933
 
932
934
  model
933
- .command('create')
935
+ .command('create', { hidden: true })
934
936
  .description('Create a new Model policy.')
935
937
  .requiredOption('--name <name>', 'Policy name (required)')
936
938
  .option('--all-models', 'Allow all models (use with --excluded for exceptions)')
@@ -1018,7 +1020,7 @@ Learn more: ${DOCS_MODEL}
1018
1020
  });
1019
1021
 
1020
1022
  model
1021
- .command('update <id>')
1023
+ .command('update <id>', { hidden: true })
1022
1024
  .description('Update an existing Model policy. Only provided fields are changed.')
1023
1025
  .option('--name <name>', 'New name')
1024
1026
  .option('--all-models', 'Replace config with "allow all"')
@@ -1319,7 +1321,7 @@ function registerTool(policy) {
1319
1321
  .command('tool')
1320
1322
  .description('Manage Tool policies: controls for shell commands (TERMINAL_COMMAND) and MCP tool calls (MCP_TOOL).')
1321
1323
  .addHelpText('after', `
1322
- Tool policies live in a separate backend table from Cost/Model/Security
1324
+ Tool policies live in a separate backend table from Cost/Security
1323
1325
  policies and are reached via /api/v1/command-policies/.
1324
1326
 
1325
1327
  Learn more: ${DOCS_TOOL}
@@ -1972,9 +1974,9 @@ function registerLegacy(policy) {
1972
1974
 
1973
1975
  policy
1974
1976
  .command('create')
1975
- .description('Create a Cost/Model/Security policy. Prefer `policy cost|model|security create` for type-specific flags.')
1977
+ .description('Create a Cost/Security policy. Prefer `policy cost|security create` for type-specific flags.')
1976
1978
  .requiredOption('--name <name>', 'Policy name (required)')
1977
- .requiredOption('--type <type>', 'Policy type: COST, MODEL, or SECURITY (required)')
1979
+ .requiredOption('--type <type>', 'Policy type: COST or SECURITY (required)')
1978
1980
  .requiredOption('--config <json>', 'Policy configuration as a JSON string')
1979
1981
  .option('--enabled', 'Enable the policy (default: true)', true)
1980
1982
  .option('--no-enabled', 'Create the policy in disabled state')
@@ -1984,7 +1986,6 @@ function registerLegacy(policy) {
1984
1986
  .addHelpText('after', `
1985
1987
  Advanced / power-user escape hatch. For guided flag-based creation prefer:
1986
1988
  $ unbound policy cost create --help
1987
- $ unbound policy model create --help
1988
1989
  $ unbound policy security create --help
1989
1990
  $ unbound policy tool create-terminal --help (TOOL policies go through this path)
1990
1991
  $ unbound policy tool create-mcp --help
@@ -2030,7 +2031,7 @@ Learn more: ${DOCS_POLICIES}
2030
2031
 
2031
2032
  policy
2032
2033
  .command('update <id>')
2033
- .description('Update a Cost/Model/Security policy via raw config. Prefer `policy cost|model|security update`.')
2034
+ .description('Update a Cost/Security policy via raw config. Prefer `policy cost|security update`.')
2034
2035
  .option('--name <name>', 'New name')
2035
2036
  .option('--config <json>', 'Replace config with raw JSON')
2036
2037
  .option('--enabled', 'Enable')
@@ -2077,18 +2078,17 @@ Learn more: ${DOCS_POLICIES}
2077
2078
  function register(program) {
2078
2079
  const policy = program
2079
2080
  .command('policy')
2080
- .description('Manage policies. Four types: cost, model, security, tool.')
2081
+ .description('Manage policies. Three types: cost, security, tool.')
2081
2082
  .addHelpText('after', `
2082
- Unbound has four policy types. Each has its own subcommand for guided
2083
+ Unbound has three policy types. Each has its own subcommand for guided
2083
2084
  flag-based create/update. Tool policies live in a separate backend table
2084
2085
  and are reached via \`unbound policy tool\`.
2085
2086
 
2086
2087
  cost Monthly budget limits per user group. ${DOCS_COST}
2087
- model Control which AI models are available. ${DOCS_MODEL}
2088
2088
  security Guardrails (PII, secrets), routing rules. ${DOCS_SECURITY}
2089
2089
  tool Shell command and MCP tool controls. ${DOCS_TOOL}
2090
2090
 
2091
- Generic commands (Cost/Model/Security only):
2091
+ Generic commands (Cost/Security only):
2092
2092
  list List policies, optionally filtered by type
2093
2093
  get <id> Get policy details
2094
2094
  delete <id> Delete a policy
@@ -2098,7 +2098,6 @@ Generic commands (Cost/Model/Security only):
2098
2098
  Examples:
2099
2099
  $ unbound policy form-data # see available names
2100
2100
  $ unbound policy cost create --name "Eng Budget" --monthly-budget 1000 --group engg
2101
- $ unbound policy model create --name "No Opus" --all-models --excluded claude-3-opus
2102
2101
  $ unbound policy security create --name "Block PII" --sub-type guardrails --guardrail PII:BLOCK
2103
2102
  $ unbound policy tool create-terminal --name "Block rm -rf" --command-family filesystem \\
2104
2103
  --field command='rm -rf*' --action BLOCK --custom-message "Blocked by policy"
@@ -12,7 +12,6 @@ const SUPPORTED_TOOL_TYPES = [
12
12
  'CLINE',
13
13
  'GEMINI_CLI',
14
14
  'CODEX',
15
- 'UNBOUND_CODEX',
16
15
  'KILO_CODE',
17
16
  'CUSTOM_ACCESS',
18
17
  ];
@@ -181,4 +180,4 @@ Examples:
181
180
  });
182
181
  }
183
182
 
184
- module.exports = { register };
183
+ module.exports = { register, SUPPORTED_TOOL_TYPES };
package/src/index.js CHANGED
@@ -113,12 +113,12 @@ MDM AI TOOLS DISCOVERY
113
113
  $ unbound discover status Show scan schedule and logs
114
114
 
115
115
  POLICY MANAGEMENT
116
- Unbound has four policy types. Each type has its own subcommand with guided flags.
116
+ Unbound has three policy types. Each type has its own subcommand with guided flags.
117
117
  Docs: https://docs.getunbound.ai/policies
118
118
 
119
119
  $ unbound policy Overview and subcommand list
120
120
  $ unbound policy form-data Reference data (user groups, models, guardrails, etc.)
121
- $ unbound policy list List Cost/Model/Security policies
121
+ $ unbound policy list List Cost/Security policies
122
122
  $ unbound policy get <id> View a policy's details
123
123
  $ unbound policy delete <id> Delete a policy
124
124
  $ unbound policy effective <id> View effective policies for a user or group
@@ -126,9 +126,6 @@ POLICY MANAGEMENT
126
126
  Cost policies — monthly budget limits per user group:
127
127
  $ unbound policy cost create --name "Eng Budget" --monthly-budget 1000 --group engg
128
128
 
129
- Model policies — control which AI models are available:
130
- $ unbound policy model create --name "No Opus" --all-models --excluded claude-3-opus
131
-
132
129
  Security policies — guardrails for PII/secrets and routing rules:
133
130
  $ unbound policy security create --name "Block PII" --sub-type guardrails --guardrail PII:BLOCK
134
131
 
@@ -0,0 +1,132 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const path = require('path');
4
+ const { spawnSync } = require('child_process');
5
+ const { Command } = require('commander');
6
+ const config = require('../src/config');
7
+ const api = require('../src/api');
8
+ const output = require('../src/output');
9
+ const { register } = require('../src/commands/policy');
10
+
11
+ // Model policies only take effect on the paused LLM-gateway path, so help must
12
+ // advertise three policy types (cost, security, tool) and must not mention or
13
+ // offer model policies. The `policy model` command family stays functional
14
+ // (hidden, not removed) so existing scripts don't break.
15
+
16
+ function policyCommand() {
17
+ const program = new Command();
18
+ register(program);
19
+ return program.commands.find((c) => c.name() === 'policy');
20
+ }
21
+
22
+ function fullHelp(cmd) {
23
+ let buf = '';
24
+ cmd.configureOutput({ writeOut: (s) => { buf += s; } });
25
+ cmd.outputHelp();
26
+ return buf;
27
+ }
28
+
29
+ // Render real CLI help by running the binary, so the top-level help in
30
+ // src/index.js is exercised, not just the separately registered policy command.
31
+ const CLI = path.join(__dirname, '..', 'src', 'index.js');
32
+ function cliHelp(args) {
33
+ const r = spawnSync('node', [CLI, ...args], { encoding: 'utf8' });
34
+ assert.equal(r.status, 0, `\`${args.join(' ')}\` should exit 0\n${r.stderr}`);
35
+ return r.stdout;
36
+ }
37
+
38
+ test('policy help advertises three policy types, not four', () => {
39
+ const help = fullHelp(policyCommand());
40
+ assert.ok(help.includes('three policy types'), 'help should say "three policy types"');
41
+ assert.ok(!help.includes('four policy types'), 'help must not say "four policy types"');
42
+ });
43
+
44
+ test('policy help no longer advertises model policies', () => {
45
+ const help = fullHelp(policyCommand());
46
+ assert.ok(!help.includes('Control which AI models are available'), 'model type row must be gone');
47
+ assert.ok(!help.includes('policy model create'), 'model example must be gone');
48
+ });
49
+
50
+ test('policy help does not list model as a subcommand', () => {
51
+ const help = fullHelp(policyCommand());
52
+ assert.ok(!/\n\s*model\s/.test(help), 'model must be hidden from the subcommand list');
53
+ });
54
+
55
+ // The generic list/get/delete/create/update commands share the `policy --help`
56
+ // screen, so their descriptions must not advertise model either.
57
+ test('policy generic command descriptions drop Model', () => {
58
+ const help = fullHelp(policyCommand());
59
+ assert.ok(!help.includes('Cost, Model'), 'generic descriptions must not say "Cost, Model"');
60
+ assert.ok(!help.includes('Cost/Model'), 'generic descriptions must not say "Cost/Model"');
61
+ });
62
+
63
+ test('top-level `unbound --help` advertises three policy types, not four', () => {
64
+ const help = cliHelp(['--help']);
65
+ assert.ok(help.includes('three policy types'), 'top-level help should say "three policy types"');
66
+ assert.ok(!help.includes('four policy types'), 'top-level help must not say "four policy types"');
67
+ assert.ok(!help.includes('policy model create'), 'top-level help must not show a model example');
68
+ });
69
+
70
+ test('rendered `policy --help` names three types and drops Model', () => {
71
+ const help = cliHelp(['policy', '--help']);
72
+ assert.ok(help.includes('Three types'), 'policy help should say "Three types"');
73
+ assert.ok(!help.includes('Four types'), 'policy help must not say "Four types"');
74
+ assert.ok(!help.includes('Cost, Model') && !help.includes('Cost/Model'), 'no Model in generic descriptions');
75
+ });
76
+
77
+ test('policy model command family remains registered and hidden', () => {
78
+ const policy = policyCommand();
79
+ const model = policy.commands.find((c) => c.name() === 'model');
80
+ assert.ok(model, 'model command must still exist');
81
+ assert.equal(model._hidden, true, 'model command must be hidden');
82
+ for (const sub of ['list', 'create', 'update']) {
83
+ const m = model.commands.find((c) => c.name() === sub);
84
+ assert.ok(m, `model ${sub} must still exist`);
85
+ assert.equal(m._hidden, true, `model ${sub} must be hidden`);
86
+ }
87
+ });
88
+
89
+ // Hidden must not mean broken: a hidden model subcommand still parses and runs
90
+ // its action against the backend, so existing scripts keep working.
91
+ test('hidden `policy model list` still executes and calls the backend', async () => {
92
+ const orig = { isLoggedIn: config.isLoggedIn, get: api.get, info: output.info, table: output.table, error: output.error, json: output.json, exitCode: process.exitCode };
93
+ let reachedApi = false;
94
+ let sentType = null;
95
+ config.isLoggedIn = () => true;
96
+ api.get = async (_path, { query } = {}) => { reachedApi = true; sentType = query && query.type; return { policies: [] }; };
97
+ output.info = () => {};
98
+ output.table = () => {};
99
+ output.error = () => {};
100
+ output.json = () => {};
101
+ try {
102
+ const program = new Command();
103
+ register(program);
104
+ await program.parseAsync(['node', 'x', 'policy', 'model', 'list']);
105
+ assert.equal(reachedApi, true, 'hidden model list must reach the backend');
106
+ assert.equal(sentType, 'MODEL', 'model list must query type MODEL');
107
+ } finally {
108
+ config.isLoggedIn = orig.isLoggedIn;
109
+ api.get = orig.get;
110
+ output.info = orig.info;
111
+ output.table = orig.table;
112
+ output.error = orig.error;
113
+ output.json = orig.json;
114
+ process.exitCode = orig.exitCode;
115
+ }
116
+ });
117
+
118
+ // The legacy `policy create --help` escape-hatch list is a visible help surface;
119
+ // it must not steer a user to `policy model create`, which never applies on the
120
+ // hooks path — the exact failure this fix is about.
121
+ test('`policy create --help` escape hatch no longer points at model policies', () => {
122
+ const help = cliHelp(['policy', 'create', '--help']);
123
+ assert.ok(!help.includes('policy model create'), 'escape hatch must not suggest `policy model create`');
124
+ });
125
+
126
+ // `policy tool --help` overview names the other policy types; it must say
127
+ // Cost/Security, not Cost/Model/Security.
128
+ test('`policy tool --help` names Cost/Security, not Model', () => {
129
+ const help = cliHelp(['policy', 'tool', '--help']);
130
+ assert.ok(!help.includes('Cost/Model/Security'), 'tool help must not say "Cost/Model/Security"');
131
+ assert.ok(help.includes('Cost/Security'), 'tool help should say "Cost/Security"');
132
+ });
@@ -0,0 +1,42 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+ const config = require('../src/config');
5
+ const api = require('../src/api');
6
+ const output = require('../src/output');
7
+ const { register } = require('../src/commands/policy');
8
+
9
+ // Backward-compat: `policy model ...` is hidden from help and MODEL is dropped
10
+ // from the advertised generic types, but the machinery must still accept MODEL.
11
+ // An existing script using the legacy raw `policy create --type MODEL` must keep
12
+ // working — de-advertising a type must not disable it.
13
+
14
+ function stub() {
15
+ const orig = {
16
+ isLoggedIn: config.isLoggedIn, post: api.post,
17
+ success: output.success, error: output.error, json: output.json, keyValue: output.keyValue,
18
+ exitCode: process.exitCode,
19
+ };
20
+ const seen = { reachedApi: false, sentType: null, errored: false };
21
+ config.isLoggedIn = () => true;
22
+ api.post = async (_p, { body }) => { seen.reachedApi = true; seen.sentType = body.type; return { id: 'p_1', ...body }; };
23
+ output.success = () => {};
24
+ output.error = () => { seen.errored = true; };
25
+ output.json = () => {};
26
+ output.keyValue = () => {};
27
+ return { seen, restore() { Object.assign(config, { isLoggedIn: orig.isLoggedIn }); api.post = orig.post; output.success = orig.success; output.error = orig.error; output.json = orig.json; output.keyValue = orig.keyValue; process.exitCode = orig.exitCode; } };
28
+ }
29
+
30
+ test('legacy `policy create --type MODEL` still reaches the backend with type MODEL', async () => {
31
+ const { seen, restore } = stub();
32
+ try {
33
+ const program = new Command();
34
+ register(program);
35
+ await program.parseAsync(['node', 'x', 'policy', 'create', '--name', 'No Opus', '--type', 'MODEL', '--config', '{"all_models":true}']);
36
+ assert.equal(seen.reachedApi, true, 'MODEL create must still hit the policies endpoint');
37
+ assert.equal(seen.sentType, 'MODEL', 'the type sent must remain MODEL');
38
+ assert.equal(seen.errored, false, 'MODEL must not be rejected as an invalid type');
39
+ } finally {
40
+ restore();
41
+ }
42
+ });
@@ -0,0 +1,55 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { SUPPORTED_TOOL_TYPES } = require('../src/commands/tools');
6
+
7
+ // Cross-repo contract: every tool type the CLI offers must be one the backend's
8
+ // agents API accepts, or `tools connect` sends a request the backend answers
9
+ // with 400. Rather than mirror the backend list by hand (which silently drifts),
10
+ // this reads the REAL allowlist from ai-gateway-data at
11
+ // webapp/api/v1/agent_handlers.py::SUPPORTED_AGENT_TOOL_TYPES.
12
+ //
13
+ // The backend repo isn't a dependency of the CLI, so when it isn't checked out
14
+ // beside this one the test skips rather than failing — it runs wherever the
15
+ // fleet is present (a dev machine, the cross-repo CI stage) and is a no-op
16
+ // elsewhere. Point it explicitly with AI_GATEWAY_DATA_DIR.
17
+
18
+ const HANDLERS_REL = 'webapp/api/v1/agent_handlers.py';
19
+
20
+ function findBackendFile() {
21
+ const candidates = [];
22
+ if (process.env.AI_GATEWAY_DATA_DIR) {
23
+ candidates.push(path.join(process.env.AI_GATEWAY_DATA_DIR, HANDLERS_REL));
24
+ }
25
+ // Common layouts: a sibling checkout next to unbound-cli.
26
+ const repoRoot = path.join(__dirname, '..');
27
+ candidates.push(path.join(repoRoot, '..', 'ai-gateway-data', HANDLERS_REL));
28
+ candidates.push(path.join(repoRoot, '..', '..', 'ai-gateway-data', HANDLERS_REL));
29
+ return candidates.find((p) => { try { return fs.existsSync(p); } catch { return false; } });
30
+ }
31
+
32
+ function parseBackendAllowlist(file) {
33
+ const src = fs.readFileSync(file, 'utf8');
34
+ const m = src.match(/SUPPORTED_AGENT_TOOL_TYPES\s*=\s*\[([\s\S]*?)\]/);
35
+ assert.ok(m, 'could not locate SUPPORTED_AGENT_TOOL_TYPES in agent_handlers.py');
36
+ const types = [...m[1].matchAll(/['"]([A-Z_]+)['"]/g)].map((x) => x[1]);
37
+ assert.ok(types.length > 0, 'parsed an empty backend allowlist');
38
+ return new Set(types);
39
+ }
40
+
41
+ const backendFile = findBackendFile();
42
+
43
+ test('every CLI-offered tool type is in the REAL backend agent_handlers allowlist',
44
+ { skip: backendFile ? false : 'ai-gateway-data not checked out (set AI_GATEWAY_DATA_DIR)' },
45
+ () => {
46
+ const accepted = parseBackendAllowlist(backendFile);
47
+ for (const type of SUPPORTED_TOOL_TYPES) {
48
+ assert.ok(accepted.has(type),
49
+ `CLI offers ${type} but the backend allowlist does not accept it (would 400)`);
50
+ }
51
+ // Guard the specific regression: the removed type must be absent from the CLI
52
+ // list precisely because the backend rejects it.
53
+ assert.ok(!SUPPORTED_TOOL_TYPES.includes('UNBOUND_CODEX') || accepted.has('UNBOUND_CODEX'),
54
+ 'UNBOUND_CODEX may only be offered if the backend accepts it');
55
+ });
@@ -0,0 +1,85 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ const config = require('../src/config');
6
+ const api = require('../src/api');
7
+ const output = require('../src/output');
8
+ const { register, SUPPORTED_TOOL_TYPES } = require('../src/commands/tools');
9
+
10
+ // Mirror of ai-gateway-data webapp/api/v1/agent_handlers.py SUPPORTED_AGENT_TOOL_TYPES.
11
+ // `tools connect` must only offer types this backend accepts; anything else gets a 400.
12
+ const BACKEND_ACCEPTED = new Set([
13
+ 'CLAUDE_CODE', 'UNBOUND_CLAUDE_CODE', 'CURSOR', 'COPILOT',
14
+ 'ROO_CODE', 'CLINE', 'GEMINI_CLI', 'CODEX', 'KILO_CODE', 'OPENCLAW',
15
+ 'CUSTOM_ACCESS', 'AUGMENT_CODE',
16
+ ]);
17
+
18
+ // Drive the real `tools connect` action with login + api stubbed. Returns
19
+ // { reachedApi, sentType, errored } so a test can tell a type accepted by the
20
+ // CLI (reaches the connect call) from one the CLI rejects before any request.
21
+ async function connect(toolType) {
22
+ const orig = {
23
+ isLoggedIn: config.isLoggedIn,
24
+ post: api.post,
25
+ error: output.error,
26
+ success: output.success,
27
+ keyValue: output.keyValue,
28
+ json: output.json,
29
+ exitCode: process.exitCode,
30
+ };
31
+ let reachedApi = false;
32
+ let sentType = null;
33
+ let errored = false;
34
+ let errorMsg = '';
35
+ config.isLoggedIn = () => true;
36
+ api.post = async (_path, { body }) => { reachedApi = true; sentType = body.tool_type; return { tool_type: body.tool_type, status: 'connected' }; };
37
+ output.error = (m) => { errored = true; errorMsg = String(m ?? ''); };
38
+ output.success = () => {};
39
+ output.keyValue = () => {};
40
+ output.json = () => {};
41
+ try {
42
+ const program = new Command();
43
+ register(program);
44
+ await program.parseAsync(['node', 'x', 'tools', 'connect', toolType]);
45
+ return { reachedApi, sentType, errored, errorMsg, exitCode: process.exitCode };
46
+ } finally {
47
+ config.isLoggedIn = orig.isLoggedIn;
48
+ api.post = orig.post;
49
+ output.error = orig.error;
50
+ output.success = orig.success;
51
+ output.keyValue = orig.keyValue;
52
+ output.json = orig.json;
53
+ process.exitCode = orig.exitCode;
54
+ }
55
+ }
56
+
57
+ // The backend rejects UNBOUND_CODEX with a 400, so the CLI must not offer it.
58
+ test('tools connect UNBOUND_CODEX is rejected before any backend call', async () => {
59
+ const r = await connect('UNBOUND_CODEX');
60
+ assert.equal(r.reachedApi, false, 'must not send an UNBOUND_CODEX connect request');
61
+ assert.equal(r.errored, true, 'must report an unsupported-type error');
62
+ assert.equal(r.exitCode, 1, 'must exit non-zero so scripts see the rejection');
63
+ // The error points the user at the real types: it names the supported set,
64
+ // including the correct CODEX to reach for instead.
65
+ assert.match(r.errorMsg, /Supported types:/);
66
+ assert.match(r.errorMsg, /\bCODEX\b/);
67
+ });
68
+
69
+ // A representative valid type still connects.
70
+ test('tools connect CODEX reaches the backend with tool_type CODEX', async () => {
71
+ const r = await connect('CODEX');
72
+ assert.equal(r.reachedApi, true);
73
+ assert.equal(r.sentType, 'CODEX');
74
+ });
75
+
76
+ // Drive the actual production allowlist: every type the CLI offers must reach the
77
+ // backend and be one the backend accepts. Adding a backend-rejected type to
78
+ // SUPPORTED_TOOL_TYPES would fail here rather than recur as a runtime 400.
79
+ test('every CLI-offered tool type is accepted by the backend', async () => {
80
+ for (const type of SUPPORTED_TOOL_TYPES) {
81
+ const r = await connect(type);
82
+ assert.equal(r.reachedApi, true, `CLI should offer ${type}`);
83
+ assert.ok(BACKEND_ACCEPTED.has(r.sentType), `CLI offers ${r.sentType} which the backend rejects`);
84
+ }
85
+ });