ec-web 0.0.1 → 0.0.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.
@@ -0,0 +1,1195 @@
1
+ /**
2
+ * Role Definitions 数据源 — 管理角色定义及其权限配置
3
+ *
4
+ * 功能:
5
+ * 1. 读取 roles.json 或内置角色配置
6
+ * 2. 提供角色定义的 CRUD API
7
+ * 3. WebSocket 推送角色配置变化
8
+ *
9
+ * 数据流:
10
+ * - 读取:从 evolclaw/dist/config/roles.ts 导入 readRolesConfig
11
+ * - 写入:保存到 .evolclaw/roles.json
12
+ * - 推送:2秒轮询 + JSON diff
13
+ */
14
+ import { getModelCatalogSnapshot } from './models.js';
15
+ import { resolveParentDistModule, toFileUrl } from './parent-package.js';
16
+ import { resolvePaths } from '../paths.js';
17
+ import { ipcQuery } from '../ipc-client.js';
18
+ // 动态导入 evolclaw 主项目的角色配置模块
19
+ async function getRolesModule() {
20
+ const rolesPath = resolveParentDistModule('config', 'roles.js');
21
+ const cmPath = resolveParentDistModule('config', 'config-manager.js');
22
+ const resolverPath = resolveParentDistModule('config', 'peer-role-resolver.js');
23
+ const authPath = resolveParentDistModule('core', 'auth', 'auth-gateway.js');
24
+ // Windows 上需要转换为 file:// URL
25
+ try {
26
+ const rolesMod = await import(toFileUrl(rolesPath));
27
+ const cmMod = await import(toFileUrl(cmPath));
28
+ const resolverMod = await import(toFileUrl(resolverPath));
29
+ const authMod = await import(toFileUrl(authPath));
30
+ if (!rolesMod.readRolesConfig)
31
+ throw new Error('readRolesConfig not found in roles.js');
32
+ if (!rolesMod.getBuiltinRolesConfig)
33
+ throw new Error('getBuiltinRolesConfig not found in roles.js');
34
+ if (!rolesMod.roleExists)
35
+ throw new Error('roleExists not found in roles.js');
36
+ if (!cmMod.read)
37
+ throw new Error('read not found in config-manager.js');
38
+ if (!cmMod.write)
39
+ throw new Error('write not found in config-manager.js');
40
+ if (!cmMod.ConfigTarget)
41
+ throw new Error('ConfigTarget not found in config-manager.js');
42
+ if (!authMod.buildAuthSubject)
43
+ throw new Error('buildAuthSubject not found in auth-gateway.js');
44
+ if (!authMod.authorizeOperation)
45
+ throw new Error('authorizeOperation not found in auth-gateway.js');
46
+ return {
47
+ readRolesConfig: rolesMod.readRolesConfig,
48
+ getBuiltinRolesConfig: rolesMod.getBuiltinRolesConfig,
49
+ roleExists: rolesMod.roleExists,
50
+ read: cmMod.read,
51
+ write: cmMod.write,
52
+ ConfigTarget: cmMod.ConfigTarget,
53
+ resolver: resolverMod,
54
+ buildAuthSubject: authMod.buildAuthSubject,
55
+ authorizeOperation: authMod.authorizeOperation,
56
+ };
57
+ }
58
+ catch (err) {
59
+ console.error('[role-definitions] Failed to import roles modules:', err);
60
+ throw err;
61
+ }
62
+ }
63
+ async function getRoleConstraintsModule() {
64
+ const constraintsPath = resolveParentDistModule('config', 'role-constraints.js');
65
+ const mod = await import(toFileUrl(constraintsPath));
66
+ if (!mod.isModelAllowedByPatterns)
67
+ throw new Error('isModelAllowedByPatterns not found in role-constraints.js');
68
+ return {
69
+ isModelAllowedByPatterns: mod.isModelAllowedByPatterns,
70
+ };
71
+ }
72
+ async function getOperationRegistryModule() {
73
+ const registryPath = resolveParentDistModule('core', 'command', 'operation-registry.js');
74
+ const mod = await import(toFileUrl(registryPath));
75
+ if (!mod.listOperations)
76
+ throw new Error('listOperations not found in operation-registry.js');
77
+ return {
78
+ listOperations: mod.listOperations,
79
+ };
80
+ }
81
+ async function execRoleMenu(aid, payload, auth) {
82
+ const response = await ipcQuery(resolvePaths().socket, {
83
+ type: 'menu.exec',
84
+ payload: { ...payload, name: 'role', agent: aid, args: { self: aid, ...(payload.args ?? {}) } },
85
+ auth: { source: 'ecweb', actorAid: auth.actorAid ?? null, localDirect: !!auth.localDirect },
86
+ }, 10_000);
87
+ if (!response?.ok || !response.response)
88
+ throw new Error(response?.error || 'Role Menu is unavailable');
89
+ return response.response;
90
+ }
91
+ function roleMenuHttpStatus(code) {
92
+ if (code === 'NOT_FOUND')
93
+ return 404;
94
+ if (code === 'CONFLICT')
95
+ return 409;
96
+ if (code === 'PERMISSION_DENIED' || code === 'NOT_ALLOWED')
97
+ return 403;
98
+ if (code === 'TEMPORARILY_UNAVAILABLE')
99
+ return 503;
100
+ return 400;
101
+ }
102
+ function sendRoleMenuError(res, response) {
103
+ const error = response?.error ?? { code: 'INTERNAL_ERROR', message: 'Role Menu request failed' };
104
+ sendJson(res, roleMenuHttpStatus(error.code), {
105
+ error: error.message,
106
+ code: error.code,
107
+ data: error.data,
108
+ });
109
+ }
110
+ async function rolePolicyRevision(aid, auth) {
111
+ const response = await execRoleMenu(aid, {
112
+ type: 'menu.query', id: `ecweb-role-revision-${Date.now()}`,
113
+ args: { view: 'defaults' },
114
+ }, auth);
115
+ if (response.error)
116
+ throw Object.assign(new Error(response.error.message), { menuResponse: response });
117
+ return response.data.policyRevision;
118
+ }
119
+ async function buildMenuSnapshot(aid, agents, auth) {
120
+ const [roleOptions, defaults] = await Promise.all([
121
+ execRoleMenu(aid, { type: 'menu.options', id: `ecweb-role-options-${Date.now()}`, args: { option: 'roles' } }, auth),
122
+ execRoleMenu(aid, { type: 'menu.query', id: `ecweb-role-defaults-${Date.now()}`, args: { view: 'defaults' } }, auth),
123
+ ]);
124
+ if (roleOptions.error)
125
+ throw Object.assign(new Error(roleOptions.error.message), { menuResponse: roleOptions });
126
+ if (defaults.error)
127
+ throw Object.assign(new Error(defaults.error.message), { menuResponse: defaults });
128
+ const userRoles = roleOptions.data.filter(item => item.kind === 'user');
129
+ const details = await Promise.all(userRoles.map(item => execRoleMenu(aid, {
130
+ type: 'menu.query', id: `ecweb-role-${item.value}-${Date.now()}`,
131
+ args: { view: 'role', roleId: item.value },
132
+ }, auth)));
133
+ const roles = {};
134
+ for (const detail of details) {
135
+ if (!detail.error)
136
+ roles[detail.data.roleId] = detail.data.definition;
137
+ }
138
+ return {
139
+ $schema_version: 1,
140
+ aid,
141
+ self: aid,
142
+ agents: summarizeAgents(agents),
143
+ defaultRoles: defaults.data.defaultRoles,
144
+ policyRevision: defaults.data.policyRevision,
145
+ roles,
146
+ };
147
+ }
148
+ function isWriteMethod(method) {
149
+ return method === 'PUT' || method === 'POST' || method === 'DELETE';
150
+ }
151
+ function isReadMethod(method) {
152
+ return method === 'GET';
153
+ }
154
+ async function authorizeRolePolicyOperation(modules, processConfig, auth, aid, operation, args) {
155
+ const actor = auth.actorAid || '';
156
+ const processOwners = Array.isArray(processConfig?.owners) ? processConfig.owners.map(String) : [];
157
+ const syntheticOwner = !!auth.localDirect || (!!actor && processOwners.includes(actor));
158
+ const subject = modules.buildAuthSubject({
159
+ selfAid: aid,
160
+ actorId: auth.localDirect ? 'local-direct' : actor || undefined,
161
+ channel: `ecweb#${aid}`,
162
+ channelType: 'aun',
163
+ channelId: actor || 'ecweb',
164
+ chatType: 'private',
165
+ conversationId: actor || 'ecweb',
166
+ processOwners,
167
+ ...(syntheticOwner ? {
168
+ roleDetail: {
169
+ effectiveRole: 'owner',
170
+ source: 'agent-config-owner',
171
+ isAuthenticated: true,
172
+ allowAccess: true,
173
+ roleExists: true,
174
+ },
175
+ } : {}),
176
+ });
177
+ return modules.authorizeOperation({
178
+ source: 'ecweb',
179
+ subject,
180
+ intent: {
181
+ operation,
182
+ scope: 'agent',
183
+ source: 'ecweb',
184
+ args: {
185
+ self: aid,
186
+ ...args,
187
+ },
188
+ },
189
+ });
190
+ }
191
+ const MODEL_PERMISSION_FIELD = 'baseagents.claude.model';
192
+ const MODEL_PATTERN_OPTIONS = ['*', 'claude-opus-*', 'claude-sonnet-*', 'claude-haiku-*'];
193
+ const COMMAND_PERMISSION_SCOPES = new Set(['relation', 'role', 'agent', 'process', 'filesystem', 'control', 'raw-cli']);
194
+ const COMMAND_CONSTRAINT_KEYS = new Set([
195
+ 'ownPeerOnly',
196
+ 'ownAgentOnly',
197
+ 'targetCurrentAgentOnly',
198
+ 'requireAgentAdmin',
199
+ 'requireAgentOwner',
200
+ 'privateOnly',
201
+ 'groupOnly',
202
+ 'requireDaemonOwner',
203
+ 'requireControlChannel',
204
+ 'requireExplicitDangerousGrant',
205
+ 'requireFieldOverride',
206
+ 'allowedArgs',
207
+ 'deniedArgs',
208
+ 'forbiddenFlags',
209
+ 'allowedConfigKeys',
210
+ 'allowedPrefixes',
211
+ 'timeoutMs',
212
+ 'outputLimitBytes',
213
+ 'cwdPolicy',
214
+ 'envAllowlist',
215
+ ]);
216
+ const COMMAND_BOOLEAN_CONSTRAINTS = new Set([
217
+ 'ownPeerOnly',
218
+ 'ownAgentOnly',
219
+ 'targetCurrentAgentOnly',
220
+ 'requireAgentAdmin',
221
+ 'requireAgentOwner',
222
+ 'privateOnly',
223
+ 'groupOnly',
224
+ 'requireDaemonOwner',
225
+ 'requireControlChannel',
226
+ 'requireExplicitDangerousGrant',
227
+ ]);
228
+ const COMMAND_STRING_ARRAY_CONSTRAINTS = new Set([
229
+ 'forbiddenFlags',
230
+ 'allowedConfigKeys',
231
+ 'allowedPrefixes',
232
+ 'envAllowlist',
233
+ ]);
234
+ const COMMAND_INTEGER_CONSTRAINTS = new Set(['timeoutMs', 'outputLimitBytes']);
235
+ const ROLE_USAGE_COST_BASIS = new Set(['gateway', 'official']);
236
+ const ROLE_USAGE_SCOPES = new Set(['subject', 'role']);
237
+ const ROLE_USAGE_RESET_MODES = new Set(['never', 'daily', 'weekly', 'monthly']);
238
+ const ROLE_USAGE_CURRENCIES = new Set(['CNY', 'USD']);
239
+ function sendJson(res, status, payload) {
240
+ res.writeHead(status, { 'Content-Type': 'application/json' });
241
+ res.end(JSON.stringify(payload));
242
+ }
243
+ function readJsonBody(req) {
244
+ return new Promise((resolve, reject) => {
245
+ let body = '';
246
+ req.on('data', (chunk) => {
247
+ body += chunk.toString();
248
+ if (body.length > 1024 * 1024) {
249
+ reject(new Error('Request body too large'));
250
+ req.destroy();
251
+ }
252
+ });
253
+ req.on('end', () => {
254
+ if (!body.trim()) {
255
+ resolve({});
256
+ return;
257
+ }
258
+ try {
259
+ resolve(JSON.parse(body));
260
+ }
261
+ catch (err) {
262
+ reject(err);
263
+ }
264
+ });
265
+ req.on('error', reject);
266
+ });
267
+ }
268
+ function parseUrl(rawUrl) {
269
+ const qIdx = rawUrl.indexOf('?');
270
+ if (qIdx === -1)
271
+ return { path: rawUrl, query: {} };
272
+ const query = {};
273
+ for (const pair of rawUrl.slice(qIdx + 1).split('&')) {
274
+ const [k, v] = pair.split('=');
275
+ if (k)
276
+ query[decodeURIComponent(k)] = decodeURIComponent(v || '');
277
+ }
278
+ return { path: rawUrl.slice(0, qIdx), query };
279
+ }
280
+ async function getAgentsFromIpc() {
281
+ try {
282
+ const { ipcQuery } = await import('../ipc-client.js');
283
+ const resp = await ipcQuery(resolvePaths().socket, { type: 'evolagent.list' }, 3000);
284
+ return resp?.agents ?? [];
285
+ }
286
+ catch {
287
+ return [];
288
+ }
289
+ }
290
+ function summarizeAgents(agents) {
291
+ return agents
292
+ .filter(agent => agent?.aid)
293
+ .map(agent => ({
294
+ aid: agent.aid,
295
+ displayName: agent.displayName ?? agent.personalName,
296
+ name: agent.name,
297
+ }));
298
+ }
299
+ function resolveRequestedAid(raw, agents) {
300
+ const requested = typeof raw === 'string' ? raw.trim() : '';
301
+ if (requested)
302
+ return requested;
303
+ return agents.find(agent => agent?.aid)?.aid || null;
304
+ }
305
+ function isReservedRoleName(roleName) {
306
+ return roleName === 'owner' || roleName === 'admin';
307
+ }
308
+ function isValidUserRoleName(roleName) {
309
+ return /^[a-z0-9_-]+$/.test(roleName) && !isReservedRoleName(roleName);
310
+ }
311
+ function builtinUserRoles(modules) {
312
+ return new Set(Object.keys(modules.getBuiltinRolesConfig().roles || {}));
313
+ }
314
+ function readAgentConfig(modules, aid) {
315
+ return modules.read(modules.ConfigTarget.Agent, { self: aid }) || { aid, channels: [] };
316
+ }
317
+ function writeAgentConfig(modules, aid, config) {
318
+ modules.write(modules.ConfigTarget.Agent, { ...config, aid, channels: Array.isArray(config.channels) ? config.channels : [] }, { self: aid });
319
+ }
320
+ function getAgentRoleDefinitions(config) {
321
+ return config.roles?.definitions && typeof config.roles.definitions === 'object'
322
+ ? config.roles.definitions
323
+ : {};
324
+ }
325
+ function updateAgentRoles(modules, aid, updater) {
326
+ const config = readAgentConfig(modules, aid);
327
+ const roles = config.roles && typeof config.roles === 'object' ? { ...config.roles } : {};
328
+ if (roles.definitions && typeof roles.definitions === 'object') {
329
+ roles.definitions = { ...roles.definitions };
330
+ }
331
+ if (roles.defaultRoles && typeof roles.defaultRoles === 'object') {
332
+ roles.defaultRoles = { ...roles.defaultRoles };
333
+ }
334
+ updater(roles, config);
335
+ const next = { ...config, roles };
336
+ writeAgentConfig(modules, aid, next);
337
+ return next;
338
+ }
339
+ function normalizeDefaultRoleInput(value, current) {
340
+ if (value === null || value === '')
341
+ return null;
342
+ if (typeof value === 'string')
343
+ return value;
344
+ return current ?? null;
345
+ }
346
+ function validateRoleDefinitionName(roleName) {
347
+ if (!roleName || !/^[a-z0-9_-]+$/.test(roleName))
348
+ return 'Invalid role name';
349
+ if (isReservedRoleName(roleName))
350
+ return 'owner/admin are management identities and cannot be user role definitions';
351
+ return null;
352
+ }
353
+ function inferSelectionMode(allowedModels = []) {
354
+ if (allowedModels.length === 0)
355
+ return 'explicit';
356
+ const patternCount = allowedModels.filter(m => m === '*' || m.endsWith('*')).length;
357
+ if (patternCount === allowedModels.length)
358
+ return 'pattern';
359
+ if (patternCount === 0)
360
+ return 'explicit';
361
+ return 'mixed';
362
+ }
363
+ function normalizeStringList(value) {
364
+ if (!Array.isArray(value))
365
+ return null;
366
+ const out = [];
367
+ const seen = new Set();
368
+ for (const item of value) {
369
+ if (typeof item !== 'string')
370
+ return null;
371
+ const trimmed = item.trim();
372
+ if (!trimmed)
373
+ return null;
374
+ if (!seen.has(trimmed)) {
375
+ seen.add(trimmed);
376
+ out.push(trimmed);
377
+ }
378
+ }
379
+ return out;
380
+ }
381
+ function isRecord(value) {
382
+ return !!value && typeof value === 'object' && !Array.isArray(value);
383
+ }
384
+ function validateScalarArrayMap(value, label, errors) {
385
+ if (!isRecord(value)) {
386
+ errors.push(`${label} must be an object`);
387
+ return;
388
+ }
389
+ for (const [argName, allowedValues] of Object.entries(value)) {
390
+ if (!Array.isArray(allowedValues)) {
391
+ errors.push(`${label}.${argName} must be an array`);
392
+ continue;
393
+ }
394
+ for (const item of allowedValues) {
395
+ if (!['string', 'number', 'boolean'].includes(typeof item)) {
396
+ errors.push(`${label}.${argName} entries must be string, number, or boolean`);
397
+ }
398
+ }
399
+ }
400
+ }
401
+ function validateCommandPermissionConstraints(rule, constraints, errors) {
402
+ if (!isRecord(constraints)) {
403
+ errors.push(`${rule}.constraints must be an object`);
404
+ return;
405
+ }
406
+ for (const [key, value] of Object.entries(constraints)) {
407
+ const label = `${rule}.constraints.${key}`;
408
+ if (!COMMAND_CONSTRAINT_KEYS.has(key)) {
409
+ errors.push(`${label} is not supported`);
410
+ continue;
411
+ }
412
+ if (COMMAND_BOOLEAN_CONSTRAINTS.has(key) && typeof value !== 'boolean') {
413
+ errors.push(`${label} must be a boolean`);
414
+ }
415
+ else if (key === 'requireFieldOverride' && typeof value !== 'string') {
416
+ errors.push(`${label} must be a string`);
417
+ }
418
+ else if (COMMAND_STRING_ARRAY_CONSTRAINTS.has(key)) {
419
+ if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) {
420
+ errors.push(`${label} must be a string array`);
421
+ }
422
+ }
423
+ else if (COMMAND_INTEGER_CONSTRAINTS.has(key)) {
424
+ if (!Number.isInteger(value) || value < 0)
425
+ errors.push(`${label} must be a non-negative integer`);
426
+ }
427
+ else if (key === 'cwdPolicy' && !['agentProject', 'evolclawHome', 'none'].includes(String(value))) {
428
+ errors.push(`${label} must be agentProject, evolclawHome, or none`);
429
+ }
430
+ else if (key === 'allowedArgs' || key === 'deniedArgs') {
431
+ validateScalarArrayMap(value, label, errors);
432
+ }
433
+ }
434
+ }
435
+ function validateCommandPermissions(value) {
436
+ const errors = [];
437
+ if (value === undefined)
438
+ return { ok: true, errors };
439
+ if (!isRecord(value)) {
440
+ return { ok: false, errors: ['commandPermissions must be an object'] };
441
+ }
442
+ for (const [rule, permission] of Object.entries(value)) {
443
+ if (!isRecord(permission)) {
444
+ errors.push(`${rule} must be an object`);
445
+ continue;
446
+ }
447
+ if (typeof permission.allow !== 'boolean') {
448
+ errors.push(`${rule}.allow must be a boolean`);
449
+ }
450
+ if (permission.dangerous !== undefined && typeof permission.dangerous !== 'boolean') {
451
+ errors.push(`${rule}.dangerous must be a boolean`);
452
+ }
453
+ if (permission.reason !== undefined && typeof permission.reason !== 'string') {
454
+ errors.push(`${rule}.reason must be a string`);
455
+ }
456
+ if (permission.scopes !== undefined) {
457
+ if (!Array.isArray(permission.scopes)) {
458
+ errors.push(`${rule}.scopes must be an array`);
459
+ }
460
+ else {
461
+ for (const scope of permission.scopes) {
462
+ if (typeof scope !== 'string' || !COMMAND_PERMISSION_SCOPES.has(scope)) {
463
+ errors.push(`${rule}.scopes contains unsupported scope: ${String(scope)}`);
464
+ }
465
+ }
466
+ }
467
+ }
468
+ if (permission.constraints !== undefined) {
469
+ validateCommandPermissionConstraints(rule, permission.constraints, errors);
470
+ }
471
+ }
472
+ return { ok: errors.length === 0, errors };
473
+ }
474
+ function validateRoleUsageLimits(value) {
475
+ const errors = [];
476
+ if (value === undefined)
477
+ return { ok: true, errors };
478
+ if (!isRecord(value)) {
479
+ return { ok: false, errors: ['usageLimits must be an object'] };
480
+ }
481
+ if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
482
+ errors.push('usageLimits.enabled must be a boolean');
483
+ }
484
+ if (value.resetMode !== undefined && !ROLE_USAGE_RESET_MODES.has(String(value.resetMode))) {
485
+ errors.push('usageLimits.resetMode must be never, daily, weekly, or monthly');
486
+ }
487
+ if (value.currency !== undefined && !ROLE_USAGE_CURRENCIES.has(String(value.currency))) {
488
+ errors.push('usageLimits.currency must be CNY or USD');
489
+ }
490
+ const amount = value.limitAmount;
491
+ if (amount !== undefined && amount !== null) {
492
+ if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
493
+ errors.push('usageLimits.limitAmount must be a non-negative number or null');
494
+ }
495
+ }
496
+ if (value.costBasis !== undefined && !ROLE_USAGE_COST_BASIS.has(String(value.costBasis))) {
497
+ errors.push('usageLimits.costBasis must be gateway or official');
498
+ }
499
+ if (value.scope !== undefined && !ROLE_USAGE_SCOPES.has(String(value.scope))) {
500
+ errors.push('usageLimits.scope must be subject or role');
501
+ }
502
+ return { ok: errors.length === 0, errors };
503
+ }
504
+ function normalizeRoleUsageLimits(value) {
505
+ if (value === undefined)
506
+ return undefined;
507
+ const out = {};
508
+ if (typeof value.enabled === 'boolean')
509
+ out.enabled = value.enabled;
510
+ if (ROLE_USAGE_RESET_MODES.has(String(value.resetMode)))
511
+ out.resetMode = String(value.resetMode);
512
+ if (ROLE_USAGE_CURRENCIES.has(String(value.currency)))
513
+ out.currency = String(value.currency);
514
+ const amount = value.limitAmount;
515
+ if (amount === null)
516
+ out.limitAmount = null;
517
+ else if (typeof amount === 'number' && Number.isFinite(amount) && amount >= 0)
518
+ out.limitAmount = amount;
519
+ if (ROLE_USAGE_COST_BASIS.has(String(value.costBasis)))
520
+ out.costBasis = String(value.costBasis);
521
+ if (ROLE_USAGE_SCOPES.has(String(value.scope)))
522
+ out.scope = String(value.scope);
523
+ return out;
524
+ }
525
+ function normalizeRoleDefinitionForSave(roleDef) {
526
+ if (!isRecord(roleDef) || roleDef.usageLimits === undefined)
527
+ return roleDef;
528
+ return { ...roleDef, usageLimits: normalizeRoleUsageLimits(roleDef.usageLimits) };
529
+ }
530
+ function getModelPermission(roleDef) {
531
+ const permissions = roleDef?.permissions;
532
+ if (!permissions || typeof permissions !== 'object')
533
+ return {};
534
+ const perm = permissions[MODEL_PERMISSION_FIELD];
535
+ return perm && typeof perm === 'object' ? perm : {};
536
+ }
537
+ function stripSelectionModeFromPermission(perm) {
538
+ if (!perm || typeof perm !== 'object')
539
+ return perm;
540
+ const { selectionMode: _selectionMode, ...rest } = perm;
541
+ return rest;
542
+ }
543
+ function extractModelPermissionPayload(payload, currentPerm) {
544
+ const source = payload?.modelPermission && typeof payload.modelPermission === 'object'
545
+ ? payload.modelPermission
546
+ : payload;
547
+ return {
548
+ defaultModel: source.defaultModel ?? source.default ?? currentPerm.default,
549
+ allowOverride: source.allowOverride ?? currentPerm.allowOverride ?? false,
550
+ allowedModels: source.allowedModels ?? currentPerm.allowedModels,
551
+ };
552
+ }
553
+ async function validateModelPermissionInput(input) {
554
+ const errors = [];
555
+ const defaultModel = typeof input.defaultModel === 'string' ? input.defaultModel.trim() : '';
556
+ const allowedModels = normalizeStringList(input.allowedModels);
557
+ const allowOverride = input.allowOverride;
558
+ if (!defaultModel)
559
+ errors.push('defaultModel must be a non-empty string');
560
+ if (typeof allowOverride !== 'boolean')
561
+ errors.push('allowOverride must be a boolean');
562
+ if (!allowedModels || allowedModels.length === 0)
563
+ errors.push('allowedModels must be a non-empty string array');
564
+ if (defaultModel && allowedModels && allowedModels.length > 0) {
565
+ const { isModelAllowedByPatterns } = await getRoleConstraintsModule();
566
+ if (!isModelAllowedByPatterns(defaultModel, allowedModels)) {
567
+ errors.push('defaultModel must be allowed by allowedModels');
568
+ }
569
+ }
570
+ if (errors.length > 0 || !allowedModels) {
571
+ return { ok: false, errors };
572
+ }
573
+ return { ok: true, errors: [], value: { defaultModel, allowOverride, allowedModels } };
574
+ }
575
+ async function validateRoleModelPermission(roleDef) {
576
+ const permissions = roleDef?.permissions;
577
+ if (!permissions || typeof permissions !== 'object')
578
+ return { ok: true, errors: [] };
579
+ const perm = permissions[MODEL_PERMISSION_FIELD];
580
+ if (!perm || typeof perm !== 'object')
581
+ return { ok: true, errors: [] };
582
+ permissions[MODEL_PERMISSION_FIELD] = stripSelectionModeFromPermission(perm);
583
+ const result = await validateModelPermissionInput({
584
+ defaultModel: permissions[MODEL_PERMISSION_FIELD].default,
585
+ allowOverride: permissions[MODEL_PERMISSION_FIELD].allowOverride,
586
+ allowedModels: permissions[MODEL_PERMISSION_FIELD].allowedModels,
587
+ });
588
+ return { ok: result.ok, errors: result.errors };
589
+ }
590
+ async function filterAllowedCatalogModels(models, allowedModels) {
591
+ const { isModelAllowedByPatterns } = await getRoleConstraintsModule();
592
+ return models.filter(model => !model.isAlias && isModelAllowedByPatterns(model.id, allowedModels));
593
+ }
594
+ async function buildModelPermissionResponse(roleName, roleDef, override) {
595
+ const currentPerm = getModelPermission(roleDef);
596
+ const defaultModel = override?.defaultModel ?? currentPerm.default ?? '';
597
+ const allowOverride = override?.allowOverride ?? currentPerm.allowOverride ?? false;
598
+ const allowedModels = override?.allowedModels ?? (Array.isArray(currentPerm.allowedModels) ? currentPerm.allowedModels : []);
599
+ const catalog = await getModelCatalogSnapshot('claude');
600
+ const matchingModels = await filterAllowedCatalogModels(catalog.models, allowedModels);
601
+ return {
602
+ role: roleName,
603
+ field: MODEL_PERMISSION_FIELD,
604
+ permission: {
605
+ default: defaultModel,
606
+ allowOverride,
607
+ allowedModels,
608
+ },
609
+ selectionMode: inferSelectionMode(allowedModels),
610
+ patternOptions: MODEL_PATTERN_OPTIONS,
611
+ catalog,
612
+ matchingModels,
613
+ stats: {
614
+ catalogTotal: catalog.models.filter(model => !model.isAlias).length,
615
+ matchedTotal: matchingModels.length,
616
+ }
617
+ };
618
+ }
619
+ async function buildSnapshot(params = {}, auth = {}) {
620
+ try {
621
+ const agents = await getAgentsFromIpc();
622
+ const aid = resolveRequestedAid(params.aid, agents);
623
+ if (!aid)
624
+ return { $schema_version: 1, aid: null, agents: [], roles: {}, defaultRoles: { private: null, group: null } };
625
+ return await buildMenuSnapshot(aid, agents, auth);
626
+ }
627
+ catch (err) {
628
+ console.error('[role-definitions] Failed to build snapshot:', err);
629
+ return { $schema_version: 1, aid: null, agents: [], roles: {}, error: String(err) };
630
+ }
631
+ }
632
+ export const roleDefinitionsSource = {
633
+ kind: 'roleDefinitions',
634
+ async snapshot(params = {}) {
635
+ return buildSnapshot(params, params.__menuAuth ?? {});
636
+ },
637
+ subscribe(_params, push) {
638
+ let lastJson = '';
639
+ let stopped = false;
640
+ const tick = async () => {
641
+ if (stopped)
642
+ return;
643
+ try {
644
+ const snap = await buildSnapshot(_params, _params.__menuAuth ?? {});
645
+ const json = JSON.stringify(snap);
646
+ if (json !== lastJson) {
647
+ lastJson = json;
648
+ push(snap);
649
+ }
650
+ }
651
+ catch (err) {
652
+ console.error('[role-definitions] Polling error:', err);
653
+ }
654
+ };
655
+ // 立即执行一次
656
+ tick();
657
+ const timer = setInterval(tick, 2000); // 2秒轮询
658
+ return () => {
659
+ stopped = true;
660
+ clearInterval(timer);
661
+ };
662
+ }
663
+ };
664
+ async function handleRoleMenuCompatibility(req, res, auth, aid, agents, parsedUrl) {
665
+ const urlPath = parsedUrl.path;
666
+ const roleRoute = urlPath.match(/^\/api\/role-definitions\/([^/]+)$/);
667
+ const roleNestedRoute = urlPath.match(/^\/api\/role-definitions\/([^/]+)\/([^/]+)$/);
668
+ const decodeRole = (raw) => {
669
+ try {
670
+ return decodeURIComponent(raw);
671
+ }
672
+ catch {
673
+ return raw;
674
+ }
675
+ };
676
+ const requireAid = () => {
677
+ if (aid)
678
+ return true;
679
+ sendJson(res, 400, { error: 'aid is required' });
680
+ return false;
681
+ };
682
+ const execute = async (payload, successStatus = 200) => {
683
+ const response = await execRoleMenu(aid, payload, auth);
684
+ if (response.error) {
685
+ sendRoleMenuError(res, response);
686
+ return null;
687
+ }
688
+ if (successStatus > 0)
689
+ sendJson(res, successStatus, response.data);
690
+ return response.data;
691
+ };
692
+ try {
693
+ if (req.method === 'GET' && urlPath === '/api/role-definitions') {
694
+ if (!requireAid())
695
+ return true;
696
+ try {
697
+ sendJson(res, 200, await buildMenuSnapshot(aid, agents, auth));
698
+ }
699
+ catch (error) {
700
+ if (error?.menuResponse)
701
+ sendRoleMenuError(res, error.menuResponse);
702
+ else
703
+ throw error;
704
+ }
705
+ return true;
706
+ }
707
+ if (req.method === 'GET' && urlPath === '/api/role-definitions/operations') {
708
+ if (!requireAid())
709
+ return true;
710
+ const data = await execute({
711
+ type: 'menu.options', id: `ecweb-role-operations-${Date.now()}`, args: { option: 'operations' },
712
+ }, 0);
713
+ if (data)
714
+ sendJson(res, 200, { operations: data.map((item) => ({ id: item.value, ...item })) });
715
+ return true;
716
+ }
717
+ if (req.method === 'PUT' && urlPath === '/api/role-definitions') {
718
+ if (!requireAid())
719
+ return true;
720
+ const body = await readJsonBody(req);
721
+ if (body.roles !== undefined) {
722
+ sendJson(res, 400, { error: 'Bulk role replacement is not supported; update roles individually' });
723
+ return true;
724
+ }
725
+ const revision = await rolePolicyRevision(aid, auth);
726
+ const data = await execute({
727
+ type: 'menu.update', id: `ecweb-role-defaults-update-${Date.now()}`,
728
+ args: { resource: 'defaultRoles', expectedPolicyRevision: revision },
729
+ value: JSON.stringify(body.defaultRoles ?? {}),
730
+ }, 0);
731
+ if (data)
732
+ sendJson(res, 200, { ok: true, data: await buildMenuSnapshot(aid, agents, auth) });
733
+ return true;
734
+ }
735
+ if (req.method === 'POST' && urlPath === '/api/role-definitions') {
736
+ if (!requireAid())
737
+ return true;
738
+ const body = await readJsonBody(req);
739
+ const { name, ...definition } = body;
740
+ const revision = await rolePolicyRevision(aid, auth);
741
+ const data = await execute({
742
+ type: 'menu.action', id: `ecweb-role-create-${Date.now()}`, action: 'create',
743
+ args: { roleId: name, definition, expectedPolicyRevision: revision },
744
+ }, 0);
745
+ if (data)
746
+ sendJson(res, 201, { ok: true, role: data.definition, policyRevision: data.policyRevision });
747
+ return true;
748
+ }
749
+ if (req.method === 'GET' && roleRoute) {
750
+ if (!requireAid())
751
+ return true;
752
+ const data = await execute({
753
+ type: 'menu.query', id: `ecweb-role-detail-${Date.now()}`,
754
+ args: { view: 'role', roleId: decodeRole(roleRoute[1]) },
755
+ }, 0);
756
+ if (data)
757
+ sendJson(res, 200, data.definition);
758
+ return true;
759
+ }
760
+ if (req.method === 'PUT' && roleRoute) {
761
+ if (!requireAid())
762
+ return true;
763
+ const updates = await readJsonBody(req);
764
+ const revision = await rolePolicyRevision(aid, auth);
765
+ const data = await execute({
766
+ type: 'menu.update', id: `ecweb-role-update-${Date.now()}`,
767
+ args: { resource: 'definition', roleId: decodeRole(roleRoute[1]), expectedPolicyRevision: revision },
768
+ value: JSON.stringify(updates),
769
+ }, 0);
770
+ if (data)
771
+ sendJson(res, 200, { ok: true, policyRevision: data.policyRevision });
772
+ return true;
773
+ }
774
+ if (req.method === 'POST' && roleNestedRoute?.[2] === 'reset') {
775
+ if (!requireAid())
776
+ return true;
777
+ const revision = await rolePolicyRevision(aid, auth);
778
+ const data = await execute({
779
+ type: 'menu.action', id: `ecweb-role-reset-${Date.now()}`, action: 'reset',
780
+ args: { roleId: decodeRole(roleNestedRoute[1]), expectedPolicyRevision: revision },
781
+ }, 0);
782
+ if (data) {
783
+ const detail = await execRoleMenu(aid, {
784
+ type: 'menu.query', id: `ecweb-role-reset-detail-${Date.now()}`,
785
+ args: { view: 'role', roleId: decodeRole(roleNestedRoute[1]) },
786
+ }, auth);
787
+ sendJson(res, 200, { ok: true, role: detail.data?.definition, policyRevision: data.policyRevision });
788
+ }
789
+ return true;
790
+ }
791
+ if (req.method === 'DELETE' && roleRoute) {
792
+ if (!requireAid())
793
+ return true;
794
+ const revision = await rolePolicyRevision(aid, auth);
795
+ const actionArgs = {
796
+ roleId: decodeRole(roleRoute[1]),
797
+ expectedPolicyRevision: revision,
798
+ };
799
+ if (Object.prototype.hasOwnProperty.call(parsedUrl.query, 'replacementRoleId')) {
800
+ actionArgs.replacementRoleId = parsedUrl.query.replacementRoleId || null;
801
+ }
802
+ else if (parsedUrl.query.clearReferences === 'true') {
803
+ actionArgs.replacementRoleId = null;
804
+ }
805
+ const data = await execute({
806
+ type: 'menu.action', id: `ecweb-role-delete-${Date.now()}`, action: 'delete', args: actionArgs,
807
+ }, 0);
808
+ if (data)
809
+ sendJson(res, 200, { ok: true, ...data });
810
+ return true;
811
+ }
812
+ if (req.method === 'PUT' && roleNestedRoute?.[2] === 'model-permissions') {
813
+ if (!requireAid())
814
+ return true;
815
+ const payload = await readJsonBody(req);
816
+ const revision = await rolePolicyRevision(aid, auth);
817
+ const permission = {
818
+ default: payload.defaultModel ?? payload.default,
819
+ allowOverride: payload.allowOverride === true,
820
+ allowedModels: Array.isArray(payload.allowedModels) ? payload.allowedModels : [],
821
+ };
822
+ const data = await execute({
823
+ type: 'menu.update', id: `ecweb-role-model-update-${Date.now()}`,
824
+ args: { resource: 'definition', roleId: decodeRole(roleNestedRoute[1]), expectedPolicyRevision: revision },
825
+ value: JSON.stringify({ permissions: { [MODEL_PERMISSION_FIELD]: permission } }),
826
+ }, 0);
827
+ if (data)
828
+ sendJson(res, 200, { success: true, data: await buildModelPermissionResponse(decodeRole(roleNestedRoute[1]), data.definition) });
829
+ return true;
830
+ }
831
+ }
832
+ catch (error) {
833
+ if (error?.menuResponse)
834
+ sendRoleMenuError(res, error.menuResponse);
835
+ else
836
+ sendJson(res, 500, { error: error?.message || String(error) });
837
+ return true;
838
+ }
839
+ return false;
840
+ }
841
+ // HTTP API handler
842
+ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
843
+ try {
844
+ const parsedUrl = parseUrl(req.url || '');
845
+ const urlPath = parsedUrl.path;
846
+ const agents = await getAgentsFromIpc();
847
+ const aid = resolveRequestedAid(parsedUrl.query.aid, agents);
848
+ if (await handleRoleMenuCompatibility(req, res, auth, aid, agents, parsedUrl))
849
+ return;
850
+ if (isWriteMethod(req.method)) {
851
+ sendJson(res, 405, { error: 'This Role endpoint is not supported; use Role Menu' });
852
+ return;
853
+ }
854
+ const modules = await getRolesModule();
855
+ const { readRolesConfig, getBuiltinRolesConfig, read, ConfigTarget } = modules;
856
+ const roleRoute = urlPath.match(/^\/api\/role-definitions\/([^/]+)$/);
857
+ const roleNestedRoute = urlPath.match(/^\/api\/role-definitions\/([^/]+)\/([^/]+)$/);
858
+ const decodeRoleName = (raw) => {
859
+ try {
860
+ return decodeURIComponent(raw);
861
+ }
862
+ catch {
863
+ return raw;
864
+ }
865
+ };
866
+ if (req.method === 'GET' && urlPath === '/api/role-definitions/operations') {
867
+ const { listOperations } = await getOperationRegistryModule();
868
+ sendJson(res, 200, { operations: listOperations() });
869
+ return;
870
+ }
871
+ if ((isWriteMethod(req.method) || (isReadMethod(req.method) && aid)) && urlPath !== '/api/role-definitions/operations') {
872
+ if (!aid) {
873
+ sendJson(res, 400, { error: 'aid is required' });
874
+ return;
875
+ }
876
+ const processConfig = read(ConfigTarget.Process) || {};
877
+ const operation = isWriteMethod(req.method) ? 'role.policy.write' : 'role.policy.read';
878
+ const decision = await authorizeRolePolicyOperation(modules, processConfig, auth, aid, operation, {
879
+ method: req.method,
880
+ path: urlPath,
881
+ });
882
+ if (!decision.allow) {
883
+ console.warn('[role-definitions] Role policy access forbidden:', {
884
+ method: req.method,
885
+ url: req.url,
886
+ actorAid: auth.actorAid,
887
+ localDirect: !!auth.localDirect,
888
+ operation,
889
+ code: decision.code,
890
+ reason: decision.reason,
891
+ });
892
+ sendJson(res, 403, {
893
+ error: isWriteMethod(req.method)
894
+ ? 'forbidden: agent owner required'
895
+ : 'forbidden: role policy read not allowed',
896
+ code: decision.code,
897
+ reason: decision.reason,
898
+ actorRole: decision.subject?.role,
899
+ });
900
+ return;
901
+ }
902
+ }
903
+ if (req.method === 'GET' && urlPath === '/api/role-definitions') {
904
+ sendJson(res, 200, await buildSnapshot({ aid }));
905
+ return;
906
+ }
907
+ if (req.method === 'PUT' && urlPath === '/api/role-definitions') {
908
+ try {
909
+ const incoming = await readJsonBody(req);
910
+ const currentConfig = readRolesConfig(aid || undefined);
911
+ if (incoming.roles && typeof incoming.roles === 'object') {
912
+ for (const [roleName, roleDef] of Object.entries(incoming.roles)) {
913
+ const nameError = validateRoleDefinitionName(roleName);
914
+ if (nameError) {
915
+ sendJson(res, 400, { error: `${nameError}: ${roleName}` });
916
+ return;
917
+ }
918
+ const validation = await validateRoleModelPermission(roleDef);
919
+ if (!validation.ok) {
920
+ sendJson(res, 400, { error: `Invalid model permissions for role ${roleName}`, errors: validation.errors });
921
+ return;
922
+ }
923
+ const commandValidation = validateCommandPermissions(roleDef?.commandPermissions);
924
+ if (!commandValidation.ok) {
925
+ sendJson(res, 400, { error: `Invalid command permissions for role ${roleName}`, errors: commandValidation.errors });
926
+ return;
927
+ }
928
+ const usageValidation = validateRoleUsageLimits(roleDef?.usageLimits);
929
+ if (!usageValidation.ok) {
930
+ sendJson(res, 400, { error: `Invalid usage limits for role ${roleName}`, errors: usageValidation.errors });
931
+ return;
932
+ }
933
+ }
934
+ }
935
+ updateAgentRoles(modules, aid, (roles) => {
936
+ if (incoming.defaultRoles && typeof incoming.defaultRoles === 'object') {
937
+ roles.defaultRoles = {
938
+ private: normalizeDefaultRoleInput(incoming.defaultRoles.private, currentConfig.defaultRoles?.private),
939
+ group: normalizeDefaultRoleInput(incoming.defaultRoles.group, currentConfig.defaultRoles?.group),
940
+ };
941
+ }
942
+ if (incoming.roles && typeof incoming.roles === 'object') {
943
+ const nextDefinitions = {};
944
+ for (const [roleName, roleDef] of Object.entries(incoming.roles)) {
945
+ nextDefinitions[roleName] = normalizeRoleDefinitionForSave(roleDef);
946
+ }
947
+ roles.definitions = nextDefinitions;
948
+ }
949
+ });
950
+ sendJson(res, 200, { ok: true, data: await buildSnapshot({ aid }) });
951
+ }
952
+ catch (err) {
953
+ console.error('[role-definitions] Failed to update agent role config:', err);
954
+ sendJson(res, 400, { error: err.message });
955
+ }
956
+ return;
957
+ }
958
+ if (req.method === 'POST' && urlPath === '/api/role-definitions') {
959
+ try {
960
+ const data = await readJsonBody(req);
961
+ const { name, description, permissions, commandPermissions, usageLimits } = data;
962
+ const nameError = validateRoleDefinitionName(name);
963
+ if (nameError) {
964
+ sendJson(res, 400, { error: nameError });
965
+ return;
966
+ }
967
+ const config = readRolesConfig(aid || undefined);
968
+ if (config.roles[name]) {
969
+ sendJson(res, 409, { error: 'Role already exists' });
970
+ return;
971
+ }
972
+ const newRole = {
973
+ description: typeof description === 'string' ? description : '',
974
+ allowAccess: typeof data.allowAccess === 'boolean' ? data.allowAccess : true,
975
+ permissions: isRecord(permissions) ? permissions : {},
976
+ commandPermissions: isRecord(commandPermissions) ? commandPermissions : {}
977
+ };
978
+ const validation = await validateRoleModelPermission(newRole);
979
+ if (!validation.ok) {
980
+ sendJson(res, 400, { error: 'Invalid model permissions', errors: validation.errors });
981
+ return;
982
+ }
983
+ const commandValidation = validateCommandPermissions(newRole.commandPermissions);
984
+ if (!commandValidation.ok) {
985
+ sendJson(res, 400, { error: 'Invalid command permissions', errors: commandValidation.errors });
986
+ return;
987
+ }
988
+ const usageValidation = validateRoleUsageLimits(usageLimits);
989
+ if (!usageValidation.ok) {
990
+ sendJson(res, 400, { error: 'Invalid usage limits', errors: usageValidation.errors });
991
+ return;
992
+ }
993
+ if (usageLimits !== undefined) {
994
+ newRole.usageLimits = normalizeRoleUsageLimits(usageLimits);
995
+ }
996
+ updateAgentRoles(modules, aid, (roles) => {
997
+ roles.definitions = { ...(roles.definitions || {}), [name]: newRole };
998
+ });
999
+ sendJson(res, 201, { ok: true, role: newRole });
1000
+ }
1001
+ catch (err) {
1002
+ console.error('[role-definitions] Failed to create role:', err);
1003
+ sendJson(res, 400, { error: err.message });
1004
+ }
1005
+ return;
1006
+ }
1007
+ if (req.method === 'GET' && roleNestedRoute?.[2] === 'configurable-models') {
1008
+ const roleName = decodeRoleName(roleNestedRoute[1]);
1009
+ const config = readRolesConfig(aid || undefined);
1010
+ const roleDef = config.roles[roleName];
1011
+ if (!roleDef) {
1012
+ sendJson(res, 404, { success: false, error: 'Role not found' });
1013
+ return;
1014
+ }
1015
+ const data = await buildModelPermissionResponse(roleName, roleDef);
1016
+ sendJson(res, 200, { success: true, data });
1017
+ return;
1018
+ }
1019
+ if (req.method === 'POST' && roleNestedRoute?.[2] === 'preview-models') {
1020
+ const roleName = decodeRoleName(roleNestedRoute[1]);
1021
+ const config = readRolesConfig(aid || undefined);
1022
+ const roleDef = config.roles[roleName];
1023
+ if (!roleDef) {
1024
+ sendJson(res, 404, { success: false, error: 'Role not found' });
1025
+ return;
1026
+ }
1027
+ const payload = await readJsonBody(req);
1028
+ const currentPerm = getModelPermission(roleDef);
1029
+ const validation = await validateModelPermissionInput(extractModelPermissionPayload(payload, currentPerm));
1030
+ if (!validation.ok || !validation.value) {
1031
+ sendJson(res, 400, { success: false, error: 'Invalid model permissions', errors: validation.errors });
1032
+ return;
1033
+ }
1034
+ const data = await buildModelPermissionResponse(roleName, roleDef, validation.value);
1035
+ sendJson(res, 200, { success: true, data });
1036
+ return;
1037
+ }
1038
+ if (req.method === 'PUT' && roleNestedRoute?.[2] === 'model-permissions') {
1039
+ const roleName = decodeRoleName(roleNestedRoute[1]);
1040
+ const config = readRolesConfig(aid || undefined);
1041
+ const roleDef = config.roles[roleName];
1042
+ if (!roleDef) {
1043
+ sendJson(res, 404, { success: false, error: 'Role not found' });
1044
+ return;
1045
+ }
1046
+ const payload = await readJsonBody(req);
1047
+ const currentPerm = getModelPermission(roleDef);
1048
+ const validation = await validateModelPermissionInput(extractModelPermissionPayload(payload, currentPerm));
1049
+ if (!validation.ok || !validation.value) {
1050
+ sendJson(res, 400, { success: false, error: 'Invalid model permissions', errors: validation.errors });
1051
+ return;
1052
+ }
1053
+ roleDef.permissions = roleDef.permissions && typeof roleDef.permissions === 'object'
1054
+ ? roleDef.permissions
1055
+ : {};
1056
+ const existingPerm = stripSelectionModeFromPermission(getModelPermission(roleDef));
1057
+ roleDef.permissions[MODEL_PERMISSION_FIELD] = {
1058
+ ...existingPerm,
1059
+ default: validation.value.defaultModel,
1060
+ allowOverride: validation.value.allowOverride,
1061
+ allowedModels: validation.value.allowedModels,
1062
+ };
1063
+ updateAgentRoles(modules, aid, (roles) => {
1064
+ roles.definitions = { ...(roles.definitions || {}), [roleName]: roleDef };
1065
+ });
1066
+ const data = await buildModelPermissionResponse(roleName, roleDef, validation.value);
1067
+ sendJson(res, 200, { success: true, data });
1068
+ return;
1069
+ }
1070
+ if (req.method === 'POST' && roleNestedRoute?.[2] === 'reset') {
1071
+ const roleName = decodeRoleName(roleNestedRoute[1]);
1072
+ const builtinConfig = getBuiltinRolesConfig();
1073
+ const builtinRole = builtinConfig.roles[roleName];
1074
+ if (!builtinRole) {
1075
+ sendJson(res, 404, { error: 'Role not found in builtin config' });
1076
+ return;
1077
+ }
1078
+ updateAgentRoles(modules, aid, (roles) => {
1079
+ const definitions = { ...(roles.definitions || {}) };
1080
+ delete definitions[roleName];
1081
+ if (Object.keys(definitions).length)
1082
+ roles.definitions = definitions;
1083
+ else
1084
+ delete roles.definitions;
1085
+ });
1086
+ sendJson(res, 200, { ok: true, role: builtinRole });
1087
+ return;
1088
+ }
1089
+ if (req.method === 'GET' && roleRoute) {
1090
+ const roleName = decodeRoleName(roleRoute[1]);
1091
+ const config = readRolesConfig(aid || undefined);
1092
+ const roleDef = config.roles[roleName];
1093
+ if (!roleDef) {
1094
+ sendJson(res, 404, { error: 'Role not found' });
1095
+ return;
1096
+ }
1097
+ sendJson(res, 200, roleDef);
1098
+ return;
1099
+ }
1100
+ if (req.method === 'DELETE' && roleRoute) {
1101
+ const roleName = decodeRoleName(roleRoute[1]);
1102
+ const builtinRoles = builtinUserRoles(modules);
1103
+ if (builtinRoles.has(roleName) || isReservedRoleName(roleName)) {
1104
+ sendJson(res, 403, { error: 'Cannot delete builtin or management role' });
1105
+ return;
1106
+ }
1107
+ const agentConfig = readAgentConfig(modules, aid);
1108
+ const definitions = getAgentRoleDefinitions(agentConfig);
1109
+ if (!definitions[roleName]) {
1110
+ sendJson(res, 404, { error: 'Role not found' });
1111
+ return;
1112
+ }
1113
+ updateAgentRoles(modules, aid, (roles) => {
1114
+ const nextDefinitions = { ...(roles.definitions || {}) };
1115
+ delete nextDefinitions[roleName];
1116
+ if (Object.keys(nextDefinitions).length)
1117
+ roles.definitions = nextDefinitions;
1118
+ else
1119
+ delete roles.definitions;
1120
+ });
1121
+ sendJson(res, 200, { ok: true });
1122
+ return;
1123
+ }
1124
+ if (req.method === 'PUT' && roleRoute) {
1125
+ try {
1126
+ const roleName = decodeRoleName(roleRoute[1]);
1127
+ const updates = await readJsonBody(req);
1128
+ const nameError = validateRoleDefinitionName(roleName);
1129
+ if (nameError) {
1130
+ sendJson(res, 400, { error: nameError });
1131
+ return;
1132
+ }
1133
+ const config = readRolesConfig(aid || undefined);
1134
+ const currentRole = config.roles[roleName];
1135
+ if (!currentRole) {
1136
+ sendJson(res, 404, { error: 'Role not found' });
1137
+ return;
1138
+ }
1139
+ const nextRole = {
1140
+ ...currentRole,
1141
+ ...updates
1142
+ };
1143
+ if (updates.permissions && typeof updates.permissions === 'object') {
1144
+ const mergedPermissions = { ...(currentRole.permissions || {}) };
1145
+ for (const [permKey, permValue] of Object.entries(updates.permissions)) {
1146
+ const currentPerm = mergedPermissions[permKey];
1147
+ if (currentPerm && typeof currentPerm === 'object' && !Array.isArray(currentPerm) &&
1148
+ permValue && typeof permValue === 'object' && !Array.isArray(permValue)) {
1149
+ mergedPermissions[permKey] = { ...currentPerm, ...permValue };
1150
+ }
1151
+ else {
1152
+ mergedPermissions[permKey] = permValue;
1153
+ }
1154
+ }
1155
+ nextRole.permissions = mergedPermissions;
1156
+ }
1157
+ if (Object.prototype.hasOwnProperty.call(updates, 'commandPermissions')) {
1158
+ const commandValidation = validateCommandPermissions(updates.commandPermissions);
1159
+ if (!commandValidation.ok) {
1160
+ sendJson(res, 400, { error: 'Invalid command permissions', errors: commandValidation.errors });
1161
+ return;
1162
+ }
1163
+ nextRole.commandPermissions = updates.commandPermissions;
1164
+ }
1165
+ if (Object.prototype.hasOwnProperty.call(updates, 'usageLimits')) {
1166
+ const usageValidation = validateRoleUsageLimits(updates.usageLimits);
1167
+ if (!usageValidation.ok) {
1168
+ sendJson(res, 400, { error: 'Invalid usage limits', errors: usageValidation.errors });
1169
+ return;
1170
+ }
1171
+ nextRole.usageLimits = normalizeRoleUsageLimits(updates.usageLimits);
1172
+ }
1173
+ const validation = await validateRoleModelPermission(nextRole);
1174
+ if (!validation.ok) {
1175
+ sendJson(res, 400, { error: 'Invalid model permissions', errors: validation.errors });
1176
+ return;
1177
+ }
1178
+ updateAgentRoles(modules, aid, (roles) => {
1179
+ roles.definitions = { ...(roles.definitions || {}), [roleName]: nextRole };
1180
+ });
1181
+ sendJson(res, 200, { ok: true });
1182
+ }
1183
+ catch (err) {
1184
+ console.error('[role-definitions] Failed to update role:', err);
1185
+ sendJson(res, 400, { error: err.message });
1186
+ }
1187
+ return;
1188
+ }
1189
+ sendJson(res, 404, { error: 'Not found' });
1190
+ }
1191
+ catch (err) {
1192
+ console.error('[role-definitions] API error:', err);
1193
+ sendJson(res, 500, { error: err.message });
1194
+ }
1195
+ }