@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/panel.js ADDED
@@ -0,0 +1,391 @@
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.host', type: 'string', group: 'server', placeholder: '127.0.0.1' },
34
+ {
35
+ path: 'server.port',
36
+ type: 'integer',
37
+ group: 'server',
38
+ nullable: true,
39
+ min: 1,
40
+ max: 65535,
41
+ placeholder: '留空 = 复用 DSH 端口',
42
+ },
43
+ { path: 'server.mountPath', type: 'string', group: 'server' },
44
+ { path: 'server.streaming', type: 'boolean', group: 'server' },
45
+ { path: 'server.pushNotifications', type: 'boolean', group: 'server' },
46
+ { path: 'server.extendedCard', type: 'boolean', group: 'server' },
47
+ { path: 'auth.mode', type: 'enum', options: ['none', 'bearer', 'apiKey'], group: 'auth' },
48
+ { path: 'auth.tokenEnv', type: 'string', group: 'auth', nullable: true, placeholder: 'A2A_AUTH_TOKEN' },
49
+ { path: 'auth.allowRemoteUnauthenticated', type: 'boolean', group: 'auth' },
50
+ { path: 'agent.cwd', type: 'string', group: 'agent', nullable: true, placeholder: '/srv/a2a-workspace' },
51
+ { path: 'agent.preset', type: 'string', group: 'agent', nullable: true },
52
+ { path: 'agent.provider', type: 'string', group: 'agent', nullable: true },
53
+ { path: 'agent.model', type: 'string', group: 'agent', nullable: true },
54
+ { path: 'agent.reasoningEffort', type: 'string', group: 'agent', nullable: true },
55
+ {
56
+ path: 'agent.sandboxMode',
57
+ type: 'enum',
58
+ options: ['inherit', 'read-only', 'workspace-write', 'danger-full-access'],
59
+ group: 'agent',
60
+ },
61
+ { path: 'agent.approvalPolicy', type: 'enum', options: ['inherit', 'ask', 'never'], group: 'agent' },
62
+ { path: 'agent.maxConcurrentTasks', type: 'integer', group: 'agent', min: 1, max: 64 },
63
+ { path: 'agent.queueTimeoutMs', type: 'integer', group: 'agent', min: 0, max: 3600000 },
64
+ { path: 'agent.turnTimeoutMs', type: 'integer', group: 'agent', min: 1000, max: 86400000 },
65
+ { path: 'limits.maxRequestBytes', type: 'integer', group: 'limits', min: 1024, max: 268435456 },
66
+ { path: 'limits.maxFileBytes', type: 'integer', group: 'limits', min: 0, max: 268435456 },
67
+ { path: 'tasks.ttlMs', type: 'integer', group: 'limits', min: 0, max: 2592000000 },
68
+ { path: 'tasks.maxTasks', type: 'integer', group: 'limits', min: 1, max: 100000 },
69
+ { path: 'push.allowPrivateTargets', type: 'boolean', group: 'limits' },
70
+ ]
71
+
72
+ const FIELD_BY_PATH = new Map(FIELDS.map((field) => [field.path, field]))
73
+
74
+ function readAt(source, path) {
75
+ let cursor = source
76
+ for (const part of path.split('.')) {
77
+ if (!isPlainObject(cursor)) return undefined
78
+ cursor = cursor[part]
79
+ }
80
+ return cursor
81
+ }
82
+
83
+ function setAt(target, path, value) {
84
+ const parts = path.split('.')
85
+ let cursor = target
86
+ for (const part of parts.slice(0, -1)) {
87
+ if (!isPlainObject(cursor[part])) cursor[part] = {}
88
+ cursor = cursor[part]
89
+ }
90
+ cursor[parts[parts.length - 1]] = value
91
+ }
92
+
93
+ function deleteAt(target, path) {
94
+ const parts = path.split('.')
95
+ let cursor = target
96
+ for (const part of parts.slice(0, -1)) {
97
+ if (!isPlainObject(cursor) || !isPlainObject(cursor[part])) return
98
+ cursor = cursor[part]
99
+ }
100
+ delete cursor[parts[parts.length - 1]]
101
+ }
102
+
103
+ /** Merge values over `inherited` while preserving unrelated override keys. */
104
+ function mergeConfig(inherited, override) {
105
+ const base = structuredClone(inherited ?? {})
106
+ if (!isPlainObject(override)) return base
107
+ const merge = (target, patch) => {
108
+ for (const [key, value] of Object.entries(patch)) {
109
+ if (isPlainObject(value) && isPlainObject(target[key])) merge(target[key], value)
110
+ else target[key] = structuredClone(value)
111
+ }
112
+ }
113
+ merge(base, override)
114
+ return base
115
+ }
116
+
117
+ /**
118
+ * Coerce one submitted value to the declared field type.
119
+ *
120
+ * @param {{ path: string, type: string, options?: string[], nullable?: boolean, min?: number, max?: number }} field
121
+ * @param {unknown} raw
122
+ * @returns {unknown} the coerced value, or `null` to unset the field
123
+ */
124
+ function coerceField(field, raw) {
125
+ const label = field.path
126
+ if (raw === null || raw === undefined) {
127
+ if (field.nullable) return null
128
+ throw new Error(`${label} must not be empty`)
129
+ }
130
+ switch (field.type) {
131
+ case 'string':
132
+ case 'textarea': {
133
+ if (typeof raw !== 'string') throw new Error(`${label} must be a string`)
134
+ const value = raw.trim()
135
+ if (value === '') {
136
+ if (field.nullable) return null
137
+ throw new Error(`${label} must not be empty`)
138
+ }
139
+ return raw
140
+ }
141
+ case 'integer': {
142
+ const value = typeof raw === 'number' ? raw : Number(raw)
143
+ if (!Number.isInteger(value)) throw new Error(`${label} must be an integer`)
144
+ if (field.min !== undefined && value < field.min) throw new Error(`${label} must be >= ${field.min}`)
145
+ if (field.max !== undefined && value > field.max) throw new Error(`${label} must be <= ${field.max}`)
146
+ return value
147
+ }
148
+ case 'boolean': {
149
+ if (typeof raw !== 'boolean') throw new Error(`${label} must be a boolean`)
150
+ return raw
151
+ }
152
+ case 'enum': {
153
+ if (typeof raw !== 'string' || !field.options.includes(raw)) {
154
+ throw new Error(`${label} must be one of ${field.options.join(', ')}`)
155
+ }
156
+ return raw
157
+ }
158
+ default:
159
+ throw new Error(`${label} has an unsupported field type`)
160
+ }
161
+ }
162
+
163
+ /** Build the GET payload: declared fields, values, secret presence. */
164
+ function describe(config, entry, warnings) {
165
+ if (!isPlainObject(config)) return { fields: [], secrets: { inline: false, tokenEnv: null }, warnings }
166
+ const fields = FIELDS.map((field) => {
167
+ const value = readAt(config, field.path)
168
+ return {
169
+ path: field.path,
170
+ type: field.type,
171
+ group: field.group,
172
+ ...(field.options === undefined ? {} : { options: field.options }),
173
+ ...(field.nullable === undefined ? {} : { nullable: field.nullable }),
174
+ ...(field.min === undefined ? {} : { min: field.min }),
175
+ ...(field.max === undefined ? {} : { max: field.max }),
176
+ ...(field.placeholder === undefined ? {} : { placeholder: field.placeholder }),
177
+ value: value === undefined ? null : value,
178
+ }
179
+ })
180
+ return {
181
+ entryId: entry?.options?.id ?? PLUGIN_ROW_ID,
182
+ fields,
183
+ secrets: {
184
+ inline: typeof config.auth?.token === 'string' && config.auth.token !== '',
185
+ tokenEnv: typeof config.auth?.tokenEnv === 'string' ? config.auth.tokenEnv : null,
186
+ clientCount: Array.isArray(config.auth?.clients) ? config.auth.clients.length : 0,
187
+ },
188
+ warnings,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Create the `/panel/config` router.
194
+ *
195
+ * @param {object} options
196
+ * @param {object} options.ctx owning Cordis context
197
+ * @param {Readonly<object>} options.config the active normalized config
198
+ * @param {string} options.mountPath A2A mount path (for messages)
199
+ * @param {{ info: Function, warn: Function }} options.logger
200
+ * @param {() => object} [options.status] live listening-mode description
201
+ * @param {() => object} [options.health] live health snapshot
202
+ * @returns {import('express').Router}
203
+ */
204
+ function safeHealth(health, logger) {
205
+ try {
206
+ return health()
207
+ } catch (error) {
208
+ logger.warn(`[a2a] panel health snapshot failed: ${errorMessage(error)}`)
209
+ return null
210
+ }
211
+ }
212
+
213
+ export function createPanelRouter({ ctx, config, mountPath, logger, status, health }) {
214
+ const router = express.Router()
215
+ const json = express.json({ limit: 256 * 1024, strict: false })
216
+
217
+ /** @returns {boolean} true when the request was rejected and already answered */
218
+ function authorize(req, res) {
219
+ const connection = ctx.get?.('connection')
220
+ if (connection !== undefined && typeof connection.requestRejection === 'function') {
221
+ try {
222
+ const rejection = connection.requestRejection({ headers: req.headers })
223
+ if (rejection !== undefined) {
224
+ res.status(rejection).json({
225
+ error: rejection === 401 ? 'UNAUTHENTICATED' : 'FORBIDDEN',
226
+ message: 'This endpoint requires the authenticated Harness Web session.',
227
+ })
228
+ return true
229
+ }
230
+ return false
231
+ } catch (error) {
232
+ logger.warn(`[a2a] panel authorization failed: ${errorMessage(error)}`)
233
+ res.status(403).json({ error: 'FORBIDDEN', message: 'Cannot verify the Harness Web session.' })
234
+ return true
235
+ }
236
+ }
237
+ if (!isLoopbackAddress(req.socket?.remoteAddress ?? '')) {
238
+ res.status(403).json({ error: 'FORBIDDEN', message: 'Configuration editing is limited to the local Harness host.' })
239
+ return true
240
+ }
241
+ return false
242
+ }
243
+
244
+ function editor() {
245
+ return ctx.get?.('configEditor')
246
+ }
247
+
248
+ function locateEntry(configEditor) {
249
+ const entries = configEditor.entries()
250
+ return (
251
+ entries.find((entry) => entry.options?.name === PLUGIN_ROW_NAME) ??
252
+ entries.find((entry) => entry.options?.name === PLUGIN_NAME) ??
253
+ entries.find((entry) => entry.options?.id === PLUGIN_ROW_ID)
254
+ )
255
+ }
256
+
257
+ router.get('/config', (req, res) => {
258
+ if (authorize(req, res)) return
259
+ const configEditor = editor()
260
+ if (configEditor === undefined) {
261
+ res.status(501).json({ error: 'UNAVAILABLE', message: 'Configuration editing requires the Harness config editor.' })
262
+ return
263
+ }
264
+ let entry
265
+ try {
266
+ entry = locateEntry(configEditor)
267
+ } catch (error) {
268
+ res.status(500).json({ error: 'INTERNAL', message: errorMessage(error) })
269
+ return
270
+ }
271
+ if (entry === undefined) {
272
+ res.status(404).json({ error: 'NOT_FOUND', message: `No active ${PLUGIN_NAME} row is managed by this profile.` })
273
+ return
274
+ }
275
+ let live = config
276
+ try {
277
+ live = normalizeConfig(entry.options?.config ?? {})
278
+ } catch (error) {
279
+ logger.warn(`[a2a] live config is invalid, showing the active snapshot: ${errorMessage(error)}`)
280
+ }
281
+ const warnings = []
282
+ if (live.server.extendedCard && !resolveCredentials(live).length) {
283
+ warnings.push('extendedCard is disabled at mount unless authentication is configured.')
284
+ }
285
+ const payload = describe(live, entry, warnings)
286
+ res.setHeader('Cache-Control', 'no-store')
287
+ res.json({
288
+ ok: true,
289
+ plugin: { name: PLUGIN_NAME, version: '1.1.0' },
290
+ mountPath,
291
+ ...payload,
292
+ ...(typeof status === 'function' ? { server: status() } : {}),
293
+ ...(typeof health === 'function' ? { health: safeHealth(health, logger) } : {}),
294
+ })
295
+ })
296
+
297
+ router.put('/config', json, async (req, res) => {
298
+ if (authorize(req, res)) return
299
+ const configEditor = editor()
300
+ if (configEditor === undefined) {
301
+ res.status(501).json({ error: 'UNAVAILABLE', message: 'Configuration editing requires the Harness config editor.' })
302
+ return
303
+ }
304
+ let entry
305
+ let layers
306
+ try {
307
+ entry = locateEntry(configEditor)
308
+ layers = configEditor.configuration().find((item) => item.entry === entry)
309
+ } catch (error) {
310
+ res.status(500).json({ error: 'INTERNAL', message: errorMessage(error) })
311
+ return
312
+ }
313
+ if (entry === undefined || layers === undefined) {
314
+ res.status(404).json({ error: 'NOT_FOUND', message: `No active ${PLUGIN_NAME} row is managed by this profile.` })
315
+ return
316
+ }
317
+
318
+ const body = req.body
319
+ if (!isPlainObject(body) || (!isPlainObject(body.values) && body.token === undefined && body.clearToken !== true)) {
320
+ res.status(400).json({ error: 'INVALID_ARGUMENT', message: 'Expected { values, token?, clearToken? }.' })
321
+ return
322
+ }
323
+
324
+ const next = mergeConfig(layers.inherited, layers.override)
325
+ try {
326
+ for (const [path, raw] of Object.entries(body.values ?? {})) {
327
+ const field = FIELD_BY_PATH.get(path)
328
+ if (field === undefined) throw new Error(`unknown configuration field "${path}"`)
329
+ const value = coerceField(field, raw)
330
+ if (value === null) {
331
+ deleteAt(next, path)
332
+ const inheritedValue = readAt(layers.inherited, path)
333
+ if (inheritedValue !== undefined) setAt(next, path, structuredClone(inheritedValue))
334
+ } else {
335
+ setAt(next, path, value)
336
+ }
337
+ }
338
+
339
+ if (body.clearToken === true) {
340
+ deleteAt(next, 'auth.token')
341
+ } else if (body.token !== undefined) {
342
+ if (typeof body.token !== 'string') throw new Error('token must be a string')
343
+ if (body.token.trim() === '') throw new Error('token must not be empty')
344
+ setAt(next, 'auth.token', body.token)
345
+ }
346
+
347
+ const normalized = normalizeConfig(next)
348
+ const credentials = resolveCredentials(normalized)
349
+ if (normalized.auth.mode !== 'none' && credentials.length === 0) {
350
+ throw new Error(
351
+ normalized.auth.tokenEnv
352
+ ? `auth.tokenEnv "${normalized.auth.tokenEnv}" is empty; set the environment variable or provide a token`
353
+ : 'auth.mode requires auth.token, auth.tokens, auth.clients or auth.tokenEnv',
354
+ )
355
+ }
356
+ } catch (error) {
357
+ res.status(400).json({ error: 'INVALID_ARGUMENT', message: errorMessage(error) })
358
+ return
359
+ }
360
+
361
+ try {
362
+ await configEditor.edit(entry, () => next)
363
+ } catch (error) {
364
+ logger.warn(`[a2a] config edit failed: ${errorMessage(error)}`)
365
+ res.status(409).json({ error: 'CONFLICT', message: errorMessage(error) })
366
+ return
367
+ }
368
+
369
+ // The row may have been replaced while reconciling; read the live config
370
+ // back rather than echoing our submitted draft.
371
+ let live = next
372
+ let liveEntry = entry
373
+ try {
374
+ const fresh = locateEntry(configEditor)
375
+ if (fresh !== undefined) liveEntry = fresh
376
+ live = normalizeConfig(fresh?.options?.config ?? next)
377
+ } catch {
378
+ /* keep the submitted view when the row is mid-reload */
379
+ }
380
+ logger.info('[a2a] configuration updated from the settings panel')
381
+ res.json({
382
+ ok: true,
383
+ plugin: { name: PLUGIN_NAME, version: '1.1.0' },
384
+ mountPath,
385
+ ...describe(live, liveEntry, []),
386
+ ...(typeof status === 'function' ? { server: status() } : {}),
387
+ })
388
+ })
389
+
390
+ return router
391
+ }
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
+