unbound-cli 1.20.0 → 1.20.2
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/package.json +1 -1
- package/src/auth.js +22 -3
- package/src/lib/pack-export.js +2 -0
- package/src/lib/pack-plan.js +24 -2
- package/test/auth-server-leak.test.js +11 -5
- package/test/login-scope-callback.test.js +135 -3
- package/test/pack-export.test.js +9 -0
- package/test/pack-plan.test.js +17 -0
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Shared browser-based authentication flow.
|
|
3
3
|
* Used by both `login` and `setup` commands.
|
|
4
4
|
*/
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const http = require('http');
|
|
6
7
|
const os = require('os');
|
|
7
8
|
const { URL } = require('url');
|
|
@@ -35,6 +36,8 @@ function warnIfNotStored(stored) {
|
|
|
35
36
|
*/
|
|
36
37
|
async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {}) {
|
|
37
38
|
const server = http.createServer();
|
|
39
|
+
// One-time nonce: only the redirect the dashboard was handed can complete this login.
|
|
40
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
38
41
|
let callbackResolve;
|
|
39
42
|
let callbackReject;
|
|
40
43
|
|
|
@@ -73,7 +76,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
|
|
|
73
76
|
function respondAndShutdown(res, statusCode, html) {
|
|
74
77
|
res.on('finish', shutdownServer);
|
|
75
78
|
res.on('close', shutdownServer);
|
|
76
|
-
res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
|
|
79
|
+
res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Connection': 'close' });
|
|
77
80
|
res.end(html);
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -84,6 +87,14 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
|
|
|
84
87
|
let callbackHandled = false;
|
|
85
88
|
let stored;
|
|
86
89
|
|
|
90
|
+
// Compare byte lengths, not string lengths: a multi-byte candidate would make timingSafeEqual throw.
|
|
91
|
+
function stateMatches(candidate) {
|
|
92
|
+
if (typeof candidate !== 'string') return false;
|
|
93
|
+
const given = Buffer.from(candidate);
|
|
94
|
+
const expected = Buffer.from(state);
|
|
95
|
+
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
server.on('request', (req, res) => {
|
|
88
99
|
const url = new URL(req.url, `http://localhost`);
|
|
89
100
|
|
|
@@ -93,6 +104,13 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
|
|
|
93
104
|
return;
|
|
94
105
|
}
|
|
95
106
|
|
|
107
|
+
// A request without this login's nonce is not the dashboard's redirect: answer it and keep waiting.
|
|
108
|
+
if (!stateMatches(url.searchParams.get('state'))) {
|
|
109
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Connection': 'close' });
|
|
110
|
+
res.end(authPages.failurePage('Invalid login callback'));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
if (callbackHandled) {
|
|
97
115
|
res.writeHead(409, { 'Connection': 'close' });
|
|
98
116
|
res.end('Callback already processed');
|
|
@@ -103,7 +121,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
|
|
|
103
121
|
const apiKey = url.searchParams.get('api_key');
|
|
104
122
|
const email = url.searchParams.get('email');
|
|
105
123
|
const orgName = url.searchParams.get('org');
|
|
106
|
-
//
|
|
124
|
+
// Nonce-gated, but the reason is still stripped of control characters before display as defence in depth.
|
|
107
125
|
const reason = (url.searchParams.get('error') || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200);
|
|
108
126
|
// `scope` echoes what the dashboard minted; an older dashboard sends none.
|
|
109
127
|
const grantedScope = url.searchParams.get('scope');
|
|
@@ -130,7 +148,8 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
|
|
|
130
148
|
});
|
|
131
149
|
|
|
132
150
|
const port = server.address().port;
|
|
133
|
-
|
|
151
|
+
// 127.0.0.1 literal: the server binds only IPv4, so a `localhost` redirect could land on a squatter at [::1].
|
|
152
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?state=${state}`;
|
|
134
153
|
// `machine` names the login token after this host, so a re-login replaces that token instead of adding one.
|
|
135
154
|
const params = new URLSearchParams({ callback_url: callbackUrl, app_type: 'cli', machine: os.hostname(), ...scope });
|
|
136
155
|
const authUrl = `${frontendUrl}/automations/api-key-callback?${params.toString()}`;
|
package/src/lib/pack-export.js
CHANGED
|
@@ -80,6 +80,8 @@ function genericRowToPolicy(row, ref) {
|
|
|
80
80
|
} else if (
|
|
81
81
|
kind === 'model'
|
|
82
82
|
&& ('support_all_models' in config || 'allowed_model_ids' in config || 'excluded_model_ids' in config)
|
|
83
|
+
// Name patterns have no sugar form; as raw config they cannot read as "allow all".
|
|
84
|
+
&& !('allowed_model_patterns' in config)
|
|
83
85
|
// "allow nothing" has no sugar form; keep it as raw config so the file validates.
|
|
84
86
|
&& (config.support_all_models || (config.allowed_model_ids || []).length)
|
|
85
87
|
) {
|
package/src/lib/pack-plan.js
CHANGED
|
@@ -91,9 +91,26 @@ function normalizeModelConfig(config) {
|
|
|
91
91
|
support_all_models: !!config.support_all_models,
|
|
92
92
|
allowed_model_ids: sortedNumbers(config.allowed_model_ids),
|
|
93
93
|
excluded_model_ids: sortedNumbers(config.excluded_model_ids),
|
|
94
|
+
// Any match counts, so pattern order is not a change.
|
|
95
|
+
...(Array.isArray(config.allowed_model_patterns) && { allowed_model_patterns: [...config.allowed_model_patterns].sort() }),
|
|
96
|
+
...(Array.isArray(config.excluded_model_patterns) && { excluded_model_patterns: [...config.excluded_model_patterns].sort() }),
|
|
94
97
|
};
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
// The backend stores a fixed model list as exact-name patterns when every id resolves to a name one can spell; plan that stored shape so it does not drift.
|
|
101
|
+
function asStoredModelConfig(config, aiModels) {
|
|
102
|
+
const ids = config && config.allowed_model_ids;
|
|
103
|
+
if (!config || config.support_all_models !== false || 'allowed_model_patterns' in config || !Array.isArray(ids) || !ids.length || !ids.every(Number.isInteger)) return config;
|
|
104
|
+
const found = (aiModels || []).filter((m) => ids.includes(Number(m.id)));
|
|
105
|
+
// An empty list or an id missing from the catalog stays a fixed list, so nothing widens or drops.
|
|
106
|
+
if (new Set(found.map((m) => Number(m.id))).size !== new Set(ids).size) return config;
|
|
107
|
+
const names = [...new Set(found.map((m) => m.name))].sort();
|
|
108
|
+
if (!names.every((name) => /^[A-Za-z0-9._@:-]+$/.test(name))) return config;
|
|
109
|
+
// Excluded ids never applied to a fixed list, so the backend drops them on conversion too.
|
|
110
|
+
const { allowed_model_ids: _ids, excluded_model_ids: _excluded, ...rest } = config;
|
|
111
|
+
return { ...rest, support_all_models: true, allowed_model_patterns: names };
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
function normalizeConfig(kind, config) {
|
|
98
115
|
if (kind === 'security') return normalizeSecurityConfig(config);
|
|
99
116
|
if (kind === 'model') return normalizeModelConfig(config);
|
|
@@ -229,6 +246,8 @@ function genericDesired(policy, ref, where) {
|
|
|
229
246
|
if (kind === 'security' && policy.guardrails) config = guardrailsToConfig(policy, ref, where);
|
|
230
247
|
else if (kind === 'model' && policy.models) config = modelsToConfig(policy, ref, where);
|
|
231
248
|
else config = policy.config || {};
|
|
249
|
+
const unconverted = config;
|
|
250
|
+
if (kind === 'model') config = asStoredModelConfig(config, ref.aiModels);
|
|
232
251
|
|
|
233
252
|
const scopeIds = resolveIds(ref.userGroups, policy.scope && policy.scope.userGroups, 'user group', { where });
|
|
234
253
|
const toolTypes = [...new Set(policy.scope && policy.scope.toolTypes ? policy.scope.toolTypes : [])].sort();
|
|
@@ -260,7 +279,8 @@ function genericDesired(policy, ref, where) {
|
|
|
260
279
|
label_ids: labelIds,
|
|
261
280
|
};
|
|
262
281
|
if (policy.priority !== undefined) proj.priority = policy.priority;
|
|
263
|
-
|
|
282
|
+
const legacyConfig = config !== unconverted ? normalizeConfig(kind, unconverted) : undefined;
|
|
283
|
+
return { table: 'generic', body, proj, mcp: null, legacyConfig };
|
|
264
284
|
}
|
|
265
285
|
|
|
266
286
|
/**
|
|
@@ -449,7 +469,9 @@ function buildPlan(desired, live, { prune = false } = {}) {
|
|
|
449
469
|
continue;
|
|
450
470
|
}
|
|
451
471
|
|
|
452
|
-
|
|
472
|
+
let changes = diffFields(d.proj, liveProj);
|
|
473
|
+
// A model list stored as ids before the backend saved patterns is the same policy.
|
|
474
|
+
if (changes.length && d.legacyConfig && !diffFields({ ...d.proj, config: d.legacyConfig }, liveProj).length) changes = [];
|
|
453
475
|
if (!changes.length) {
|
|
454
476
|
ops.push({ ...base, op: 'unchanged', row, changes: [] });
|
|
455
477
|
continue;
|
|
@@ -64,11 +64,17 @@ function expectConnectionRefused(port) {
|
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
67
|
+
// Mirrors the dashboard: keep callback_url's own query (the state nonce) and
|
|
68
|
+
// set the success params the /callback handler reads (api_key/email/org).
|
|
69
|
+
function withCallbackParams(callbackUrl, params) {
|
|
70
|
+
const u = new URL(callbackUrl);
|
|
71
|
+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
|
72
|
+
return u.toString();
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
function buildSuccessCallback(authUrl) {
|
|
70
76
|
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
71
|
-
return
|
|
77
|
+
return withCallbackParams(callbackUrl, { api_key: 'sk-test', email: 'a@b.co', org: 'Acme' });
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
// Monkeypatch config so no real ~/.unbound write happens. Restored in finally.
|
|
@@ -149,13 +155,13 @@ test('loginWithBrowser: duplicate /callback cannot overwrite the stored credenti
|
|
|
149
155
|
|
|
150
156
|
const fakeOpen = async (authUrl) => {
|
|
151
157
|
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
152
|
-
await getKeepAlive(agent,
|
|
158
|
+
await getKeepAlive(agent, withCallbackParams(callbackUrl, { api_key: 'sk-first', email: 'a@b.co', org: 'Acme' }));
|
|
153
159
|
// Second callback with a different key: teardown is deferred to the
|
|
154
160
|
// response flush, so the server may still be up — it must answer 409
|
|
155
161
|
// without persisting; once down, ECONNREFUSED is equally acceptable.
|
|
156
162
|
// Either way the first credentials must be the only ones stored.
|
|
157
163
|
try {
|
|
158
|
-
const dup = await getKeepAlive(agent,
|
|
164
|
+
const dup = await getKeepAlive(agent, withCallbackParams(callbackUrl, { api_key: 'sk-attacker', email: 'x@y.z', org: 'Evil' }));
|
|
159
165
|
assert.equal(dup.statusCode, 409, 'late duplicate must be rejected, not processed');
|
|
160
166
|
} catch (err) {
|
|
161
167
|
if (err.code !== 'ECONNREFUSED') throw err;
|
|
@@ -19,6 +19,13 @@ function get(url) {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Mirrors the dashboard: `new URL(callback_url)` keeps the state nonce, then the result params are set on it.
|
|
23
|
+
function withCallbackParams(callbackUrl, params) {
|
|
24
|
+
const u = new URL(callbackUrl);
|
|
25
|
+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
|
26
|
+
return u.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
function withStubbedConfig(fn) {
|
|
23
30
|
const saved = { setApiKey: config.setApiKey, writeConfig: config.writeConfig, readConfig: config.readConfig };
|
|
24
31
|
config.setApiKey = () => {};
|
|
@@ -33,7 +40,7 @@ test('loginWithBrowser: scope flags ride on the auth URL and the echoed scope co
|
|
|
33
40
|
const fakeOpen = async (authUrl) => {
|
|
34
41
|
authParams = new URL(authUrl).searchParams;
|
|
35
42
|
const callbackUrl = authParams.get('callback_url');
|
|
36
|
-
setImmediate(() => get(
|
|
43
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test', email: 'a@b.co', org: 'Acme', scope: 'role:Analytics Viewer' })));
|
|
37
44
|
};
|
|
38
45
|
|
|
39
46
|
const result = await auth.loginWithBrowser('http://frontend.invalid', {
|
|
@@ -51,7 +58,7 @@ test('loginWithBrowser: control characters in the callback error never reach the
|
|
|
51
58
|
await withStubbedConfig(async () => {
|
|
52
59
|
const fakeOpen = async (authUrl) => {
|
|
53
60
|
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
54
|
-
setImmediate(() => get(
|
|
61
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { error: 'bad\u001b[31m role\u0007' })));
|
|
55
62
|
};
|
|
56
63
|
await assert.rejects(
|
|
57
64
|
() => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'x' } }),
|
|
@@ -65,7 +72,7 @@ test('loginWithBrowser: the dashboard\'s error reason is the rejection and shows
|
|
|
65
72
|
let page;
|
|
66
73
|
const fakeOpen = async (authUrl) => {
|
|
67
74
|
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
68
|
-
setImmediate(async () => { page = await get(
|
|
75
|
+
setImmediate(async () => { page = await get(withCallbackParams(callbackUrl, { error: 'Unknown role "Policy-Auditor"' })); });
|
|
69
76
|
};
|
|
70
77
|
|
|
71
78
|
await assert.rejects(
|
|
@@ -77,3 +84,128 @@ test('loginWithBrowser: the dashboard\'s error reason is the rejection and shows
|
|
|
77
84
|
assert.match(page.body, /Unknown role "Policy-Auditor"/);
|
|
78
85
|
});
|
|
79
86
|
});
|
|
87
|
+
|
|
88
|
+
// WEB-6462: the callback is gated by a per-login `state` nonce and pinned to 127.0.0.1, so a local
|
|
89
|
+
// page or an IPv6 bind-squatter can neither complete the login nor read the dashboard's redirect.
|
|
90
|
+
|
|
91
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
92
|
+
|
|
93
|
+
test('loginWithBrowser: callback_url is pinned to 127.0.0.1 and carries a 32-hex state nonce', async () => {
|
|
94
|
+
await withStubbedConfig(async () => {
|
|
95
|
+
let callbackUrl;
|
|
96
|
+
const fakeOpen = async (authUrl) => {
|
|
97
|
+
callbackUrl = new URL(new URL(authUrl).searchParams.get('callback_url'));
|
|
98
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test' })));
|
|
99
|
+
};
|
|
100
|
+
await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
101
|
+
assert.equal(callbackUrl.hostname, '127.0.0.1');
|
|
102
|
+
assert.equal(callbackUrl.pathname, '/callback');
|
|
103
|
+
assert.match(callbackUrl.searchParams.get('state'), /^[0-9a-f]{32}$/);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('loginWithBrowser: a wrong state gets 400 and the real callback still completes with 200', async () => {
|
|
108
|
+
await withStubbedConfig(async () => {
|
|
109
|
+
const storedKeys = [];
|
|
110
|
+
config.setApiKey = (key) => { storedKeys.push(key); };
|
|
111
|
+
let forged;
|
|
112
|
+
let real;
|
|
113
|
+
const fakeOpen = async (authUrl) => {
|
|
114
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
115
|
+
setImmediate(async () => {
|
|
116
|
+
forged = await get(withCallbackParams(callbackUrl, { state: 'f'.repeat(32), api_key: 'sk-attacker' }));
|
|
117
|
+
// The login settles before this response body flushes, so hold the promise and await it below.
|
|
118
|
+
real = get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
122
|
+
assert.equal(forged.statusCode, 400);
|
|
123
|
+
assert.equal((await real).statusCode, 200, 'the forged request must not consume the one-shot callback');
|
|
124
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
125
|
+
assert.deepEqual(storedKeys, ['sk-real']);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('loginWithBrowser: a callback without state gets 400 and the login still completes', async () => {
|
|
130
|
+
await withStubbedConfig(async () => {
|
|
131
|
+
let bare;
|
|
132
|
+
const fakeOpen = async (authUrl) => {
|
|
133
|
+
const callbackUrl = new URL(new URL(authUrl).searchParams.get('callback_url'));
|
|
134
|
+
const noState = new URL(callbackUrl); noState.searchParams.delete('state'); noState.searchParams.set('api_key', 'sk-attacker');
|
|
135
|
+
setImmediate(async () => {
|
|
136
|
+
bare = await get(noState.toString());
|
|
137
|
+
await get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
141
|
+
assert.equal(bare.statusCode, 400);
|
|
142
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('loginWithBrowser: a wrong-state error= neither shows its text nor settles the login', async () => {
|
|
147
|
+
await withStubbedConfig(async () => {
|
|
148
|
+
let forged;
|
|
149
|
+
let callbackUrl;
|
|
150
|
+
const fakeOpen = async (authUrl) => {
|
|
151
|
+
callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
152
|
+
setImmediate(async () => {
|
|
153
|
+
forged = await get(withCallbackParams(callbackUrl, { state: '0'.repeat(32), error: 'ATTACKER-TEXT' }));
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
let settled = false;
|
|
157
|
+
const login = auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }).finally(() => { settled = true; });
|
|
158
|
+
await sleep(100);
|
|
159
|
+
assert.equal(forged.statusCode, 400);
|
|
160
|
+
assert.doesNotMatch(forged.body, /ATTACKER-TEXT/);
|
|
161
|
+
assert.equal(settled, false, 'a forged error must not reject the login');
|
|
162
|
+
// Finish the login so the server does not outlive the test.
|
|
163
|
+
await get(withCallbackParams(callbackUrl, { api_key: 'sk-real' }));
|
|
164
|
+
await login;
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('loginWithBrowser: a 32-char multi-byte state gets 400 instead of crashing the server', async () => {
|
|
169
|
+
await withStubbedConfig(async () => {
|
|
170
|
+
let forged;
|
|
171
|
+
const fakeOpen = async (authUrl) => {
|
|
172
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
173
|
+
setImmediate(async () => {
|
|
174
|
+
// Same JS length as the real nonce, different UTF-8 byte length.
|
|
175
|
+
forged = await get(withCallbackParams(callbackUrl, { state: '\u00e9'.repeat(32), api_key: 'sk-attacker' }));
|
|
176
|
+
await get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
180
|
+
assert.equal(forged.statusCode, 400);
|
|
181
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('loginWithBrowser: each login mints a different state', async () => {
|
|
186
|
+
await withStubbedConfig(async () => {
|
|
187
|
+
const states = [];
|
|
188
|
+
const fakeOpen = async (authUrl) => {
|
|
189
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
190
|
+
states.push(new URL(callbackUrl).searchParams.get('state'));
|
|
191
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test' })));
|
|
192
|
+
};
|
|
193
|
+
await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
194
|
+
await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
|
|
195
|
+
assert.equal(states.length, 2);
|
|
196
|
+
assert.notEqual(states[0], states[1]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('loginWithBrowser: error= with the correct state still rejects with the dashboard reason', async () => {
|
|
201
|
+
await withStubbedConfig(async () => {
|
|
202
|
+
const fakeOpen = async (authUrl) => {
|
|
203
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
204
|
+
setImmediate(() => get(withCallbackParams(callbackUrl, { error: 'Role not permitted' })));
|
|
205
|
+
};
|
|
206
|
+
await assert.rejects(
|
|
207
|
+
() => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
|
|
208
|
+
/Role not permitted/,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
package/test/pack-export.test.js
CHANGED
|
@@ -67,6 +67,15 @@ test('round trip keeps a disabled guardrail entry disabled', () => {
|
|
|
67
67
|
assert.deepEqual(ops.map((o) => o.op), ['unchanged']);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test('a model policy stored as name patterns exports as raw config and plans unchanged', () => {
|
|
71
|
+
const live = { tool: [], generic: [{ ...LIVE.generic[1], config: { support_all_models: true, allowed_model_patterns: ['claude-sonnet'], excluded_model_ids: [] } }] };
|
|
72
|
+
const doc = exp.liveToPack('base', live, REF);
|
|
73
|
+
assert.equal(doc.policies[0].models, undefined);
|
|
74
|
+
assert.deepEqual(doc.policies[0].config, live.generic[0].config);
|
|
75
|
+
const ops = plan.buildPlan(plan.toDesired(schema.validatePack(doc, 'base.yaml'), REF), live, { prune: true });
|
|
76
|
+
assert.deepEqual(ops.map((o) => o.op), ['unchanged']);
|
|
77
|
+
});
|
|
78
|
+
|
|
70
79
|
test('export keeps a loadable file for an empty BLOCK message and an allow-nothing model policy', () => {
|
|
71
80
|
const live = {
|
|
72
81
|
tool: [{ ...LIVE.tool[0], name: 'quiet-block', code_slug: 'quiet-block', custom_message: '' }],
|
package/test/pack-plan.test.js
CHANGED
|
@@ -67,6 +67,23 @@ test('buildPlan: in-sync org yields only unchanged ops', () => {
|
|
|
67
67
|
assert.equal(plan.summarize(ops).drift, 0);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test('buildPlan: an allowed-model list matches the name patterns the backend stores it as', () => {
|
|
71
|
+
const desired = plan.toDesired(pack([{ ...MODEL, models: { allowed: ['gpt-4o', 'claude-sonnet'] } }]), REF);
|
|
72
|
+
assert.deepEqual(desired[0].body.config, { support_all_models: true, allowed_model_patterns: ['claude-sonnet', 'gpt-4o'] });
|
|
73
|
+
const live = { ...LIVE_MODEL, config: { support_all_models: true, allowed_model_patterns: ['claude-sonnet', 'gpt-4o'] } };
|
|
74
|
+
assert.deepEqual(plan.buildPlan(desired, { tool: [], generic: [live] }).map((o) => o.op), ['unchanged']);
|
|
75
|
+
const reordered = { ...live, config: { ...live.config, allowed_model_patterns: ['gpt-4o', 'claude-sonnet'] } };
|
|
76
|
+
assert.deepEqual(plan.buildPlan(desired, { tool: [], generic: [reordered] }).map((o) => o.op), ['unchanged']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('toDesired: an empty or partly unknown id list stays a fixed list', () => {
|
|
80
|
+
for (const ids of [[], [100, 999]]) {
|
|
81
|
+
const config = { support_all_models: false, allowed_model_ids: ids, excluded_model_ids: [] };
|
|
82
|
+
const [d] = plan.toDesired(pack([{ id: 'models', kind: 'model', config }]), REF);
|
|
83
|
+
assert.deepEqual(d.body.config, config);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
70
87
|
test('buildPlan: create, patch-update, orphan and prune', () => {
|
|
71
88
|
const desired = plan.toDesired(pack([{ ...TERMINAL, action: 'AUDIT', customMessage: undefined }, { ...COST, config: { monthly_budget: 250 } }]), REF);
|
|
72
89
|
const live = { tool: [LIVE_TERMINAL, LIVE_MCP], generic: [LIVE_COST] };
|