@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/cordis.patch.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dsh-a2a bundle patch: mounts the A2A (Agent2Agent) v1.0 server row.
|
|
2
|
+
#
|
|
3
|
+
# Defaults below are safe for a loopback deployment. The row listens on its
|
|
4
|
+
# own port (127.0.0.1:41241) so A2A traffic does not share the DSH Web port;
|
|
5
|
+
# set `server.port: null` to reuse the DSH listener again. To expose the agent
|
|
6
|
+
# to other hosts, set `server.publicUrl` and enable `auth` in the profile patch:
|
|
7
|
+
#
|
|
8
|
+
# - id: a2a-server
|
|
9
|
+
# name: '@ai_mind/dsh-a2a'
|
|
10
|
+
# config:
|
|
11
|
+
# server:
|
|
12
|
+
# publicUrl: 'https://agent.example.com'
|
|
13
|
+
# auth:
|
|
14
|
+
# mode: bearer
|
|
15
|
+
# tokenEnv: A2A_AUTH_TOKEN
|
|
16
|
+
#
|
|
17
|
+
# A patch replaces the targeted row's whole `config`, so restate every field
|
|
18
|
+
# you keep from this file when overriding the row.
|
|
19
|
+
- insert:
|
|
20
|
+
- id: a2a-server
|
|
21
|
+
name: '@ai_mind/dsh-a2a'
|
|
22
|
+
config:
|
|
23
|
+
enabled: true
|
|
24
|
+
server:
|
|
25
|
+
host: 127.0.0.1
|
|
26
|
+
port: 41241
|
package/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="A2A server">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="a2a-bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#2f6df6"/>
|
|
5
|
+
<stop offset="1" stop-color="#7b3cf0"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#a2a-bg)"/>
|
|
9
|
+
<circle cx="18" cy="32" r="7" fill="none" stroke="#ffffff" stroke-width="3.5"/>
|
|
10
|
+
<circle cx="46" cy="32" r="7" fill="none" stroke="#ffffff" stroke-width="3.5"/>
|
|
11
|
+
<path d="M25.5 32h13" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round"/>
|
|
12
|
+
<path d="M32 25.5l6.5 6.5-6.5 6.5" fill="none" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
13
|
+
<circle cx="18" cy="32" r="2.2" fill="#ffffff"/>
|
|
14
|
+
<circle cx="46" cy="32" r="2.2" fill="#ffffff"/>
|
|
15
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ai_mind/dsh-a2a — an Agent2Agent (A2A) v1.0 server for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* The plugin exposes the local Harness agent over the official A2A protocol:
|
|
5
|
+
* JSON-RPC and HTTP+JSON bindings, blocking and streaming message sends, task
|
|
6
|
+
* listing/cancellation/resubscription, push notifications, and JWS-signed
|
|
7
|
+
* Agent Cards. It mounts on the Harness Web server, so the reachable origin is
|
|
8
|
+
* the same listener that serves the GUI.
|
|
9
|
+
*
|
|
10
|
+
* Configuration lives on the bundle row; see README.md for every field.
|
|
11
|
+
*
|
|
12
|
+
* @module @ai_mind/dsh-a2a
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { A2AService } from './lib/server.js'
|
|
16
|
+
import { normalizeConfig } from './lib/config.js'
|
|
17
|
+
import { ensureClientHalfDiscovered } from './lib/client-discovery.js'
|
|
18
|
+
|
|
19
|
+
/** Diagnostic plugin name shown in loader errors. */
|
|
20
|
+
export const name = 'a2a-server'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The server cannot work without the agent registry: every A2A task is
|
|
24
|
+
* executed by a real Harness agent.
|
|
25
|
+
*/
|
|
26
|
+
export const inject = ['agents']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the A2A server.
|
|
30
|
+
*
|
|
31
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
32
|
+
* @param {unknown} rawConfig the bundle row's `config`
|
|
33
|
+
*/
|
|
34
|
+
export function apply(ctx, rawConfig) {
|
|
35
|
+
let config
|
|
36
|
+
try {
|
|
37
|
+
config = normalizeConfig(rawConfig)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`a2a: invalid configuration: ${error.message}`)
|
|
40
|
+
}
|
|
41
|
+
if (!config.enabled) {
|
|
42
|
+
ctx.logger?.info?.('[a2a] plugin is disabled by config')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const service = new A2AService(ctx, config)
|
|
47
|
+
ctx.effect(() => () => service.dispose(), 'a2a: service lifecycle')
|
|
48
|
+
|
|
49
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
50
|
+
webCtx.effect(() => service.mount(webCtx.webServer), 'a2a: http routes')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// A profile that installed an older build without a browser half keeps a
|
|
54
|
+
// negative discovery verdict for this specifier; reconcile it so the
|
|
55
|
+
// settings panel appears without a restart.
|
|
56
|
+
ctx.inject(['clientModules'], (clientCtx) => {
|
|
57
|
+
ensureClientHalfDiscovered(clientCtx, service.logger)
|
|
58
|
+
})
|
|
59
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication for the A2A endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Three modes are supported:
|
|
5
|
+
* - `none` : no credentials; only loopback callers are admitted unless
|
|
6
|
+
* `auth.allowRemoteUnauthenticated` is explicitly enabled.
|
|
7
|
+
* - `bearer` : `Authorization: Bearer <token>`
|
|
8
|
+
* - `apiKey` : a configurable header carrying the token (default `x-api-key`)
|
|
9
|
+
*
|
|
10
|
+
* The public Agent Card stays reachable without credentials (it must be, for
|
|
11
|
+
* discovery); every other route is protected.
|
|
12
|
+
*
|
|
13
|
+
* @module @ai_mind/dsh-a2a/auth
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { constantTimeEqual, isLoopbackAddress } from './util.js'
|
|
17
|
+
|
|
18
|
+
/** Credential extractors per auth mode. */
|
|
19
|
+
const EXTRACTORS = {
|
|
20
|
+
bearer(req) {
|
|
21
|
+
const header = req.headers.authorization
|
|
22
|
+
if (typeof header !== 'string') return null
|
|
23
|
+
const match = /^\s*Bearer\s+(.+?)\s*$/i.exec(header)
|
|
24
|
+
return match ? match[1] : null
|
|
25
|
+
},
|
|
26
|
+
apiKey(req, config) {
|
|
27
|
+
const raw = req.headers[config.auth.headerName]
|
|
28
|
+
if (typeof raw === 'string' && raw.trim() !== '') return raw.trim()
|
|
29
|
+
if (Array.isArray(raw) && typeof raw[0] === 'string') return raw[0].trim()
|
|
30
|
+
return null
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write a 401 in the shape of the binding the caller used.
|
|
36
|
+
*
|
|
37
|
+
* @param {import('node:http').IncomingMessage} req
|
|
38
|
+
* @param {import('node:http').ServerResponse} res
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {string} options.message
|
|
41
|
+
* @param {string} options.challenge
|
|
42
|
+
* @param {string} options.contentType
|
|
43
|
+
* @param {boolean} options.restBinding
|
|
44
|
+
*/
|
|
45
|
+
function writeUnauthorized(req, res, { message, challenge, contentType, restBinding }) {
|
|
46
|
+
if (res.headersSent) return
|
|
47
|
+
res.statusCode = 401
|
|
48
|
+
res.setHeader('Content-Type', contentType)
|
|
49
|
+
res.setHeader('WWW-Authenticate', challenge)
|
|
50
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
51
|
+
const details = [
|
|
52
|
+
{
|
|
53
|
+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
54
|
+
reason: 'UNAUTHENTICATED',
|
|
55
|
+
domain: 'a2a-protocol.org',
|
|
56
|
+
metadata: { path: String(req.url ?? '') },
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
if (restBinding) {
|
|
60
|
+
res.end(
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
error: { code: 401, status: 'UNAUTHENTICATED', message, details },
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
res.end(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
jsonrpc: '2.0',
|
|
70
|
+
id: null,
|
|
71
|
+
error: { code: -32000, message, data: details },
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create the authenticator for one mounted deployment.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} options
|
|
80
|
+
* @param {Readonly<object>} options.config normalized configuration
|
|
81
|
+
* @param {{ id: string, token: string }[]} options.credentials resolved secrets
|
|
82
|
+
* @param {{ warn: (...args: unknown[]) => void, info: (...args: unknown[]) => void }} options.logger
|
|
83
|
+
* @param {(req: import('node:http').IncomingMessage) => boolean} options.isRestBinding
|
|
84
|
+
* @returns {{
|
|
85
|
+
* enabled: boolean,
|
|
86
|
+
* middleware: import('express').RequestHandler,
|
|
87
|
+
* userBuilder: (req: import('node:http').IncomingMessage) => object,
|
|
88
|
+
* describe: () => object,
|
|
89
|
+
* }}
|
|
90
|
+
*/
|
|
91
|
+
export function createAuthenticator({ config, credentials, logger, isRestBinding }) {
|
|
92
|
+
const mode = config.auth.mode
|
|
93
|
+
const enabled = mode !== 'none'
|
|
94
|
+
if (enabled && credentials.length === 0) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`a2a: auth.mode is "${mode}" but no credentials are configured; ` +
|
|
97
|
+
'set auth.tokens, auth.token, auth.clients or auth.tokenEnv',
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const challenge =
|
|
102
|
+
mode === 'bearer'
|
|
103
|
+
? `Bearer realm="${config.auth.realm}"`
|
|
104
|
+
: `ApiKey realm="${config.auth.realm}", header="${config.auth.headerName}"`
|
|
105
|
+
|
|
106
|
+
const contentType = 'application/a2a+json'
|
|
107
|
+
let warnedRemote = false
|
|
108
|
+
|
|
109
|
+
/** @returns {{ id: string } | null} */
|
|
110
|
+
function identify(req) {
|
|
111
|
+
const extractor = EXTRACTORS[mode]
|
|
112
|
+
if (extractor === undefined) return null
|
|
113
|
+
const presented = extractor(req, config)
|
|
114
|
+
if (presented === null) return null
|
|
115
|
+
let matched = null
|
|
116
|
+
for (const credential of credentials) {
|
|
117
|
+
// Every candidate is compared so timing does not reveal which one matched.
|
|
118
|
+
if (constantTimeEqual(presented, credential.token) && matched === null) matched = credential
|
|
119
|
+
}
|
|
120
|
+
return matched === null ? null : { id: matched.id }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function middleware(req, res, next) {
|
|
124
|
+
const peer = req.ip || req.socket?.remoteAddress || ''
|
|
125
|
+
const restBinding = isRestBinding(req)
|
|
126
|
+
|
|
127
|
+
if (!enabled) {
|
|
128
|
+
if (!config.auth.allowRemoteUnauthenticated && !isLoopbackAddress(peer)) {
|
|
129
|
+
if (!warnedRemote) {
|
|
130
|
+
warnedRemote = true
|
|
131
|
+
logger.warn(
|
|
132
|
+
`[a2a] rejected unauthenticated request from ${peer || 'unknown peer'}; ` +
|
|
133
|
+
'configure auth or set auth.allowRemoteUnauthenticated=true to allow remote callers',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
writeUnauthorized(req, res, {
|
|
137
|
+
message: 'Unauthenticated: this A2A endpoint only accepts loopback callers without configured credentials.',
|
|
138
|
+
challenge,
|
|
139
|
+
contentType,
|
|
140
|
+
restBinding,
|
|
141
|
+
})
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
next()
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const identity = identify(req)
|
|
149
|
+
if (identity === null) {
|
|
150
|
+
writeUnauthorized(req, res, {
|
|
151
|
+
message: 'Unauthenticated: missing or invalid A2A credentials.',
|
|
152
|
+
challenge,
|
|
153
|
+
contentType,
|
|
154
|
+
restBinding,
|
|
155
|
+
})
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
req.a2aClient = identity
|
|
159
|
+
next()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function userBuilder(req) {
|
|
163
|
+
const identity = req.a2aClient
|
|
164
|
+
if (!identity) {
|
|
165
|
+
return {
|
|
166
|
+
get isAuthenticated() {
|
|
167
|
+
return false
|
|
168
|
+
},
|
|
169
|
+
get userName() {
|
|
170
|
+
return ''
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
get isAuthenticated() {
|
|
176
|
+
return true
|
|
177
|
+
},
|
|
178
|
+
get userName() {
|
|
179
|
+
return identity.id
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
enabled,
|
|
186
|
+
middleware,
|
|
187
|
+
userBuilder,
|
|
188
|
+
describe: () => ({
|
|
189
|
+
mode,
|
|
190
|
+
enabled,
|
|
191
|
+
clientCount: credentials.length,
|
|
192
|
+
header: mode === 'apiKey' ? config.auth.headerName : 'authorization',
|
|
193
|
+
allowRemoteUnauthenticated: config.auth.allowRemoteUnauthenticated,
|
|
194
|
+
}),
|
|
195
|
+
}
|
|
196
|
+
}
|