ec-web 0.0.20 → 0.0.22

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,818 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { resolvePaths } from '../paths.js';
5
+ import { detectBaseAgentDetails } from './baseagent-detector.js';
6
+ import { resolveParentDistModule, toFileUrl } from './parent-package.js';
7
+ const BASEAGENTS = ['claude', 'codex', 'gemini', 'ecagent'];
8
+ const BASEAGENT_SET = new Set(BASEAGENTS);
9
+ const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
10
+ const PROHIBITED_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
11
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
12
+ const AID_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
13
+ const AID_DOMAIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
14
+ const SERVICE_NAME = /^[a-z0-9_-]+$/;
15
+ const SERVICE_SOURCES = new Set(['instance', 'static']);
16
+ const SERVICE_TYPES = new Set(['http', 'websocket', 'ssh']);
17
+ const SERVICE_VISIBILITIES = new Set(['public', 'private', 'aun-auth']);
18
+ const SENSITIVE_METADATA_KEY = /(?:^|[_-])(endpoint|url|uri|token|access[_-]?token|authorization|cookie|secret|password|private[_-]?key|key|cert|certificate)(?:$|[_-])/i;
19
+ const DAEMON_EXACT_PATHS = new Set([
20
+ 'owners',
21
+ 'fullaccess.enabled',
22
+ 'aun.defaultAidDomain',
23
+ 'aun.minEvolVersion',
24
+ 'aun.menuTokenRequired',
25
+ 'aun.defaultEncrypt',
26
+ 'services',
27
+ 'configSnapshots',
28
+ 'idleMonitor.enabled',
29
+ 'idleMonitor.timeout',
30
+ 'idleMonitor.apiRetryTimeout',
31
+ 'idleMonitor.maxExecutionTime',
32
+ 'watch.logTypes',
33
+ 'debug.logLevel',
34
+ 'debug.aunTrace',
35
+ 'debug.aunSdkLog',
36
+ 'debug.upmsg',
37
+ 'debug.eckSnapshots',
38
+ ]);
39
+ const DEFAULTS_EXACT_PATHS = new Set([
40
+ 'active_baseagent',
41
+ 'projects.defaultPath',
42
+ 'responseMode',
43
+ 'responseModeParams.single-session.pre_tool_1stmsgchk',
44
+ 'responseModeParams.single-session.tool_use_reminder',
45
+ 'config.chatMode',
46
+ 'config.mentionMode',
47
+ 'config.show_activities',
48
+ 'session_renew.enabled',
49
+ 'session_renew.after_hours',
50
+ 'session_renew.fallback_action',
51
+ ]);
52
+ const BASEAGENT_COMMON_FIELDS = new Set([
53
+ 'model',
54
+ 'auxiliaryModel',
55
+ 'auxiliaryEffort',
56
+ 'fallbackModels',
57
+ 'apiKey',
58
+ ]);
59
+ const BASEAGENT_FIELDS = {
60
+ claude: new Set([
61
+ ...BASEAGENT_COMMON_FIELDS,
62
+ 'effort',
63
+ 'baseUrl',
64
+ 'headers',
65
+ 'queryParams',
66
+ 'pathToClaudeCodeExecutable',
67
+ 'useSettingSources',
68
+ 'agentProgressSummaries',
69
+ 'excludeDynamicSections',
70
+ ]),
71
+ codex: new Set([
72
+ ...BASEAGENT_COMMON_FIELDS,
73
+ 'effort',
74
+ 'baseUrl',
75
+ 'headers',
76
+ 'queryParams',
77
+ 'enableRequestUserInput',
78
+ 'approvalsReviewer',
79
+ ]),
80
+ gemini: new Set([
81
+ ...BASEAGENT_COMMON_FIELDS,
82
+ 'cliPath',
83
+ 'mode',
84
+ 'useVertex',
85
+ 'project',
86
+ 'location',
87
+ ]),
88
+ ecagent: new Set([
89
+ ...BASEAGENT_COMMON_FIELDS,
90
+ 'effort',
91
+ 'baseUrl',
92
+ 'headers',
93
+ 'queryParams',
94
+ 'timeoutMs',
95
+ 'maxRetries',
96
+ 'maxRetryDelayMs',
97
+ ]),
98
+ };
99
+ const BOOLEAN_PATHS = new Set([
100
+ 'fullaccess.enabled',
101
+ 'aun.menuTokenRequired',
102
+ 'aun.defaultEncrypt',
103
+ 'configSnapshots',
104
+ 'idleMonitor.enabled',
105
+ 'debug.aunTrace',
106
+ 'debug.aunSdkLog',
107
+ 'debug.upmsg',
108
+ 'debug.eckSnapshots',
109
+ 'responseModeParams.single-session.pre_tool_1stmsgchk',
110
+ 'responseModeParams.single-session.tool_use_reminder',
111
+ 'session_renew.enabled',
112
+ ]);
113
+ const BASEAGENT_BOOLEAN_FIELDS = new Set([
114
+ 'useSettingSources',
115
+ 'agentProgressSummaries',
116
+ 'excludeDynamicSections',
117
+ 'enableRequestUserInput',
118
+ 'useVertex',
119
+ ]);
120
+ const RESTART_DAEMON_PREFIXES = [
121
+ 'owners',
122
+ 'aun.',
123
+ 'services',
124
+ 'configSnapshots',
125
+ 'idleMonitor.',
126
+ 'debug.',
127
+ ];
128
+ const RESTART_DEFAULTS_PREFIXES = [
129
+ 'active_baseagent',
130
+ 'baseagents.',
131
+ ];
132
+ let configManagerPromise = null;
133
+ async function getConfigManager() {
134
+ if (!configManagerPromise) {
135
+ configManagerPromise = import(toFileUrl(resolveParentDistModule('config', 'config-manager.js')))
136
+ .then((mod) => {
137
+ if (!mod.ConfigTarget || typeof mod.read !== 'function' || typeof mod.mutateConfig !== 'function' || typeof mod.validateConfig !== 'function') {
138
+ throw new Error('Required ConfigManager exports are unavailable');
139
+ }
140
+ return mod;
141
+ });
142
+ }
143
+ return configManagerPromise;
144
+ }
145
+ function isRecord(value) {
146
+ return !!value && typeof value === 'object' && !Array.isArray(value);
147
+ }
148
+ function clone(value) {
149
+ return JSON.parse(JSON.stringify(value));
150
+ }
151
+ function stableValue(value) {
152
+ if (Array.isArray(value))
153
+ return value.map(stableValue);
154
+ if (!isRecord(value))
155
+ return value;
156
+ return Object.fromEntries(Object.entries(value)
157
+ .sort(([left], [right]) => left.localeCompare(right))
158
+ .map(([key, nested]) => [key, stableValue(nested)]));
159
+ }
160
+ export function basicSettingsRevision(config) {
161
+ return crypto.createHash('sha256').update(JSON.stringify(stableValue(config))).digest('hex');
162
+ }
163
+ function pathSegments(fieldPath) {
164
+ if (!fieldPath || fieldPath.startsWith('.') || fieldPath.endsWith('.') || fieldPath.includes('..')) {
165
+ throw new Error(`Invalid settings path: ${fieldPath || '(empty)'}`);
166
+ }
167
+ const segments = fieldPath.split('.');
168
+ if (segments.some(segment => !segment || PROHIBITED_PATH_SEGMENTS.has(segment))) {
169
+ throw new Error(`Unsafe settings path: ${fieldPath}`);
170
+ }
171
+ return segments;
172
+ }
173
+ function baseagentPath(fieldPath) {
174
+ const match = fieldPath.match(/^baseagents\.([a-z0-9_-]+)\.([A-Za-z][A-Za-z0-9]*)$/);
175
+ if (!match)
176
+ return null;
177
+ return { baseagent: match[1], field: match[2] };
178
+ }
179
+ export function isAllowedBasicSettingsPath(file, fieldPath) {
180
+ try {
181
+ pathSegments(fieldPath);
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ if (file === 'daemon')
187
+ return DAEMON_EXACT_PATHS.has(fieldPath);
188
+ if (DEFAULTS_EXACT_PATHS.has(fieldPath))
189
+ return true;
190
+ const parsed = baseagentPath(fieldPath);
191
+ return !!parsed && BASEAGENT_SET.has(parsed.baseagent) && BASEAGENT_FIELDS[parsed.baseagent]?.has(parsed.field);
192
+ }
193
+ function assertString(value, fieldPath, options = {}) {
194
+ if (typeof value !== 'string')
195
+ throw new Error(`${fieldPath} must be a string`);
196
+ if (!options.allowEmpty && !value.trim())
197
+ throw new Error(`${fieldPath} cannot be empty`);
198
+ if (value.length > (options.max ?? 4096))
199
+ throw new Error(`${fieldPath} is too long`);
200
+ return value;
201
+ }
202
+ function assertBoolean(value, fieldPath) {
203
+ if (typeof value !== 'boolean')
204
+ throw new Error(`${fieldPath} must be a boolean`);
205
+ }
206
+ function assertFiniteNumber(value, fieldPath, options = {}) {
207
+ if (typeof value !== 'number' || !Number.isFinite(value))
208
+ throw new Error(`${fieldPath} must be a finite number`);
209
+ if (options.integer && !Number.isInteger(value))
210
+ throw new Error(`${fieldPath} must be an integer`);
211
+ if (options.min !== undefined && value < options.min)
212
+ throw new Error(`${fieldPath} must be at least ${options.min}`);
213
+ if (options.exclusiveMin !== undefined && value <= options.exclusiveMin)
214
+ throw new Error(`${fieldPath} must be greater than ${options.exclusiveMin}`);
215
+ if (options.max !== undefined && value > options.max)
216
+ throw new Error(`${fieldPath} must be at most ${options.max}`);
217
+ return value;
218
+ }
219
+ function isValidAid(value) {
220
+ const labels = value.split('.');
221
+ return labels.length >= 3 && labels.every(label => AID_LABEL.test(label));
222
+ }
223
+ function assertStringMap(value, fieldPath, headerMap) {
224
+ if (!isRecord(value))
225
+ throw new Error(`${fieldPath} must be an object`);
226
+ if (Object.keys(value).length > 100)
227
+ throw new Error(`${fieldPath} has too many entries`);
228
+ for (const [key, nested] of Object.entries(value)) {
229
+ if (!key || key.length > 256 || PROHIBITED_PATH_SEGMENTS.has(key))
230
+ throw new Error(`${fieldPath} contains an invalid key`);
231
+ if (headerMap && !HEADER_NAME.test(key))
232
+ throw new Error(`${fieldPath} contains an invalid HTTP header name: ${key}`);
233
+ if (typeof nested !== 'string')
234
+ throw new Error(`${fieldPath}.${key} must be a string`);
235
+ if (nested.length > 8192)
236
+ throw new Error(`${fieldPath}.${key} is too long`);
237
+ if (headerMap && /[\0\r\n]/.test(nested))
238
+ throw new Error(`${fieldPath}.${key} cannot contain NUL or line breaks`);
239
+ }
240
+ }
241
+ function assertServiceEndpoint(value, fieldPath) {
242
+ const raw = assertString(value, fieldPath, { max: 2048 }).trim();
243
+ let parsed;
244
+ try {
245
+ parsed = new URL(raw);
246
+ }
247
+ catch {
248
+ throw new Error(`${fieldPath} must be a valid URL`);
249
+ }
250
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) {
251
+ throw new Error(`${fieldPath} must use http, https, ws, or wss`);
252
+ }
253
+ const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
254
+ if (host !== 'localhost' && !/^127(?:\.\d{1,3}){3}$/.test(host)) {
255
+ throw new Error(`${fieldPath} must point to localhost or 127.0.0.0/8`);
256
+ }
257
+ }
258
+ function assertServiceMetadata(value, fieldPath) {
259
+ assertStringMap(value, fieldPath, false);
260
+ for (const key of Object.keys(value)) {
261
+ if (SENSITIVE_METADATA_KEY.test(key))
262
+ throw new Error(`${fieldPath} cannot contain sensitive key: ${key}`);
263
+ }
264
+ }
265
+ function assertOwners(value) {
266
+ if (!Array.isArray(value) || value.length === 0)
267
+ throw new Error('owners must contain at least one AID');
268
+ if (value.length > 100)
269
+ throw new Error('owners has too many entries');
270
+ const normalized = value.map(item => assertString(item, 'owners[]', { max: 253 }).trim());
271
+ if (normalized.some(owner => !isValidAid(owner)))
272
+ throw new Error('owners contains an invalid AID');
273
+ if (new Set(normalized).size !== normalized.length)
274
+ throw new Error('owners contains duplicate AIDs');
275
+ }
276
+ function assertServices(value) {
277
+ if (!Array.isArray(value))
278
+ throw new Error('services must be an array');
279
+ if (value.length > 100)
280
+ throw new Error('services has too many entries');
281
+ const names = new Set();
282
+ value.forEach((service, index) => {
283
+ if (!isRecord(service))
284
+ throw new Error(`services[${index}] must be an object`);
285
+ const allowedKeys = new Set(['name', 'enabled', 'port', 'proxy']);
286
+ if (Object.keys(service).some(key => !allowedKeys.has(key)))
287
+ throw new Error(`services[${index}] contains an unsupported field`);
288
+ const name = assertString(service.name, `services[${index}].name`, { max: 64 });
289
+ if (!SERVICE_NAME.test(name))
290
+ throw new Error(`services[${index}].name must contain only lowercase letters, digits, _ or -`);
291
+ if (names.has(name))
292
+ throw new Error(`services contains duplicate name: ${name}`);
293
+ names.add(name);
294
+ assertBoolean(service.enabled, `services[${index}].enabled`);
295
+ if (service.port !== undefined)
296
+ assertFiniteNumber(service.port, `services[${index}].port`, { integer: true, min: 1, max: 65535 });
297
+ if (service.proxy !== undefined) {
298
+ if (!isRecord(service.proxy))
299
+ throw new Error(`services[${index}].proxy must be an object`);
300
+ const allowedProxyKeys = new Set(['enabled', 'source', 'endpoint', 'serviceType', 'visibility', 'metadata']);
301
+ if (Object.keys(service.proxy).some(key => !allowedProxyKeys.has(key)))
302
+ throw new Error(`services[${index}].proxy contains an unsupported field`);
303
+ if (service.proxy.enabled !== undefined)
304
+ assertBoolean(service.proxy.enabled, `services[${index}].proxy.enabled`);
305
+ if (service.proxy.source !== undefined && !SERVICE_SOURCES.has(String(service.proxy.source)))
306
+ throw new Error(`services[${index}].proxy.source must be instance or static`);
307
+ if (service.proxy.endpoint !== undefined)
308
+ assertServiceEndpoint(service.proxy.endpoint, `services[${index}].proxy.endpoint`);
309
+ if (service.proxy.serviceType !== undefined && !SERVICE_TYPES.has(String(service.proxy.serviceType))) {
310
+ throw new Error(`services[${index}].proxy.serviceType must be http, websocket, or ssh`);
311
+ }
312
+ if (service.proxy.visibility !== undefined && !SERVICE_VISIBILITIES.has(String(service.proxy.visibility))) {
313
+ throw new Error(`services[${index}].proxy.visibility is invalid`);
314
+ }
315
+ if (service.proxy.metadata !== undefined)
316
+ assertServiceMetadata(service.proxy.metadata, `services[${index}].proxy.metadata`);
317
+ if (service.proxy.enabled === true && (!SERVICE_SOURCES.has(String(service.proxy.source)) || !service.proxy.visibility)) {
318
+ throw new Error(`services[${index}].proxy requires source and visibility when enabled`);
319
+ }
320
+ if (service.proxy.enabled === true && service.proxy.source === 'static' && !service.proxy.endpoint) {
321
+ throw new Error(`services[${index}].proxy.endpoint is required when source is static`);
322
+ }
323
+ }
324
+ });
325
+ }
326
+ function assertStringArray(value, fieldPath) {
327
+ if (!Array.isArray(value))
328
+ throw new Error(`${fieldPath} must be an array`);
329
+ if (value.length > 100)
330
+ throw new Error(`${fieldPath} has too many entries`);
331
+ const strings = value.map(item => assertString(item, `${fieldPath}[]`, { max: 256 }).trim());
332
+ if (new Set(strings).size !== strings.length)
333
+ throw new Error(`${fieldPath} contains duplicate values`);
334
+ }
335
+ function assertFallbackModels(value, fieldPath) {
336
+ if (!Array.isArray(value))
337
+ throw new Error(`${fieldPath} must be an array`);
338
+ if (value.length > 3)
339
+ throw new Error(`${fieldPath} supports at most 3 models`);
340
+ const models = new Set();
341
+ value.forEach((item, index) => {
342
+ if (!isRecord(item))
343
+ throw new Error(`${fieldPath}[${index}] must be an object`);
344
+ if (Object.keys(item).some(key => key !== 'model' && key !== 'effort'))
345
+ throw new Error(`${fieldPath}[${index}] contains an unsupported field`);
346
+ const model = assertString(item.model, `${fieldPath}[${index}].model`, { max: 512 }).trim();
347
+ if (models.has(model))
348
+ throw new Error(`${fieldPath} contains duplicate models`);
349
+ models.add(model);
350
+ if (item.effort !== undefined) {
351
+ const effort = assertString(item.effort, `${fieldPath}[${index}].effort`, { max: 16 });
352
+ if (!EFFORTS.has(effort))
353
+ throw new Error(`${fieldPath}[${index}].effort is invalid`);
354
+ }
355
+ });
356
+ }
357
+ function assertHttpUrl(value, fieldPath) {
358
+ const raw = assertString(value, fieldPath, { max: 2048 }).trim();
359
+ let parsed;
360
+ try {
361
+ parsed = new URL(raw);
362
+ }
363
+ catch {
364
+ throw new Error(`${fieldPath} must be a valid URL`);
365
+ }
366
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
367
+ throw new Error(`${fieldPath} must use http or https`);
368
+ }
369
+ function validateDaemonValue(fieldPath, value) {
370
+ if (BOOLEAN_PATHS.has(fieldPath))
371
+ return assertBoolean(value, fieldPath);
372
+ if (fieldPath === 'owners')
373
+ return assertOwners(value);
374
+ if (fieldPath === 'services')
375
+ return assertServices(value);
376
+ if (fieldPath === 'watch.logTypes')
377
+ return assertStringArray(value, fieldPath);
378
+ if (fieldPath === 'aun.defaultAidDomain') {
379
+ const domain = assertString(value, fieldPath, { max: 253 }).trim();
380
+ if (!AID_DOMAIN.test(domain) || /^(?:\d{1,3}\.){3}\d{1,3}$/.test(domain) || domain.toLowerCase().endsWith('.localhost')) {
381
+ throw new Error(`${fieldPath} must be a valid multi-level domain`);
382
+ }
383
+ return;
384
+ }
385
+ if (fieldPath === 'aun.minEvolVersion') {
386
+ const version = assertString(value, fieldPath, { max: 64 }).trim();
387
+ if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version))
388
+ throw new Error(`${fieldPath} must be a semantic version such as 1.6.0`);
389
+ return;
390
+ }
391
+ if (fieldPath === 'debug.logLevel') {
392
+ if (!['DEBUG', 'INFO', 'WARN', 'ERROR'].includes(String(value)))
393
+ throw new Error(`${fieldPath} is invalid`);
394
+ return;
395
+ }
396
+ if (fieldPath === 'idleMonitor.timeout' || fieldPath === 'idleMonitor.apiRetryTimeout') {
397
+ assertFiniteNumber(value, fieldPath, { exclusiveMin: 0 });
398
+ return;
399
+ }
400
+ if (fieldPath === 'idleMonitor.maxExecutionTime') {
401
+ assertFiniteNumber(value, fieldPath, { min: 0 });
402
+ return;
403
+ }
404
+ throw new Error(`Unsupported daemon settings path: ${fieldPath}`);
405
+ }
406
+ function validateBaseagentValue(baseagent, field, fieldPath, value) {
407
+ if (BASEAGENT_BOOLEAN_FIELDS.has(field))
408
+ return assertBoolean(value, fieldPath);
409
+ if (field === 'model' || field === 'auxiliaryModel' || field === 'apiKey' || field === 'pathToClaudeCodeExecutable' || field === 'cliPath' || field === 'project' || field === 'location') {
410
+ const stringValue = assertString(value, fieldPath, { max: field === 'apiKey' ? 8192 : 4096 });
411
+ if (field === 'apiKey' && ['<redacted>', '******'].includes(stringValue))
412
+ throw new Error(`${fieldPath} must contain a replacement secret, not the redacted placeholder`);
413
+ return;
414
+ }
415
+ if (field === 'effort' || field === 'auxiliaryEffort') {
416
+ const effort = assertString(value, fieldPath, { max: 16 });
417
+ if (!EFFORTS.has(effort))
418
+ throw new Error(`${fieldPath} is invalid`);
419
+ return;
420
+ }
421
+ if (field === 'baseUrl')
422
+ return assertHttpUrl(value, fieldPath);
423
+ if (field === 'headers')
424
+ return assertStringMap(value, fieldPath, true);
425
+ if (field === 'queryParams')
426
+ return assertStringMap(value, fieldPath, false);
427
+ if (field === 'fallbackModels')
428
+ return assertFallbackModels(value, fieldPath);
429
+ if (field === 'approvalsReviewer') {
430
+ if (!['user', 'auto_review', 'guardian_subagent'].includes(String(value)))
431
+ throw new Error(`${fieldPath} is invalid`);
432
+ return;
433
+ }
434
+ if (field === 'mode') {
435
+ if (baseagent !== 'gemini' || !['cli', 'sdk'].includes(String(value)))
436
+ throw new Error(`${fieldPath} is invalid`);
437
+ return;
438
+ }
439
+ if (field === 'timeoutMs')
440
+ return void assertFiniteNumber(value, fieldPath, { integer: true, min: 1, max: 3_600_000 });
441
+ if (field === 'maxRetries')
442
+ return void assertFiniteNumber(value, fieldPath, { integer: true, min: 0, max: 100 });
443
+ if (field === 'maxRetryDelayMs')
444
+ return void assertFiniteNumber(value, fieldPath, { integer: true, min: 0, max: 3_600_000 });
445
+ throw new Error(`Unsupported Base Agent settings path: ${fieldPath}`);
446
+ }
447
+ function validateDefaultsValue(fieldPath, value) {
448
+ if (BOOLEAN_PATHS.has(fieldPath))
449
+ return assertBoolean(value, fieldPath);
450
+ const parsed = baseagentPath(fieldPath);
451
+ if (parsed)
452
+ return validateBaseagentValue(parsed.baseagent, parsed.field, fieldPath, value);
453
+ if (fieldPath === 'active_baseagent') {
454
+ if (!BASEAGENT_SET.has(String(value)))
455
+ throw new Error(`${fieldPath} is invalid`);
456
+ return;
457
+ }
458
+ if (fieldPath === 'projects.defaultPath') {
459
+ const configuredPath = assertString(value, fieldPath, { max: 4096 }).trim();
460
+ if (!path.isAbsolute(configuredPath))
461
+ throw new Error(`${fieldPath} must be an absolute path`);
462
+ return;
463
+ }
464
+ if (fieldPath === 'responseMode') {
465
+ if (value !== 'single-session')
466
+ throw new Error(`${fieldPath} must be single-session`);
467
+ return;
468
+ }
469
+ if (fieldPath === 'config.chatMode') {
470
+ if (!['interactive', 'proactive'].includes(String(value)))
471
+ throw new Error(`${fieldPath} is invalid`);
472
+ return;
473
+ }
474
+ if (fieldPath === 'config.mentionMode') {
475
+ if (!['disabled', 'mention-only'].includes(String(value)))
476
+ throw new Error(`${fieldPath} is invalid`);
477
+ return;
478
+ }
479
+ if (fieldPath === 'config.show_activities') {
480
+ if (!['all', 'text', 'none'].includes(String(value)))
481
+ throw new Error(`${fieldPath} is invalid`);
482
+ return;
483
+ }
484
+ if (fieldPath === 'session_renew.after_hours')
485
+ return void assertFiniteNumber(value, fieldPath, { exclusiveMin: 0 });
486
+ if (fieldPath === 'session_renew.fallback_action') {
487
+ if (!['continue', 'new'].includes(String(value)))
488
+ throw new Error(`${fieldPath} is invalid`);
489
+ return;
490
+ }
491
+ throw new Error(`Unsupported defaults settings path: ${fieldPath}`);
492
+ }
493
+ export function validateBasicSettingsChange(file, change) {
494
+ if (!isRecord(change))
495
+ throw new Error('Each change must be an object');
496
+ if (!isAllowedBasicSettingsPath(file, change.path))
497
+ throw new Error(`Settings path is not editable: ${change.path}`);
498
+ if (change.op !== 'set' && change.op !== 'unset')
499
+ throw new Error(`Invalid operation for ${change.path}`);
500
+ if (change.op === 'unset') {
501
+ if (change.path === 'owners' || change.path === 'active_baseagent' || change.path === 'services') {
502
+ throw new Error(`${change.path} cannot be unset`);
503
+ }
504
+ return;
505
+ }
506
+ if (!Object.prototype.hasOwnProperty.call(change, 'value'))
507
+ throw new Error(`Missing value for ${change.path}`);
508
+ if (file === 'daemon')
509
+ validateDaemonValue(change.path, change.value);
510
+ else
511
+ validateDefaultsValue(change.path, change.value);
512
+ }
513
+ function validateBaseagentAvailability(config, changes) {
514
+ const details = detectBaseAgentDetails(config);
515
+ const detected = new Set(details.filter(item => item.detected).map(item => item.id));
516
+ const configured = new Set(details.filter(item => item.configured).map(item => item.id));
517
+ for (const change of changes) {
518
+ if (change.path === 'active_baseagent' && change.op === 'set' && !detected.has(String(change.value))) {
519
+ throw Object.assign(new Error(`active_baseagent is not available on this machine: ${String(change.value)}`), { statusCode: 400 });
520
+ }
521
+ const parsed = baseagentPath(change.path);
522
+ if (parsed && !detected.has(parsed.baseagent) && !configured.has(parsed.baseagent)) {
523
+ throw Object.assign(new Error(`Base Agent is not available on this machine: ${parsed.baseagent}`), { statusCode: 400 });
524
+ }
525
+ }
526
+ }
527
+ function setAtPath(root, segments, value) {
528
+ let cursor = root;
529
+ for (const segment of segments.slice(0, -1)) {
530
+ if (!isRecord(cursor[segment]))
531
+ cursor[segment] = {};
532
+ cursor = cursor[segment];
533
+ }
534
+ cursor[segments[segments.length - 1]] = clone(value);
535
+ }
536
+ function unsetAtPath(root, segments) {
537
+ let cursor = root;
538
+ for (const segment of segments.slice(0, -1)) {
539
+ if (!isRecord(cursor[segment]))
540
+ return;
541
+ cursor = cursor[segment];
542
+ }
543
+ delete cursor[segments[segments.length - 1]];
544
+ }
545
+ export function applyBasicSettingsChanges(config, file, changes) {
546
+ if (!Array.isArray(changes) || changes.length === 0)
547
+ throw new Error('changes must be a non-empty array');
548
+ if (changes.length > 200)
549
+ throw new Error('Too many settings changes');
550
+ const seen = new Set();
551
+ for (const change of changes) {
552
+ validateBasicSettingsChange(file, change);
553
+ if (seen.has(change.path))
554
+ throw new Error(`Duplicate settings path: ${change.path}`);
555
+ seen.add(change.path);
556
+ const segments = pathSegments(change.path);
557
+ if (change.op === 'set')
558
+ setAtPath(config, segments, change.value);
559
+ else
560
+ unsetAtPath(config, segments);
561
+ }
562
+ return config;
563
+ }
564
+ export function restartRequiredForChanges(file, changes) {
565
+ const prefixes = file === 'daemon' ? RESTART_DAEMON_PREFIXES : RESTART_DEFAULTS_PREFIXES;
566
+ return changes.map(change => change.path).filter(fieldPath => prefixes.some(prefix => fieldPath === prefix || fieldPath.startsWith(prefix)));
567
+ }
568
+ function redactSecrets(config, file, secrets, secretRefs, options = {}) {
569
+ const redacted = clone(config);
570
+ if (file !== 'defaults' || !isRecord(redacted.baseagents))
571
+ return redacted;
572
+ for (const [name, raw] of Object.entries(redacted.baseagents)) {
573
+ if (!isRecord(raw))
574
+ continue;
575
+ if (Object.prototype.hasOwnProperty.call(raw, 'apiKey')) {
576
+ const keyPath = `defaults.baseagents.${name}.apiKey`;
577
+ const value = typeof raw.apiKey === 'string' ? raw.apiKey.trim() : '';
578
+ const envMatch = value.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/) || value.match(/^\$ENV:([A-Za-z_][A-Za-z0-9_]*)$/);
579
+ if (envMatch) {
580
+ secretRefs[keyPath] = envMatch[1];
581
+ raw.apiKey = envMatch[1];
582
+ }
583
+ else {
584
+ secrets[keyPath] = true;
585
+ raw.apiKey = '******';
586
+ }
587
+ }
588
+ // Headers and query parameters are also common credential channels. They
589
+ // remain visible to local callers for editing, but remote snapshots must
590
+ // not disclose any map value, including values under custom key names.
591
+ if (options.redactConnectionMaps) {
592
+ for (const field of ['headers', 'queryParams']) {
593
+ if (!Object.prototype.hasOwnProperty.call(raw, field))
594
+ continue;
595
+ const fieldPath = `defaults.baseagents.${name}.${field}`;
596
+ if (isRecord(raw[field])) {
597
+ raw[field] = Object.fromEntries(Object.keys(raw[field]).map(key => {
598
+ secrets[`${fieldPath}.${key}`] = true;
599
+ return [key, '******'];
600
+ }));
601
+ }
602
+ else {
603
+ secrets[fieldPath] = true;
604
+ raw[field] = '******';
605
+ }
606
+ }
607
+ }
608
+ }
609
+ return redacted;
610
+ }
611
+ const WATCH_LOG_FILE = /\.(?:log|jsonl|out)(?:\..+)?$/;
612
+ /**
613
+ * Keep this normalization aligned with `ec watch log`'s shortLogName().
614
+ * Configured values may already be shortened names or rotated names without
615
+ * a file extension, so both forms use the same date/hour suffix rules.
616
+ */
617
+ function shortLogName(fileName) {
618
+ const baseName = path.basename(fileName.trim());
619
+ const stem = baseName.replace(WATCH_LOG_FILE, '');
620
+ return stem
621
+ .replace(/-\d{8}-\d{2}$/, '')
622
+ .replace(/-\d{8}$/, '')
623
+ .replace(/-\d{4}-\d{2}-\d{2}(?:-\d{2})?$/, '');
624
+ }
625
+ function logicalLogType(fileName) {
626
+ const baseName = path.basename(fileName);
627
+ return WATCH_LOG_FILE.test(baseName) ? shortLogName(baseName) : null;
628
+ }
629
+ export function collectLogTypeStats(configured, logsDir = resolvePaths().logs) {
630
+ const counts = new Map();
631
+ if (Array.isArray(configured)) {
632
+ configured
633
+ .filter(item => typeof item === 'string' && item.trim())
634
+ .forEach(item => {
635
+ const type = shortLogName(item);
636
+ if (type)
637
+ counts.set(type, counts.get(type) || 0);
638
+ });
639
+ }
640
+ try {
641
+ for (const entry of fs.readdirSync(logsDir, { withFileTypes: true })) {
642
+ if (!entry.isFile())
643
+ continue;
644
+ const type = logicalLogType(entry.name);
645
+ if (type)
646
+ counts.set(type, (counts.get(type) || 0) + 1);
647
+ }
648
+ }
649
+ catch {
650
+ // An empty or not-yet-created logs directory is a valid state.
651
+ }
652
+ const types = [...counts.keys()].sort((left, right) => left.localeCompare(right));
653
+ return { types, counts: Object.fromEntries(types.map(type => [type, counts.get(type) || 0])) };
654
+ }
655
+ export function collectLogTypes(configured, logsDir = resolvePaths().logs) {
656
+ return collectLogTypeStats(configured, logsDir).types;
657
+ }
658
+ async function readSnapshot(options = {}) {
659
+ const modules = await getConfigManager();
660
+ const daemonFile = modules.read(modules.ConfigTarget.Process, undefined, { cache: false, expand: false });
661
+ const defaultsFile = modules.read(modules.ConfigTarget.Defaults, undefined, { cache: false, expand: false });
662
+ // mutateConfig starts a missing file from the schema skeleton. Use the same
663
+ // logical value for the optimistic-concurrency revision so first save works.
664
+ const rawDaemon = daemonFile ?? { $schema_version: 6 };
665
+ const rawDefaults = defaultsFile ?? { $schema_version: 5 };
666
+ const secrets = {};
667
+ const secretRefs = {};
668
+ const logTypeStats = collectLogTypeStats(rawDaemon?.watch?.logTypes);
669
+ return {
670
+ daemon: redactSecrets(rawDaemon, 'daemon', secrets, secretRefs, options),
671
+ defaults: redactSecrets(rawDefaults, 'defaults', secrets, secretRefs, options),
672
+ revisions: {
673
+ daemon: basicSettingsRevision(rawDaemon),
674
+ defaults: basicSettingsRevision(rawDefaults),
675
+ },
676
+ secrets,
677
+ secretRefs,
678
+ baseagents: detectBaseAgentDetails(rawDefaults),
679
+ logTypes: logTypeStats.types,
680
+ logTypeCounts: logTypeStats.counts,
681
+ };
682
+ }
683
+ function sendJson(res, status, payload) {
684
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
685
+ res.end(JSON.stringify(payload));
686
+ }
687
+ function readJsonBody(req, maxBytes = 512 * 1024) {
688
+ return new Promise((resolve, reject) => {
689
+ let body = '';
690
+ req.on('data', (chunk) => {
691
+ body += chunk.toString();
692
+ if (body.length > maxBytes) {
693
+ reject(Object.assign(new Error('Request body too large'), { statusCode: 413 }));
694
+ req.destroy();
695
+ }
696
+ });
697
+ req.on('end', () => {
698
+ try {
699
+ resolve(body.trim() ? JSON.parse(body) : {});
700
+ }
701
+ catch {
702
+ reject(Object.assign(new Error('Request body must be valid JSON'), { statusCode: 400 }));
703
+ }
704
+ });
705
+ req.on('error', reject);
706
+ });
707
+ }
708
+ function errorStatus(error) {
709
+ if (Number.isInteger(error?.statusCode))
710
+ return error.statusCode;
711
+ if (error?.code === 'BASIC_SETTINGS_REVISION_CONFLICT')
712
+ return 409;
713
+ if (error?.code === 'SCHEMA_INVALID' || error?.code === 'AUN_GATEWAY_URL_REMOVED')
714
+ return 400;
715
+ if (error instanceof Error && /settings|changes|must|cannot|invalid|unsupported|duplicate|empty|value|path/i.test(error.message))
716
+ return 400;
717
+ return 500;
718
+ }
719
+ function requestedSecretPath(file, fieldPath) {
720
+ if (file !== 'defaults')
721
+ return false;
722
+ const match = fieldPath.match(/^baseagents\.([a-z0-9_-]+)\.apiKey$/);
723
+ return !!match && BASEAGENT_SET.has(match[1]);
724
+ }
725
+ function readAtPath(root, fieldPath) {
726
+ return fieldPath.split('.').reduce((current, segment) => {
727
+ if (!isRecord(current))
728
+ return undefined;
729
+ return current[segment];
730
+ }, root);
731
+ }
732
+ /**
733
+ * Normalize the API pathname before matching it. Service proxies may forward
734
+ * either origin-form or absolute-form request targets, and some proxy layers
735
+ * append a trailing slash to nested resources.
736
+ */
737
+ function basicSettingsPath(rawUrl) {
738
+ try {
739
+ const pathname = new URL(rawUrl || '/', 'http://localhost').pathname;
740
+ return pathname.replace(/\/+$/, '') || '/';
741
+ }
742
+ catch {
743
+ return rawUrl.split('?')[0].replace(/\/+$/, '') || '/';
744
+ }
745
+ }
746
+ export async function handleBasicSettingsApi(req, res, options = {}) {
747
+ try {
748
+ const url = new URL(req.url || '', 'http://localhost');
749
+ const pathname = basicSettingsPath(req.url || '');
750
+ if (req.method === 'GET' && pathname === '/api/basic-settings') {
751
+ sendJson(res, 200, { success: true, data: await readSnapshot(options) });
752
+ return;
753
+ }
754
+ if (req.method === 'GET' && pathname === '/api/basic-settings/secret') {
755
+ const file = url.searchParams.get('file') || 'defaults';
756
+ const fieldPath = url.searchParams.get('path') || '';
757
+ if (!requestedSecretPath(file, fieldPath)) {
758
+ throw Object.assign(new Error('Only Base Agent apiKey fields can be revealed'), { statusCode: 400 });
759
+ }
760
+ const modules = await getConfigManager();
761
+ const rawDefaults = modules.read(modules.ConfigTarget.Defaults, undefined, { cache: false, expand: false });
762
+ const value = readAtPath(rawDefaults, fieldPath);
763
+ if (typeof value !== 'string' || !value.trim()) {
764
+ throw Object.assign(new Error('API Key is not configured'), { statusCode: 404 });
765
+ }
766
+ sendJson(res, 200, { success: true, data: { file, path: fieldPath, value } });
767
+ return;
768
+ }
769
+ if (req.method === 'PATCH' && pathname === '/api/basic-settings') {
770
+ const body = await readJsonBody(req);
771
+ if (!isRecord(body) || (body.file !== 'daemon' && body.file !== 'defaults'))
772
+ throw new Error('file must be daemon or defaults');
773
+ if (typeof body.revision !== 'string' || !/^[a-f0-9]{64}$/.test(body.revision))
774
+ throw new Error('revision is required');
775
+ if (!Array.isArray(body.changes))
776
+ throw new Error('changes must be an array');
777
+ const file = body.file;
778
+ const changes = body.changes;
779
+ changes.forEach(change => validateBasicSettingsChange(file, change));
780
+ const modules = await getConfigManager();
781
+ const target = file === 'daemon' ? modules.ConfigTarget.Process : modules.ConfigTarget.Defaults;
782
+ const mutation = modules.mutateConfig(target, undefined, config => {
783
+ if (basicSettingsRevision(config) !== body.revision) {
784
+ throw Object.assign(new Error('Configuration changed after it was loaded. Reload and try again.'), {
785
+ code: 'BASIC_SETTINGS_REVISION_CONFLICT',
786
+ });
787
+ }
788
+ if (file === 'defaults')
789
+ validateBaseagentAvailability(config, changes);
790
+ applyBasicSettingsChanges(config, file, changes);
791
+ const errors = modules.validateConfig(target, config);
792
+ if (errors.length > 0) {
793
+ throw Object.assign(new Error(errors.join('; ')), { code: 'SCHEMA_INVALID' });
794
+ }
795
+ });
796
+ const restartPaths = restartRequiredForChanges(file, changes);
797
+ sendJson(res, 200, {
798
+ success: true,
799
+ changed: mutation.changed,
800
+ restartRequired: mutation.changed && restartPaths.length > 0,
801
+ restartPaths: mutation.changed ? restartPaths : [],
802
+ data: await readSnapshot(options),
803
+ });
804
+ return;
805
+ }
806
+ sendJson(res, 405, { success: false, error: 'Method not allowed' });
807
+ }
808
+ catch (error) {
809
+ const status = errorStatus(error);
810
+ if (status >= 500)
811
+ console.error('[basic-settings] API error:', error);
812
+ sendJson(res, status, {
813
+ success: false,
814
+ code: error?.code,
815
+ error: error instanceof Error ? error.message : String(error),
816
+ });
817
+ }
818
+ }