@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/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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "A2A Server",
4
+ "description": "Exposes this DeepSeek Harness agent to other agents over the Agent2Agent (A2A) protocol v1.0, with JSON-RPC and HTTP+JSON bindings, streaming, tasks and signed Agent Cards."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "A2A 服务端",
4
+ "description": "通过 Agent2Agent(A2A)v1.0 协议把本机 DeepSeek Harness 智能体暴露给其他智能体,支持 JSON-RPC 与 HTTP+JSON 两种绑定、流式响应、任务管理与签名 Agent Card。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@ai_mind/dsh-a2a",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.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
+ }