@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/LICENSE +21 -0
- package/README.md +467 -2
- package/client.js +927 -0
- package/cordis.patch.yml +26 -0
- package/icon.svg +15 -0
- package/index.js +59 -0
- package/lib/auth.js +196 -0
- package/lib/bridge.js +518 -0
- package/lib/card.js +214 -0
- package/lib/client-discovery.js +58 -0
- package/lib/config.js +576 -0
- package/lib/executor.js +357 -0
- package/lib/files.js +52 -0
- package/lib/limits.js +133 -0
- package/lib/panel.js +391 -0
- package/lib/prompt.js +161 -0
- package/lib/server.js +656 -0
- package/lib/store.js +288 -0
- package/lib/util.js +221 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -4
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
|
+
}
|