@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/LICENSE +21 -0
- package/README.md +444 -2
- package/client.js +868 -0
- package/cordis.patch.yml +21 -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 +565 -0
- package/lib/executor.js +357 -0
- package/lib/files.js +52 -0
- package/lib/limits.js +133 -0
- package/lib/panel.js +357 -0
- package/lib/prompt.js +161 -0
- package/lib/server.js +456 -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/store.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task and push-notification storage for the A2A server.
|
|
3
|
+
*
|
|
4
|
+
* `A2ATaskStore` implements the SDK's `TaskStore` contract with:
|
|
5
|
+
* - per-(tenant, authenticated client) isolation, so one credential never
|
|
6
|
+
* sees another client's tasks;
|
|
7
|
+
* - the SDK-compatible cursor pagination, descending status-time ordering
|
|
8
|
+
* and `includeArtifacts` semantics required by `ListTasks`;
|
|
9
|
+
* - TTL and capacity eviction, but only for tasks in a terminal state so an
|
|
10
|
+
* in-flight execution can never be dropped from under its event bus.
|
|
11
|
+
*
|
|
12
|
+
* `GuardedPushNotificationStore` adds webhook URL validation (SSRF guard) and
|
|
13
|
+
* a per-task configuration cap in front of the SDK's in-memory store.
|
|
14
|
+
*
|
|
15
|
+
* @module @ai_mind/dsh-a2a/store
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { RequestMalformedError } from '@a2a-js/sdk/errors'
|
|
19
|
+
import { newId, parsePublicHttpUrl } from './util.js'
|
|
20
|
+
|
|
21
|
+
/** Terminal A2A task states (COMPLETED, FAILED, CANCELED, REJECTED). */
|
|
22
|
+
const TERMINAL_STATES = new Set([3, 4, 5, 7])
|
|
23
|
+
|
|
24
|
+
/** Default page size used when the request omits `pageSize`, per the spec. */
|
|
25
|
+
const DEFAULT_PAGE_SIZE = 50
|
|
26
|
+
const MAX_PAGE_SIZE = 100
|
|
27
|
+
|
|
28
|
+
function isTerminal(task) {
|
|
29
|
+
return TERMINAL_STATES.has(task?.status?.state)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @returns {string} the isolation key for one call context. */
|
|
33
|
+
function scopeKey(context) {
|
|
34
|
+
const tenant = typeof context?.tenant === 'string' ? context.tenant : ''
|
|
35
|
+
const owner = typeof context?.user?.userName === 'string' ? context.user.userName : ''
|
|
36
|
+
return `${tenant}\u0000${owner}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function encodePageToken(timestamp, id) {
|
|
40
|
+
return Buffer.from(JSON.stringify({ t: timestamp, i: id }), 'utf8').toString('base64url')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodePageToken(token) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(Buffer.from(String(token), 'base64url').toString('utf8'))
|
|
46
|
+
if (typeof parsed?.t !== 'string' || typeof parsed?.i !== 'string') return null
|
|
47
|
+
return { timestamp: parsed.t, id: parsed.i }
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lastTimestamp(task) {
|
|
54
|
+
return typeof task?.status?.timestamp === 'string' ? task.status.timestamp : ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Task store with owner scoping, cursor pagination and bounded retention.
|
|
59
|
+
*/
|
|
60
|
+
export class A2ATaskStore {
|
|
61
|
+
/** @type {Map<string, Map<string, { task: object, updatedAt: number }>>} */
|
|
62
|
+
#buckets = new Map()
|
|
63
|
+
#ttlMs
|
|
64
|
+
#maxTasks
|
|
65
|
+
#logger
|
|
66
|
+
#saved = 0
|
|
67
|
+
#terminalCount = 0
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} options
|
|
71
|
+
* @param {number} options.ttlMs retention for terminal tasks (0 disables time-based eviction)
|
|
72
|
+
* @param {number} options.maxTasks global capacity for terminal tasks
|
|
73
|
+
* @param {{ warn: (...args: unknown[]) => void }} options.logger
|
|
74
|
+
*/
|
|
75
|
+
constructor({ ttlMs, maxTasks, logger }) {
|
|
76
|
+
this.#ttlMs = ttlMs
|
|
77
|
+
this.#maxTasks = maxTasks
|
|
78
|
+
this.#logger = logger
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#bucket(context) {
|
|
82
|
+
return this.#buckets.get(scopeKey(context))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async save(task, context) {
|
|
86
|
+
if (task === undefined || task === null || typeof task.id !== 'string' || task.id === '') {
|
|
87
|
+
throw new Error('a2a: task store save requires a task with a non-empty id')
|
|
88
|
+
}
|
|
89
|
+
const key = scopeKey(context)
|
|
90
|
+
let bucket = this.#buckets.get(key)
|
|
91
|
+
if (bucket === undefined) {
|
|
92
|
+
bucket = new Map()
|
|
93
|
+
this.#buckets.set(key, bucket)
|
|
94
|
+
}
|
|
95
|
+
const previous = bucket.get(task.id)
|
|
96
|
+
const wasTerminal = previous !== undefined && isTerminal(previous.task)
|
|
97
|
+
const nowTerminal = isTerminal(task)
|
|
98
|
+
if (!wasTerminal && nowTerminal) this.#terminalCount += 1
|
|
99
|
+
if (wasTerminal && !nowTerminal) this.#terminalCount -= 1
|
|
100
|
+
// Retention follows the task's own status time (the durable "last update"
|
|
101
|
+
// fact), falling back to arrival time when a client supplied no timestamp.
|
|
102
|
+
const statusTime = Date.parse(task.status?.timestamp ?? '')
|
|
103
|
+
const updatedAt = Number.isFinite(statusTime) ? statusTime : Date.now()
|
|
104
|
+
bucket.set(task.id, { task: structuredClone(task), updatedAt })
|
|
105
|
+
this.#saved += 1
|
|
106
|
+
this.#evictOverflow()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async load(taskId, context) {
|
|
110
|
+
const entry = this.#bucket(context)?.get(taskId)
|
|
111
|
+
return entry === undefined ? undefined : structuredClone(entry.task)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async list(params, context) {
|
|
115
|
+
const {
|
|
116
|
+
contextId,
|
|
117
|
+
status,
|
|
118
|
+
pageSize = DEFAULT_PAGE_SIZE,
|
|
119
|
+
pageToken,
|
|
120
|
+
statusTimestampAfter,
|
|
121
|
+
includeArtifacts = false,
|
|
122
|
+
} = params ?? {}
|
|
123
|
+
|
|
124
|
+
const bucket = this.#bucket(context)
|
|
125
|
+
let tasks = bucket === undefined ? [] : [...bucket.values()].map((entry) => entry.task)
|
|
126
|
+
|
|
127
|
+
if (contextId) tasks = tasks.filter((task) => task.contextId === contextId)
|
|
128
|
+
if (status !== undefined && status !== 0) tasks = tasks.filter((task) => task.status?.state === status)
|
|
129
|
+
if (statusTimestampAfter) {
|
|
130
|
+
const threshold = new Date(statusTimestampAfter).getTime()
|
|
131
|
+
if (Number.isFinite(threshold)) {
|
|
132
|
+
tasks = tasks.filter((task) => {
|
|
133
|
+
const timestamp = lastTimestamp(task)
|
|
134
|
+
return timestamp !== '' && new Date(timestamp).getTime() > threshold
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
tasks.sort((left, right) => {
|
|
140
|
+
const timeLeft = lastTimestamp(left)
|
|
141
|
+
const timeRight = lastTimestamp(right)
|
|
142
|
+
if (timeRight !== timeLeft) return timeRight.localeCompare(timeLeft)
|
|
143
|
+
return right.id.localeCompare(left.id)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const totalSize = tasks.length
|
|
147
|
+
|
|
148
|
+
if (pageToken) {
|
|
149
|
+
const cursor = decodePageToken(pageToken)
|
|
150
|
+
if (cursor === null) {
|
|
151
|
+
tasks = []
|
|
152
|
+
} else {
|
|
153
|
+
const index = tasks.findIndex(
|
|
154
|
+
(task) => lastTimestamp(task) === cursor.timestamp && task.id === cursor.id,
|
|
155
|
+
)
|
|
156
|
+
tasks = index === -1 ? [] : tasks.slice(index + 1)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const size = Math.min(
|
|
161
|
+
Math.max(Number.isInteger(pageSize) ? pageSize : DEFAULT_PAGE_SIZE, 1),
|
|
162
|
+
MAX_PAGE_SIZE,
|
|
163
|
+
)
|
|
164
|
+
const page = tasks.slice(0, size)
|
|
165
|
+
const resultTasks = page.map((task) => {
|
|
166
|
+
const copy = structuredClone(task)
|
|
167
|
+
if (!includeArtifacts) copy.artifacts = []
|
|
168
|
+
return copy
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
let nextPageToken = ''
|
|
172
|
+
if (page.length > 0 && tasks.length > page.length) {
|
|
173
|
+
const last = page[page.length - 1]
|
|
174
|
+
nextPageToken = encodePageToken(lastTimestamp(last), last.id)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { tasks: resultTasks, nextPageToken, pageSize: size, totalSize }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Remove terminal tasks older than the TTL and, when over capacity, the
|
|
182
|
+
* oldest terminal tasks until the store fits.
|
|
183
|
+
*
|
|
184
|
+
* @param {number} [now]
|
|
185
|
+
* @returns {{ expired: number, evicted: number }}
|
|
186
|
+
*/
|
|
187
|
+
sweep(now = Date.now()) {
|
|
188
|
+
let expired = 0
|
|
189
|
+
if (this.#ttlMs > 0) {
|
|
190
|
+
for (const [key, bucket] of this.#buckets) {
|
|
191
|
+
for (const [id, entry] of bucket) {
|
|
192
|
+
if (!isTerminal(entry.task)) continue
|
|
193
|
+
if (now - entry.updatedAt < this.#ttlMs) continue
|
|
194
|
+
bucket.delete(id)
|
|
195
|
+
this.#terminalCount -= 1
|
|
196
|
+
expired += 1
|
|
197
|
+
}
|
|
198
|
+
if (bucket.size === 0) this.#buckets.delete(key)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const evicted = this.#evictOverflow()
|
|
202
|
+
return { expired, evicted }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Enforce `maxTasks` by dropping the oldest terminal entries. */
|
|
206
|
+
#evictOverflow() {
|
|
207
|
+
if (this.#terminalCount <= this.#maxTasks) return 0
|
|
208
|
+
const candidates = []
|
|
209
|
+
for (const [key, bucket] of this.#buckets) {
|
|
210
|
+
for (const [id, entry] of bucket) {
|
|
211
|
+
if (!isTerminal(entry.task)) continue
|
|
212
|
+
candidates.push({ key, id, updatedAt: entry.updatedAt })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
candidates.sort((left, right) => left.updatedAt - right.updatedAt)
|
|
216
|
+
let removed = 0
|
|
217
|
+
for (const candidate of candidates) {
|
|
218
|
+
if (this.#terminalCount <= this.#maxTasks) break
|
|
219
|
+
const bucket = this.#buckets.get(candidate.key)
|
|
220
|
+
if (bucket === undefined) continue
|
|
221
|
+
if (bucket.delete(candidate.id)) {
|
|
222
|
+
this.#terminalCount -= 1
|
|
223
|
+
removed += 1
|
|
224
|
+
if (bucket.size === 0) this.#buckets.delete(candidate.key)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (removed > 0) {
|
|
228
|
+
this.#logger.warn(`[a2a] task store at capacity; evicted ${removed} terminal task(s)`)
|
|
229
|
+
}
|
|
230
|
+
return removed
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @returns {{ tasks: number, scopes: number, saved: number, terminal: number }} */
|
|
234
|
+
stats() {
|
|
235
|
+
let tasks = 0
|
|
236
|
+
for (const bucket of this.#buckets.values()) tasks += bucket.size
|
|
237
|
+
return { tasks, scopes: this.#buckets.size, saved: this.#saved, terminal: this.#terminalCount }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Validate push-notification webhook targets before the SDK persists them.
|
|
243
|
+
*
|
|
244
|
+
* The A2A spec lets clients supply an arbitrary webhook URL; without a guard a
|
|
245
|
+
* remote caller could make the Host POST to internal services (SSRF).
|
|
246
|
+
*/
|
|
247
|
+
export class GuardedPushNotificationStore {
|
|
248
|
+
#inner
|
|
249
|
+
#allowPrivate
|
|
250
|
+
#maxConfigs
|
|
251
|
+
#logger
|
|
252
|
+
|
|
253
|
+
constructor(inner, { allowPrivateTargets, maxConfigsPerTask = 10, logger }) {
|
|
254
|
+
this.#inner = inner
|
|
255
|
+
this.#allowPrivate = allowPrivateTargets
|
|
256
|
+
this.#maxConfigs = maxConfigsPerTask
|
|
257
|
+
this.#logger = logger
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async save(taskId, context, pushNotificationConfig) {
|
|
261
|
+
try {
|
|
262
|
+
parsePublicHttpUrl(pushNotificationConfig?.url, { allowPrivate: this.#allowPrivate })
|
|
263
|
+
} catch (error) {
|
|
264
|
+
this.#logger.warn(`[a2a] rejected push notification config for task ${taskId}: ${error.message}`)
|
|
265
|
+
throw new RequestMalformedError(error.message)
|
|
266
|
+
}
|
|
267
|
+
const existing = await this.#inner.load(taskId, context)
|
|
268
|
+
if (existing.length >= this.#maxConfigs && !pushNotificationConfig.id) {
|
|
269
|
+
throw new RequestMalformedError(`push notification configuration limit reached (${this.#maxConfigs})`)
|
|
270
|
+
}
|
|
271
|
+
if (!pushNotificationConfig.id) pushNotificationConfig.id = newId()
|
|
272
|
+
return this.#inner.save(taskId, context, pushNotificationConfig)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
load(taskId, context) {
|
|
276
|
+
return this.#inner.load(taskId, context)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
loadWithMetadata(taskId, context) {
|
|
280
|
+
return this.#inner.loadWithMetadata(taskId, context)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
delete(taskId, context, configId) {
|
|
284
|
+
return this.#inner.delete(taskId, context, configId)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export { TERMINAL_STATES }
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, dependency-free helpers shared by the A2A server modules.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is protocol- or platform-level plumbing, deliberately free
|
|
5
|
+
* of DeepSeek Harness imports so the modules can be unit-tested in isolation.
|
|
6
|
+
*
|
|
7
|
+
* @module @ai_mind/dsh-a2a/util
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
11
|
+
import { isIP } from 'node:net'
|
|
12
|
+
|
|
13
|
+
/** @returns {boolean} whether `value` is a plain object (not an array/null/class instance). */
|
|
14
|
+
export function isPlainObject(value) {
|
|
15
|
+
if (typeof value !== 'object' || value === null) return false
|
|
16
|
+
const proto = Object.getPrototypeOf(value)
|
|
17
|
+
return proto === Object.prototype || proto === null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Deep-clone a JSON-compatible value. */
|
|
21
|
+
export function cloneJson(value) {
|
|
22
|
+
return value === undefined ? undefined : structuredClone(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Merge `patch` into `base` recursively. Arrays and scalars replace the base
|
|
27
|
+
* value; plain objects merge. `undefined` values in `patch` are ignored so a
|
|
28
|
+
* parsed YAML document cannot erase a default by omission.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} base
|
|
31
|
+
* @param {object} patch
|
|
32
|
+
* @returns {object} a fresh merged object
|
|
33
|
+
*/
|
|
34
|
+
export function deepMerge(base, patch) {
|
|
35
|
+
const result = isPlainObject(base) ? structuredClone(base) : {}
|
|
36
|
+
if (!isPlainObject(patch)) return result
|
|
37
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
38
|
+
if (value === undefined) continue
|
|
39
|
+
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
40
|
+
result[key] = deepMerge(result[key], value)
|
|
41
|
+
} else {
|
|
42
|
+
result[key] = cloneJson(value)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Recursively freeze a JSON-compatible value in place and return it. */
|
|
49
|
+
export function deepFreeze(value) {
|
|
50
|
+
if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value
|
|
51
|
+
Object.freeze(value)
|
|
52
|
+
for (const child of Object.values(value)) deepFreeze(child)
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @returns {string} a fresh RFC 4122 version 4 UUID. */
|
|
57
|
+
export function newId() {
|
|
58
|
+
return randomUUID()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {string} the current time as a millisecond-precision ISO 8601 UTC string. */
|
|
62
|
+
export function nowIso() {
|
|
63
|
+
return new Date().toISOString()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compare two strings without leaking their contents through timing.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} left
|
|
70
|
+
* @param {string} right
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function constantTimeEqual(left, right) {
|
|
74
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false
|
|
75
|
+
const a = Buffer.from(left, 'utf8')
|
|
76
|
+
const b = Buffer.from(right, 'utf8')
|
|
77
|
+
if (a.length !== b.length) {
|
|
78
|
+
// Still consume a comparison to keep the failure path uniform.
|
|
79
|
+
timingSafeEqual(a, a)
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
return timingSafeEqual(a, b)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @returns {boolean} whether a socket address names the local host. */
|
|
86
|
+
export function isLoopbackAddress(address) {
|
|
87
|
+
if (typeof address !== 'string' || address.length === 0) return false
|
|
88
|
+
const bare = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
|
|
89
|
+
if (bare === '::1' || bare === '0:0:0:0:0:0:0:1') return true
|
|
90
|
+
const zone = bare.indexOf('%')
|
|
91
|
+
const host = zone === -1 ? bare : bare.slice(0, zone)
|
|
92
|
+
if (host === '::1') return true
|
|
93
|
+
if (isIP(host) === 4) {
|
|
94
|
+
const [a] = host.split('.').map(Number)
|
|
95
|
+
return a === 127
|
|
96
|
+
}
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Classify a URL host as an internal address that a remote push-notification
|
|
102
|
+
* target must not reach by default.
|
|
103
|
+
*
|
|
104
|
+
* Literal addresses and well-known local names are rejected. DNS names are not
|
|
105
|
+
* resolved here (the HTTP client resolves them at delivery time), so this is a
|
|
106
|
+
* first-line SSRF guard rather than a complete one; deployments that must not
|
|
107
|
+
* reach internal networks should also enforce egress policy at the network
|
|
108
|
+
* layer.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} hostname a URL hostname without brackets or port
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function isPrivateHostname(hostname) {
|
|
114
|
+
if (typeof hostname !== 'string') return true
|
|
115
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
116
|
+
if (host === 'localhost' || host.endsWith('.localhost')) return true
|
|
117
|
+
if (host.endsWith('.local') || host.endsWith('.internal')) return true
|
|
118
|
+
const family = isIP(host)
|
|
119
|
+
if (family === 4) {
|
|
120
|
+
const parts = host.split('.').map(Number)
|
|
121
|
+
const [a, b] = parts
|
|
122
|
+
if (a === 0 || a === 10 || a === 127) return true
|
|
123
|
+
if (a === 169 && b === 254) return true
|
|
124
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
125
|
+
if (a === 192 && b === 168) return true
|
|
126
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
127
|
+
if (a === 198 && (b === 18 || b === 19)) return true
|
|
128
|
+
if (a >= 224) return true
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
if (family === 6) {
|
|
132
|
+
if (host === '::' || host === '::1') return true
|
|
133
|
+
const first = host.split(':')[0]
|
|
134
|
+
const firstValue = Number.parseInt(first || '0', 16)
|
|
135
|
+
if (Number.isFinite(firstValue)) {
|
|
136
|
+
if ((firstValue & 0xfe00) === 0xfc00) return true // fc00::/7 unique local
|
|
137
|
+
if ((firstValue & 0xffc0) === 0xfe80) return true // fe80::/10 link local
|
|
138
|
+
if (firstValue === 0) return true // ::ffff:0:0/96, ::/128
|
|
139
|
+
}
|
|
140
|
+
if (host.startsWith('::ffff:')) return isPrivateHostname(host.slice('::ffff:'.length))
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validate a push-notification URL and, when private targets are not allowed,
|
|
148
|
+
* reject loopback/link-local/private destinations.
|
|
149
|
+
*
|
|
150
|
+
* @param {unknown} value candidate URL
|
|
151
|
+
* @param {{ allowPrivate?: boolean }} [options]
|
|
152
|
+
* @returns {URL} the parsed URL
|
|
153
|
+
* @throws {Error} when the URL is not an acceptable HTTP(S) target
|
|
154
|
+
*/
|
|
155
|
+
export function parsePublicHttpUrl(value, options = {}) {
|
|
156
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
157
|
+
throw new Error('push notification URL must be a non-empty string')
|
|
158
|
+
}
|
|
159
|
+
let url
|
|
160
|
+
try {
|
|
161
|
+
url = new URL(value)
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error(`push notification URL is not a valid absolute URL: ${value}`)
|
|
164
|
+
}
|
|
165
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
166
|
+
throw new Error(`push notification URL must use http(s), got ${url.protocol}`)
|
|
167
|
+
}
|
|
168
|
+
if (url.username || url.password) {
|
|
169
|
+
throw new Error('push notification URL must not embed credentials')
|
|
170
|
+
}
|
|
171
|
+
if (!options.allowPrivate && isPrivateHostname(url.hostname)) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`push notification URL targets a private or loopback host (${url.hostname}); ` +
|
|
174
|
+
'set push.allowPrivateTargets=true to permit internal webhooks',
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
return url
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Truncate a string to at most `max` characters, appending a marker. */
|
|
181
|
+
export function truncate(value, max, marker = '\n…[truncated]') {
|
|
182
|
+
if (typeof value !== 'string' || value.length <= max) return value
|
|
183
|
+
return value.slice(0, Math.max(0, max - marker.length)) + marker
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @returns {string} a human-readable message for any thrown value. */
|
|
187
|
+
export function errorMessage(error) {
|
|
188
|
+
if (error instanceof Error) return error.message || error.name
|
|
189
|
+
if (typeof error === 'string') return error
|
|
190
|
+
try {
|
|
191
|
+
return JSON.stringify(error)
|
|
192
|
+
} catch {
|
|
193
|
+
return String(error)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Make a user-supplied file name safe to join under a directory.
|
|
199
|
+
* Path separators, control characters and leading dots are neutralized.
|
|
200
|
+
*
|
|
201
|
+
* @param {string | undefined} name
|
|
202
|
+
* @param {string} fallback
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
export function safeFileName(name, fallback = 'attachment') {
|
|
206
|
+
const base = typeof name === 'string' && name.trim() !== '' ? name : fallback
|
|
207
|
+
const cleaned = base
|
|
208
|
+
.replace(/[\\/]+/g, '_')
|
|
209
|
+
.replace(/[\u0000-\u001f\u007f]/g, '_')
|
|
210
|
+
.replace(/^\.+/, '')
|
|
211
|
+
.replace(/\s+/g, ' ')
|
|
212
|
+
.trim()
|
|
213
|
+
const bounded = cleaned.slice(0, 120)
|
|
214
|
+
return bounded.length > 0 ? bounded : fallback
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Normalize an origin/base URL by dropping trailing slashes. */
|
|
218
|
+
export function trimTrailingSlash(value) {
|
|
219
|
+
return typeof value === 'string' ? value.replace(/\/+$/, '') : value
|
|
220
|
+
}
|
|
221
|
+
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai_mind/dsh-a2a",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Production A2A (Agent2Agent) v1.0 server for DeepSeek Harness agents: JSON-RPC + HTTP+JSON bindings, streaming, tasks, push notifications and signed Agent Cards.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"a2a",
|
|
9
|
+
"agent2agent",
|
|
10
|
+
"deepseek",
|
|
11
|
+
"harness",
|
|
12
|
+
"dsh",
|
|
13
|
+
"agent",
|
|
14
|
+
"json-rpc",
|
|
15
|
+
"sse"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./index.js",
|
|
23
|
+
"./client": "./client.js",
|
|
24
|
+
"./locale/*.json": "./locale/*.json",
|
|
25
|
+
"./icon": "./icon.svg"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.js",
|
|
29
|
+
"client.js",
|
|
30
|
+
"lib/**/*.js",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"locale/*.json",
|
|
33
|
+
"icon.svg",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"immediately": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@a2a-js/sdk": "1.3.0",
|
|
48
|
+
"express": "^5.1.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test test/*.test.js"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20"
|
|
55
|
+
}
|
|
56
|
+
}
|