@ai_mind/dsh-a2a 0.0.0-stage → 1.1.0

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/lib/config.js ADDED
@@ -0,0 +1,576 @@
1
+ /**
2
+ * A2A server configuration: defaults, normalization and validation.
3
+ *
4
+ * The loader passes the row's `config` as plain JSON (YAML scalars already
5
+ * resolved), so validation happens here at activation time and fails loudly
6
+ * with the offending path. `normalizeConfig` returns a deep-frozen object the
7
+ * rest of the plugin can treat as immutable.
8
+ *
9
+ * @module @ai_mind/dsh-a2a/config
10
+ */
11
+
12
+ import { isIP } from 'node:net'
13
+ import { isPlainObject, deepMerge, deepFreeze } from './util.js'
14
+
15
+ /** Error raised for an invalid plugin configuration. */
16
+ export class ConfigError extends Error {
17
+ constructor(message) {
18
+ super(message)
19
+ this.name = 'A2AConfigError'
20
+ }
21
+ }
22
+
23
+ /** Paths whose prefix already belongs to the Harness Web GUI. */
24
+ const RESERVED_MOUNT_PREFIXES = ['/api', '/plugins', '/.well-known']
25
+
26
+ /** Default Agent Card describing what a Harness agent can do. */
27
+ const DEFAULT_SKILLS = [
28
+ {
29
+ id: 'general',
30
+ name: 'General-purpose coding assistant',
31
+ description:
32
+ 'Answers questions, writes and edits code, runs commands, and completes multi-step tasks in the session workspace.',
33
+ tags: ['general', 'coding', 'analysis', 'files', 'automation'],
34
+ examples: [
35
+ 'Summarize the repository in this workspace.',
36
+ 'Fix the failing test and explain the root cause.',
37
+ ],
38
+ },
39
+ ]
40
+
41
+ /** Complete default configuration. */
42
+ export const DEFAULT_CONFIG = {
43
+ enabled: true,
44
+ server: {
45
+ mountPath: '/a2a',
46
+ cardPath: '/.well-known/agent-card.json',
47
+ publicUrl: null,
48
+ host: '127.0.0.1',
49
+ port: null,
50
+ trustProxy: false,
51
+ streaming: true,
52
+ pushNotifications: true,
53
+ extendedCard: false,
54
+ cardCacheSeconds: 300,
55
+ health: true,
56
+ logRequests: false,
57
+ cors: {
58
+ enabled: false,
59
+ origins: [],
60
+ allowCredentials: false,
61
+ maxAgeSeconds: 600,
62
+ },
63
+ },
64
+ card: {
65
+ name: 'DeepSeek Harness Agent',
66
+ description:
67
+ 'A DeepSeek Harness agent exposed over the Agent2Agent protocol. It can read and write files, run commands, search the web, and carry out multi-step tasks in its workspace.',
68
+ version: '1.0.0',
69
+ provider: null,
70
+ documentationUrl: null,
71
+ iconUrl: null,
72
+ defaultInputModes: ['text/plain', 'application/json', 'image/png', 'image/jpeg', 'image/webp'],
73
+ defaultOutputModes: ['text/plain', 'application/json'],
74
+ skills: DEFAULT_SKILLS,
75
+ extensions: [],
76
+ extended: {
77
+ description: null,
78
+ documentationUrl: null,
79
+ skills: [],
80
+ },
81
+ },
82
+ auth: {
83
+ mode: 'none',
84
+ tokens: [],
85
+ clients: [],
86
+ token: null,
87
+ tokenEnv: null,
88
+ headerName: 'x-api-key',
89
+ allowRemoteUnauthenticated: false,
90
+ realm: 'a2a',
91
+ },
92
+ agent: {
93
+ preset: null,
94
+ provider: null,
95
+ model: null,
96
+ reasoningEffort: null,
97
+ maxTokens: null,
98
+ cwd: null,
99
+ sandboxMode: 'workspace-write',
100
+ approvalPolicy: 'never',
101
+ maxConcurrentTasks: 4,
102
+ queueTimeoutMs: 60000,
103
+ turnTimeoutMs: 900000,
104
+ contextIdleTtlMs: 900000,
105
+ },
106
+ limits: {
107
+ maxRequestBytes: 8 * 1024 * 1024,
108
+ maxFileBytes: 16 * 1024 * 1024,
109
+ maxPromptChars: 200000,
110
+ maxArtifactChars: 200000,
111
+ },
112
+ files: {
113
+ enabled: true,
114
+ directory: '.a2a-inbox',
115
+ retentionMs: 24 * 60 * 60 * 1000,
116
+ },
117
+ tasks: {
118
+ ttlMs: 24 * 60 * 60 * 1000,
119
+ maxTasks: 2000,
120
+ sweepIntervalMs: 60000,
121
+ },
122
+ push: {
123
+ allowPrivateTargets: false,
124
+ timeoutMs: 5000,
125
+ },
126
+ signing: {
127
+ enabled: false,
128
+ privateKeyPem: null,
129
+ privateKeyFile: null,
130
+ keyId: null,
131
+ algorithm: 'ES256',
132
+ jku: null,
133
+ cacheSeconds: 300,
134
+ },
135
+ logging: {
136
+ requests: false,
137
+ },
138
+ }
139
+
140
+ const TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG))
141
+
142
+ const SECTION_KEYS = {
143
+ server: new Set(Object.keys(DEFAULT_CONFIG.server)),
144
+ 'server.cors': new Set(Object.keys(DEFAULT_CONFIG.server.cors)),
145
+ card: new Set(Object.keys(DEFAULT_CONFIG.card)),
146
+ 'card.provider': new Set(['organization', 'url']),
147
+ 'card.extended': new Set(Object.keys(DEFAULT_CONFIG.card.extended)),
148
+ auth: new Set(Object.keys(DEFAULT_CONFIG.auth)),
149
+ 'auth.clients': new Set(['id', 'token']),
150
+ agent: new Set(Object.keys(DEFAULT_CONFIG.agent)),
151
+ limits: new Set(Object.keys(DEFAULT_CONFIG.limits)),
152
+ files: new Set(Object.keys(DEFAULT_CONFIG.files)),
153
+ tasks: new Set(Object.keys(DEFAULT_CONFIG.tasks)),
154
+ push: new Set(Object.keys(DEFAULT_CONFIG.push)),
155
+ signing: new Set(Object.keys(DEFAULT_CONFIG.signing)),
156
+ logging: new Set(Object.keys(DEFAULT_CONFIG.logging)),
157
+ skills: new Set(['id', 'name', 'description', 'tags', 'examples', 'inputModes', 'outputModes', 'securityRequirements']),
158
+ extensions: new Set(['uri', 'description', 'required', 'params']),
159
+ }
160
+
161
+ /** Throw a configuration error with its exact path. */
162
+ function fail(path, message) {
163
+ throw new ConfigError(`${path}: ${message}`)
164
+ }
165
+
166
+ /** Reject unknown keys so a typo cannot silently fall back to a default. */
167
+ function checkKeys(value, path) {
168
+ if (!isPlainObject(value)) return
169
+ const allowed = SECTION_KEYS[path]
170
+ if (allowed === undefined) return
171
+ for (const key of Object.keys(value)) {
172
+ if (!allowed.has(key)) fail(path, `unknown field "${key}"`)
173
+ }
174
+ }
175
+
176
+ function expectObject(value, path) {
177
+ if (!isPlainObject(value)) fail(path, 'must be an object')
178
+ return value
179
+ }
180
+
181
+ function expectBoolean(value, path) {
182
+ if (typeof value !== 'boolean') fail(path, 'must be a boolean')
183
+ return value
184
+ }
185
+
186
+ function expectString(value, path, { allowEmpty = false } = {}) {
187
+ if (typeof value !== 'string') fail(path, 'must be a string')
188
+ if (!allowEmpty && value.trim() === '') fail(path, 'must not be empty')
189
+ return value
190
+ }
191
+
192
+ function expectNullableString(value, path, options) {
193
+ if (value === null || value === undefined) return null
194
+ return expectString(value, path, options)
195
+ }
196
+
197
+ function expectInteger(value, path, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
198
+ if (!Number.isInteger(value)) fail(path, 'must be an integer')
199
+ if (value < min || value > max) fail(path, `must be between ${min} and ${max}`)
200
+ return value
201
+ }
202
+
203
+ function expectEnum(value, path, allowed) {
204
+ if (!allowed.includes(value)) fail(path, `must be one of ${allowed.map((item) => JSON.stringify(item)).join(', ')}`)
205
+ return value
206
+ }
207
+
208
+ function expectStringArray(value, path, { nonEmpty = false } = {}) {
209
+ if (!Array.isArray(value)) fail(path, 'must be an array of strings')
210
+ const items = value.map((item, index) => expectString(item, `${path}[${index}]`, { allowEmpty: false }))
211
+ if (nonEmpty && items.length === 0) fail(path, 'must contain at least one entry')
212
+ return items
213
+ }
214
+
215
+ function validatePath(value, path, { reserved = false } = {}) {
216
+ expectString(value, path)
217
+ if (!value.startsWith('/')) fail(path, 'must start with "/"')
218
+ if (value === '/') fail(path, 'must not be the server root')
219
+ if (value.endsWith('/')) fail(path, 'must not end with "/"')
220
+ if (/[?#\s]/.test(value)) fail(path, 'must not contain whitespace, "?" or "#"')
221
+ if (reserved) {
222
+ for (const prefix of RESERVED_MOUNT_PREFIXES) {
223
+ if (value === prefix || value.startsWith(`${prefix}/`)) {
224
+ fail(path, `"${value}" collides with a Harness Web route (${RESERVED_MOUNT_PREFIXES.join(', ')})`)
225
+ }
226
+ }
227
+ }
228
+ return value
229
+ }
230
+
231
+ function validateSkills(value, path) {
232
+ if (!Array.isArray(value)) fail(path, 'must be an array')
233
+ return value.map((skill, index) => {
234
+ const at = `${path}[${index}]`
235
+ expectObject(skill, at)
236
+ checkKeys(skill, 'skills')
237
+ const id = expectString(skill.id, `${at}.id`)
238
+ const name = expectString(skill.name, `${at}.name`)
239
+ const description = expectString(skill.description, `${at}.description`)
240
+ const tags = expectStringArray(skill.tags ?? [], `${at}.tags`)
241
+ const result = { id, name, description, tags }
242
+ if (skill.examples !== undefined) result.examples = expectStringArray(skill.examples, `${at}.examples`)
243
+ if (skill.inputModes !== undefined) result.inputModes = expectStringArray(skill.inputModes, `${at}.inputModes`)
244
+ if (skill.outputModes !== undefined) result.outputModes = expectStringArray(skill.outputModes, `${at}.outputModes`)
245
+ if (skill.securityRequirements !== undefined) {
246
+ if (!Array.isArray(skill.securityRequirements)) fail(`${at}.securityRequirements`, 'must be an array')
247
+ result.securityRequirements = skill.securityRequirements
248
+ }
249
+ return result
250
+ })
251
+ }
252
+
253
+ function validateExtensions(value, path) {
254
+ if (!Array.isArray(value)) fail(path, 'must be an array')
255
+ return value.map((extension, index) => {
256
+ const at = `${path}[${index}]`
257
+ expectObject(extension, at)
258
+ checkKeys(extension, 'extensions')
259
+ return {
260
+ uri: expectString(extension.uri, `${at}.uri`),
261
+ description: expectNullableString(extension.description, `${at}.description`) ?? '',
262
+ required: extension.required === undefined ? false : expectBoolean(extension.required, `${at}.required`),
263
+ params: extension.params === undefined ? undefined : extension.params,
264
+ }
265
+ })
266
+ }
267
+
268
+ function validateCors(cors, path) {
269
+ expectObject(cors, path)
270
+ checkKeys(cors, 'server.cors')
271
+ const result = {
272
+ enabled: expectBoolean(cors.enabled, `${path}.enabled`),
273
+ origins: expectStringArray(cors.origins, `${path}.origins`),
274
+ allowCredentials: expectBoolean(cors.allowCredentials, `${path}.allowCredentials`),
275
+ maxAgeSeconds: expectInteger(cors.maxAgeSeconds, `${path}.maxAgeSeconds`, { min: 0, max: 86400 }),
276
+ }
277
+ if (result.allowCredentials && result.origins.includes('*')) {
278
+ fail(`${path}.origins`, 'a wildcard origin cannot be combined with allowCredentials')
279
+ }
280
+ return result
281
+ }
282
+
283
+ function validateClients(auth, path) {
284
+ if (!Array.isArray(auth.clients)) fail(`${path}.clients`, 'must be an array')
285
+ const seen = new Set()
286
+ return auth.clients.map((client, index) => {
287
+ const at = `${path}.clients[${index}]`
288
+ expectObject(client, at)
289
+ checkKeys(client, 'auth.clients')
290
+ const id = expectString(client.id, `${at}.id`)
291
+ if (seen.has(id)) fail(`${at}.id`, `duplicate client id "${id}"`)
292
+ seen.add(id)
293
+ return { id, token: expectString(client.token, `${at}.token`) }
294
+ })
295
+ }
296
+
297
+ function validateSigning(signing, path) {
298
+ expectObject(signing, path)
299
+ checkKeys(signing, 'signing')
300
+ const enabled = expectBoolean(signing.enabled, `${path}.enabled`)
301
+ const result = {
302
+ enabled,
303
+ privateKeyPem: expectNullableString(signing.privateKeyPem, `${path}.privateKeyPem`, { allowEmpty: false }),
304
+ privateKeyFile: expectNullableString(signing.privateKeyFile, `${path}.privateKeyFile`, { allowEmpty: false }),
305
+ keyId: expectNullableString(signing.keyId, `${path}.keyId`, { allowEmpty: false }),
306
+ algorithm: expectEnum(signing.algorithm, `${path}.algorithm`, [
307
+ 'ES256',
308
+ 'ES384',
309
+ 'ES512',
310
+ 'RS256',
311
+ 'RS384',
312
+ 'RS512',
313
+ 'PS256',
314
+ 'PS384',
315
+ 'PS512',
316
+ 'EdDSA',
317
+ ]),
318
+ jku: expectNullableString(signing.jku, `${path}.jku`, { allowEmpty: false }),
319
+ cacheSeconds: expectInteger(signing.cacheSeconds, `${path}.cacheSeconds`, { min: 0, max: 86400 }),
320
+ }
321
+ if (enabled) {
322
+ if (!result.privateKeyPem && !result.privateKeyFile) {
323
+ fail(`${path}.privateKeyPem`, 'signing requires privateKeyPem or privateKeyFile')
324
+ }
325
+ if (result.privateKeyPem && result.privateKeyFile) {
326
+ fail(`${path}.privateKeyFile`, 'set only one of privateKeyPem and privateKeyFile')
327
+ }
328
+ if (!result.keyId) fail(`${path}.keyId`, 'signing requires keyId')
329
+ }
330
+ return result
331
+ }
332
+
333
+ /**
334
+ * Merge user configuration over the defaults and validate it.
335
+ *
336
+ * @param {unknown} raw the loader-provided row configuration
337
+ * @returns {Readonly<object>} a validated, deep-frozen configuration
338
+ * @throws {ConfigError} on any invalid or unknown field
339
+ */
340
+ export function normalizeConfig(raw) {
341
+ if (raw !== undefined && raw !== null && !isPlainObject(raw)) {
342
+ throw new ConfigError('config: must be a mapping')
343
+ }
344
+ const merged = deepMerge(DEFAULT_CONFIG, raw ?? {})
345
+
346
+ for (const key of Object.keys(merged)) {
347
+ if (!TOP_LEVEL_KEYS.has(key)) throw new ConfigError(`config: unknown field "${key}"`)
348
+ }
349
+
350
+ // server
351
+ expectObject(merged.server, 'server')
352
+ checkKeys(merged.server, 'server')
353
+ merged.server.mountPath = validatePath(merged.server.mountPath, 'server.mountPath', { reserved: true })
354
+ merged.server.cardPath = validatePath(merged.server.cardPath, 'server.cardPath')
355
+ merged.server.publicUrl = expectNullableString(merged.server.publicUrl, 'server.publicUrl', { allowEmpty: false })
356
+ merged.server.host = expectString(merged.server.host, 'server.host')
357
+ if (merged.server.host !== 'localhost' && isIP(merged.server.host) === 0) {
358
+ fail('server.host', 'must be an IPv4/IPv6 literal or "localhost"')
359
+ }
360
+ merged.server.port =
361
+ merged.server.port === null || merged.server.port === undefined
362
+ ? null
363
+ : expectInteger(merged.server.port, 'server.port', { min: 1, max: 65535 })
364
+ if (merged.server.publicUrl !== null) {
365
+ let url
366
+ try {
367
+ url = new URL(merged.server.publicUrl)
368
+ } catch {
369
+ fail('server.publicUrl', 'must be an absolute http(s) URL')
370
+ }
371
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
372
+ fail('server.publicUrl', 'must use http or https')
373
+ }
374
+ if (url.search || url.hash) fail('server.publicUrl', 'must not contain a query string or fragment')
375
+ merged.server.publicUrl = merged.server.publicUrl.replace(/\/+$/, '')
376
+ }
377
+ merged.server.trustProxy = expectBoolean(merged.server.trustProxy, 'server.trustProxy')
378
+ merged.server.streaming = expectBoolean(merged.server.streaming, 'server.streaming')
379
+ merged.server.pushNotifications = expectBoolean(merged.server.pushNotifications, 'server.pushNotifications')
380
+ merged.server.extendedCard = expectBoolean(merged.server.extendedCard, 'server.extendedCard')
381
+ merged.server.cardCacheSeconds = expectInteger(merged.server.cardCacheSeconds, 'server.cardCacheSeconds', {
382
+ min: 0,
383
+ max: 86400,
384
+ })
385
+ merged.server.health = expectBoolean(merged.server.health, 'server.health')
386
+ merged.server.logRequests = expectBoolean(merged.server.logRequests, 'server.logRequests')
387
+ merged.server.cors = validateCors(merged.server.cors, 'server.cors')
388
+
389
+ // card
390
+ expectObject(merged.card, 'card')
391
+ checkKeys(merged.card, 'card')
392
+ merged.card.name = expectString(merged.card.name, 'card.name')
393
+ merged.card.description = expectString(merged.card.description, 'card.description')
394
+ merged.card.version = expectString(merged.card.version, 'card.version')
395
+ merged.card.documentationUrl = expectNullableString(merged.card.documentationUrl, 'card.documentationUrl', {
396
+ allowEmpty: false,
397
+ })
398
+ merged.card.iconUrl = expectNullableString(merged.card.iconUrl, 'card.iconUrl', { allowEmpty: false })
399
+ if (merged.card.provider !== null) {
400
+ expectObject(merged.card.provider, 'card.provider')
401
+ checkKeys(merged.card.provider, 'card.provider')
402
+ merged.card.provider = {
403
+ organization: expectString(merged.card.provider.organization, 'card.provider.organization'),
404
+ url: expectString(merged.card.provider.url, 'card.provider.url'),
405
+ }
406
+ }
407
+ merged.card.defaultInputModes = expectStringArray(merged.card.defaultInputModes, 'card.defaultInputModes', {
408
+ nonEmpty: true,
409
+ })
410
+ merged.card.defaultOutputModes = expectStringArray(merged.card.defaultOutputModes, 'card.defaultOutputModes', {
411
+ nonEmpty: true,
412
+ })
413
+ merged.card.skills = validateSkills(merged.card.skills, 'card.skills')
414
+ if (merged.card.skills.length === 0) fail('card.skills', 'must contain at least one skill')
415
+ merged.card.extensions = validateExtensions(merged.card.extensions, 'card.extensions')
416
+ expectObject(merged.card.extended, 'card.extended')
417
+ checkKeys(merged.card.extended, 'card.extended')
418
+ merged.card.extended = {
419
+ description: expectNullableString(merged.card.extended.description, 'card.extended.description', {
420
+ allowEmpty: false,
421
+ }),
422
+ documentationUrl: expectNullableString(merged.card.extended.documentationUrl, 'card.extended.documentationUrl', {
423
+ allowEmpty: false,
424
+ }),
425
+ skills: validateSkills(merged.card.extended.skills ?? [], 'card.extended.skills'),
426
+ }
427
+
428
+ // auth
429
+ expectObject(merged.auth, 'auth')
430
+ checkKeys(merged.auth, 'auth')
431
+ merged.auth.mode = expectEnum(merged.auth.mode, 'auth.mode', ['none', 'bearer', 'apiKey'])
432
+ merged.auth.tokens = expectStringArray(merged.auth.tokens, 'auth.tokens')
433
+ merged.auth.clients = validateClients(merged.auth, 'auth')
434
+ merged.auth.token = expectNullableString(merged.auth.token, 'auth.token', { allowEmpty: false })
435
+ merged.auth.tokenEnv = expectNullableString(merged.auth.tokenEnv, 'auth.tokenEnv', { allowEmpty: false })
436
+ merged.auth.headerName = expectString(merged.auth.headerName, 'auth.headerName').toLowerCase()
437
+ if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(merged.auth.headerName)) {
438
+ fail('auth.headerName', 'must be a valid HTTP header name')
439
+ }
440
+ merged.auth.allowRemoteUnauthenticated = expectBoolean(
441
+ merged.auth.allowRemoteUnauthenticated,
442
+ 'auth.allowRemoteUnauthenticated',
443
+ )
444
+ merged.auth.realm = expectString(merged.auth.realm, 'auth.realm')
445
+
446
+ // agent
447
+ expectObject(merged.agent, 'agent')
448
+ checkKeys(merged.agent, 'agent')
449
+ merged.agent.preset = expectNullableString(merged.agent.preset, 'agent.preset', { allowEmpty: false })
450
+ merged.agent.provider = expectNullableString(merged.agent.provider, 'agent.provider', { allowEmpty: false })
451
+ merged.agent.model = expectNullableString(merged.agent.model, 'agent.model', { allowEmpty: false })
452
+ if ((merged.agent.provider === null) !== (merged.agent.model === null)) {
453
+ fail('agent.model', 'provider and model must be configured together')
454
+ }
455
+ merged.agent.reasoningEffort = expectNullableString(merged.agent.reasoningEffort, 'agent.reasoningEffort', {
456
+ allowEmpty: false,
457
+ })
458
+ merged.agent.maxTokens =
459
+ merged.agent.maxTokens === null
460
+ ? null
461
+ : expectInteger(merged.agent.maxTokens, 'agent.maxTokens', { min: 1, max: 10000000 })
462
+ merged.agent.cwd = expectNullableString(merged.agent.cwd, 'agent.cwd', { allowEmpty: false })
463
+ if (merged.agent.cwd !== null && !merged.agent.cwd.startsWith('/')) {
464
+ fail('agent.cwd', 'must be an absolute path')
465
+ }
466
+ merged.agent.sandboxMode = expectEnum(merged.agent.sandboxMode, 'agent.sandboxMode', [
467
+ 'inherit',
468
+ 'read-only',
469
+ 'workspace-write',
470
+ 'danger-full-access',
471
+ ])
472
+ merged.agent.approvalPolicy = expectEnum(merged.agent.approvalPolicy, 'agent.approvalPolicy', ['inherit', 'ask', 'never'])
473
+ merged.agent.maxConcurrentTasks = expectInteger(merged.agent.maxConcurrentTasks, 'agent.maxConcurrentTasks', {
474
+ min: 1,
475
+ max: 64,
476
+ })
477
+ merged.agent.queueTimeoutMs = expectInteger(merged.agent.queueTimeoutMs, 'agent.queueTimeoutMs', {
478
+ min: 0,
479
+ max: 3600000,
480
+ })
481
+ merged.agent.turnTimeoutMs = expectInteger(merged.agent.turnTimeoutMs, 'agent.turnTimeoutMs', {
482
+ min: 1000,
483
+ max: 86400000,
484
+ })
485
+ merged.agent.contextIdleTtlMs = expectInteger(merged.agent.contextIdleTtlMs, 'agent.contextIdleTtlMs', {
486
+ min: 0,
487
+ max: 86400000,
488
+ })
489
+
490
+ // limits
491
+ expectObject(merged.limits, 'limits')
492
+ checkKeys(merged.limits, 'limits')
493
+ for (const [key, min, max] of [
494
+ ['maxRequestBytes', 1024, 256 * 1024 * 1024],
495
+ ['maxFileBytes', 0, 256 * 1024 * 1024],
496
+ ['maxPromptChars', 1024, 10000000],
497
+ ['maxArtifactChars', 1024, 10000000],
498
+ ]) {
499
+ merged.limits[key] = expectInteger(merged.limits[key], `limits.${key}`, { min, max })
500
+ }
501
+
502
+ // files
503
+ expectObject(merged.files, 'files')
504
+ checkKeys(merged.files, 'files')
505
+ merged.files.enabled = expectBoolean(merged.files.enabled, 'files.enabled')
506
+ merged.files.directory = expectString(merged.files.directory, 'files.directory')
507
+ if (merged.files.directory.startsWith('/') || merged.files.directory.includes('..')) {
508
+ fail('files.directory', 'must be a relative path inside the workspace without ".."')
509
+ }
510
+ merged.files.retentionMs = expectInteger(merged.files.retentionMs, 'files.retentionMs', {
511
+ min: 0,
512
+ max: 30 * 86400000,
513
+ })
514
+
515
+ // tasks
516
+ expectObject(merged.tasks, 'tasks')
517
+ checkKeys(merged.tasks, 'tasks')
518
+ merged.tasks.ttlMs = expectInteger(merged.tasks.ttlMs, 'tasks.ttlMs', { min: 0, max: 30 * 86400000 })
519
+ merged.tasks.maxTasks = expectInteger(merged.tasks.maxTasks, 'tasks.maxTasks', { min: 1, max: 100000 })
520
+ merged.tasks.sweepIntervalMs = expectInteger(merged.tasks.sweepIntervalMs, 'tasks.sweepIntervalMs', {
521
+ min: 5000,
522
+ max: 3600000,
523
+ })
524
+
525
+ // push
526
+ expectObject(merged.push, 'push')
527
+ checkKeys(merged.push, 'push')
528
+ merged.push.allowPrivateTargets = expectBoolean(merged.push.allowPrivateTargets, 'push.allowPrivateTargets')
529
+ merged.push.timeoutMs = expectInteger(merged.push.timeoutMs, 'push.timeoutMs', { min: 100, max: 60000 })
530
+
531
+ // signing
532
+ merged.signing = validateSigning(merged.signing, 'signing')
533
+
534
+ // logging
535
+ expectObject(merged.logging, 'logging')
536
+ checkKeys(merged.logging, 'logging')
537
+ merged.logging.requests = expectBoolean(merged.logging.requests, 'logging.requests')
538
+
539
+ merged.enabled = expectBoolean(merged.enabled, 'enabled')
540
+
541
+ return deepFreeze(merged)
542
+ }
543
+
544
+ /**
545
+ * Resolve the configured bearer/API-key credentials, including environment
546
+ * variables. Called at mount time so a restart picks up rotated secrets.
547
+ *
548
+ * @param {Readonly<object>} config normalized configuration
549
+ * @param {NodeJS.ProcessEnv} [env]
550
+ * @returns {{ id: string, token: string }[]}
551
+ */
552
+ export function resolveCredentials(config, env = process.env) {
553
+ const clients = new Map()
554
+ for (const client of config.auth.clients) clients.set(client.id, client.token)
555
+ config.auth.tokens.forEach((token, index) => {
556
+ const id = config.auth.tokens.length === 1 ? 'default' : `token-${index + 1}`
557
+ if (!clients.has(id)) clients.set(id, token)
558
+ })
559
+ if (config.auth.token) {
560
+ if (!clients.has('default')) clients.set('default', config.auth.token)
561
+ }
562
+ if (config.auth.tokenEnv) {
563
+ const raw = env[config.auth.tokenEnv]
564
+ if (typeof raw === 'string' && raw.trim() !== '') {
565
+ const values = raw
566
+ .split(/[\n,]/)
567
+ .map((value) => value.trim())
568
+ .filter(Boolean)
569
+ values.forEach((token, index) => {
570
+ const id = values.length === 1 ? 'env' : `env-${index + 1}`
571
+ if (!clients.has(id)) clients.set(id, token)
572
+ })
573
+ }
574
+ }
575
+ return [...clients].map(([id, token]) => ({ id, token }))
576
+ }