@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/card.js ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Agent Card construction and JWS signing for A2A v1.0.
3
+ *
4
+ * The public card is served at the well-known URI. Optional JWS signatures
5
+ * follow RFC 7515 over the RFC 8785 canonical form computed by the official
6
+ * SDK, so a card signed here verifies with any conforming A2A client.
7
+ *
8
+ * @module @ai_mind/dsh-a2a/card
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs'
12
+ import { createPrivateKey } from 'node:crypto'
13
+ import { A2A_PROTOCOL_VERSION, generateAgentCardSignature } from '@a2a-js/sdk'
14
+ import { trimTrailingSlash } from './util.js'
15
+
16
+ /** Security scheme name advertised for bearer or API-key authentication. */
17
+ export const SECURITY_SCHEME_NAME = 'a2a'
18
+
19
+ /** @returns {boolean} whether the deployment requires credentials. */
20
+ export function isAuthEnabled(config, credentials) {
21
+ return config.auth.mode !== 'none' && credentials.length > 0
22
+ }
23
+
24
+ /**
25
+ * Build the `securitySchemes` / `securityRequirements` pair for the public
26
+ * card. An unsecured deployment returns empty objects.
27
+ *
28
+ * @param {object} config
29
+ * @param {{ id: string, token: string }[]} credentials
30
+ * @returns {{ securitySchemes: object, securityRequirements: object[] }}
31
+ */
32
+ export function buildSecurity(config, credentials) {
33
+ if (!isAuthEnabled(config, credentials)) {
34
+ return { securitySchemes: {}, securityRequirements: [] }
35
+ }
36
+ if (config.auth.mode === 'bearer') {
37
+ return {
38
+ securitySchemes: {
39
+ [SECURITY_SCHEME_NAME]: {
40
+ httpAuthSecurityScheme: {
41
+ scheme: 'Bearer',
42
+ bearerFormat: 'opaque',
43
+ description: 'Send the configured A2A access token as an HTTP Bearer credential.',
44
+ },
45
+ },
46
+ },
47
+ securityRequirements: [{ schemes: { [SECURITY_SCHEME_NAME]: { list: [] } } }],
48
+ }
49
+ }
50
+ return {
51
+ securitySchemes: {
52
+ [SECURITY_SCHEME_NAME]: {
53
+ apiKeySecurityScheme: {
54
+ location: 'header',
55
+ name: config.auth.headerName,
56
+ description: `Send the configured A2A access token in the ${config.auth.headerName} header.`,
57
+ },
58
+ },
59
+ },
60
+ securityRequirements: [{ schemes: { [SECURITY_SCHEME_NAME]: { list: [] } } }],
61
+ }
62
+ }
63
+
64
+ /** Drop absent optional entries so the served card stays ProtoJSON-clean. */
65
+ function compactSkill(skill) {
66
+ const result = {
67
+ id: skill.id,
68
+ name: skill.name,
69
+ description: skill.description,
70
+ tags: skill.tags ?? [],
71
+ }
72
+ if (skill.examples?.length) result.examples = skill.examples
73
+ if (skill.inputModes?.length) result.inputModes = skill.inputModes
74
+ if (skill.outputModes?.length) result.outputModes = skill.outputModes
75
+ if (skill.securityRequirements?.length) result.securityRequirements = skill.securityRequirements
76
+ return result
77
+ }
78
+
79
+ /**
80
+ * Assemble the Agent Card for this deployment.
81
+ *
82
+ * @param {Readonly<object>} config
83
+ * @param {object} options
84
+ * @param {string} options.publicBaseUrl absolute origin, no trailing slash
85
+ * @param {{ id: string, token: string }[]} options.credentials
86
+ * @returns {object} an A2A v1.0 AgentCard in ProtoJSON shape
87
+ */
88
+ export function buildAgentCard(config, { publicBaseUrl, credentials }) {
89
+ const base = trimTrailingSlash(publicBaseUrl)
90
+ const endpoint = `${base}${config.server.mountPath}`
91
+ const security = buildSecurity(config, credentials)
92
+
93
+ const card = {
94
+ name: config.card.name,
95
+ description: config.card.description,
96
+ supportedInterfaces: [
97
+ {
98
+ url: endpoint,
99
+ protocolBinding: 'JSONRPC',
100
+ tenant: '',
101
+ protocolVersion: A2A_PROTOCOL_VERSION,
102
+ },
103
+ {
104
+ url: `${endpoint}/rest`,
105
+ protocolBinding: 'HTTP+JSON',
106
+ tenant: '',
107
+ protocolVersion: A2A_PROTOCOL_VERSION,
108
+ },
109
+ ],
110
+ version: config.card.version,
111
+ capabilities: {
112
+ streaming: config.server.streaming,
113
+ pushNotifications: config.server.pushNotifications,
114
+ extendedAgentCard: config.server.extendedCard,
115
+ },
116
+ securitySchemes: security.securitySchemes,
117
+ securityRequirements: security.securityRequirements,
118
+ defaultInputModes: config.card.defaultInputModes,
119
+ defaultOutputModes: config.card.defaultOutputModes,
120
+ skills: config.card.skills.map(compactSkill),
121
+ signatures: [],
122
+ }
123
+
124
+ if (config.card.extensions.length > 0) {
125
+ card.capabilities.extensions = config.card.extensions.map((extension) => {
126
+ const value = {
127
+ uri: extension.uri,
128
+ description: extension.description ?? '',
129
+ required: extension.required === true,
130
+ }
131
+ if (extension.params !== undefined) value.params = extension.params
132
+ return value
133
+ })
134
+ }
135
+ if (config.card.provider) card.provider = config.card.provider
136
+ if (config.card.documentationUrl) card.documentationUrl = config.card.documentationUrl
137
+ if (config.card.iconUrl) card.iconUrl = config.card.iconUrl
138
+
139
+ return card
140
+ }
141
+
142
+ /**
143
+ * Build the authenticated extended card: the public card with additional
144
+ * skills and optional description overrides.
145
+ *
146
+ * @param {Readonly<object>} config
147
+ * @param {object} options same options as {@link buildAgentCard}
148
+ * @returns {object}
149
+ */
150
+ export function buildExtendedCard(config, options) {
151
+ const card = buildAgentCard(config, options)
152
+ const extended = config.card.extended
153
+ if (extended.description) card.description = extended.description
154
+ if (extended.documentationUrl) card.documentationUrl = extended.documentationUrl
155
+ if (extended.skills.length > 0) {
156
+ const seen = new Set(card.skills.map((skill) => skill.id))
157
+ for (const skill of extended.skills) {
158
+ if (seen.has(skill.id)) continue
159
+ card.skills.push(compactSkill(skill))
160
+ }
161
+ }
162
+ return card
163
+ }
164
+
165
+ /**
166
+ * Create the JWS signature generator, or `null` when signing is disabled.
167
+ * Signatures are cached for `signing.cacheSeconds` keyed on the card content.
168
+ *
169
+ * @param {Readonly<object>} config
170
+ * @param {{ warn: (message: string) => void }} logger
171
+ * @returns {Promise<((card: object) => Promise<object>) | null>}
172
+ */
173
+ export function createCardSigner(config) {
174
+ if (!config.signing.enabled) return null
175
+
176
+ let pem = config.signing.privateKeyPem
177
+ if (!pem) {
178
+ try {
179
+ pem = readFileSync(config.signing.privateKeyFile, 'utf8')
180
+ } catch (error) {
181
+ throw new Error(`a2a: cannot read signing.privateKeyFile "${config.signing.privateKeyFile}": ${error.message}`)
182
+ }
183
+ }
184
+ let key
185
+ try {
186
+ key = createPrivateKey({ key: pem, format: 'pem' })
187
+ } catch (error) {
188
+ throw new Error(`a2a: signing key is not a valid PEM private key: ${error.message}`)
189
+ }
190
+
191
+ const header = { alg: config.signing.algorithm, typ: 'JOSE', kid: config.signing.keyId }
192
+ if (config.signing.jku) header.jku = config.signing.jku
193
+ const sign = generateAgentCardSignature(key, header)
194
+
195
+ const cacheMs = config.signing.cacheSeconds * 1000
196
+ let cacheKey
197
+ let cacheValue
198
+ let cacheAt = 0
199
+
200
+ return async (card) => {
201
+ const body = JSON.stringify(card)
202
+ const now = Date.now()
203
+ if (cacheMs > 0 && cacheValue !== undefined && cacheKey === body && now - cacheAt < cacheMs) {
204
+ return cacheValue
205
+ }
206
+ const signed = await sign(structuredClone(card))
207
+ if (cacheMs > 0) {
208
+ cacheKey = body
209
+ cacheValue = signed
210
+ cacheAt = now
211
+ }
212
+ return signed
213
+ }
214
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Hot-install discovery for the browser half.
3
+ *
4
+ * `@deepseek-ai/dsh-client-modules` caches, per Loader specifier, whether that
5
+ * package carries a `dsh.client` half. A profile that installed this bundle
6
+ * while it had no browser half keeps a negative verdict for the process
7
+ * lifetime, so adding `client.js` to an already-loaded package would otherwise
8
+ * only appear after a restart.
9
+ *
10
+ * This shim clears that one negative verdict and asks the scanner to reconcile
11
+ * the row once. It touches only the scanner's public-ish compiled fields and
12
+ * silently degrades to "client half appears after restart" when they are
13
+ * absent or changed. On a fresh boot the package is scanned with `dsh.client`
14
+ * already present and the shim is a no-op.
15
+ *
16
+ * @module @ai_mind/dsh-a2a/client-discovery
17
+ */
18
+
19
+ import { errorMessage } from './util.js'
20
+
21
+ const PACKAGE_NAME = '@ai_mind/dsh-a2a'
22
+
23
+ /**
24
+ * Ask the client-module scanner to re-read this package's manifest.
25
+ *
26
+ * @param {object} ctx owning Cordis context
27
+ * @param {{ info: Function, warn: Function }} logger
28
+ * @returns {boolean} whether a reconciliation was triggered
29
+ */
30
+ export function ensureClientHalfDiscovered(ctx, logger) {
31
+ const scanner = ctx.get?.('clientModules')
32
+ if (scanner === undefined || scanner === null) return false
33
+ if (!(scanner.pkgMeta instanceof Map) || !(scanner.dirty instanceof Set) || typeof scanner.flush !== 'function') {
34
+ return false
35
+ }
36
+ if (scanner.table instanceof Map && scanner.table.has(PACKAGE_NAME)) return false
37
+
38
+ const suffix = `\u0000${PACKAGE_NAME}`
39
+ let cleared = 0
40
+ for (const key of [...scanner.pkgMeta.keys()]) {
41
+ if (typeof key === 'string' && key.endsWith(suffix)) {
42
+ scanner.pkgMeta.delete(key)
43
+ cleared += 1
44
+ }
45
+ }
46
+
47
+ scanner.dirty.add(PACKAGE_NAME)
48
+ try {
49
+ scanner.flush((error) => {
50
+ logger.warn(`[a2a] client module discovery failed: ${errorMessage(error)}`)
51
+ })
52
+ } catch (error) {
53
+ logger.warn(`[a2a] client module discovery failed: ${errorMessage(error)}`)
54
+ return false
55
+ }
56
+ if (cleared > 0) logger.info('[a2a] cleared a cached "no browser half" verdict and re-scanned the package')
57
+ return true
58
+ }