@ai_mind/dsh-a2a 0.0.0-stage → 1.0.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/panel.js ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * In-panel configuration API for the A2A settings page.
3
+ *
4
+ * The browser panel is authenticated by the DSH connection (the same signed
5
+ * credential the GUI uses), never by the A2A token: an operator who can open
6
+ * Settings may edit the plugin, while a remote A2A client cannot. Writes go
7
+ * through the Host `configEditor` service, which validates, persists to the
8
+ * profile patch, and reconciles the Loader row.
9
+ *
10
+ * Secrets are write-only: the current inline token is reported as a boolean,
11
+ * never returned, and `signing.privateKeyPem` is not exposed at all.
12
+ *
13
+ * @module @ai_mind/dsh-a2a/panel
14
+ */
15
+
16
+ import express from 'express'
17
+ import { normalizeConfig, resolveCredentials } from './config.js'
18
+ import { errorMessage, isLoopbackAddress, isPlainObject } from './util.js'
19
+
20
+ const PLUGIN_NAME = '@ai_mind/dsh-a2a'
21
+ const PLUGIN_ROW_NAME = '@ai_mind/dsh-a2a'
22
+ const PLUGIN_ROW_ID = 'a2a-server'
23
+
24
+ /**
25
+ * Editable fields, grouped for the settings panel. Labels are supplied by the
26
+ * client locale keyed on `path`.
27
+ */
28
+ const FIELDS = [
29
+ { path: 'card.name', type: 'string', group: 'card' },
30
+ { path: 'card.description', type: 'textarea', group: 'card' },
31
+ { path: 'card.version', type: 'string', group: 'card' },
32
+ { path: 'server.publicUrl', type: 'string', group: 'server', nullable: true, placeholder: 'https://agent.example.com' },
33
+ { path: 'server.mountPath', type: 'string', group: 'server' },
34
+ { path: 'server.streaming', type: 'boolean', group: 'server' },
35
+ { path: 'server.pushNotifications', type: 'boolean', group: 'server' },
36
+ { path: 'server.extendedCard', type: 'boolean', group: 'server' },
37
+ { path: 'auth.mode', type: 'enum', options: ['none', 'bearer', 'apiKey'], group: 'auth' },
38
+ { path: 'auth.tokenEnv', type: 'string', group: 'auth', nullable: true, placeholder: 'A2A_AUTH_TOKEN' },
39
+ { path: 'auth.allowRemoteUnauthenticated', type: 'boolean', group: 'auth' },
40
+ { path: 'agent.cwd', type: 'string', group: 'agent', nullable: true, placeholder: '/srv/a2a-workspace' },
41
+ { path: 'agent.preset', type: 'string', group: 'agent', nullable: true },
42
+ { path: 'agent.provider', type: 'string', group: 'agent', nullable: true },
43
+ { path: 'agent.model', type: 'string', group: 'agent', nullable: true },
44
+ { path: 'agent.reasoningEffort', type: 'string', group: 'agent', nullable: true },
45
+ {
46
+ path: 'agent.sandboxMode',
47
+ type: 'enum',
48
+ options: ['inherit', 'read-only', 'workspace-write', 'danger-full-access'],
49
+ group: 'agent',
50
+ },
51
+ { path: 'agent.approvalPolicy', type: 'enum', options: ['inherit', 'ask', 'never'], group: 'agent' },
52
+ { path: 'agent.maxConcurrentTasks', type: 'integer', group: 'agent', min: 1, max: 64 },
53
+ { path: 'agent.queueTimeoutMs', type: 'integer', group: 'agent', min: 0, max: 3600000 },
54
+ { path: 'agent.turnTimeoutMs', type: 'integer', group: 'agent', min: 1000, max: 86400000 },
55
+ { path: 'limits.maxRequestBytes', type: 'integer', group: 'limits', min: 1024, max: 268435456 },
56
+ { path: 'limits.maxFileBytes', type: 'integer', group: 'limits', min: 0, max: 268435456 },
57
+ { path: 'tasks.ttlMs', type: 'integer', group: 'limits', min: 0, max: 2592000000 },
58
+ { path: 'tasks.maxTasks', type: 'integer', group: 'limits', min: 1, max: 100000 },
59
+ { path: 'push.allowPrivateTargets', type: 'boolean', group: 'limits' },
60
+ ]
61
+
62
+ const FIELD_BY_PATH = new Map(FIELDS.map((field) => [field.path, field]))
63
+
64
+ function readAt(source, path) {
65
+ let cursor = source
66
+ for (const part of path.split('.')) {
67
+ if (!isPlainObject(cursor)) return undefined
68
+ cursor = cursor[part]
69
+ }
70
+ return cursor
71
+ }
72
+
73
+ function setAt(target, path, value) {
74
+ const parts = path.split('.')
75
+ let cursor = target
76
+ for (const part of parts.slice(0, -1)) {
77
+ if (!isPlainObject(cursor[part])) cursor[part] = {}
78
+ cursor = cursor[part]
79
+ }
80
+ cursor[parts[parts.length - 1]] = value
81
+ }
82
+
83
+ function deleteAt(target, path) {
84
+ const parts = path.split('.')
85
+ let cursor = target
86
+ for (const part of parts.slice(0, -1)) {
87
+ if (!isPlainObject(cursor) || !isPlainObject(cursor[part])) return
88
+ cursor = cursor[part]
89
+ }
90
+ delete cursor[parts[parts.length - 1]]
91
+ }
92
+
93
+ /** Merge values over `inherited` while preserving unrelated override keys. */
94
+ function mergeConfig(inherited, override) {
95
+ const base = structuredClone(inherited ?? {})
96
+ if (!isPlainObject(override)) return base
97
+ const merge = (target, patch) => {
98
+ for (const [key, value] of Object.entries(patch)) {
99
+ if (isPlainObject(value) && isPlainObject(target[key])) merge(target[key], value)
100
+ else target[key] = structuredClone(value)
101
+ }
102
+ }
103
+ merge(base, override)
104
+ return base
105
+ }
106
+
107
+ /**
108
+ * Coerce one submitted value to the declared field type.
109
+ *
110
+ * @param {{ path: string, type: string, options?: string[], nullable?: boolean, min?: number, max?: number }} field
111
+ * @param {unknown} raw
112
+ * @returns {unknown} the coerced value, or `null` to unset the field
113
+ */
114
+ function coerceField(field, raw) {
115
+ const label = field.path
116
+ if (raw === null || raw === undefined) {
117
+ if (field.nullable) return null
118
+ throw new Error(`${label} must not be empty`)
119
+ }
120
+ switch (field.type) {
121
+ case 'string':
122
+ case 'textarea': {
123
+ if (typeof raw !== 'string') throw new Error(`${label} must be a string`)
124
+ const value = raw.trim()
125
+ if (value === '') {
126
+ if (field.nullable) return null
127
+ throw new Error(`${label} must not be empty`)
128
+ }
129
+ return raw
130
+ }
131
+ case 'integer': {
132
+ const value = typeof raw === 'number' ? raw : Number(raw)
133
+ if (!Number.isInteger(value)) throw new Error(`${label} must be an integer`)
134
+ if (field.min !== undefined && value < field.min) throw new Error(`${label} must be >= ${field.min}`)
135
+ if (field.max !== undefined && value > field.max) throw new Error(`${label} must be <= ${field.max}`)
136
+ return value
137
+ }
138
+ case 'boolean': {
139
+ if (typeof raw !== 'boolean') throw new Error(`${label} must be a boolean`)
140
+ return raw
141
+ }
142
+ case 'enum': {
143
+ if (typeof raw !== 'string' || !field.options.includes(raw)) {
144
+ throw new Error(`${label} must be one of ${field.options.join(', ')}`)
145
+ }
146
+ return raw
147
+ }
148
+ default:
149
+ throw new Error(`${label} has an unsupported field type`)
150
+ }
151
+ }
152
+
153
+ /** Build the GET payload: declared fields, values, secret presence. */
154
+ function describe(config, entry, warnings) {
155
+ if (!isPlainObject(config)) return { fields: [], secrets: { inline: false, tokenEnv: null }, warnings }
156
+ const fields = FIELDS.map((field) => {
157
+ const value = readAt(config, field.path)
158
+ return {
159
+ path: field.path,
160
+ type: field.type,
161
+ group: field.group,
162
+ ...(field.options === undefined ? {} : { options: field.options }),
163
+ ...(field.nullable === undefined ? {} : { nullable: field.nullable }),
164
+ ...(field.min === undefined ? {} : { min: field.min }),
165
+ ...(field.max === undefined ? {} : { max: field.max }),
166
+ ...(field.placeholder === undefined ? {} : { placeholder: field.placeholder }),
167
+ value: value === undefined ? null : value,
168
+ }
169
+ })
170
+ return {
171
+ entryId: entry?.options?.id ?? PLUGIN_ROW_ID,
172
+ fields,
173
+ secrets: {
174
+ inline: typeof config.auth?.token === 'string' && config.auth.token !== '',
175
+ tokenEnv: typeof config.auth?.tokenEnv === 'string' ? config.auth.tokenEnv : null,
176
+ clientCount: Array.isArray(config.auth?.clients) ? config.auth.clients.length : 0,
177
+ },
178
+ warnings,
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Create the `/panel/config` router.
184
+ *
185
+ * @param {object} options
186
+ * @param {object} options.ctx owning Cordis context
187
+ * @param {Readonly<object>} options.config the active normalized config
188
+ * @param {string} options.mountPath A2A mount path (for messages)
189
+ * @param {{ info: Function, warn: Function }} options.logger
190
+ * @returns {import('express').Router}
191
+ */
192
+ export function createPanelRouter({ ctx, config, mountPath, logger }) {
193
+ const router = express.Router()
194
+ const json = express.json({ limit: 256 * 1024, strict: false })
195
+
196
+ /** @returns {boolean} true when the request was rejected and already answered */
197
+ function authorize(req, res) {
198
+ const connection = ctx.get?.('connection')
199
+ if (connection !== undefined && typeof connection.requestRejection === 'function') {
200
+ try {
201
+ const rejection = connection.requestRejection({ headers: req.headers })
202
+ if (rejection !== undefined) {
203
+ res.status(rejection).json({
204
+ error: rejection === 401 ? 'UNAUTHENTICATED' : 'FORBIDDEN',
205
+ message: 'This endpoint requires the authenticated Harness Web session.',
206
+ })
207
+ return true
208
+ }
209
+ return false
210
+ } catch (error) {
211
+ logger.warn(`[a2a] panel authorization failed: ${errorMessage(error)}`)
212
+ res.status(403).json({ error: 'FORBIDDEN', message: 'Cannot verify the Harness Web session.' })
213
+ return true
214
+ }
215
+ }
216
+ if (!isLoopbackAddress(req.socket?.remoteAddress ?? '')) {
217
+ res.status(403).json({ error: 'FORBIDDEN', message: 'Configuration editing is limited to the local Harness host.' })
218
+ return true
219
+ }
220
+ return false
221
+ }
222
+
223
+ function editor() {
224
+ return ctx.get?.('configEditor')
225
+ }
226
+
227
+ function locateEntry(configEditor) {
228
+ const entries = configEditor.entries()
229
+ return (
230
+ entries.find((entry) => entry.options?.name === PLUGIN_ROW_NAME) ??
231
+ entries.find((entry) => entry.options?.name === PLUGIN_NAME) ??
232
+ entries.find((entry) => entry.options?.id === PLUGIN_ROW_ID)
233
+ )
234
+ }
235
+
236
+ router.get('/config', (req, res) => {
237
+ if (authorize(req, res)) return
238
+ const configEditor = editor()
239
+ if (configEditor === undefined) {
240
+ res.status(501).json({ error: 'UNAVAILABLE', message: 'Configuration editing requires the Harness config editor.' })
241
+ return
242
+ }
243
+ let entry
244
+ try {
245
+ entry = locateEntry(configEditor)
246
+ } catch (error) {
247
+ res.status(500).json({ error: 'INTERNAL', message: errorMessage(error) })
248
+ return
249
+ }
250
+ if (entry === undefined) {
251
+ res.status(404).json({ error: 'NOT_FOUND', message: `No active ${PLUGIN_NAME} row is managed by this profile.` })
252
+ return
253
+ }
254
+ let live = config
255
+ try {
256
+ live = normalizeConfig(entry.options?.config ?? {})
257
+ } catch (error) {
258
+ logger.warn(`[a2a] live config is invalid, showing the active snapshot: ${errorMessage(error)}`)
259
+ }
260
+ const warnings = []
261
+ if (live.server.extendedCard && !resolveCredentials(live).length) {
262
+ warnings.push('extendedCard is disabled at mount unless authentication is configured.')
263
+ }
264
+ const payload = describe(live, entry, warnings)
265
+ res.setHeader('Cache-Control', 'no-store')
266
+ res.json({ ok: true, plugin: { name: PLUGIN_NAME, version: '1.0.0' }, mountPath, ...payload })
267
+ })
268
+
269
+ router.put('/config', json, async (req, res) => {
270
+ if (authorize(req, res)) return
271
+ const configEditor = editor()
272
+ if (configEditor === undefined) {
273
+ res.status(501).json({ error: 'UNAVAILABLE', message: 'Configuration editing requires the Harness config editor.' })
274
+ return
275
+ }
276
+ let entry
277
+ let layers
278
+ try {
279
+ entry = locateEntry(configEditor)
280
+ layers = configEditor.configuration().find((item) => item.entry === entry)
281
+ } catch (error) {
282
+ res.status(500).json({ error: 'INTERNAL', message: errorMessage(error) })
283
+ return
284
+ }
285
+ if (entry === undefined || layers === undefined) {
286
+ res.status(404).json({ error: 'NOT_FOUND', message: `No active ${PLUGIN_NAME} row is managed by this profile.` })
287
+ return
288
+ }
289
+
290
+ const body = req.body
291
+ if (!isPlainObject(body) || (!isPlainObject(body.values) && body.token === undefined && body.clearToken !== true)) {
292
+ res.status(400).json({ error: 'INVALID_ARGUMENT', message: 'Expected { values, token?, clearToken? }.' })
293
+ return
294
+ }
295
+
296
+ const next = mergeConfig(layers.inherited, layers.override)
297
+ try {
298
+ for (const [path, raw] of Object.entries(body.values ?? {})) {
299
+ const field = FIELD_BY_PATH.get(path)
300
+ if (field === undefined) throw new Error(`unknown configuration field "${path}"`)
301
+ const value = coerceField(field, raw)
302
+ if (value === null) {
303
+ deleteAt(next, path)
304
+ const inheritedValue = readAt(layers.inherited, path)
305
+ if (inheritedValue !== undefined) setAt(next, path, structuredClone(inheritedValue))
306
+ } else {
307
+ setAt(next, path, value)
308
+ }
309
+ }
310
+
311
+ if (body.clearToken === true) {
312
+ deleteAt(next, 'auth.token')
313
+ } else if (body.token !== undefined) {
314
+ if (typeof body.token !== 'string') throw new Error('token must be a string')
315
+ if (body.token.trim() === '') throw new Error('token must not be empty')
316
+ setAt(next, 'auth.token', body.token)
317
+ }
318
+
319
+ const normalized = normalizeConfig(next)
320
+ const credentials = resolveCredentials(normalized)
321
+ if (normalized.auth.mode !== 'none' && credentials.length === 0) {
322
+ throw new Error(
323
+ normalized.auth.tokenEnv
324
+ ? `auth.tokenEnv "${normalized.auth.tokenEnv}" is empty; set the environment variable or provide a token`
325
+ : 'auth.mode requires auth.token, auth.tokens, auth.clients or auth.tokenEnv',
326
+ )
327
+ }
328
+ } catch (error) {
329
+ res.status(400).json({ error: 'INVALID_ARGUMENT', message: errorMessage(error) })
330
+ return
331
+ }
332
+
333
+ try {
334
+ await configEditor.edit(entry, () => next)
335
+ } catch (error) {
336
+ logger.warn(`[a2a] config edit failed: ${errorMessage(error)}`)
337
+ res.status(409).json({ error: 'CONFLICT', message: errorMessage(error) })
338
+ return
339
+ }
340
+
341
+ // The row may have been replaced while reconciling; read the live config
342
+ // back rather than echoing our submitted draft.
343
+ let live = next
344
+ let liveEntry = entry
345
+ try {
346
+ const fresh = locateEntry(configEditor)
347
+ if (fresh !== undefined) liveEntry = fresh
348
+ live = normalizeConfig(fresh?.options?.config ?? next)
349
+ } catch {
350
+ /* keep the submitted view when the row is mid-reload */
351
+ }
352
+ logger.info('[a2a] configuration updated from the settings panel')
353
+ res.json({ ok: true, plugin: { name: PLUGIN_NAME, version: '1.0.0' }, mountPath, ...describe(live, liveEntry, []) })
354
+ })
355
+
356
+ return router
357
+ }
package/lib/prompt.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Convert an inbound A2A message into one DeepSeek Harness prompt.
3
+ *
4
+ * Text parts become prompt text, structured `data` parts become fenced JSON,
5
+ * URL parts are referenced verbatim, and `raw` file parts are written into the
6
+ * session workspace so the agent's sandboxed tools can read them.
7
+ *
8
+ * @module @ai_mind/dsh-a2a/prompt
9
+ */
10
+
11
+ import { mkdir, writeFile } from 'node:fs/promises'
12
+ import { join, resolve, sep } from 'node:path'
13
+ import { newId, safeFileName, truncate } from './util.js'
14
+
15
+ /** Common media types mapped to a file extension for materialized files. */
16
+ const MEDIA_EXTENSIONS = {
17
+ 'text/plain': '.txt',
18
+ 'text/markdown': '.md',
19
+ 'text/csv': '.csv',
20
+ 'application/json': '.json',
21
+ 'application/pdf': '.pdf',
22
+ 'application/zip': '.zip',
23
+ 'image/png': '.png',
24
+ 'image/jpeg': '.jpg',
25
+ 'image/webp': '.webp',
26
+ 'image/gif': '.gif',
27
+ 'audio/mpeg': '.mp3',
28
+ 'audio/wav': '.wav',
29
+ 'video/mp4': '.mp4',
30
+ }
31
+
32
+ function extensionFor(mediaType) {
33
+ if (typeof mediaType !== 'string') return ''
34
+ const base = mediaType.split(';', 1)[0].trim().toLowerCase()
35
+ return MEDIA_EXTENSIONS[base] ?? ''
36
+ }
37
+
38
+ function decodeRawValue(value) {
39
+ if (typeof value === 'string') return Buffer.from(value, 'base64')
40
+ if (Buffer.isBuffer(value)) return value
41
+ if (value instanceof Uint8Array) return Buffer.from(value)
42
+ throw new Error('file part has an unsupported raw value')
43
+ }
44
+
45
+ /**
46
+ * Ensure `child` resolves inside `parent`.
47
+ *
48
+ * @param {string} parent absolute directory
49
+ * @param {string} child candidate path
50
+ * @returns {string}
51
+ */
52
+ function assertInside(parent, child) {
53
+ const root = resolve(parent)
54
+ const target = resolve(child)
55
+ if (target !== root && !target.startsWith(`${root}${sep}`)) {
56
+ throw new Error('resolved attachment path escapes the workspace directory')
57
+ }
58
+ return target
59
+ }
60
+
61
+ /**
62
+ * Build the prompt for one A2A message.
63
+ *
64
+ * @param {object} options
65
+ * @param {object} options.message A2A `Message` in SDK object form
66
+ * @param {Readonly<object>} options.config normalized plugin configuration
67
+ * @param {string} options.cwd absolute workspace directory for this session
68
+ * @param {{ warn: (...args: unknown[]) => void }} options.logger
69
+ * @returns {Promise<{ text: string, files: { name: string, path: string, mediaType: string, bytes: number }[] }>}
70
+ */
71
+ export async function buildAgentPrompt({ message, config, cwd, logger }) {
72
+ const blocks = []
73
+ const files = []
74
+ const parts = Array.isArray(message?.parts) ? message.parts : []
75
+ let rawIndex = 0
76
+
77
+ for (const part of parts) {
78
+ const content = part?.content
79
+ if (content === undefined || content === null) continue
80
+ switch (content.$case) {
81
+ case 'text': {
82
+ const value = typeof content.value === 'string' ? content.value : ''
83
+ if (value.trim() !== '') blocks.push(value)
84
+ break
85
+ }
86
+ case 'data': {
87
+ let rendered
88
+ try {
89
+ rendered = JSON.stringify(content.value, null, 2)
90
+ } catch {
91
+ rendered = String(content.value)
92
+ }
93
+ blocks.push(['```json', rendered, '```'].join('\n'))
94
+ break
95
+ }
96
+ case 'url': {
97
+ const label = part.filename ? ` "${part.filename}"` : ''
98
+ const type = part.mediaType ? ` (${part.mediaType})` : ''
99
+ blocks.push(`[Attached file${label}${type}]: ${content.value}`)
100
+ break
101
+ }
102
+ case 'raw': {
103
+ rawIndex += 1
104
+ const bytes = decodeRawValue(content.value)
105
+ if (bytes.byteLength > config.limits.maxFileBytes) {
106
+ throw new Error(
107
+ `file attachment is ${bytes.byteLength} bytes, exceeding limits.maxFileBytes (${config.limits.maxFileBytes})`,
108
+ )
109
+ }
110
+ if (!config.files.enabled) {
111
+ blocks.push(
112
+ `[Attached file${part.filename ? ` "${part.filename}"` : ''} was received but files.enabled is false; contents were not written to disk]`,
113
+ )
114
+ break
115
+ }
116
+ const extension = extensionFor(part.mediaType)
117
+ const baseName = part.filename ? safeFileName(part.filename) : `attachment-${rawIndex}${extension}`
118
+ const directoryName = safeFileName(message?.taskId || newId(), 'task')
119
+ const directory = assertInside(cwd, join(cwd, config.files.directory, directoryName))
120
+ const uniquePrefix = `${String(rawIndex).padStart(2, '0')}-`
121
+ const target = assertInside(cwd, join(directory, `${uniquePrefix}${baseName}`))
122
+ await mkdir(directory, { recursive: true })
123
+ await writeFile(target, bytes, { mode: 0o600 })
124
+ files.push({
125
+ name: baseName,
126
+ path: target,
127
+ mediaType: part.mediaType ?? 'application/octet-stream',
128
+ bytes: bytes.byteLength,
129
+ })
130
+ logger.info(`[a2a] materialized attachment ${target} (${bytes.byteLength} bytes)`)
131
+ blocks.push(
132
+ `[Attached file "${baseName}" (${part.mediaType ?? 'application/octet-stream'}) is available at: ${target}]`,
133
+ )
134
+ break
135
+ }
136
+ default:
137
+ break
138
+ }
139
+ }
140
+
141
+ if (blocks.length === 0) {
142
+ throw new Error('message contains no usable text, data, URL or raw file parts')
143
+ }
144
+
145
+ const text = truncate(blocks.join('\n\n'), config.limits.maxPromptChars)
146
+ return { text, files }
147
+ }
148
+
149
+ /**
150
+ * @param {object} message A2A `Message` in SDK object form
151
+ * @returns {string} concatenated text parts only, for status messages/logging
152
+ */
153
+ export function messageText(message) {
154
+ if (!Array.isArray(message?.parts)) return ''
155
+ return message.parts
156
+ .map((part) => (part?.content?.$case === 'text' ? part.content.value : ''))
157
+ .filter(Boolean)
158
+ .join(' ')
159
+ .trim()
160
+ }
161
+