@nelsonlongxiang/dsh-open-a2a-net 0.6.40 → 0.6.41
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 +1 -0
- package/README_zh.md +1 -0
- package/lib/a2a-client.js +3 -2
- package/lib/config.js +1 -0
- package/lib/faces/files.js +2 -2
- package/lib/faces/session-log.js +3 -3
- package/lib/faces/wire.js +11 -16
- package/lib/index.js +45 -9
- package/lib/link-keys.js +29 -0
- package/lib/skill/references/setup.md +5 -1
- package/lib/tools/files.js +3 -1
- package/lib/types/a2a-client.d.ts +3 -1
- package/lib/types/config.d.ts +6 -0
- package/lib/types/faces/files.d.ts +2 -1
- package/lib/types/faces/session-log.d.ts +2 -0
- package/lib/types/faces/wire.d.ts +2 -1
- package/lib/types/link-keys.d.ts +13 -0
- package/lib/types/tools/files.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -171,6 +171,7 @@ Row config overlays in the profile's patch layers; every key has a schema defaul
|
|
|
171
171
|
| `originClaimUrl` | `''` | Override for the envelope's `callerUrl` (pocket-tunneled hosts); `''` derives it. |
|
|
172
172
|
| `originClaimUnboundWindow` | `true` | OriginClaim audience-binding window: also sign and accept the legacy envelope that names no audience (accepted ones counted as `unboundInbound`; those whose bound form missed here also as `boundMissInbound`); `false` signs and accepts the audience-bound form only. |
|
|
173
173
|
| `originClaimAudiences` | `[]` | Extra origins this host answers to as an envelope audience, for port-mapped or proxied hosts whose dialed URL is neither the card URL nor the arrival address. |
|
|
174
|
+
| `linkKeys` | `{}` | Link keys (#317): `{ "<peer origin>": "<key>" }`. Requests to that peer carry this key instead of `apiKey`; this host's peer faces (`/a2a/direct`, `/a2a/log`, `/a2a/blob`) accept it only from a caller whose OriginClaim verifies and names that origin (a POST must be Sig3 body-bound); the control routes never accept it. Lets a public node link to the fleet without holding the fleet apiKey; both ends configure the same link key. |
|
|
174
175
|
| `gateAgentLessCalls` | `'audit'` | Host-side (agent-less) routing calls: audit them, or `refuse`. |
|
|
175
176
|
| `settlementAuth` | `'observe'` | Settlement authorization (#114): `off`, `observe` (record what would be refused, settle as before), or `enforce`. |
|
|
176
177
|
| `asyncNudgeDelayMs` | `120000` | Delay before a delivered-but-unconsumed async route re-wakes its target with a one-line nudge; `0` disables. |
|
package/README_zh.md
CHANGED
|
@@ -129,6 +129,7 @@ a2a-net inbox --new # 未读来信;--wait
|
|
|
129
129
|
| `originClaimUrl` | `''` | 信封 `callerUrl` 的覆盖值(pocket 隧道宿主用);`''` 自动推导。 |
|
|
130
130
|
| `originClaimUnboundWindow` | `true` | OriginClaim 受众绑定过渡窗口:同时签发并接受不带受众的旧信封(接受数计入 `unboundInbound`;其中绑定形式在本机未命中的另计 `boundMissInbound`);`false` 只签发、只接受绑定受众的信封。 |
|
|
131
131
|
| `originClaimAudiences` | `[]` | 本宿主额外认作信封受众的 origin,用于端口映射或反向代理宿主(对端拨号的 URL 既不是卡片 URL 也不是到达地址)。 |
|
|
132
|
+
| `linkKeys` | `{}` | 链路 key(#317):`{ "<对端 origin>": "<key>" }`。发往该对端的请求改带这把 key;本机对端面(`/a2a/direct`、`/a2a/log`、`/a2a/blob`)只在调用方 OriginClaim 验签通过、且签名者正是该 origin 时接受它(POST 须 Sig3 绑定请求体);控制路由不接受。用于公网节点与舰队互联而不持有舰队 apiKey,两端配同一把链路 key。 |
|
|
132
133
|
| `gateAgentLessCalls` | `'audit'` | 宿主侧(无 agent)路由调用:审计,或 `refuse` 拒绝。 |
|
|
133
134
|
| `settlementAuth` | `'observe'` | 结算授权(#114):`off`、`observe`(记下本会拒绝的,照旧结算)或 `enforce`。 |
|
|
134
135
|
| `asyncNudgeDelayMs` | `120000` | 已投递但未被消费的异步路由,在此延迟后用一行催办重新唤醒目标;`0` 关闭。 |
|
package/lib/a2a-client.js
CHANGED
|
@@ -33,8 +33,9 @@ export class A2aClient {
|
|
|
33
33
|
const headers = {};
|
|
34
34
|
if (json)
|
|
35
35
|
headers['Content-Type'] = 'application/json';
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const apiKey = this.options.apiKeyFor?.(url) ?? this.options.apiKey;
|
|
37
|
+
if (apiKey !== '')
|
|
38
|
+
headers['X-API-Key'] = apiKey;
|
|
38
39
|
// GETs are card fetches: name ourselves so the peer serves the full
|
|
39
40
|
// card (gh#7 G1). POSTs carry caller_session in the body already.
|
|
40
41
|
if (!json && this.options.cardCaller !== '')
|
package/lib/config.js
CHANGED
|
@@ -102,6 +102,7 @@ export const Config = s.object({
|
|
|
102
102
|
originClaimUnboundWindow: s.boolean().default(true),
|
|
103
103
|
/** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
|
|
104
104
|
originClaimAudiences: s.array(s.string()).default([]),
|
|
105
|
+
linkKeys: s.dict(s.string()).default({}),
|
|
105
106
|
/** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
|
|
106
107
|
originClaimUrl: s.string().default(''),
|
|
107
108
|
/**
|
package/lib/faces/files.js
CHANGED
|
@@ -33,7 +33,7 @@ export function grantAdmits(to, caller, signer, zone, localCanvasMembers, cli =
|
|
|
33
33
|
* @param d - the captures listed on {@link FileFaceDeps}.
|
|
34
34
|
*/
|
|
35
35
|
export function registerFileFaces(webServer, d) {
|
|
36
|
-
const { ctx, config, blobStore, canvasStore, verifyOriginClaim,
|
|
36
|
+
const { ctx, config, blobStore, canvasStore, verifyOriginClaim, peerKeyAdmits, controlRoute, recordActivity } = d;
|
|
37
37
|
const now = d.now ?? (() => Date.now());
|
|
38
38
|
const pulls = new Map();
|
|
39
39
|
const json = (res, status, body) => {
|
|
@@ -63,7 +63,7 @@ export function registerFileFaces(webServer, d) {
|
|
|
63
63
|
json(res, 405, { ok: false, error: 'GET is the only method on this face' });
|
|
64
64
|
return;
|
|
65
65
|
}
|
|
66
|
-
if (
|
|
66
|
+
if (!await peerKeyAdmits(req)) {
|
|
67
67
|
json(res, 401, { ok: false, error: 'unauthorized: X-API-Key required (apiKey is configured)' });
|
|
68
68
|
return;
|
|
69
69
|
}
|
package/lib/faces/session-log.js
CHANGED
|
@@ -4,7 +4,7 @@ import { TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_LIMIT } from "../session-trans
|
|
|
4
4
|
* @param d - the captures listed on {@link SessionLogFaceDeps}.
|
|
5
5
|
*/
|
|
6
6
|
export function registerSessionLogFaces(webServer, d) {
|
|
7
|
-
const { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, directoryPeerCandidates, memoizedCardFetch } = d;
|
|
7
|
+
const { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, peerRequestHeaders, directoryPeerCandidates, memoizedCardFetch } = d;
|
|
8
8
|
// Session transcript reader (0.6.11): the workbench inspector shows the
|
|
9
9
|
// tail of a LOCAL node's own conversation log inline — owner ruling
|
|
10
10
|
// 2026-10-20 banned navigation, and reading is the need jumps used to
|
|
@@ -116,7 +116,7 @@ export function registerSessionLogFaces(webServer, d) {
|
|
|
116
116
|
const listUrl = new URL('/a2a/log', base);
|
|
117
117
|
listUrl.searchParams.set('caller_session', config.team);
|
|
118
118
|
try {
|
|
119
|
-
const listResponse = await peerFetch(listUrl, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
|
|
119
|
+
const listResponse = await peerFetch(listUrl, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json', ...peerRequestHeaders(new URL('/a2a/log', base).toString(), 'GET') } });
|
|
120
120
|
const listBody = await listResponse.json().catch(() => ({ ok: false, error: `peer answered HTTP ${listResponse.status}` }));
|
|
121
121
|
if (listResponse.ok && listBody.ok === true && Array.isArray(listBody.sessions)) {
|
|
122
122
|
const picked = suffix !== '' ? listBody.sessions.find(row => row.id.includes(suffix)) : listBody.sessions[0];
|
|
@@ -144,7 +144,7 @@ export function registerSessionLogFaces(webServer, d) {
|
|
|
144
144
|
target.searchParams.set('id', resolvedId);
|
|
145
145
|
target.searchParams.set('limit', String(limit));
|
|
146
146
|
try {
|
|
147
|
-
const response = await peerFetch(target, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
|
|
147
|
+
const response = await peerFetch(target, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json', ...peerRequestHeaders(new URL('/a2a/log', base).toString(), 'GET') } });
|
|
148
148
|
const body = await response.json().catch(() => ({ ok: false, error: `peer answered HTTP ${response.status}` }));
|
|
149
149
|
if (response.ok && body.ok === true) {
|
|
150
150
|
finish({ ...body, via: base });
|
package/lib/faces/wire.js
CHANGED
|
@@ -92,7 +92,7 @@ export class SyncAnswered {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
export function registerWireFaces(webServer, d) {
|
|
95
|
-
const { ctx, logger, config, cliDeposits, canvasStore, canvasNameReserved, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, remoteCliProof, callerLinkedToThisHost, recordActivity,
|
|
95
|
+
const { ctx, logger, config, cliDeposits, canvasStore, canvasNameReserved, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, remoteCliProof, callerLinkedToThisHost, recordActivity, peerKeyAdmits, a2aCallerRefusal, a2aCallerAdmission, allowlistVersionNow, observeAuthorization, scanSessionLogList, readSessionLogTail, taskLedger, refuseIfFlooded, refuseIfQueueFull, settleAndAnnounce, waitForForwardBirth, session, resolveAgentForTeam, parseCanvasTeamName, canvasLiveAgent, wakeColdTeam, probeConsumption, sessionTeamOf, localSessionOfHandle, steerRelay, fromClause, sanitizeFromClause, armReceiptAutosend, eventCountOf, receiptExpectationOf, armAsyncNudge, liveAgent, callerIsAgent, selfRouteText, noWakeFaceText, archivedColdMatchText, noColdMatchText, nativeTeamsPrepare, nativeTeamsRound, routeIntoAgent, } = d;
|
|
96
96
|
const syncAnswered = new SyncAnswered();
|
|
97
97
|
// gh#45 CLI 户口卡面 (peer face, unsigned card serve): the deposited
|
|
98
98
|
// CLI's SIGNED card is served verbatim under its own base URL — an
|
|
@@ -257,13 +257,10 @@ export function registerWireFaces(webServer, d) {
|
|
|
257
257
|
return;
|
|
258
258
|
}
|
|
259
259
|
// V2 (gh#7, S1): the read-only log face rides the same apiKey gate
|
|
260
|
-
// as delivery — a configured key must be presented here too.
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
send(401, { ok: false, error: 'unauthorized: X-API-Key required (apiKey is configured)', code: -32000 });
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
260
|
+
// as delivery — a configured key (or #317 link key) must be presented here too.
|
|
261
|
+
if (!await peerKeyAdmits(req)) {
|
|
262
|
+
send(401, { ok: false, error: 'unauthorized: X-API-Key required (apiKey is configured)', code: -32000 });
|
|
263
|
+
return;
|
|
267
264
|
}
|
|
268
265
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
269
266
|
const caller = url.searchParams.get('caller_session') ?? '';
|
|
@@ -465,14 +462,12 @@ export function registerWireFaces(webServer, d) {
|
|
|
465
462
|
// used to let anyone reachable steer sessions while the control
|
|
466
463
|
// routes demanded the key, making it decorative for delivery.
|
|
467
464
|
// Empty-key deployments keep the documented open posture.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
465
|
+
// #317: a link key admits only its own verified, body-bound peer.
|
|
466
|
+
if (!await peerKeyAdmits(req, rawBody)) {
|
|
467
|
+
const payload = JSON.stringify({ error: 'unauthorized: X-API-Key required on the delivery face (apiKey is configured)', code: -32000 });
|
|
468
|
+
res.writeHead(401, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
469
|
+
res.end(payload);
|
|
470
|
+
return;
|
|
476
471
|
}
|
|
477
472
|
// #282 S2: our own `<canvas>@<fp8>` is our canvas; a foreign suffix stays for the forward walk.
|
|
478
473
|
const ownerQualified = splitCanvasOwner(typeof body.team === 'string' ? body.team : '');
|
package/lib/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, ran
|
|
|
9
9
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { networkInterfaces } from 'node:os';
|
|
11
11
|
import { selectLanIPv4 } from "./lan-ip.js";
|
|
12
|
+
import { isLinkKey, normalizeLinkKeys, originOf, outboundKeyFor } from "./link-keys.js";
|
|
12
13
|
import { dirname, join } from 'node:path';
|
|
13
14
|
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
14
15
|
// The package own version, read from package.json at load time so the
|
|
@@ -199,7 +200,9 @@ export function apply(ctx, config) {
|
|
|
199
200
|
}
|
|
200
201
|
};
|
|
201
202
|
// gh#193: the one door to peers — while isolated nothing leaves this host.
|
|
202
|
-
|
|
203
|
+
// Review of #318 R4: never follow a peer's redirect — X-API-Key and the OriginClaim headers are not stripped on
|
|
204
|
+
// a cross-origin hop, so a 3xx would hand them to whoever the Location names. A redirect is an HTTP failure.
|
|
205
|
+
const peerFetch = (url, init) => config.isolated ? Promise.reject(new Error('isolated: outbound peer traffic is off on this host')) : globalThis.fetch(url, { ...init, redirect: 'manual' });
|
|
203
206
|
const fetchImpl = (url, init) => peerFetch(url, init);
|
|
204
207
|
/** Constant-time secret comparison: hash both sides to equal-length digests. */
|
|
205
208
|
function secretEqual(a, b) {
|
|
@@ -207,6 +210,39 @@ export function apply(ctx, config) {
|
|
|
207
210
|
const digestB = createHash('sha256').update(b).digest();
|
|
208
211
|
return timingSafeEqual(digestA, digestB);
|
|
209
212
|
}
|
|
213
|
+
// #317 link keys (src/link-keys.ts): a per-peer X-API-Key on the peer faces, never on the control routes.
|
|
214
|
+
const linkKeyMap = normalizeLinkKeys(config.linkKeys);
|
|
215
|
+
/**
|
|
216
|
+
* The peer-face key gate (/a2a/direct, /a2a/log, /a2a/blob): this host's apiKey as before; a link key only
|
|
217
|
+
* from a host whose OriginClaim verifies here BOUND to this very request (audience-bound v2 — never a v1
|
|
218
|
+
* envelope replayed onto another path; a POST also body-bound, Sig3), and only the key configured for that
|
|
219
|
+
* signer's own origin (review of #318 R1, R3). A leaked link key cannot act without that peer's node key.
|
|
220
|
+
* An empty apiKey keeps the open posture.
|
|
221
|
+
* @param req - the inbound peer request.
|
|
222
|
+
* @param rawBody - the request body, for a POST.
|
|
223
|
+
*/
|
|
224
|
+
async function peerKeyAdmits(req, rawBody) {
|
|
225
|
+
if (config.apiKey === '')
|
|
226
|
+
return true;
|
|
227
|
+
const presented = String(req.headers['x-api-key'] ?? '');
|
|
228
|
+
if (secretEqual(presented, config.apiKey))
|
|
229
|
+
return true;
|
|
230
|
+
if (!isLinkKey(presented, linkKeyMap, secretEqual))
|
|
231
|
+
return false;
|
|
232
|
+
const claim = await verifyOriginClaim(req, rawBody !== undefined ? { rawBody } : undefined);
|
|
233
|
+
if (!claim.ok || claim.cli === true || !claim.bound || (rawBody !== undefined && claim.bodyBound !== true))
|
|
234
|
+
return false;
|
|
235
|
+
const expected = linkKeyMap.get(originOf(claim.callerUrl));
|
|
236
|
+
return expected !== undefined && secretEqual(presented, expected);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The headers of one signed peer read (the remote session-log proxy): the OriginClaim for `url` (origin and
|
|
240
|
+
* path, no query) and the key for that target — its link key, else this host's apiKey (review of #318 R2).
|
|
241
|
+
*/
|
|
242
|
+
function peerRequestHeaders(url, method) {
|
|
243
|
+
const key = outboundKeyFor(url, linkKeyMap, config.apiKey);
|
|
244
|
+
return { ...originClaimHeaders({ url, method }), ...(key !== '' ? { 'X-API-Key': key } : {}) };
|
|
245
|
+
}
|
|
210
246
|
/**
|
|
211
247
|
* No-key trust for the control routes (gh#7 N1, V1): ONLY a loopback
|
|
212
248
|
* socket — header-only trust (Sec-Fetch-Site, Origin) admitted any
|
|
@@ -471,6 +507,7 @@ export function apply(ctx, config) {
|
|
|
471
507
|
}
|
|
472
508
|
const client = new A2aClient({
|
|
473
509
|
apiKey: config.apiKey,
|
|
510
|
+
apiKeyFor: url => outboundKeyFor(url, linkKeyMap, config.apiKey),
|
|
474
511
|
sessionId: session,
|
|
475
512
|
cardCaller: config.team,
|
|
476
513
|
// gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md): every
|
|
@@ -2073,21 +2110,20 @@ export function apply(ctx, config) {
|
|
|
2073
2110
|
*/
|
|
2074
2111
|
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2075
2112
|
return new Promise((resolve) => {
|
|
2113
|
+
// #306: synchronous replies use the same reading-turn attribution as automatic receipts.
|
|
2114
|
+
// Registering and steering are synchronous, so this is the exact pre-steer floor.
|
|
2076
2115
|
const floorAtRegistration = sessionEventsOf(agent).length;
|
|
2077
2116
|
const waiter = registerFinalWaiter(agent, (text, placeholder) => {
|
|
2078
|
-
//
|
|
2079
|
-
// from this session's own events, that the steer carrying this task was read and that the reply is the
|
|
2080
|
-
// reading turn's own last text — the only reply a peer may declare final (`reply_final`).
|
|
2117
|
+
// Preserve #305's reply_final contract: only this task's own completed reading turn qualifies.
|
|
2081
2118
|
let ownTurn = false;
|
|
2082
2119
|
if (placeholder !== true && taskId !== undefined && taskId !== '') {
|
|
2083
2120
|
const events = sessionEventsOf(agent);
|
|
2084
2121
|
const read = consumptionIndex(events, taskId, floorAtRegistration);
|
|
2085
|
-
// The receipt's own window (review of #305 F5): a back-to-back next turn's text is not this turn's.
|
|
2086
2122
|
const window = read === undefined ? undefined : readingTurnReply(events, read);
|
|
2087
2123
|
ownTurn = window !== undefined && window.completed && window.reply !== '' && window.reply === text;
|
|
2088
2124
|
}
|
|
2089
2125
|
resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}), ...(ownTurn ? { ownTurn: true } : {}) });
|
|
2090
|
-
});
|
|
2126
|
+
}, taskId !== undefined && taskId !== '' ? { taskId, steerFloor: floorAtRegistration } : undefined);
|
|
2091
2127
|
// The header names the sender first: `caller` is the routing node's
|
|
2092
2128
|
// own label (the session that issued the route), while `team` is this
|
|
2093
2129
|
// request's target — showing the target as "remote team" hid the
|
|
@@ -2488,6 +2524,7 @@ export function apply(ctx, config) {
|
|
|
2488
2524
|
joinedSessions,
|
|
2489
2525
|
idempotencyStore,
|
|
2490
2526
|
verifyOriginClaim,
|
|
2527
|
+
peerKeyAdmits,
|
|
2491
2528
|
remoteCliProof,
|
|
2492
2529
|
forwardReceiptBasis: (taskId, caller, cliHandle, claim) => forwardReceiptBasis(taskLedger.find(taskId), caller, cliHandle, claim),
|
|
2493
2530
|
forwardDirect: request => forwardDirect(request), ownCanvasFingerprint,
|
|
@@ -2495,7 +2532,6 @@ export function apply(ctx, config) {
|
|
|
2495
2532
|
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
2496
2533
|
recordActivity,
|
|
2497
2534
|
bumpLegacyInbound: () => { legacyInboundCount += 1; },
|
|
2498
|
-
secretEqual,
|
|
2499
2535
|
a2aCallerRefusal,
|
|
2500
2536
|
a2aCallerAdmission,
|
|
2501
2537
|
allowlistVersionNow,
|
|
@@ -2647,8 +2683,8 @@ export function apply(ctx, config) {
|
|
|
2647
2683
|
}),
|
|
2648
2684
|
}), 'a2a: quick-dispatch route');
|
|
2649
2685
|
// gh#77: the session-log faces (local transcript reader + remote-log proxy) live in src/faces/session-log.ts.
|
|
2650
|
-
registerSessionLogFaces(webServer, { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, directoryPeerCandidates, memoizedCardFetch });
|
|
2651
|
-
registerFileFaces(webServer, { ctx, config, blobStore, canvasStore, verifyOriginClaim,
|
|
2686
|
+
registerSessionLogFaces(webServer, { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, peerRequestHeaders, directoryPeerCandidates, memoizedCardFetch });
|
|
2687
|
+
registerFileFaces(webServer, { ctx, config, blobStore, canvasStore, verifyOriginClaim, peerKeyAdmits, controlRoute, recordActivity });
|
|
2652
2688
|
});
|
|
2653
2689
|
/**
|
|
2654
2690
|
* Settle one card-fetch outcome into the peer store and the plain card:
|
package/lib/link-keys.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #317 link keys: a per-peer `X-API-Key` so a public node never holds the fleet key. Outbound requests to a
|
|
3
|
+
* linked origin carry that link's key; inbound, a link key is accepted on the peer faces only when the
|
|
4
|
+
* caller's verified OriginClaim names exactly that origin. The control routes never accept a link key.
|
|
5
|
+
*/
|
|
6
|
+
import { originOf } from "./origin-claim.js";
|
|
7
|
+
export { originOf };
|
|
8
|
+
/** The configured link keys keyed by normalized origin; blank keys and unparsable origins are dropped. */
|
|
9
|
+
export function normalizeLinkKeys(linkKeys) {
|
|
10
|
+
const out = new Map();
|
|
11
|
+
for (const [peer, key] of Object.entries(linkKeys ?? {})) {
|
|
12
|
+
const origin = originOf(peer);
|
|
13
|
+
if (origin !== '' && typeof key === 'string' && key !== '')
|
|
14
|
+
out.set(origin, key);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
/** The `X-API-Key` to send to `url`: its link key, else this host's apiKey ('' omits the header). */
|
|
19
|
+
export function outboundKeyFor(url, linkKeys, apiKey) {
|
|
20
|
+
return linkKeys.get(originOf(url)) ?? apiKey;
|
|
21
|
+
}
|
|
22
|
+
/** Whether `presented` is one of the link keys (every key is compared, in constant time each). */
|
|
23
|
+
export function isLinkKey(presented, linkKeys, secretEqual) {
|
|
24
|
+
let matched = false;
|
|
25
|
+
for (const key of linkKeys.values())
|
|
26
|
+
if (secretEqual(presented, key))
|
|
27
|
+
matched = true;
|
|
28
|
+
return matched;
|
|
29
|
+
}
|
|
@@ -65,7 +65,11 @@ Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
|
|
|
65
65
|
key is set, and a host sends ITS OWN `apiKey` on every peer request. So
|
|
66
66
|
keyed hosts that talk to each other must carry the SAME key (or both
|
|
67
67
|
none, loopback-only). A mismatch shows up as HTTP 401
|
|
68
|
-
`X-API-Key required on the delivery face`.
|
|
68
|
+
`X-API-Key required on the delivery face`. To link a host that must NOT
|
|
69
|
+
hold the fleet key (a public node), give that one link its own key instead:
|
|
70
|
+
`linkKeys: { '<other host origin>': '<link key>' }` on BOTH ends (#317).
|
|
71
|
+
A link key is accepted only on the peer faces, only from the verified
|
|
72
|
+
peer it names, and never on the control routes.
|
|
69
73
|
3. **Announce and seed each other.** Seeding BOTH ways is what makes the
|
|
70
74
|
hosts linked: each side then reads the other's full card. One-sided
|
|
71
75
|
seeding does not link.
|
package/lib/tools/files.js
CHANGED
|
@@ -20,6 +20,7 @@ import { BLOB_FILE_MAX, formatFileRef, parseFileRef, sanitizeBlobName } from "..
|
|
|
20
20
|
import { grantAdmits } from "../faces/files.js";
|
|
21
21
|
import { LOCAL_ROUTE } from "../node-self.js";
|
|
22
22
|
import { originOf } from "../origin-claim.js";
|
|
23
|
+
import { normalizeLinkKeys, outboundKeyFor } from "../link-keys.js";
|
|
23
24
|
/** Files one send may carry (one message; the FloodGuard per-transfer figure). */
|
|
24
25
|
export const FILE_SEND_MAX = 16;
|
|
25
26
|
/** The inbox directory under a session's workspace. */
|
|
@@ -127,10 +128,11 @@ export function createFileTools(d) {
|
|
|
127
128
|
}
|
|
128
129
|
else {
|
|
129
130
|
const target = `${ref.from}/a2a/blob`;
|
|
131
|
+
const key = outboundKeyFor(target, normalizeLinkKeys(config.linkKeys), config.apiKey); // #317 link key
|
|
130
132
|
let response;
|
|
131
133
|
try {
|
|
132
134
|
response = await d.peerFetch(`${target}?sha256=${ref.sha256}&caller_session=${encodeURIComponent(caller)}`, {
|
|
133
|
-
headers: { ...d.originClaimHeaders({ url: target, method: 'GET' }), ...(
|
|
135
|
+
headers: { ...d.originClaimHeaders({ url: target, method: 'GET' }), ...(key !== '' ? { 'X-API-Key': key } : {}) },
|
|
134
136
|
redirect: 'manual',
|
|
135
137
|
signal: AbortSignal.any([exec.signal, AbortSignal.timeout(120_000)]),
|
|
136
138
|
});
|
|
@@ -13,7 +13,7 @@ export type A2aSchedule = (callback: () => void, delayMs: number) => () => void;
|
|
|
13
13
|
export type A2aFetch = (url: string, init: {
|
|
14
14
|
readonly method: 'GET' | 'POST';
|
|
15
15
|
readonly headers: Record<string, string>;
|
|
16
|
-
/**
|
|
16
|
+
/** Unused by the client itself: the host's peerFetch already refuses every redirect (review of #318 R4). */
|
|
17
17
|
readonly redirect?: 'follow' | 'error' | 'manual';
|
|
18
18
|
readonly body?: string;
|
|
19
19
|
readonly signal?: AbortSignal;
|
|
@@ -26,6 +26,8 @@ export type A2aFetch = (url: string, init: {
|
|
|
26
26
|
export interface A2aClientOptions {
|
|
27
27
|
/** `X-API-Key` value sent on peer requests; empty omits the header. */
|
|
28
28
|
readonly apiKey: string;
|
|
29
|
+
/** #317: the key for one target URL (a link key), when it differs from `apiKey`. */
|
|
30
|
+
readonly apiKeyFor?: (url: string) => string;
|
|
29
31
|
/** This node's caller label stamped on outbound routes. */
|
|
30
32
|
readonly sessionId: string;
|
|
31
33
|
/**
|
package/lib/types/config.d.ts
CHANGED
|
@@ -83,6 +83,12 @@ export interface Config {
|
|
|
83
83
|
* proxied hosts name the URL peers dial).
|
|
84
84
|
*/
|
|
85
85
|
readonly originClaimAudiences: string[];
|
|
86
|
+
/**
|
|
87
|
+
* #317: per-peer `X-API-Key`, keyed by the peer's origin. Sent instead of `apiKey` on requests to that peer,
|
|
88
|
+
* and accepted on this host's peer faces only from a caller whose verified OriginClaim names that origin.
|
|
89
|
+
* Never accepted on the control routes. Lets a public node link without holding the fleet key.
|
|
90
|
+
*/
|
|
91
|
+
readonly linkKeys: Record<string, string>;
|
|
86
92
|
/**
|
|
87
93
|
* #19 D2: card-face sessionTeams excerpt exposure — 'full' | 'masked'
|
|
88
94
|
* | 'off' (owner-level; default full). See the schema comment.
|
|
@@ -30,7 +30,8 @@ export interface FileFaceDeps {
|
|
|
30
30
|
readonly verifyOriginClaim: (req: IncomingMessage, options?: {
|
|
31
31
|
readonly cli?: boolean;
|
|
32
32
|
}) => Promise<OriginClaimResult>;
|
|
33
|
-
|
|
33
|
+
/** #317: the peer-face key gate — this host's apiKey, or a link key from the verified peer it belongs to. */
|
|
34
|
+
readonly peerKeyAdmits: (req: IncomingMessage, rawBody?: Uint8Array) => Promise<boolean>;
|
|
34
35
|
readonly controlRoute: (handler: (req: IncomingMessage, res: ServerResponse) => void) => (req: IncomingMessage, res: ServerResponse) => void;
|
|
35
36
|
readonly recordActivity: (dir: 'in' | 'out', team: string, peer: string, ok: boolean) => void;
|
|
36
37
|
readonly now?: () => number;
|
|
@@ -34,6 +34,8 @@ export interface SessionLogFaceDeps {
|
|
|
34
34
|
};
|
|
35
35
|
readonly peerStore: Pick<PeerStore, 'list'>;
|
|
36
36
|
readonly peerFetch: typeof globalThis.fetch;
|
|
37
|
+
/** #317 review R2: the signed, keyed headers of one peer read (OriginClaim + the target's key). */
|
|
38
|
+
readonly peerRequestHeaders: (url: string, method: string) => Record<string, string>;
|
|
37
39
|
readonly directoryPeerCandidates: (fetch: (url: string) => Promise<A2aPeerCard | undefined>, team: string, failures: string[]) => Promise<string[]>;
|
|
38
40
|
readonly memoizedCardFetch: () => (url: string) => Promise<A2aPeerCard | undefined>;
|
|
39
41
|
}
|
|
@@ -148,7 +148,8 @@ export interface WireDeps {
|
|
|
148
148
|
readonly ownCardUrl: string;
|
|
149
149
|
readonly recordActivity: (dir: 'in' | 'out', team: string, peer: string, ok: boolean) => void;
|
|
150
150
|
readonly bumpLegacyInbound: () => void;
|
|
151
|
-
|
|
151
|
+
/** #317: the peer-face key gate — this host's apiKey, or a link key from the verified peer it belongs to. */
|
|
152
|
+
readonly peerKeyAdmits: (req: IncomingMessage, rawBody?: Uint8Array) => Promise<boolean>;
|
|
152
153
|
readonly a2aCallerRefusal: (caller: string, targetTeam?: string) => string | undefined;
|
|
153
154
|
/** #114 S2: the same decision with the rule and basis that admitted the caller. */
|
|
154
155
|
readonly a2aCallerAdmission: (caller: string, targetTeam?: string, cliProof?: RemoteCliProof) => Admission;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #317 link keys: a per-peer `X-API-Key` so a public node never holds the fleet key. Outbound requests to a
|
|
3
|
+
* linked origin carry that link's key; inbound, a link key is accepted on the peer faces only when the
|
|
4
|
+
* caller's verified OriginClaim names exactly that origin. The control routes never accept a link key.
|
|
5
|
+
*/
|
|
6
|
+
import { originOf } from './origin-claim.ts';
|
|
7
|
+
export { originOf };
|
|
8
|
+
/** The configured link keys keyed by normalized origin; blank keys and unparsable origins are dropped. */
|
|
9
|
+
export declare function normalizeLinkKeys(linkKeys: Readonly<Record<string, unknown>> | undefined): ReadonlyMap<string, string>;
|
|
10
|
+
/** The `X-API-Key` to send to `url`: its link key, else this host's apiKey ('' omits the header). */
|
|
11
|
+
export declare function outboundKeyFor(url: string, linkKeys: ReadonlyMap<string, string>, apiKey: string): string;
|
|
12
|
+
/** Whether `presented` is one of the link keys (every key is compared, in constant time each). */
|
|
13
|
+
export declare function isLinkKey(presented: string, linkKeys: ReadonlyMap<string, string>, secretEqual: (a: string, b: string) => boolean): boolean;
|
|
@@ -12,7 +12,7 @@ export declare const FILE_SEND_MAX = 16;
|
|
|
12
12
|
export declare const FILE_INBOX_DIR = "a2a-inbox";
|
|
13
13
|
export interface FileToolsDeps {
|
|
14
14
|
readonly ctx: Context;
|
|
15
|
-
readonly config: Pick<Config, 'apiKey' | 'team'>;
|
|
15
|
+
readonly config: Pick<Config, 'apiKey' | 'team' | 'linkKeys'>;
|
|
16
16
|
readonly blobStore: BlobStore;
|
|
17
17
|
readonly canvasMembersOf: (name: string) => readonly string[];
|
|
18
18
|
/** This host's own card URL (live: it is known only once the web server settled). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nelsonlongxiang/dsh-open-a2a-net",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.41",
|
|
4
4
|
"description": "Open A2A network plugin for DeepSeek Harness: signed agent cards, decentralized peer/zone discovery, direct routing model tools, and joinable session nodes with a sidebar control",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|