@nelsonlongxiang/dsh-open-a2a-net 0.5.13
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/README.md +115 -0
- package/README_zh.md +80 -0
- package/cordis.patch.yml +21 -0
- package/images/image-01.png +0 -0
- package/images/image-02.png +0 -0
- package/lib/a2a-client.js +166 -0
- package/lib/card.js +159 -0
- package/lib/client/A2aControl.js +239 -0
- package/lib/client/index.js +35 -0
- package/lib/client/locales.js +55 -0
- package/lib/client.js +756 -0
- package/lib/client.js.map +1 -0
- package/lib/group-store.js +131 -0
- package/lib/index.js +1485 -0
- package/lib/invariant.js +16 -0
- package/lib/joined-store.js +98 -0
- package/lib/peer-store.js +169 -0
- package/lib/types/a2a-client.d.ts +73 -0
- package/lib/types/card.d.ts +54 -0
- package/lib/types/client/A2aControl.d.ts +64 -0
- package/lib/types/client/index.d.ts +27 -0
- package/lib/types/client/locales.d.ts +7 -0
- package/lib/types/client.d.ts +73 -0
- package/lib/types/group-store.d.ts +58 -0
- package/lib/types/index.d.ts +113 -0
- package/lib/types/invariant.d.ts +8 -0
- package/lib/types/joined-store.d.ts +61 -0
- package/lib/types/peer-store.d.ts +91 -0
- package/lib/types/types.d.ts +97 -0
- package/lib/types/zone.d.ts +37 -0
- package/lib/types.js +5 -0
- package/lib/zone.js +55 -0
- package/package.json +157 -0
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @nelsonlongxiang/dsh-open-a2a-net
|
|
2
|
+
|
|
3
|
+
Open A2A network plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH): turns a DSH
|
|
4
|
+
deployment into one node of a decentralized agent network — no central server, no registry.
|
|
5
|
+
|
|
6
|
+
Each node publishes a **signed, expiring agent card** at `/.well-known/agent-card.json`, discovers peers through
|
|
7
|
+
**seed URLs and referrals** (each card lists the peers it knows), resolves names through **zone delegations**
|
|
8
|
+
(GNUnet GNS-style: a card may delegate a name to another zone), and routes **directly** to a peer's team with
|
|
9
|
+
failover across the reachable half of candidates. Every main session can additionally be exposed as a **session
|
|
10
|
+
node** other hosts can discover and join from the web sidebar.
|
|
11
|
+
|
|
12
|
+
| The sidebar network panel | Context message display |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
|  |  |
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx -p @deepseek-ai/dsh dsh plugin --profile <name> add @nelsonlongxiang/dsh-open-a2a-net
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
(As a package specifier, a local path, or a Git URL; the package runs `pnpm build` in `prepare`, so a source
|
|
23
|
+
install builds on install. Note: a standalone Git install currently fails to build outside a DSH profile —
|
|
24
|
+
the official `@deepseek-ai/*` npm packages lag the harness source this plugin compiles against, so install
|
|
25
|
+
from the registry or inside a profile whose harness provides current `@deepseek-ai` peers.) Then restart the
|
|
26
|
+
profile. The plugin composes beside the stock
|
|
27
|
+
webserver row: it registers its card, state, and control routes on the shared listener and its model tools on the
|
|
28
|
+
shared tool runtime.
|
|
29
|
+
|
|
30
|
+
## What you get
|
|
31
|
+
|
|
32
|
+
- **Model tools** — `a2a_teams` lists the teams this node can see (own, peers', joined sessions'); `a2a_route`
|
|
33
|
+
sends one message to a team, reuses `context_id` to continue a remote conversation, and fails over across
|
|
34
|
+
candidates when a peer is unreachable.
|
|
35
|
+
- **Sidebar control** — a footer action in the web sidebar listing this host's sessions as joinable network
|
|
36
|
+
nodes: title, recent-activity excerpt, and team per row, join/leave in place, and a wake action for cold rows
|
|
37
|
+
(persisted join intent whose session is not loaded yet — opening the session remounts the node). Session teams
|
|
38
|
+
are `<team>/<id8>`; web sessions use their id's first 8 chars, imported sessions (`import-<uuid>`) keep the
|
|
39
|
+
`import-` prefix plus the uuid's first 8 hex chars so imported ids cannot collapse into a handful of teams.
|
|
40
|
+
- **Archive leaves the network** — archiving a session (workspace registry state) prunes its join
|
|
41
|
+
intent and unmounts its node: at boot settlement before any wake, on every state read (a mid-session
|
|
42
|
+
archive disappears within one panel poll), and as a route-time guard that never wakes an archived
|
|
43
|
+
target.
|
|
44
|
+
- **Honest waits** — a synchronous route whose target never answers releases the caller at a 180s
|
|
45
|
+
reply-wait deadline with the delivered shape and the receipt contract (the target answers on its own
|
|
46
|
+
cadence); `async: true` skips the wait entirely. The network panel dims an in-flight row past 120s as
|
|
47
|
+
a stale wait instead of implying live progress, and its title carries the package version badge.
|
|
48
|
+
- **Announce** — `announce: true` publishes this node's card (team, capabilities, referrals, joined session
|
|
49
|
+
teams) so peers find it without any directory.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
Row config overlays in the profile's patch layers; every key has a schema default.
|
|
54
|
+
|
|
55
|
+
| Key | Default | Purpose |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `announce` | `false` | Publish this node's agent card for peer discovery. |
|
|
58
|
+
| `peers` | `[]` | Seed base URLs; referred peers are learned from their cards. |
|
|
59
|
+
| `delegates` | `[]` | Zone delegations: `{ name, url, publicKey }` published on the card. |
|
|
60
|
+
| `team` | `'dsh'` | Team name this node exposes for direct routing. |
|
|
61
|
+
| `session` | `''` | Caller label; `''` derives a stable `dsh-host-<8hex>` per home. |
|
|
62
|
+
| `agentName` | `'DeepSeek Harness A2A node'` | Human-facing name on the card. |
|
|
63
|
+
| `apiKey` | `''` | `X-API-Key` sent on peer requests; non-empty also gates the control routes. |
|
|
64
|
+
| `sessionNodes` | `true` | Expose main sessions as joinable network nodes. |
|
|
65
|
+
| `wakeJoinedOnBoot` | `false` | Materialize every cold joined session's agent on mount (needs the api gateway; wake-on-route and the sidebar wake button stay available without it). |
|
|
66
|
+
| `cardTtlMs` / `flushTimeoutMs` / `routeTimeoutMs` | see schema | Card lifetime and route timing budgets. |
|
|
67
|
+
|
|
68
|
+
Example — one announcing node with a seed:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
- id: a2a
|
|
72
|
+
name: '@nelsonlongxiang/dsh-open-a2a-net'
|
|
73
|
+
config:
|
|
74
|
+
announce: true
|
|
75
|
+
peers: ['http://127.0.0.1:41243']
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Trust model
|
|
79
|
+
|
|
80
|
+
Cards are Ed25519-signed and expire; `peers` referrals and `sessionTeams` listings on a card are unsigned and
|
|
81
|
+
read fresh. The `/__dsh_a2a/join` and `/__dsh_a2a/leave` control routes require the configured `apiKey`
|
|
82
|
+
(constant-time compare) when one is set; with an empty key they trust only same-origin browsers and loopback
|
|
83
|
+
callers. Set an `apiKey` before exposing the listener beyond loopback.
|
|
84
|
+
|
|
85
|
+
## Collaboration SOP (network practice, v0.1)
|
|
86
|
+
|
|
87
|
+
Distilled from the 0.1.x–0.5.x delivery cycles by the network's research node; adopted by the maintainer.
|
|
88
|
+
|
|
89
|
+
- **Roles** — one session, one office, no crossing workspace boundaries: a research node (protocol/design
|
|
90
|
+
review, verdict criteria, source-line references with must-fix/recommend labels), a maintainer node
|
|
91
|
+
(version rulings, releases, adoption receipts), a test node (the single execution point for all live-host
|
|
92
|
+
verification; production hosts are never used for ad-hoc testing), and an install/ops node (production
|
|
93
|
+
profile deployment, single-owner, waits for the maintenance window).
|
|
94
|
+
- **Dispatch discipline** — test tasks go to the test node with version + commit + decidable criteria +
|
|
95
|
+
receipt target; review tasks go to the research node with source pointers, not paraphrases.
|
|
96
|
+
- **Receipt contract** — tasks longer than minutes route with `async: true` (wait:false); receipts read
|
|
97
|
+
`[A2A receipt] task <task_id> <outcome summary>`; the task id is caller-born, request-carried,
|
|
98
|
+
peer-echoed, and steered-header-transmitted — four-way agreement before a receipt correlates.
|
|
99
|
+
- **Upgrade chain** — test node green → maintainer receipt → ops node requests the window → production
|
|
100
|
+
upgrade → maintainer's browser-face final check → network-wide notice.
|
|
101
|
+
- **Reuse red lines** — shared pure logic extracts to a library; shared runtime capability extracts to a
|
|
102
|
+
Service; plugins never value-import each other; production hosts carry no verification duty (no
|
|
103
|
+
dev-who-self-tests).
|
|
104
|
+
|
|
105
|
+
## Verify
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
pnpm install # auto-install-peers pulls the DSH peer train from npm
|
|
109
|
+
pnpm verify # typecheck, build, source tests, and built-artifact layout guard
|
|
110
|
+
pnpm build # host lib (including lib/a2a-client.js), client types, and browser bundle (lib/client.js)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
package/README_zh.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @nelsonlongxiang/dsh-open-a2a-net
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的开放 A2A 网络插件:把一个 DSH 部署变成去中心化 agent 网络中的一个节点——没有中心服务器,没有注册中心。
|
|
6
|
+
|
|
7
|
+
每个节点在 `/.well-known/agent-card.json` 发布**带签名、会过期**的 agent 卡片,通过**种子 URL 与相互引荐**发现 peers(每张卡片列出它认识的节点),通过**区域委托**解析名字(GNUnet GNS 风格:卡片可把一个名字委托给另一个 zone),并**直连**路由到 peer 的团队、在可达半数候选间故障切换。每个主会话还可以作为**会话节点**暴露,供其他宿主从 Web 侧栏发现并加入。
|
|
8
|
+
|
|
9
|
+
| 侧栏网络面板 | 上下文消息展示 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
|  |  |
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx -p @deepseek-ai/dsh dsh plugin --profile <name> add @nelsonlongxiang/dsh-open-a2a-net
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
(支持包名、本地路径或 Git URL;包内 `prepare` 会在安装时执行 `pnpm build`。注意:脱离 DSH profile 的独立 Git 安装目前无法构建——官方 `@deepseek-ai/*` npm 快照落后于本插件编译所对照的 harness 源码,请从 registry 安装,或在由 harness 提供当前 `@deepseek-ai` peers 的 profile 内安装。)然后重启 profile。插件组合在标准 webserver 行旁边:在共享监听器上注册卡片、状态与控制路由,在共享工具运行时上注册模型工具。
|
|
20
|
+
|
|
21
|
+
## 你能得到什么
|
|
22
|
+
|
|
23
|
+
- **模型工具** —— `a2a_teams` 列出本节点可见的团队(自有、peers 的、已加入会话的);`a2a_route` 向一个团队发送消息,用 `context_id` 继续远端会话,peer 不可达时在候选间故障切换。
|
|
24
|
+
- **侧栏控制** —— Web 侧栏底部的操作入口,把本宿主的会话列为可加入的网络节点:标题、近期活动摘录、每行所属团队,就地加入/退出,冷行(已持久化加入意图但会话未加载)可唤醒——打开会话即重挂节点。会话团队名为 `<team>/<id8>`;Web 会话取 id 前 8 位,导入会话(`import-<uuid>`)保留 `import-` 前缀加 uuid 前 8 位十六进制,避免导入 id 塌缩成少数几个团队。
|
|
25
|
+
- **归档即离网** —— 归档会话(workspace 注册表状态)会剪除其加入意图并卸载其节点:唤醒前的启动结算、每次状态读取(会话中归档在一个面板轮询周期内消失)、以及永不唤醒已归档目标的路由时守卫。
|
|
26
|
+
- **诚实的等待** —— 目标始终不应答的同步路由,在 180s 回复等待期限后以已投递形态与回执契约释放调用方(目标按自己的节奏作答);`async: true` 完全跳过等待。网络面板对超过 120s 的在途行降亮度标记为过期等待而非暗示活跃进度,标题带包版本徽标。
|
|
27
|
+
- **通告** —— `announce: true` 发布本节点卡片(团队、能力、引荐、已加入会话团队),peers 无需任何目录即可发现它。
|
|
28
|
+
|
|
29
|
+
## 配置
|
|
30
|
+
|
|
31
|
+
行配置叠加在 profile 的 patch 层;每个键都有 schema 默认值。
|
|
32
|
+
|
|
33
|
+
| 键 | 默认值 | 用途 |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `announce` | `false` | 发布本节点 agent 卡片供 peer 发现。 |
|
|
36
|
+
| `peers` | `[]` | 种子 base URL;被引荐的 peers 从其卡片学习。 |
|
|
37
|
+
| `delegates` | `[]` | 区域委托:`{ name, url, publicKey }` 发布在卡片上。 |
|
|
38
|
+
| `team` | `'dsh'` | 本节点暴露给直连路由的团队名。 |
|
|
39
|
+
| `session` | `''` | 调用方标签;`''` 为每个 home 派生稳定的 `dsh-host-<8hex>`。 |
|
|
40
|
+
| `agentName` | `'DeepSeek Harness A2A node'` | 卡片上的人类可读名称。 |
|
|
41
|
+
| `apiKey` | `''` | peer 请求携带的 `X-API-Key`;非空时同时门禁控制路由。 |
|
|
42
|
+
| `sessionNodes` | `true` | 把主会话暴露为可加入的网络节点。 |
|
|
43
|
+
| `wakeJoinedOnBoot` | `false` | 挂载时物化所有冷加入会话的 agent(需要 api gateway;无它时路由唤醒与侧栏唤醒按钮仍可用)。 |
|
|
44
|
+
| `cardTtlMs` / `flushTimeoutMs` / `routeTimeoutMs` | 见 schema | 卡片生命周期与路由时序预算。 |
|
|
45
|
+
|
|
46
|
+
示例——一个带种子的通告节点:
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
- id: a2a
|
|
50
|
+
name: '@nelsonlongxiang/dsh-open-a2a-net'
|
|
51
|
+
config:
|
|
52
|
+
announce: true
|
|
53
|
+
peers: ['http://127.0.0.1:41243']
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 信任模型
|
|
57
|
+
|
|
58
|
+
卡片经 Ed25519 签名且会过期;卡片上的 `peers` 引荐与 `sessionTeams` 列表未签名、每次现读。`/__dsh_a2a/join` 与 `/__dsh_a2a/leave` 控制路由在配置了 `apiKey` 时要求之(常数时间比较);密钥为空时仅信任同源浏览器与 loopback 调用方。把监听器暴露到 loopback 之外前请先设置 `apiKey`。
|
|
59
|
+
|
|
60
|
+
## 协作 SOP(网络实践,v0.1)
|
|
61
|
+
|
|
62
|
+
由网络研究节点从 0.1.x–0.5.x 交付周期蒸馏;维护者采纳。
|
|
63
|
+
|
|
64
|
+
- **角色** —— 一个会话一个职能,不越工作区边界:研究节点(协议/设计评审、裁决标准、带 must-fix/recommend 标签的源码行引用)、维护者节点(版本裁决、发布、采纳回执)、测试节点(所有活体验证的唯一执行点;生产宿主绝不用于临时测试)、安装/运维节点(生产 profile 部署、单一 owner、等待维护窗口)。
|
|
65
|
+
- **派发纪律** —— 测试任务发测试节点,带版本 + 提交 + 可判定标准 + 回执目标;评审任务发研究节点,带源码指针而非转述。
|
|
66
|
+
- **回执契约** —— 超过分钟级的任务用 `async: true`(wait:false)路由;回执读作 `[A2A receipt] task <task_id> <outcome summary>`;task id 由调用方出生、请求携带、peer 回显、steering 头传输——四方一致才关联回执。
|
|
67
|
+
- **升级链** —— 测试节点绿 → 维护者回执 → 运维节点申请窗口 → 生产升级 → 维护者浏览器面终检 → 全网通告。
|
|
68
|
+
- **复用红线** —— 共享纯逻辑提取库;共享运行时能力提取 Service;插件之间禁止值 import;生产宿主不承担验证职责(禁止开发者自测)。
|
|
69
|
+
|
|
70
|
+
## 验证
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
pnpm install # auto-install-peers 从 npm 拉取 DSH peer 线
|
|
74
|
+
pnpm verify # typecheck、构建、源码测试与构建产物布局守卫
|
|
75
|
+
pnpm build # host lib(含 lib/a2a-client.js)、client 类型与浏览器 bundle(lib/client.js)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 许可证
|
|
79
|
+
|
|
80
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Bundle patch for @nelsonlongxiang/dsh-open-a2a-net: mounts the a2a row into
|
|
2
|
+
# the host composition of a dsh profile. The plugin registers its
|
|
3
|
+
# /.well-known/agent-card.json announce route, its /__dsh_a2a/* state and
|
|
4
|
+
# control routes, and the a2a_teams / a2a_route model tools on the shared web
|
|
5
|
+
# server and tool runtime — it owns no listener of its own, so compose it
|
|
6
|
+
# beside a webserver row (the stock web profile already has one).
|
|
7
|
+
#
|
|
8
|
+
# Install: `dsh plugin --profile <name> add @nelsonlongxiang/dsh-open-a2a-net`
|
|
9
|
+
# (npm, a local path, or Git). The `dsh plugin` command pnpm-installs the
|
|
10
|
+
# package into the profile and reconciles it into the profile's
|
|
11
|
+
# `dsh.profile.bundles` layer list.
|
|
12
|
+
#
|
|
13
|
+
# All config keys carry schema defaults: session nodes are on (the sidebar
|
|
14
|
+
# join control works out of the box), announce is opt-in. Peers, zone
|
|
15
|
+
# delegates, and an API key are set per deployment through row config
|
|
16
|
+
# overlays, never here.
|
|
17
|
+
- insert:
|
|
18
|
+
- id: a2a
|
|
19
|
+
# Node-resolvable package name — must stay in sync with package.json
|
|
20
|
+
# `name`. Quoted because `@` is a reserved YAML indicator.
|
|
21
|
+
name: '@nelsonlongxiang/dsh-open-a2a-net'
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process decentralized A2A client: the outbound peer surface — verified
|
|
3
|
+
* agent-card fetches (discovery) and direct routes to a peer's
|
|
4
|
+
* `/a2a/direct` endpoint. Timing and fetch seams are constructor-injected so
|
|
5
|
+
* tests drive them without a network.
|
|
6
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/a2a-client
|
|
7
|
+
*/
|
|
8
|
+
import { verifyCard } from "./card.js";
|
|
9
|
+
/** Direct-route HTTP budget. */
|
|
10
|
+
const HTTP_TIMEOUT_MS = 15_000;
|
|
11
|
+
/** Wire strings are absent-or-string; anything else degrades to ''. */
|
|
12
|
+
function wireText(value) {
|
|
13
|
+
return typeof value === 'string' ? value : '';
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The A2A peer client. One instance serves the whole fiber's outbound
|
|
17
|
+
* card fetches and direct routes; it owns no connection state.
|
|
18
|
+
*/
|
|
19
|
+
export class A2aClient {
|
|
20
|
+
options;
|
|
21
|
+
/** @param options - resolved caller facts and injected seams. */
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
headers(json) {
|
|
26
|
+
const headers = {};
|
|
27
|
+
if (json)
|
|
28
|
+
headers['Content-Type'] = 'application/json';
|
|
29
|
+
if (this.options.apiKey !== '')
|
|
30
|
+
headers['X-API-Key'] = this.options.apiKey;
|
|
31
|
+
return headers;
|
|
32
|
+
}
|
|
33
|
+
async http(path, init, baseUrl) {
|
|
34
|
+
if (init.signal?.aborted)
|
|
35
|
+
throw new Error('A2A request aborted before dispatch');
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timer = this.options.schedule(() => {
|
|
38
|
+
controller.abort();
|
|
39
|
+
}, HTTP_TIMEOUT_MS);
|
|
40
|
+
const abort = () => {
|
|
41
|
+
controller.abort();
|
|
42
|
+
};
|
|
43
|
+
init.signal?.addEventListener('abort', abort, { once: true });
|
|
44
|
+
try {
|
|
45
|
+
const response = await this.options.fetch(`${baseUrl}${path}`, {
|
|
46
|
+
method: init.method,
|
|
47
|
+
headers: this.headers(init.body !== undefined),
|
|
48
|
+
...(init.body !== undefined ? { body: init.body } : {}),
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
});
|
|
51
|
+
const text = await response.text();
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
throw new Error(`A2A HTTP ${String(response.status)}: ${text.slice(0, 200)}`);
|
|
54
|
+
return JSON.parse(text);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
timer();
|
|
58
|
+
init.signal?.removeEventListener('abort', abort);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Fetch one peer's agent card (discovery): team, session, capabilities.
|
|
63
|
+
* The card is verified at parse — unsigned, badly signed, or expired cards
|
|
64
|
+
* are rejected here, so a stale peer disappears from discovery without any
|
|
65
|
+
* background cleanup.
|
|
66
|
+
* @param baseUrl - the peer's base URL.
|
|
67
|
+
* @returns the verified card, or `undefined` when unreachable, malformed, or rejected.
|
|
68
|
+
*/
|
|
69
|
+
async fetchCard(baseUrl) {
|
|
70
|
+
try {
|
|
71
|
+
const candidate = await this.http('/.well-known/agent-card.json', { method: 'GET' }, baseUrl);
|
|
72
|
+
const verified = verifyCard(candidate, Date.now());
|
|
73
|
+
if (!verified.ok)
|
|
74
|
+
return undefined;
|
|
75
|
+
return verified.card;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// An unreachable or malformed peer leaves discovery to the other peers.
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Extract the reply text from a route result member: the wire carries a
|
|
84
|
+
* plain string, a `{text}` object, or anything else JSON-encoded.
|
|
85
|
+
* @param inner - the raw `result` member of a route response.
|
|
86
|
+
* @param fallback - what a nullish member encodes as.
|
|
87
|
+
* @returns the reply text.
|
|
88
|
+
*/
|
|
89
|
+
replyText(inner, fallback) {
|
|
90
|
+
if (typeof inner === 'string')
|
|
91
|
+
return inner;
|
|
92
|
+
if (inner !== null && typeof inner === 'object' && typeof inner.text === 'string') {
|
|
93
|
+
return inner.text;
|
|
94
|
+
}
|
|
95
|
+
return JSON.stringify(inner ?? fallback);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Route directly to a peer node's `/a2a/direct` endpoint. The peer steers
|
|
99
|
+
* the message into its live agent (or the named session team's agent) and
|
|
100
|
+
* answers within its own final-reply budget.
|
|
101
|
+
* @param baseUrl - the peer's base URL.
|
|
102
|
+
* @param team - target team name (the peer's own or a session team).
|
|
103
|
+
* @param message - request text.
|
|
104
|
+
* @param contextId - context id from a prior reply, for multi-turn.
|
|
105
|
+
* @param signal - caller cancellation.
|
|
106
|
+
* @param callerSession - caller label overriding the node's label (the calling session's node label).
|
|
107
|
+
* @returns the canonical route result (success or explicit failure).
|
|
108
|
+
*/
|
|
109
|
+
async routeDirect(baseUrl, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller) {
|
|
110
|
+
const args = { team, message, caller_session: callerSession ?? this.options.sessionId };
|
|
111
|
+
if (contextId !== undefined)
|
|
112
|
+
args.context_id = contextId;
|
|
113
|
+
// wait:false asks the peer to steer and answer delivered immediately —
|
|
114
|
+
// the async half of the receipt contract for cross-host long tasks.
|
|
115
|
+
if (asyncMode)
|
|
116
|
+
args.wait = false;
|
|
117
|
+
// The caller-born task id rides the request (idempotency key): a peer
|
|
118
|
+
// that echoes it keeps the receipt header correlated with this route's
|
|
119
|
+
// own result; the fallback below stamps it when the peer generated its
|
|
120
|
+
// own (pre-0.5.3 peers).
|
|
121
|
+
if (taskIdFromCaller !== undefined)
|
|
122
|
+
args.task_id = taskIdFromCaller;
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = await this.http('/a2a/direct', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
body: JSON.stringify(args),
|
|
128
|
+
...(signal !== undefined ? { signal } : {}),
|
|
129
|
+
}, baseUrl);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
return { ok: false, error: `direct route to peer failed: ${String(error)}`, code: -32000 };
|
|
133
|
+
}
|
|
134
|
+
if (raw.error !== undefined && raw.error !== null) {
|
|
135
|
+
const failure = {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: typeof raw.error === 'string' ? raw.error : JSON.stringify(raw.error),
|
|
138
|
+
};
|
|
139
|
+
if (typeof raw.code === 'number')
|
|
140
|
+
failure.code = raw.code;
|
|
141
|
+
return failure;
|
|
142
|
+
}
|
|
143
|
+
// The delivered shape (wait:false): no result member — the reply rides
|
|
144
|
+
// the receipt contract back to the caller's team instead.
|
|
145
|
+
if (raw.routed === true && raw.delivered === true) {
|
|
146
|
+
const taskId = wireText(raw.task_id) !== '' ? wireText(raw.task_id) : taskIdFromCaller ?? '';
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
team,
|
|
150
|
+
reply: `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.`,
|
|
151
|
+
task_id: taskId,
|
|
152
|
+
context_id: wireText(raw.context_id),
|
|
153
|
+
task_status: wireText(raw.task_status) === '' ? 'TASK_STATE_DELIVERED' : wireText(raw.task_status),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const reply = this.replyText(raw.result, '');
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
team,
|
|
160
|
+
reply,
|
|
161
|
+
task_id: wireText(raw.task_id) !== '' ? wireText(raw.task_id) : taskIdFromCaller ?? wireText(raw.task_id),
|
|
162
|
+
context_id: wireText(raw.context_id),
|
|
163
|
+
task_status: wireText(raw.task_status),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
package/lib/card.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expiring signed agent cards (GNUnet HELLO semantics): the card carries its
|
|
3
|
+
* own expiry and an Ed25519 signature over identity, team, capabilities, and
|
|
4
|
+
* expiry, so only the key holder can extend a card's life. Expiry and the
|
|
5
|
+
* signature are enforced wherever a card is parsed — no background cleanup.
|
|
6
|
+
* Design basis: .agents/notes/proposed/feature/2026-08-16-gnunet-prior-art-for-a2a-topology.md (slice 1).
|
|
7
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/card
|
|
8
|
+
*/
|
|
9
|
+
import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
10
|
+
/** Card validity budget. GNUnet HELLO default (2 days). */
|
|
11
|
+
export const CARD_TTL_MS = 172_800_000;
|
|
12
|
+
/** Announce re-sign cadence: four publication opportunities per TTL. */
|
|
13
|
+
export const CARD_REFRESH_MS = CARD_TTL_MS / 4;
|
|
14
|
+
/** Deterministic signing payload: field order is fixed by JSON.stringify of this literal shape. */
|
|
15
|
+
function canonicalPayload(core) {
|
|
16
|
+
return JSON.stringify({
|
|
17
|
+
capabilities: core.capabilities,
|
|
18
|
+
expiresAt: core.expiresAt,
|
|
19
|
+
name: core.name,
|
|
20
|
+
...(core.records !== undefined ? { records: core.records } : {}),
|
|
21
|
+
session: core.session,
|
|
22
|
+
team: core.team,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Encode a public key as the base64 SPKI DER the card carries.
|
|
27
|
+
* @param publicKey - the signing node's Ed25519 public key.
|
|
28
|
+
* @returns the base64 SPKI DER string.
|
|
29
|
+
*/
|
|
30
|
+
export function encodePublicKey(publicKey) {
|
|
31
|
+
return publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sign one card core with the node's Ed25519 private key.
|
|
35
|
+
* @param core - the card's lifecycle-relevant fields, expiry included.
|
|
36
|
+
* @param privateKey - the signing node's Ed25519 private key.
|
|
37
|
+
* @returns the wire card: the core plus the public key and signature.
|
|
38
|
+
*/
|
|
39
|
+
export function signCard(core, privateKey) {
|
|
40
|
+
const signature = edSign(undefined, Buffer.from(canonicalPayload(core), 'utf8'), privateKey).toString('base64');
|
|
41
|
+
return { ...core, publicKey: encodePublicKey(createPublicKey(privateKey)), signature };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Validate the signed records member: absent stays absent; any other shape
|
|
45
|
+
* rejects the card. Records are authority claims, so a malformed list is a
|
|
46
|
+
* malformed card — unlike the unsigned referral hints, which only drop.
|
|
47
|
+
* @param wire - the shape-checked card candidate.
|
|
48
|
+
* @returns the validated records, `undefined` when the card carries none, or
|
|
49
|
+
* `null` when the member is malformed (the caller rejects the card).
|
|
50
|
+
*/
|
|
51
|
+
function zoneRecords(wire) {
|
|
52
|
+
if (wire.records === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (!Array.isArray(wire.records))
|
|
55
|
+
return null;
|
|
56
|
+
for (const entry of wire.records) {
|
|
57
|
+
const record = entry;
|
|
58
|
+
if (record?.type !== 'delegate')
|
|
59
|
+
return null;
|
|
60
|
+
if (typeof record.name !== 'string' || record.name === '')
|
|
61
|
+
return null;
|
|
62
|
+
if (typeof record.url !== 'string' || record.url === '')
|
|
63
|
+
return null;
|
|
64
|
+
if (record.publicKey !== undefined && typeof record.publicKey !== 'string')
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return wire.records;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The unsigned `peers` referral list passes through only as an array of
|
|
71
|
+
* strings; any other shape drops the field instead of rejecting the card,
|
|
72
|
+
* because cards from nodes without gossip carry none.
|
|
73
|
+
* @param wire - the shape-checked card candidate.
|
|
74
|
+
* @returns the verified referral list, or `undefined` when absent or malformed.
|
|
75
|
+
*/
|
|
76
|
+
function referrals(wire) {
|
|
77
|
+
if (!Array.isArray(wire.peers) || !wire.peers.every(url => typeof url === 'string'))
|
|
78
|
+
return undefined;
|
|
79
|
+
return wire.peers;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The unsigned `sessionTeams` listing passes through only as an array of
|
|
83
|
+
* well-formed entries; any other shape drops the field instead of rejecting
|
|
84
|
+
* the card, because cards from nodes without joined sessions carry none.
|
|
85
|
+
* @param wire - the shape-checked card candidate.
|
|
86
|
+
* @returns the session-team entries, or `undefined` when absent or malformed.
|
|
87
|
+
*/
|
|
88
|
+
function sessionTeams(wire) {
|
|
89
|
+
if (!Array.isArray(wire.sessionTeams))
|
|
90
|
+
return undefined;
|
|
91
|
+
const entries = [];
|
|
92
|
+
for (const entry of wire.sessionTeams) {
|
|
93
|
+
const team = entry;
|
|
94
|
+
if (typeof team?.team !== 'string' || team.team === '')
|
|
95
|
+
continue;
|
|
96
|
+
if (typeof team.name !== 'string' || typeof team.description !== 'string')
|
|
97
|
+
continue;
|
|
98
|
+
entries.push({
|
|
99
|
+
team: team.team,
|
|
100
|
+
name: team.name,
|
|
101
|
+
description: team.description,
|
|
102
|
+
...(typeof team.workspace === 'string' && team.workspace !== '' ? { workspace: team.workspace } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Verify a parsed card: shape, signature, and expiry, in that order.
|
|
109
|
+
* @param candidate - the JSON value fetched from a peer's agent-card URL.
|
|
110
|
+
* @param now - evaluation time (epoch ms); injected for tests.
|
|
111
|
+
* @returns the verified card, or the first rejection reason.
|
|
112
|
+
*/
|
|
113
|
+
export function verifyCard(candidate, now) {
|
|
114
|
+
if (typeof candidate !== 'object' || candidate === null)
|
|
115
|
+
return { ok: false, reason: 'malformed' };
|
|
116
|
+
const wire = candidate;
|
|
117
|
+
if (typeof wire.name !== 'string' || typeof wire.session !== 'string' || typeof wire.team !== 'string') {
|
|
118
|
+
return { ok: false, reason: 'malformed' };
|
|
119
|
+
}
|
|
120
|
+
if (typeof wire.expiresAt !== 'number' || typeof wire.publicKey !== 'string' || typeof wire.signature !== 'string') {
|
|
121
|
+
return { ok: false, reason: 'unsigned' };
|
|
122
|
+
}
|
|
123
|
+
const records = zoneRecords(wire);
|
|
124
|
+
if (records === null)
|
|
125
|
+
return { ok: false, reason: 'malformed' };
|
|
126
|
+
if (wire.expiresAt <= now)
|
|
127
|
+
return { ok: false, reason: 'expired' };
|
|
128
|
+
const core = {
|
|
129
|
+
name: wire.name,
|
|
130
|
+
session: wire.session,
|
|
131
|
+
team: wire.team,
|
|
132
|
+
capabilities: wire.capabilities,
|
|
133
|
+
expiresAt: wire.expiresAt,
|
|
134
|
+
...(records !== undefined ? { records } : {}),
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
const publicKey = createPublicKey({ key: Buffer.from(wire.publicKey, 'base64'), format: 'der', type: 'spki' });
|
|
138
|
+
const valid = edVerify(undefined, Buffer.from(canonicalPayload(core), 'utf8'), publicKey, Buffer.from(wire.signature, 'base64'));
|
|
139
|
+
if (!valid)
|
|
140
|
+
return { ok: false, reason: 'bad-signature' };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { ok: false, reason: 'malformed' };
|
|
144
|
+
}
|
|
145
|
+
const peers = referrals(wire);
|
|
146
|
+
const teams = sessionTeams(wire);
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
card: {
|
|
150
|
+
...core,
|
|
151
|
+
expiresAt: wire.expiresAt,
|
|
152
|
+
...(peers !== undefined ? { peers } : {}),
|
|
153
|
+
...(teams !== undefined && teams.length > 0 ? { sessionTeams: teams } : {}),
|
|
154
|
+
...(typeof wire.lanIp === 'string' && wire.lanIp !== '' ? { lanIp: wire.lanIp } : {}),
|
|
155
|
+
publicKey: wire.publicKey,
|
|
156
|
+
signature: wire.signature,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|