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 +1 -1
- package/package.json +1 -1
- package/src/commands/policy.js +17 -18
- package/src/commands/tools.js +1 -2
- package/src/index.js +2 -5
- package/test/policy-help-model.test.js +132 -0
- package/test/policy-model-backcompat.test.js +42 -0
- package/test/tools-backend-contract.test.js +55 -0
- package/test/tools-supported-types.test.js +85 -0
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`, `
|
|
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
package/src/commands/policy.js
CHANGED
|
@@ -381,8 +381,8 @@ function registerTopLevel(policy) {
|
|
|
381
381
|
// ---- list ----
|
|
382
382
|
policy
|
|
383
383
|
.command('list')
|
|
384
|
-
.description('List Cost
|
|
385
|
-
.option('--type <type>', 'Filter by type: COST
|
|
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
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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/
|
|
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.
|
|
2081
|
+
.description('Manage policies. Three types: cost, security, tool.')
|
|
2081
2082
|
.addHelpText('after', `
|
|
2082
|
-
Unbound has
|
|
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/
|
|
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"
|
package/src/commands/tools.js
CHANGED
|
@@ -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
|
|
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/
|
|
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
|
+
});
|