dsh-a2a-groups 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * 对端登记簿:把「我认识的 A2A agent」持久化到 `<DSH_HOME>/a2a/agents.json`。
3
+ *
4
+ * ## 这个文件解决什么问题
5
+ *
6
+ * 模型和用户不该每次调用都重新输入一长串 URL 与网关 Key。登记簿把「一个短名字」
7
+ * 映射到一份完整的 `target`(`baseUrl` / `agent` / `apiKey` / 版本 / 开关),于是
8
+ * 工具层可以先 `resolve('zhang')` 再 `send(target, …)`。
9
+ *
10
+ * ## 边界(明确不做的事)
11
+ *
12
+ * - **不联网**:这里只增删查改与落盘,连通性由 `bridge.discover()` 负责。
13
+ * - **不算业务规则**:`enabled:false` 只是个标记,由调用方决定是否跳过(登记簿
14
+ * 不该替上层决定「禁用后是报错还是静默」)。
15
+ * - **不 encrypt**:`apiKey` 是明文落盘(权限 0600)。这一条是刻意的取舍,理由见
16
+ * `flush()` 上方注释——用零依赖做「真加密」意味着把密钥也放在同一台机器上,
17
+ * 只会给出「已加密」的错觉。
18
+ *
19
+ * ## 为什么用「临时文件 + rename」
20
+ *
21
+ * 插件随时可能被重启或断电。直接 `writeFile` 到目标文件时,进程死在中间会留下
22
+ * 半截 JSON,下次启动就读不出来了;写临时文件再 `rename` 是同一文件系统上的原子
23
+ * 替换,最坏结果是「丢掉最后一次修改」,而不是「登记簿整体损坏」。
24
+ *
25
+ * @module dsh-a2a/registry
26
+ */
27
+
28
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from 'node:fs';
29
+ import { dirname, join } from 'node:path';
30
+ import { randomBytes, createHash } from 'node:crypto';
31
+
32
+ /** 登记簿的文件名(放在传入的 `home` 目录下)。 */
33
+ const FILE_NAME = 'agents.json';
34
+
35
+ /** 落盘格式的版本号:以后改结构时用它做迁移分支,而不是猜字段。 */
36
+ const FORMAT_VERSION = 1;
37
+
38
+ /** 文件权限:只有属主可读写(CONTRACT §2.6 对 groups.json 的要求,这里沿用同一标准)。 */
39
+ const FILE_MODE = 0o600;
40
+
41
+ /** 目录权限:只有属主可进入。 */
42
+ const DIR_MODE = 0o700;
43
+
44
+ /**
45
+ * 判断「这是不是一个 URL 而不是名字」:解析成功且有 http(s) 协议。
46
+ */
47
+ function looksLikeUrl(value) {
48
+ if (typeof value !== 'string' || value.trim() === '') return false;
49
+ try {
50
+ const url = new URL(value);
51
+ return url.protocol === 'http:' || url.protocol === 'https:';
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** 取字符串,非字符串当空串。 */
58
+ function str(value) {
59
+ return typeof value === 'string' ? value : '';
60
+ }
61
+
62
+ /** 去尾部斜杠:`baseUrl` 参与字符串比较,必须规范化。 */
63
+ function trimSlash(value) {
64
+ return String(value ?? '').replace(/\/+$/, '');
65
+ }
66
+
67
+ /**
68
+ * 名字的规范化键:大小写不敏感,避免 `alice` 与 `Alice` 变成两条记录。
69
+ *
70
+ * @param {unknown} name - 原始名字。
71
+ * @returns {string} 比较用的键。
72
+ */
73
+ function keyOf(name) {
74
+ return str(name).trim().toLowerCase();
75
+ }
76
+
77
+ /**
78
+ * 把一个任意对象收敛成合法登记项。
79
+ *
80
+ * `name` 缺失时抛错(没有名字就无法 `resolve`,留着它只会让列表里出现幽灵项);
81
+ * `baseUrl` 缺失时也抛错(没有任何地方可调用)。其余字段缺省即省略,不做无意义
82
+ * 的 `undefined` 填充——落盘文件要人能读。
83
+ *
84
+ * @param {object} raw - 调用方给的对象。
85
+ * @returns {object} 只含合法字段的登记项。
86
+ * @throws {Error} `name` 或 `baseUrl` 缺失时(带 `code='invalid_entry'`)。
87
+ */
88
+ function normalizeEntry(raw) {
89
+ if (raw === null || typeof raw !== 'object') throw invalidEntry('entry must be an object');
90
+ const name = str(raw.name).trim();
91
+ if (name === '') throw invalidEntry('entry.name is required');
92
+ const baseUrl = trimSlash(str(raw.baseUrl));
93
+ if (baseUrl === '') throw invalidEntry(`entry "${name}" is missing baseUrl`);
94
+
95
+ const entry = { name, baseUrl };
96
+ const description = str(raw.description).trim();
97
+ if (description !== '') entry.description = description;
98
+ const agent = str(raw.agent).trim();
99
+ if (agent !== '') entry.agent = agent;
100
+ const protocolVersion = str(raw.protocolVersion).trim();
101
+ if (protocolVersion !== '') entry.protocolVersion = protocolVersion;
102
+ const apiKey = str(raw.apiKey);
103
+ if (apiKey !== '') entry.apiKey = apiKey;
104
+ const forwardProto = str(raw.forwardProto).trim();
105
+ if (forwardProto !== '') entry.forwardProto = forwardProto;
106
+ // `enabled` 缺省为 true:登记一个对端的目的就是用它,默认关闭会让人以为没生效。
107
+ entry.enabled = raw.enabled !== false;
108
+ return entry;
109
+ }
110
+
111
+ /** 造一个带稳定 code 的参数错误。 */
112
+ function invalidEntry(message) {
113
+ const error = new Error(message);
114
+ error.code = 'invalid_entry';
115
+ return error;
116
+ }
117
+
118
+ /** 取 apiKey 的短标识,只用于日志(CONTRACT §0.6)。 */
119
+ function maskSecret(value) {
120
+ if (typeof value !== 'string' || value === '') return '';
121
+ return `sha256:${createHash('sha256').update(value).digest('hex').slice(0, 8)}`;
122
+ }
123
+
124
+ /**
125
+ * 造一个对端登记簿。
126
+ *
127
+ * @param {object} options - 依赖注入。
128
+ * @param {string} [options.home] - 存放 `agents.json` 的目录(一般是 `<DSH_HOME>/a2a`)。
129
+ * @param {{warn?: Function, info?: Function, debug?: Function}} [options.logger] - 日志器。
130
+ * @param {() => number} [options.clock] - 时钟注入(记录 `updatedAt` 用;单测可固定)。
131
+ * @param {string} [options.fileName] - 覆盖文件名,单测用。
132
+ * @returns {{
133
+ * list: () => Array<object>,
134
+ * get: (name: string) => (object|undefined),
135
+ * upsert: (entry: object) => object,
136
+ * remove: (name: string) => boolean,
137
+ * resolve: (nameOrUrl: string) => (object|undefined),
138
+ * flush: () => void,
139
+ * path: () => string
140
+ * }}
141
+ */
142
+ export function createRegistry({ home, logger, clock, fileName = FILE_NAME } = {}) {
143
+ const baseDir = str(home).trim();
144
+ if (baseDir === '') {
145
+ // 没有 home 就没有持久化位置。给一个明确的错误,而不是悄悄写到进程 cwd 里
146
+ // ——那会让「配置丢了」这种事故变成「数据写到不知道哪儿去了」。
147
+ const error = new Error('createRegistry requires a home directory');
148
+ error.code = 'invalid_home';
149
+ throw error;
150
+ }
151
+ const filePath = join(baseDir, fileName);
152
+ const now = typeof clock === 'function' ? clock : () => Date.now();
153
+
154
+ /** @type {Map<string, object>} 规范化键 → 登记项(插入顺序保留,list 稳定)。 */
155
+ const entries = new Map();
156
+ let loaded = false;
157
+
158
+ function warn(message) {
159
+ if (typeof logger?.warn === 'function') logger.warn(`[a2a-registry] ${message}`);
160
+ }
161
+
162
+ /**
163
+ * 首次访问时读盘。
164
+ *
165
+ * 惰性读(而不是构造函数里读)是为了让「文件坏了」变成一次可捕获的 warn,而不是
166
+ * 让插件在 `apply()` 阶段直接起不来——登记簿丢了应该退化成「没有对端」,不该
167
+ * 让整个插件失效。
168
+ */
169
+ function ensureLoaded() {
170
+ if (loaded) return;
171
+ loaded = true;
172
+ let raw;
173
+ try {
174
+ raw = readFileSync(filePath, 'utf8');
175
+ } catch (err) {
176
+ if (err?.code !== 'ENOENT') warn(`cannot read ${filePath}: ${err?.message ?? err}`);
177
+ return;
178
+ }
179
+ if (raw.trim() === '') return;
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(raw);
183
+ } catch (err) {
184
+ // 文件坏了不要覆写它:留在原地供人抢救,本次运行从空簿开始。
185
+ warn(`${filePath} is not valid JSON (${err?.message ?? err}); starting with an empty registry and leaving the file untouched`);
186
+ return;
187
+ }
188
+ const list = Array.isArray(parsed?.agents) ? parsed.agents : [];
189
+ for (const item of list) {
190
+ try {
191
+ // `normalizeEntry` 的字段白名单顺带承担了「落盘文件被手改后也不会带进
192
+ // 意外字段」的作用;`enabled` 缺省为 true 的语义在它里面处理。
193
+ const entry = normalizeEntry(item);
194
+ entries.set(keyOf(entry.name), entry);
195
+ } catch (err) {
196
+ warn(`skipping an unusable entry in ${filePath}: ${err?.message ?? err}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * 原子落盘。
203
+ *
204
+ * 先写同目录的临时文件再 `rename`:`rename` 在同一文件系统内是原子的,所以任何
205
+ * 时刻读到的 `agents.json` 要么是旧的完整内容、要么是新的完整内容。
206
+ *
207
+ * best-effort:失败只 warn 不抛——一次盘写失败不该让用户正在进行的 A2A 调用崩掉。
208
+ *
209
+ * @returns {void}
210
+ */
211
+ function flush() {
212
+ const payload = {
213
+ version: FORMAT_VERSION,
214
+ updatedAt: new Date(now()).toISOString(),
215
+ agents: [...entries.values()],
216
+ };
217
+ const text = `${JSON.stringify(payload, null, 2)}\n`;
218
+ const temp = join(baseDir, `.${fileName}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
219
+ try {
220
+ mkdirSync(dirname(filePath), { recursive: true, mode: DIR_MODE });
221
+ writeFileSync(temp, text, { encoding: 'utf8', mode: FILE_MODE });
222
+ renameSync(temp, filePath);
223
+ // Windows 上 `mode` 基本不生效,POSIX 上它受 umask 影响;显式再 chmod 一次,
224
+ // 让 0600 成为事实而不是期望。
225
+ try {
226
+ chmodSync(filePath, FILE_MODE);
227
+ } catch {
228
+ // 某些文件系统(FAT/网络盘)不支持 chmod:不是致命问题。
229
+ }
230
+ } catch (err) {
231
+ warn(`cannot write ${filePath}: ${err?.message ?? err}`);
232
+ try {
233
+ unlinkSync(temp);
234
+ } catch {
235
+ // 临时文件可能压根没建出来。
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * 列出全部登记项(副本,调用方改它不会污染内部状态)。
242
+ *
243
+ * 返回值里 `apiKey` 保留原值:工具层需要它去发请求;日志由各调用方自己脱敏。
244
+ *
245
+ * @returns {Array<object>} 登记项数组。
246
+ */
247
+ function list() {
248
+ ensureLoaded();
249
+ return [...entries.values()].map((entry) => ({ ...entry }));
250
+ }
251
+
252
+ /**
253
+ * 按名字精确取(大小写不敏感)。
254
+ *
255
+ * @param {string} name - 对端名字。
256
+ * @returns {object|undefined} 登记项副本。
257
+ */
258
+ function get(name) {
259
+ ensureLoaded();
260
+ const entry = entries.get(keyOf(name));
261
+ return entry === undefined ? undefined : { ...entry };
262
+ }
263
+
264
+ /**
265
+ * 新增或覆盖一个登记项并立即落盘。
266
+ *
267
+ * 覆盖时按名字(大小写不敏感)匹配,但**保留调用方给的新名字的大小写**:用户
268
+ * 把 `alice` 改成 `Alice` 是在表达偏好,不该被旧记录压回去。
269
+ *
270
+ * @param {object} raw - 登记项。
271
+ * @returns {object} 写入后的登记项副本。
272
+ * @throws {Error} `name`/`baseUrl` 缺失时。
273
+ */
274
+ function upsert(raw) {
275
+ ensureLoaded();
276
+ const entry = normalizeEntry(raw);
277
+ entries.set(keyOf(entry.name), entry);
278
+ flush();
279
+ return { ...entry };
280
+ }
281
+
282
+ /**
283
+ * 删除一个登记项。
284
+ *
285
+ * @param {string} name - 对端名字。
286
+ * @returns {boolean} 是否真的删掉了(不存在返回 false,便于工具层给出准确提示)。
287
+ */
288
+ function remove(name) {
289
+ ensureLoaded();
290
+ const key = keyOf(name);
291
+ if (!entries.has(key)) return false;
292
+ entries.delete(key);
293
+ flush();
294
+ return true;
295
+ }
296
+
297
+ /**
298
+ * 把「名字或裸 URL」解析成可以直接喂给 `bridge` 的 target。
299
+ *
300
+ * 裸 URL 的判定用「能不能 `new URL()` 且是 http(s)」,而不是「像不像 http 开头」:
301
+ * `localhost:3091` 这种既像名字又像地址的输入,交给 `new URL` 是按协议判定的,
302
+ * 不会误判。裸 URL 时不带任何凭证——凭证只能来自显式登记或配置,绝不猜。
303
+ *
304
+ * @param {string} nameOrUrl - 对端名字,或 `http(s)://…` 直连地址。
305
+ * @returns {object|undefined} `{baseUrl, agent?, apiKey?, protocolVersion?, forwardProto?}`。
306
+ */
307
+ function resolve(nameOrUrl) {
308
+ ensureLoaded();
309
+ const raw = str(nameOrUrl).trim();
310
+ if (raw === '') return undefined;
311
+ if (looksLikeUrl(raw)) {
312
+ // 直连 URL 允许自带 `#agent` 片段(`…/a2a#my-agent`),这样一条字符串就能
313
+ // 同时表达网关基址与 agent 名,省掉工具层的第二个参数。
314
+ let agent;
315
+ let baseUrl = raw;
316
+ const hash = raw.indexOf('#');
317
+ if (hash !== -1) {
318
+ baseUrl = raw.slice(0, hash);
319
+ agent = raw.slice(hash + 1).trim();
320
+ }
321
+ const target = { baseUrl: trimSlash(baseUrl) };
322
+ if (agent !== undefined && agent !== '') target.agent = agent;
323
+ return target;
324
+ }
325
+ const entry = entries.get(keyOf(raw));
326
+ if (entry === undefined) return undefined;
327
+ const target = { baseUrl: entry.baseUrl };
328
+ if (entry.agent !== undefined) target.agent = entry.agent;
329
+ if (entry.apiKey !== undefined) target.apiKey = entry.apiKey;
330
+ if (entry.protocolVersion !== undefined) target.protocolVersion = entry.protocolVersion;
331
+ if (entry.forwardProto !== undefined) target.forwardProto = entry.forwardProto;
332
+ return target;
333
+ }
334
+
335
+ /** 落盘路径(工具层/诊断用)。 */
336
+ function path() {
337
+ return filePath;
338
+ }
339
+
340
+ return { list, get, upsert, remove, resolve, flush, path };
341
+ }
342
+
343
+ /** 供单测与工具层复用的脱敏(避免各处重复实现)。 */
344
+ export { maskSecret };