@nelsonlongxiang/dsh-open-a2a-net 0.6.40 → 0.6.42
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 +2 -1
- package/README_zh.md +2 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/lib/a2a-client.js +3 -2
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- 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 +102 -18
- package/lib/link-keys.js +29 -0
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- 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/origin-claim.d.ts +2 -0
- package/lib/types/tools/files.d.ts +1 -1
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +513 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1272 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
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
|
|
@@ -52,7 +53,7 @@ import { createFloodGuard } from "./flood-guard.js";
|
|
|
52
53
|
import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
|
|
53
54
|
import { resolveZone } from "./zone.js";
|
|
54
55
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
55
|
-
import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
|
|
56
|
+
import { isRootOriginUrl, sameMountedUrl, originClaimTeamWarning, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
|
|
56
57
|
import { AuthObserver } from "./auth-observer.js";
|
|
57
58
|
import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
|
|
58
59
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
@@ -172,6 +173,9 @@ export function apply(ctx, config) {
|
|
|
172
173
|
});
|
|
173
174
|
// F4 slice: say the quiet part at boot instead of leaving the exposure to
|
|
174
175
|
// be discovered from a ledger full of unexplained steering.
|
|
176
|
+
const teamWarning = originClaimTeamWarning(config.team);
|
|
177
|
+
if (teamWarning !== undefined)
|
|
178
|
+
logger.warn(teamWarning);
|
|
175
179
|
const exposure = directDeliveryExposure(config.peers, config.apiKey);
|
|
176
180
|
if (exposure.warning !== undefined)
|
|
177
181
|
logger.warn(exposure.warning);
|
|
@@ -199,7 +203,9 @@ export function apply(ctx, config) {
|
|
|
199
203
|
}
|
|
200
204
|
};
|
|
201
205
|
// gh#193: the one door to peers — while isolated nothing leaves this host.
|
|
202
|
-
|
|
206
|
+
// Review of #318 R4: never follow a peer's redirect — X-API-Key and the OriginClaim headers are not stripped on
|
|
207
|
+
// a cross-origin hop, so a 3xx would hand them to whoever the Location names. A redirect is an HTTP failure.
|
|
208
|
+
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
209
|
const fetchImpl = (url, init) => peerFetch(url, init);
|
|
204
210
|
/** Constant-time secret comparison: hash both sides to equal-length digests. */
|
|
205
211
|
function secretEqual(a, b) {
|
|
@@ -207,6 +213,39 @@ export function apply(ctx, config) {
|
|
|
207
213
|
const digestB = createHash('sha256').update(b).digest();
|
|
208
214
|
return timingSafeEqual(digestA, digestB);
|
|
209
215
|
}
|
|
216
|
+
// #317 link keys (src/link-keys.ts): a per-peer X-API-Key on the peer faces, never on the control routes.
|
|
217
|
+
const linkKeyMap = normalizeLinkKeys(config.linkKeys);
|
|
218
|
+
/**
|
|
219
|
+
* The peer-face key gate (/a2a/direct, /a2a/log, /a2a/blob): this host's apiKey as before; a link key only
|
|
220
|
+
* from a host whose OriginClaim verifies here BOUND to this very request (audience-bound v2 — never a v1
|
|
221
|
+
* envelope replayed onto another path; a POST also body-bound, Sig3), and only the key configured for that
|
|
222
|
+
* signer's own origin (review of #318 R1, R3). A leaked link key cannot act without that peer's node key.
|
|
223
|
+
* An empty apiKey keeps the open posture.
|
|
224
|
+
* @param req - the inbound peer request.
|
|
225
|
+
* @param rawBody - the request body, for a POST.
|
|
226
|
+
*/
|
|
227
|
+
async function peerKeyAdmits(req, rawBody) {
|
|
228
|
+
if (config.apiKey === '')
|
|
229
|
+
return true;
|
|
230
|
+
const presented = String(req.headers['x-api-key'] ?? '');
|
|
231
|
+
if (secretEqual(presented, config.apiKey))
|
|
232
|
+
return true;
|
|
233
|
+
if (!isLinkKey(presented, linkKeyMap, secretEqual))
|
|
234
|
+
return false;
|
|
235
|
+
const claim = await verifyOriginClaim(req, rawBody !== undefined ? { rawBody } : undefined);
|
|
236
|
+
if (!claim.ok || claim.cli === true || !claim.bound || (rawBody !== undefined && claim.bodyBound !== true))
|
|
237
|
+
return false;
|
|
238
|
+
const expected = linkKeyMap.get(originOf(claim.callerUrl));
|
|
239
|
+
return expected !== undefined && secretEqual(presented, expected);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The headers of one signed peer read (the remote session-log proxy): the OriginClaim for `url` (origin and
|
|
243
|
+
* path, no query) and the key for that target — its link key, else this host's apiKey (review of #318 R2).
|
|
244
|
+
*/
|
|
245
|
+
function peerRequestHeaders(url, method) {
|
|
246
|
+
const key = outboundKeyFor(url, linkKeyMap, config.apiKey);
|
|
247
|
+
return { ...originClaimHeaders({ url, method }), ...(key !== '' ? { 'X-API-Key': key } : {}) };
|
|
248
|
+
}
|
|
210
249
|
/**
|
|
211
250
|
* No-key trust for the control routes (gh#7 N1, V1): ONLY a loopback
|
|
212
251
|
* socket — header-only trust (Sec-Fetch-Site, Origin) admitted any
|
|
@@ -471,6 +510,7 @@ export function apply(ctx, config) {
|
|
|
471
510
|
}
|
|
472
511
|
const client = new A2aClient({
|
|
473
512
|
apiKey: config.apiKey,
|
|
513
|
+
apiKeyFor: url => outboundKeyFor(url, linkKeyMap, config.apiKey),
|
|
474
514
|
sessionId: session,
|
|
475
515
|
cardCaller: config.team,
|
|
476
516
|
// gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md): every
|
|
@@ -1618,11 +1658,13 @@ export function apply(ctx, config) {
|
|
|
1618
1658
|
// MINIMAL discovery card.
|
|
1619
1659
|
handler: async (req, res) => {
|
|
1620
1660
|
const send = (named, body_) => {
|
|
1621
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body_) });
|
|
1661
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body_), 'Cache-Control': 'private, no-store' });
|
|
1622
1662
|
res.end(body_);
|
|
1623
1663
|
void named;
|
|
1624
1664
|
};
|
|
1625
1665
|
const claim = await verifyOriginClaim(req);
|
|
1666
|
+
// #312: signed discovery is a reduced face; it never requests a full-card callback.
|
|
1667
|
+
const discoveryOnly = new URL(req.url ?? '/', 'http://x').searchParams.get('discovery') === '1';
|
|
1626
1668
|
// W3 (#18 half-side, 0.6.25): a VERIFIED envelope proves the
|
|
1627
1669
|
// caller holds a real node key — it does not prove a relationship
|
|
1628
1670
|
// with THIS host. The full card (sessionTeams/rosters =
|
|
@@ -1633,14 +1675,17 @@ export function apply(ctx, config) {
|
|
|
1633
1675
|
// Interaction with the OriginClaim narrowing: the caller is a
|
|
1634
1676
|
// bare zone; linkage judges that zone face against our canvas
|
|
1635
1677
|
// remote refs, joined handles, and the caller card's peers.
|
|
1636
|
-
const
|
|
1678
|
+
const linkedCaller = claim.ok && callerLinkedToThisHost(claim.caller, claim.callerUrl, claim.card);
|
|
1679
|
+
const showPeers = claim.ok && (configuredSeedCaller(claim.callerUrl) || linkedCaller);
|
|
1680
|
+
const named = !discoveryOnly && linkedCaller;
|
|
1637
1681
|
// gh#7 G1 (review-seat R2 ruling): the card serves two faces.
|
|
1638
1682
|
// A NAMED caller — ?caller_session= or the X-A2A-Caller header in
|
|
1639
1683
|
// the zone or zone/id8 shape — gets the FULL card. An anonymous
|
|
1640
1684
|
// fetcher (or an invalid shape, fail-closed) gets the MINIMAL
|
|
1641
|
-
// card: name/team/session/capabilities/records/
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1685
|
+
// card: name/team/session/capabilities/records/signature stay.
|
|
1686
|
+
// #312/#324 F1: peers requires a verified caller that we explicitly seed
|
|
1687
|
+
// or already link; unrelated self-signed strangers get no topology.
|
|
1688
|
+
// Meanwhile sessionTeams/teamMemberships/lanIp are
|
|
1644
1689
|
// omitted — session membership and rosters are collaboration
|
|
1645
1690
|
// facts, not discovery facts. Named determination does NOT
|
|
1646
1691
|
// require a shared team: demanding roster symmetry before the
|
|
@@ -1696,7 +1741,8 @@ export function apply(ctx, config) {
|
|
|
1696
1741
|
const memberships = teamMemberships.list().flatMap(entry => entry.teams.map(team => ({ node: `${config.team}/${id8(entry.session)}`, team })));
|
|
1697
1742
|
const body = JSON.stringify({
|
|
1698
1743
|
...currentCard,
|
|
1699
|
-
|
|
1744
|
+
// #324 F1: possession of an arbitrary self-signed identity is not a local relationship.
|
|
1745
|
+
...(showPeers ? { peers: peerStore.list() } : {}),
|
|
1700
1746
|
...(named && sessionTeams.length > 0 ? { sessionTeams } : {}),
|
|
1701
1747
|
...(named && memberships.length > 0 ? { teamMemberships: memberships } : {}),
|
|
1702
1748
|
// gh#41 fleet planning (owner ruling: any peer composes and
|
|
@@ -2073,21 +2119,20 @@ export function apply(ctx, config) {
|
|
|
2073
2119
|
*/
|
|
2074
2120
|
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2075
2121
|
return new Promise((resolve) => {
|
|
2122
|
+
// #306: synchronous replies use the same reading-turn attribution as automatic receipts.
|
|
2123
|
+
// Registering and steering are synchronous, so this is the exact pre-steer floor.
|
|
2076
2124
|
const floorAtRegistration = sessionEventsOf(agent).length;
|
|
2077
2125
|
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`).
|
|
2126
|
+
// Preserve #305's reply_final contract: only this task's own completed reading turn qualifies.
|
|
2081
2127
|
let ownTurn = false;
|
|
2082
2128
|
if (placeholder !== true && taskId !== undefined && taskId !== '') {
|
|
2083
2129
|
const events = sessionEventsOf(agent);
|
|
2084
2130
|
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
2131
|
const window = read === undefined ? undefined : readingTurnReply(events, read);
|
|
2087
2132
|
ownTurn = window !== undefined && window.completed && window.reply !== '' && window.reply === text;
|
|
2088
2133
|
}
|
|
2089
2134
|
resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}), ...(ownTurn ? { ownTurn: true } : {}) });
|
|
2090
|
-
});
|
|
2135
|
+
}, taskId !== undefined && taskId !== '' ? { taskId, steerFloor: floorAtRegistration } : undefined);
|
|
2091
2136
|
// The header names the sender first: `caller` is the routing node's
|
|
2092
2137
|
// own label (the session that issued the route), while `team` is this
|
|
2093
2138
|
// request's target — showing the target as "remote team" hid the
|
|
@@ -2146,8 +2191,21 @@ export function apply(ctx, config) {
|
|
|
2146
2191
|
boundMissRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
|
|
2147
2192
|
boundMissRecent.length = Math.min(boundMissRecent.length, 16);
|
|
2148
2193
|
};
|
|
2149
|
-
|
|
2150
|
-
|
|
2194
|
+
/** Our configured seeds anchor origin claims; mounted claims require exact mount matching. */
|
|
2195
|
+
function configuredSeedCaller(callerUrl) {
|
|
2196
|
+
return config.peers.some(peer => {
|
|
2197
|
+
try {
|
|
2198
|
+
return isRootOriginUrl(callerUrl) ? new URL(peer).origin === new URL(callerUrl).origin : sameMountedUrl(peer, callerUrl);
|
|
2199
|
+
}
|
|
2200
|
+
catch {
|
|
2201
|
+
return false;
|
|
2202
|
+
}
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
// #312: topology-only callbacks must never replace a full session/roster card cache entry.
|
|
2206
|
+
const signedDiscoveryPeers = new Map();
|
|
2207
|
+
async function verifyOriginClaim(req, options) {
|
|
2208
|
+
const claim = await verifyOriginClaimOf(req, {
|
|
2151
2209
|
cardCache,
|
|
2152
2210
|
fetch: fetchImpl,
|
|
2153
2211
|
audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
|
|
@@ -2159,6 +2217,32 @@ export function apply(ctx, config) {
|
|
|
2159
2217
|
...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
|
|
2160
2218
|
...(options?.rawBody !== undefined ? { rawBody: options.rawBody } : {}),
|
|
2161
2219
|
});
|
|
2220
|
+
// #312: recover bilateral seed evidence from a signed, reduction-only card,
|
|
2221
|
+
// never from a caller-selected unseeded URL and never recursively.
|
|
2222
|
+
const discovery = new URL(req.url ?? '/', 'http://x').searchParams.get('discovery') === '1';
|
|
2223
|
+
if (!discovery && claim.ok && claim.cli !== true && claim.card.peers === undefined && configuredSeedCaller(claim.callerUrl)) {
|
|
2224
|
+
const discoveryKey = new URL(claim.callerUrl).href.replace(/\/$/, '');
|
|
2225
|
+
const known = signedDiscoveryPeers.get(discoveryKey);
|
|
2226
|
+
if (known !== undefined && known.key === claim.card.publicKey && known.expiresAt > Date.now() && Date.now() - known.at < config.cardCacheTtlMs)
|
|
2227
|
+
return { ...claim, card: { ...claim.card, peers: known.peers } };
|
|
2228
|
+
const target = claim.callerUrl.replace(/\/$/, '') + '/.well-known/agent-card.json?discovery=1';
|
|
2229
|
+
try {
|
|
2230
|
+
const response = await peerFetch(target, { method: 'GET', headers: peerRequestHeaders(target, 'GET'), redirect: 'manual', signal: AbortSignal.timeout(5_000) });
|
|
2231
|
+
if (response.ok) {
|
|
2232
|
+
const verified = verifyCard(JSON.parse(await response.text()), Date.now());
|
|
2233
|
+
if (verified.ok && verified.card.publicKey === claim.card.publicKey && verified.card.team === claim.card.team) {
|
|
2234
|
+
if (verified.card.peers !== undefined) {
|
|
2235
|
+
signedDiscoveryPeers.set(discoveryKey, { at: Date.now(), expiresAt: verified.card.expiresAt, key: verified.card.publicKey, peers: verified.card.peers });
|
|
2236
|
+
if (signedDiscoveryPeers.size > 64)
|
|
2237
|
+
signedDiscoveryPeers.delete(signedDiscoveryPeers.keys().next().value);
|
|
2238
|
+
}
|
|
2239
|
+
return { ...claim, card: { ...claim.card, ...(verified.card.peers !== undefined ? { peers: verified.card.peers } : {}) } };
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
catch { /* unavailable refresh keeps bilateral-link checks closed */ }
|
|
2244
|
+
}
|
|
2245
|
+
return claim;
|
|
2162
2246
|
}
|
|
2163
2247
|
/**
|
|
2164
2248
|
* gh#133: the deposited card a CLI claim is judged by, or undefined when
|
|
@@ -2488,6 +2572,7 @@ export function apply(ctx, config) {
|
|
|
2488
2572
|
joinedSessions,
|
|
2489
2573
|
idempotencyStore,
|
|
2490
2574
|
verifyOriginClaim,
|
|
2575
|
+
peerKeyAdmits,
|
|
2491
2576
|
remoteCliProof,
|
|
2492
2577
|
forwardReceiptBasis: (taskId, caller, cliHandle, claim) => forwardReceiptBasis(taskLedger.find(taskId), caller, cliHandle, claim),
|
|
2493
2578
|
forwardDirect: request => forwardDirect(request), ownCanvasFingerprint,
|
|
@@ -2495,7 +2580,6 @@ export function apply(ctx, config) {
|
|
|
2495
2580
|
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
2496
2581
|
recordActivity,
|
|
2497
2582
|
bumpLegacyInbound: () => { legacyInboundCount += 1; },
|
|
2498
|
-
secretEqual,
|
|
2499
2583
|
a2aCallerRefusal,
|
|
2500
2584
|
a2aCallerAdmission,
|
|
2501
2585
|
allowlistVersionNow,
|
|
@@ -2647,8 +2731,8 @@ export function apply(ctx, config) {
|
|
|
2647
2731
|
}),
|
|
2648
2732
|
}), 'a2a: quick-dispatch route');
|
|
2649
2733
|
// 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,
|
|
2734
|
+
registerSessionLogFaces(webServer, { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, peerRequestHeaders, directoryPeerCandidates, memoizedCardFetch });
|
|
2735
|
+
registerFileFaces(webServer, { ctx, config, blobStore, canvasStore, verifyOriginClaim, peerKeyAdmits, controlRoute, recordActivity });
|
|
2652
2736
|
});
|
|
2653
2737
|
/**
|
|
2654
2738
|
* 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
|
+
}
|
package/lib/origin-claim.js
CHANGED
|
@@ -153,6 +153,10 @@ export function requestAudiences(req, configured, loopbackArrival = true) {
|
|
|
153
153
|
return out;
|
|
154
154
|
}
|
|
155
155
|
const originClaimCallerShape = /^[a-z0-9-]+$/i;
|
|
156
|
+
/** #312: explain unsupported host zone configuration without changing startup or signature policy. */
|
|
157
|
+
export function originClaimTeamWarning(team) {
|
|
158
|
+
return originClaimCallerShape.test(team) ? undefined : 'a2a: configured host team contains invalid characters; OriginClaim cannot produce a verifiable host identity. Use ^[a-z0-9-]+$ (no slash).';
|
|
159
|
+
}
|
|
156
160
|
/**
|
|
157
161
|
* Verify one request's envelope. Cheap refusals (shape, ts, audience,
|
|
158
162
|
* closed window) answer before any card fetch; the card is then read from
|
package/lib/skill/SKILL.md
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: a2a-network
|
|
3
|
-
description: Use when a task needs cross-session or cross-node collaboration over the DSH A2A network — setting it up (install, config, apiKey, peers, canvas links for one host, two peers, or hosts that cannot reach each other), joining and teaming, routing work to remote teams and agents, reconciling receipts and the task ledger, and reading the A2A workbench (/__dsh_a2a_nexus/). Covers admission rules, the receipt contract, and the failure shapes that look like bugs but are contract semantics.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# DSH A2A Network
|
|
7
|
-
|
|
8
|
-
Drive the decentralized A2A network: sessions publish agent cards, join a
|
|
9
|
-
membership layer, form canvas teams, and route work to each other through
|
|
10
|
-
`a2a_route`. One plugin (`@nelsonlongxiang/dsh-open-a2a-net`) owns the whole
|
|
11
|
-
surface on every node.
|
|
12
|
-
|
|
13
|
-
## Objects
|
|
14
|
-
|
|
15
|
-
- **Node** — one DSH host process exposing `/.well-known/agent-card.json` and
|
|
16
|
-
the `__dsh_a2a/state` face. Peers discover each other through cards.
|
|
17
|
-
- **Session node** — a live DSH session on a node. Visibility follows
|
|
18
|
-
membership: an unjoined session is invisible to peer discovery AND refused
|
|
19
|
-
at the routing admission gate.
|
|
20
|
-
- **Team** — a routable identity. Two kinds: the node's process team
|
|
21
|
-
(`<zone>/<id8>` from the session id) and canvas teams
|
|
22
|
-
(`<zone>/canvas/<name>`, user-composed multi-member groups).
|
|
23
|
-
- **Task** — one routed round. Carries a task id, an idempotency fingerprint,
|
|
24
|
-
and a receipt contract.
|
|
25
|
-
- **CLI identity** — a transient caller outside any session (the `a2a-net`
|
|
26
|
-
command, 0.6.27+): its own Ed25519 key and a handle `<name>/<16 hex>`,
|
|
27
|
-
deposited on ONE home host for up to 4h, with a mailbox instead of a
|
|
28
|
-
wakeable session. See [cli.md](references/cli.md).
|
|
29
|
-
|
|
30
|
-
## Setting up
|
|
31
|
-
|
|
32
|
-
Start with [setup.md](references/setup.md). It is a checked path for three
|
|
33
|
-
network shapes:
|
|
34
|
-
- **one host**: install, join the sessions, give them a shared team;
|
|
35
|
-
- **two hosts that reach each other**: one shared `apiKey`, `announce` plus
|
|
36
|
-
peers seeded both ways, and a canvas team naming both handles;
|
|
37
|
-
- **hosts that cannot reach each other**: host forwarding is not shipped
|
|
38
|
-
yet (#57), so tunnel or delegate per hop.
|
|
39
|
-
|
|
40
|
-
The owner does the gestures (install, config, join, allowlist, canvas). The
|
|
41
|
-
agent prepares the exact config and verifies each step with `a2a_whoami`,
|
|
42
|
-
`a2a_probe`, `a2a_teams` and one round with a receipt.
|
|
43
|
-
|
|
44
|
-
## Routing (a2a_route)
|
|
45
|
-
|
|
46
|
-
```text
|
|
47
|
-
a2a_route { team: "<zone>/<id8 or canvas name>", message, context_id?, async? }
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
- `async: true` delivers without waiting (`delivered:true` + task id); the
|
|
51
|
-
target routes a receipt — a message starting `[A2A receipt] task <id>` —
|
|
52
|
-
back to your team.
|
|
53
|
-
- Sync (default) waits for the round's final answer. A target on this host
|
|
54
|
-
gets up to 180s; past that the tool answers the delivered shape
|
|
55
|
-
(`TASK_STATE_DELIVERED`) and the receipt contract carries the rest. A
|
|
56
|
-
remote peer's delivery rides a 15s transport budget; if it fires first, the
|
|
57
|
-
result is **-32005 DELIVERED-UNSETTLED**: the delivery landed and the peer
|
|
58
|
-
keeps executing. Either way, **never re-dispatch blindly** — reconcile via
|
|
59
|
-
the task id (the receipt, `a2a_status` activity, or `a2a_tasks`) instead.
|
|
60
|
-
- Model tools cannot route to the bare process team (`<zone>` alone, the
|
|
61
|
-
host's own mailbox): target a session handle or a canvas team.
|
|
62
|
-
- `context_id` from a previous reply continues that conversation.
|
|
63
|
-
|
|
64
|
-
## Membership rules
|
|
65
|
-
|
|
66
|
-
- An unjoined session has NO network: `a2a_route` and `a2a_teams` refuse it
|
|
67
|
-
(join gate), and discovery does not list it. The session that originates a
|
|
68
|
-
task is exempt from the gate.
|
|
69
|
-
- Join through the GUI sidebar (A2A 网络) or the join gate prompt; leave
|
|
70
|
-
through the same surface. Membership is visible; leaving removes you from
|
|
71
|
-
discovery too.
|
|
72
|
-
- Inbound deliveries from a caller with no `caller_session` are refused
|
|
73
|
-
(anonymous injection is a closed hole).
|
|
74
|
-
|
|
75
|
-
## Reading the state
|
|
76
|
-
|
|
77
|
-
`__dsh_a2a/state` (per node, key-gated like every control route) carries
|
|
78
|
-
`sessions` (live/joined, with their teams), `canvas` (canvas teams; remote
|
|
79
|
-
members show `remote: true`), `remote` (peer session rows with `origin` =
|
|
80
|
-
node label + LAN IP and `via` = the peer URL), `activity` (routing ring),
|
|
81
|
-
and the ledger: `tasks` (pending, each with `settleable`),
|
|
82
|
-
`tasksStaleCount`, `tasksDead`, and `archived` (newest 64 settled rows, each
|
|
83
|
-
with `outcome`, `endedBy` and `late`) plus `archivedCount` /
|
|
84
|
-
`archivedSplit`. `sessionTeams` is a card field, not a state field. The A2A
|
|
85
|
-
workbench (`/__dsh_a2a_nexus/`, merged responsive
|
|
86
|
-
shell since 0.6.2 — the 2D planning board is the stage, directory / ledger /
|
|
87
|
-
activity / plan ride a collapsible rail, narrow screens get a drawer)
|
|
88
|
-
renders the same data for humans.
|
|
89
|
-
|
|
90
|
-
## Failure shapes that are contract semantics
|
|
91
|
-
|
|
92
|
-
- `-32000` with "declares no team membership" — the admission gate working
|
|
93
|
-
as designed; join (or route through a teamed member).
|
|
94
|
-
- `-32000` with "caller_session is required" — the delivery had no caller
|
|
95
|
-
identity; the protocol contract requires it.
|
|
96
|
-
- `-32005` — sync wait budget fired; delivery landed, reconcile the task id.
|
|
97
|
-
- "No live DSH session node accepts team X" — no session node on the host
|
|
98
|
-
that received the delivery accepted the round. Either the target session is
|
|
99
|
-
not live/joined there, or X lives on ANOTHER host and the caller delivered
|
|
100
|
-
straight to this host's `/a2a/direct` (the `a2a-net` CLI does this):
|
|
101
|
-
direct delivery never relays to peers — `a2a_route` from a session does.
|
|
102
|
-
- `[PATTERN_NO_MINT]` / `[ALLOWLIST_DENY]` on `a2a_team_join` — only an EXACT
|
|
103
|
-
allowlisted name joins; adding that name is the owner's act of creating
|
|
104
|
-
the team. `[CANVAS_TEAM]` — canvas membership is assigned in the workbench,
|
|
105
|
-
never by the roster tool.
|
|
106
|
-
|
|
107
|
-
## Details
|
|
108
|
-
|
|
109
|
-
- [setup.md](references/setup.md) — setup and checks: one host, two peers, beyond direct peers
|
|
110
|
-
- [routing.md](references/routing.md) — receipts, idempotency, ledger anatomy
|
|
111
|
-
- [discovery.md](references/discovery.md) — cards, peer boundary model, invisibility
|
|
112
|
-
- [membership.md](references/membership.md) — join/leave, allowlist, canvas teams
|
|
113
|
-
- [pitfalls.md](references/pitfalls.md) — operational pitfalls observed in production
|
|
114
|
-
- [cli.md](references/cli.md) — the `a2a-net` CLI: deposit, mailbox, inbox hooks, limits
|
|
115
|
-
- [collaboration.md](references/collaboration.md) — collaboration norm (Chinese): single-seat first, decide-then-report with no pre-authorization (a high-tier change needs one non-self node's review PASS on the same SHA), release tiers, stuck gates, credentials, receipts
|
|
116
|
-
- [intake.md](references/intake.md) — intake node (Chinese): handling `workbench-event` deliveries from workbench-market — accept/decline/ask with a reason, open a GitHub issue on accept, write the decision back with the reply token
|
|
117
|
-
- [router.md](references/router.md) — the chief's system prompt (Chinese), self-contained: route workbench events and owner requests to the right project's dev, order cross-project resource conflicts (it authorizes nothing since collaboration v1.18); immutable layer + self-kept mutable layer
|
|
1
|
+
---
|
|
2
|
+
name: a2a-network
|
|
3
|
+
description: Use when a task needs cross-session or cross-node collaboration over the DSH A2A network — setting it up (install, config, apiKey, peers, canvas links for one host, two peers, or hosts that cannot reach each other), joining and teaming, routing work to remote teams and agents, reconciling receipts and the task ledger, and reading the A2A workbench (/__dsh_a2a_nexus/). Covers admission rules, the receipt contract, and the failure shapes that look like bugs but are contract semantics.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# DSH A2A Network
|
|
7
|
+
|
|
8
|
+
Drive the decentralized A2A network: sessions publish agent cards, join a
|
|
9
|
+
membership layer, form canvas teams, and route work to each other through
|
|
10
|
+
`a2a_route`. One plugin (`@nelsonlongxiang/dsh-open-a2a-net`) owns the whole
|
|
11
|
+
surface on every node.
|
|
12
|
+
|
|
13
|
+
## Objects
|
|
14
|
+
|
|
15
|
+
- **Node** — one DSH host process exposing `/.well-known/agent-card.json` and
|
|
16
|
+
the `__dsh_a2a/state` face. Peers discover each other through cards.
|
|
17
|
+
- **Session node** — a live DSH session on a node. Visibility follows
|
|
18
|
+
membership: an unjoined session is invisible to peer discovery AND refused
|
|
19
|
+
at the routing admission gate.
|
|
20
|
+
- **Team** — a routable identity. Two kinds: the node's process team
|
|
21
|
+
(`<zone>/<id8>` from the session id) and canvas teams
|
|
22
|
+
(`<zone>/canvas/<name>`, user-composed multi-member groups).
|
|
23
|
+
- **Task** — one routed round. Carries a task id, an idempotency fingerprint,
|
|
24
|
+
and a receipt contract.
|
|
25
|
+
- **CLI identity** — a transient caller outside any session (the `a2a-net`
|
|
26
|
+
command, 0.6.27+): its own Ed25519 key and a handle `<name>/<16 hex>`,
|
|
27
|
+
deposited on ONE home host for up to 4h, with a mailbox instead of a
|
|
28
|
+
wakeable session. See [cli.md](references/cli.md).
|
|
29
|
+
|
|
30
|
+
## Setting up
|
|
31
|
+
|
|
32
|
+
Start with [setup.md](references/setup.md). It is a checked path for three
|
|
33
|
+
network shapes:
|
|
34
|
+
- **one host**: install, join the sessions, give them a shared team;
|
|
35
|
+
- **two hosts that reach each other**: one shared `apiKey`, `announce` plus
|
|
36
|
+
peers seeded both ways, and a canvas team naming both handles;
|
|
37
|
+
- **hosts that cannot reach each other**: host forwarding is not shipped
|
|
38
|
+
yet (#57), so tunnel or delegate per hop.
|
|
39
|
+
|
|
40
|
+
The owner does the gestures (install, config, join, allowlist, canvas). The
|
|
41
|
+
agent prepares the exact config and verifies each step with `a2a_whoami`,
|
|
42
|
+
`a2a_probe`, `a2a_teams` and one round with a receipt.
|
|
43
|
+
|
|
44
|
+
## Routing (a2a_route)
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
a2a_route { team: "<zone>/<id8 or canvas name>", message, context_id?, async? }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `async: true` delivers without waiting (`delivered:true` + task id); the
|
|
51
|
+
target routes a receipt — a message starting `[A2A receipt] task <id>` —
|
|
52
|
+
back to your team.
|
|
53
|
+
- Sync (default) waits for the round's final answer. A target on this host
|
|
54
|
+
gets up to 180s; past that the tool answers the delivered shape
|
|
55
|
+
(`TASK_STATE_DELIVERED`) and the receipt contract carries the rest. A
|
|
56
|
+
remote peer's delivery rides a 15s transport budget; if it fires first, the
|
|
57
|
+
result is **-32005 DELIVERED-UNSETTLED**: the delivery landed and the peer
|
|
58
|
+
keeps executing. Either way, **never re-dispatch blindly** — reconcile via
|
|
59
|
+
the task id (the receipt, `a2a_status` activity, or `a2a_tasks`) instead.
|
|
60
|
+
- Model tools cannot route to the bare process team (`<zone>` alone, the
|
|
61
|
+
host's own mailbox): target a session handle or a canvas team.
|
|
62
|
+
- `context_id` from a previous reply continues that conversation.
|
|
63
|
+
|
|
64
|
+
## Membership rules
|
|
65
|
+
|
|
66
|
+
- An unjoined session has NO network: `a2a_route` and `a2a_teams` refuse it
|
|
67
|
+
(join gate), and discovery does not list it. The session that originates a
|
|
68
|
+
task is exempt from the gate.
|
|
69
|
+
- Join through the GUI sidebar (A2A 网络) or the join gate prompt; leave
|
|
70
|
+
through the same surface. Membership is visible; leaving removes you from
|
|
71
|
+
discovery too.
|
|
72
|
+
- Inbound deliveries from a caller with no `caller_session` are refused
|
|
73
|
+
(anonymous injection is a closed hole).
|
|
74
|
+
|
|
75
|
+
## Reading the state
|
|
76
|
+
|
|
77
|
+
`__dsh_a2a/state` (per node, key-gated like every control route) carries
|
|
78
|
+
`sessions` (live/joined, with their teams), `canvas` (canvas teams; remote
|
|
79
|
+
members show `remote: true`), `remote` (peer session rows with `origin` =
|
|
80
|
+
node label + LAN IP and `via` = the peer URL), `activity` (routing ring),
|
|
81
|
+
and the ledger: `tasks` (pending, each with `settleable`),
|
|
82
|
+
`tasksStaleCount`, `tasksDead`, and `archived` (newest 64 settled rows, each
|
|
83
|
+
with `outcome`, `endedBy` and `late`) plus `archivedCount` /
|
|
84
|
+
`archivedSplit`. `sessionTeams` is a card field, not a state field. The A2A
|
|
85
|
+
workbench (`/__dsh_a2a_nexus/`, merged responsive
|
|
86
|
+
shell since 0.6.2 — the 2D planning board is the stage, directory / ledger /
|
|
87
|
+
activity / plan ride a collapsible rail, narrow screens get a drawer)
|
|
88
|
+
renders the same data for humans.
|
|
89
|
+
|
|
90
|
+
## Failure shapes that are contract semantics
|
|
91
|
+
|
|
92
|
+
- `-32000` with "declares no team membership" — the admission gate working
|
|
93
|
+
as designed; join (or route through a teamed member).
|
|
94
|
+
- `-32000` with "caller_session is required" — the delivery had no caller
|
|
95
|
+
identity; the protocol contract requires it.
|
|
96
|
+
- `-32005` — sync wait budget fired; delivery landed, reconcile the task id.
|
|
97
|
+
- "No live DSH session node accepts team X" — no session node on the host
|
|
98
|
+
that received the delivery accepted the round. Either the target session is
|
|
99
|
+
not live/joined there, or X lives on ANOTHER host and the caller delivered
|
|
100
|
+
straight to this host's `/a2a/direct` (the `a2a-net` CLI does this):
|
|
101
|
+
direct delivery never relays to peers — `a2a_route` from a session does.
|
|
102
|
+
- `[PATTERN_NO_MINT]` / `[ALLOWLIST_DENY]` on `a2a_team_join` — only an EXACT
|
|
103
|
+
allowlisted name joins; adding that name is the owner's act of creating
|
|
104
|
+
the team. `[CANVAS_TEAM]` — canvas membership is assigned in the workbench,
|
|
105
|
+
never by the roster tool.
|
|
106
|
+
|
|
107
|
+
## Details
|
|
108
|
+
|
|
109
|
+
- [setup.md](references/setup.md) — setup and checks: one host, two peers, beyond direct peers
|
|
110
|
+
- [routing.md](references/routing.md) — receipts, idempotency, ledger anatomy
|
|
111
|
+
- [discovery.md](references/discovery.md) — cards, peer boundary model, invisibility
|
|
112
|
+
- [membership.md](references/membership.md) — join/leave, allowlist, canvas teams
|
|
113
|
+
- [pitfalls.md](references/pitfalls.md) — operational pitfalls observed in production
|
|
114
|
+
- [cli.md](references/cli.md) — the `a2a-net` CLI: deposit, mailbox, inbox hooks, limits
|
|
115
|
+
- [collaboration.md](references/collaboration.md) — collaboration norm (Chinese): single-seat first, decide-then-report with no pre-authorization (a high-tier change needs one non-self node's review PASS on the same SHA), release tiers, stuck gates, credentials, receipts
|
|
116
|
+
- [intake.md](references/intake.md) — intake node (Chinese): handling `workbench-event` deliveries from workbench-market — accept/decline/ask with a reason, open a GitHub issue on accept, write the decision back with the reply token
|
|
117
|
+
- [router.md](references/router.md) — the chief's system prompt (Chinese), self-contained: route workbench events and owner requests to the right project's dev, order cross-project resource conflicts (it authorizes nothing since collaboration v1.18); immutable layer + self-kept mutable layer
|