@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.
Files changed (76) hide show
  1. package/README.md +2 -1
  2. package/README_zh.md +2 -1
  3. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  4. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  5. package/assets/stageDist/index.html +12 -12
  6. package/lib/a2a-client.js +3 -2
  7. package/lib/client.js +64 -64
  8. package/lib/client.js.map +1 -1
  9. package/lib/config.js +1 -0
  10. package/lib/faces/files.js +2 -2
  11. package/lib/faces/session-log.js +3 -3
  12. package/lib/faces/wire.js +11 -16
  13. package/lib/index.js +102 -18
  14. package/lib/link-keys.js +29 -0
  15. package/lib/origin-claim.js +4 -0
  16. package/lib/skill/SKILL.md +117 -117
  17. package/lib/skill/references/collaboration.md +428 -428
  18. package/lib/skill/references/discovery.md +78 -78
  19. package/lib/skill/references/intake.md +107 -107
  20. package/lib/skill/references/membership.md +73 -73
  21. package/lib/skill/references/pitfalls.md +51 -51
  22. package/lib/skill/references/router.md +96 -96
  23. package/lib/skill/references/routing.md +65 -65
  24. package/lib/skill/references/setup.md +5 -1
  25. package/lib/tools/files.js +3 -1
  26. package/lib/types/a2a-client.d.ts +3 -1
  27. package/lib/types/config.d.ts +6 -0
  28. package/lib/types/faces/files.d.ts +2 -1
  29. package/lib/types/faces/session-log.d.ts +2 -0
  30. package/lib/types/faces/wire.d.ts +2 -1
  31. package/lib/types/link-keys.d.ts +13 -0
  32. package/lib/types/origin-claim.d.ts +2 -0
  33. package/lib/types/tools/files.d.ts +1 -1
  34. package/nexus-stage/package.json +21 -21
  35. package/nexus-stage/scripts/mockhost.py +261 -261
  36. package/nexus-stage/src/canvas-ops.ts +191 -191
  37. package/nexus-stage/src/canvas-wire.ts +164 -164
  38. package/nexus-stage/src/cli-home-picker.ts +42 -42
  39. package/nexus-stage/src/fault.ts +56 -56
  40. package/nexus-stage/src/federation-overlay.ts +103 -103
  41. package/nexus-stage/src/federation.ts +241 -241
  42. package/nexus-stage/src/layout-doc.ts +145 -145
  43. package/nexus-stage/src/main.ts +513 -513
  44. package/nexus-stage/src/planning-card-text.ts +73 -73
  45. package/nexus-stage/src/planning-card.ts +63 -63
  46. package/nexus-stage/src/planning-edges.ts +26 -26
  47. package/nexus-stage/src/planning-fit.ts +45 -45
  48. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  49. package/nexus-stage/src/planning-legend.ts +39 -39
  50. package/nexus-stage/src/planning-member-handles.ts +61 -61
  51. package/nexus-stage/src/planning-menus.ts +422 -422
  52. package/nexus-stage/src/planning-mirror.ts +75 -75
  53. package/nexus-stage/src/planning-nodelist.ts +294 -294
  54. package/nexus-stage/src/planning-overlap.ts +159 -159
  55. package/nexus-stage/src/planning-reachability.ts +28 -28
  56. package/nexus-stage/src/planning-relayout.ts +121 -121
  57. package/nexus-stage/src/planning-roster.ts +15 -15
  58. package/nexus-stage/src/planning-search.ts +168 -168
  59. package/nexus-stage/src/planning-types.ts +115 -115
  60. package/nexus-stage/src/planning-undo.ts +68 -68
  61. package/nexus-stage/src/planning-view.ts +1499 -1499
  62. package/nexus-stage/src/planning.css +340 -340
  63. package/nexus-stage/src/seat-name-editor.ts +31 -31
  64. package/nexus-stage/src/session-drag.ts +64 -64
  65. package/nexus-stage/src/viewport.ts +106 -106
  66. package/nexus-stage/src/wb-chat.ts +684 -684
  67. package/nexus-stage/src/wb-directory.ts +237 -237
  68. package/nexus-stage/src/wb-ledger.ts +279 -279
  69. package/nexus-stage/src/wb-names.ts +43 -43
  70. package/nexus-stage/src/wb-rail.ts +147 -147
  71. package/nexus-stage/src/workbench.css +388 -388
  72. package/nexus-stage/src/workbench.ts +1272 -1272
  73. package/nexus-stage/src/world.ts +509 -509
  74. package/nexus-stage/tsconfig.json +24 -24
  75. package/nexus-stage/vite.config.ts +15 -15
  76. 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
- const peerFetch = (url, init) => config.isolated ? Promise.reject(new Error('isolated: outbound peer traffic is off on this host')) : globalThis.fetch(url, init);
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 named = claim.ok && callerLinkedToThisHost(claim.caller, claim.ok ? claim.callerUrl : '', claim.ok ? claim.card : undefined);
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/peers/signature
1642
- // stay (discovery, gossip, delegation, capability negotiation
1643
- // unbroken), while sessionTeams/teamMemberships/lanIp are
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
- peers: peerStore.list(),
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
- // #298 (review of #305 F4): the sync wait may close on a busy target's OTHER turn. `ownTurn` states,
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
- function verifyOriginClaim(req, options) {
2150
- return verifyOriginClaimOf(req, {
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, secretEqual, controlRoute, recordActivity });
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:
@@ -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
+ }
@@ -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
@@ -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