@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43

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 (72) hide show
  1. package/README.md +2 -1
  2. package/README_zh.md +2 -1
  3. package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  6. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  7. package/assets/stageDist/index.html +12 -12
  8. package/cli/a2a.mjs +24 -4
  9. package/lib/canvas-store.js +249 -17
  10. package/lib/client.js +62 -72
  11. package/lib/client.js.map +1 -1
  12. package/lib/config.js +1 -0
  13. package/lib/faces/admin.js +103 -63
  14. package/lib/faces/wire.js +3 -1
  15. package/lib/index.js +116 -22
  16. package/lib/origin-claim.js +4 -0
  17. package/lib/skill/SKILL.md +117 -117
  18. package/lib/skill/references/collaboration.md +428 -428
  19. package/lib/skill/references/discovery.md +78 -78
  20. package/lib/skill/references/intake.md +107 -107
  21. package/lib/skill/references/membership.md +73 -73
  22. package/lib/skill/references/pitfalls.md +51 -51
  23. package/lib/skill/references/router.md +96 -96
  24. package/lib/skill/references/routing.md +65 -65
  25. package/lib/types/canvas-store.d.ts +48 -1
  26. package/lib/types/config.d.ts +2 -0
  27. package/lib/types/faces/admin.d.ts +2 -0
  28. package/lib/types/origin-claim.d.ts +2 -0
  29. package/nexus-stage/package.json +21 -21
  30. package/nexus-stage/scripts/mockhost.py +261 -261
  31. package/nexus-stage/src/canvas-ops.ts +191 -191
  32. package/nexus-stage/src/canvas-wire.ts +164 -164
  33. package/nexus-stage/src/cli-home-picker.ts +42 -42
  34. package/nexus-stage/src/fault.ts +56 -56
  35. package/nexus-stage/src/federation-overlay.ts +103 -103
  36. package/nexus-stage/src/federation.ts +241 -241
  37. package/nexus-stage/src/layout-doc.ts +145 -145
  38. package/nexus-stage/src/main.ts +520 -513
  39. package/nexus-stage/src/planning-card-text.ts +73 -73
  40. package/nexus-stage/src/planning-card.ts +63 -63
  41. package/nexus-stage/src/planning-edges.ts +26 -26
  42. package/nexus-stage/src/planning-fit.ts +45 -45
  43. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  44. package/nexus-stage/src/planning-legend.ts +39 -39
  45. package/nexus-stage/src/planning-member-handles.ts +61 -61
  46. package/nexus-stage/src/planning-menus.ts +422 -422
  47. package/nexus-stage/src/planning-mirror.ts +75 -75
  48. package/nexus-stage/src/planning-nodelist.ts +294 -294
  49. package/nexus-stage/src/planning-overlap.ts +159 -159
  50. package/nexus-stage/src/planning-reachability.ts +28 -28
  51. package/nexus-stage/src/planning-relayout.ts +121 -121
  52. package/nexus-stage/src/planning-roster.ts +15 -15
  53. package/nexus-stage/src/planning-search.ts +168 -168
  54. package/nexus-stage/src/planning-types.ts +115 -115
  55. package/nexus-stage/src/planning-undo.ts +68 -68
  56. package/nexus-stage/src/planning-view.ts +1499 -1499
  57. package/nexus-stage/src/planning.css +340 -340
  58. package/nexus-stage/src/seat-name-editor.ts +31 -31
  59. package/nexus-stage/src/session-drag.ts +64 -64
  60. package/nexus-stage/src/viewport.ts +106 -106
  61. package/nexus-stage/src/wb-chat.ts +684 -684
  62. package/nexus-stage/src/wb-directory.ts +237 -237
  63. package/nexus-stage/src/wb-ledger.ts +279 -279
  64. package/nexus-stage/src/wb-names.ts +43 -43
  65. package/nexus-stage/src/wb-rail.ts +147 -147
  66. package/nexus-stage/src/workbench.css +388 -388
  67. package/nexus-stage/src/workbench.ts +1294 -1272
  68. package/nexus-stage/src/world.ts +509 -509
  69. package/nexus-stage/tsconfig.json +24 -24
  70. package/nexus-stage/vite.config.ts +15 -15
  71. package/package.json +1 -1
  72. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
package/lib/index.js CHANGED
@@ -30,7 +30,7 @@ export function resolveCallerLabel(input) {
30
30
  }
31
31
  import { encodePublicKey, signCard, verifyCard } from "./card.js";
32
32
  import { GroupStore } from "./group-store.js";
33
- import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
33
+ import { CanvasStore, canvasNameReserved, isRemoteMemberRef, legacyBareMember } from "./canvas-store.js";
34
34
  import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
35
35
  import { LayoutStore } from "./layout-store.js";
36
36
  import { peerLinkDisplay } from "./peer-link-display.js";
@@ -53,7 +53,7 @@ import { createFloodGuard } from "./flood-guard.js";
53
53
  import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
54
54
  import { resolveZone } from "./zone.js";
55
55
  import { registerA2aNetworkSkill } from "./skill-registration.js";
56
- import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
56
+ import { isRootOriginUrl, sameMountedUrl, originClaimTeamWarning, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
57
57
  import { AuthObserver } from "./auth-observer.js";
58
58
  import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
59
59
  import { MessageId } from '@deepseek-ai/dsh-llm';
@@ -173,6 +173,9 @@ export function apply(ctx, config) {
173
173
  });
174
174
  // F4 slice: say the quiet part at boot instead of leaving the exposure to
175
175
  // be discovered from a ledger full of unexplained steering.
176
+ const teamWarning = originClaimTeamWarning(config.team);
177
+ if (teamWarning !== undefined)
178
+ logger.warn(teamWarning);
176
179
  const exposure = directDeliveryExposure(config.peers, config.apiKey);
177
180
  if (exposure.warning !== undefined)
178
181
  logger.warn(exposure.warning);
@@ -594,7 +597,7 @@ export function apply(ctx, config) {
594
597
  // Canvas teams: user-composed multi-member routing groups. A session node
595
598
  // may sit in many teams; routing to <team>/canvas/<name> resolves the
596
599
  // first live member or wakes the first cold one (member order = priority).
597
- const canvasStore = new CanvasStore(join(home, 'a2a', 'canvas.json'));
600
+ const canvasStore = new CanvasStore(join(home, 'a2a', 'canvas.json'), { legacyBareMembers: config.canvasLegacyBareMembers !== false });
598
601
  // #91 file channel: the sender host's blob store (docs/design/file-channel.md).
599
602
  const blobStore = new BlobStore(join(home, 'a2a'));
600
603
  // Spatial presentation state for the full-page stage (contract v2).
@@ -915,7 +918,13 @@ export function apply(ctx, config) {
915
918
  for (const id of joinedSessions.list()) {
916
919
  if (!isArchived(id))
917
920
  continue;
918
- const ownHandle = ownCanvasHandleOf(id, true);
921
+ try {
922
+ revokeCanvasMember(id, 'archive');
923
+ }
924
+ catch (error) {
925
+ logger.error('canvas archive suspension failed: %s', String(error));
926
+ continue;
927
+ }
919
928
  sessionNodes.delete(id);
920
929
  // Sections come back at the next mount (re-publish) if this archived reading was transient; the row stays.
921
930
  nodeBinder.suspend(id);
@@ -925,9 +934,6 @@ export function apply(ctx, config) {
925
934
  // joined session matches"). Safety without the deletion: wake refuses
926
935
  // archived intents (:804), card assembly and the state cold rows filter
927
936
  // them, the reconciler skips them, and JOIN_CAP bounds the file.
928
- canvasStore.dropMember(id);
929
- if (ownHandle !== undefined)
930
- canvasStore.dropMember(ownHandle);
931
937
  // S2 consistency: an archived node's roster declarations die with it
932
938
  // — a left node keeps no team membership.
933
939
  teamMemberships.dropSession(id);
@@ -958,7 +964,13 @@ export function apply(ctx, config) {
958
964
  continue;
959
965
  if (sessionNodes.has(id) || joinedSessions.has(id))
960
966
  continue;
961
- canvasStore.removeMember(name, id);
967
+ try {
968
+ canvasStore.removeMember(name, id);
969
+ }
970
+ catch (error) {
971
+ logger.error('canvas stale-member prune failed (%s/%s): %s', name, id, String(error));
972
+ continue;
973
+ }
962
974
  logger.info(`a2a: canvas team "${name}" dropped stale member ${id8(id)} (no live node, no join intent)`);
963
975
  }
964
976
  }
@@ -1001,7 +1013,7 @@ export function apply(ctx, config) {
1001
1013
  * @param includeArchived - count archived sessions too (the archive prune
1002
1014
  * drops an archived session's own handle).
1003
1015
  */
1004
- function localHandleResolver(includeArchived = false) {
1016
+ function localHandleResolver(includeArchived = false, count = false, legacyOverride = false) {
1005
1017
  const isArchived = includeArchived ? undefined : archivedSessionFilter();
1006
1018
  const byHandle = new Map();
1007
1019
  for (const id of new Set([...joinedSessions.list(), ...sessionNodes.keys()])) {
@@ -1018,7 +1030,20 @@ export function apply(ctx, config) {
1018
1030
  published.add(row.team);
1019
1031
  }
1020
1032
  return (ref) => {
1033
+ // #334 qualified member routing is deliberately deferred; this window is only legacy bare inference.
1034
+ if (!legacyOverride && config.canvasLegacyBareMembers === false)
1035
+ return undefined;
1021
1036
  const hits = byHandle.get(ref);
1037
+ const collision = (hits?.length ?? 0) > 1 || (hits?.length === 1 && published.has(ref));
1038
+ if (count && legacyBareMember(ref)) {
1039
+ legacyInferenceResolutions++;
1040
+ if (collision) {
1041
+ legacyCollisionWarnings++;
1042
+ if (legacyCollisionRecent.length >= 16)
1043
+ legacyCollisionRecent.shift();
1044
+ legacyCollisionRecent.push({ member: ref, at: Date.now() });
1045
+ }
1046
+ }
1022
1047
  return hits?.length === 1 && !published.has(ref) ? hits[0] : undefined;
1023
1048
  };
1024
1049
  }
@@ -1029,6 +1054,30 @@ export function apply(ctx, config) {
1029
1054
  * handle-stored membership must not outlive the session's consent).
1030
1055
  * @param id - the local session id (still joined when this is read).
1031
1056
  */
1057
+ const canvasMigrationEpoch = new Date().toISOString();
1058
+ let legacyInferenceResolutions = 0, legacyCollisionWarnings = 0;
1059
+ const legacyCollisionRecent = [];
1060
+ function revokeCanvasMember(id, reason, name) {
1061
+ if (!joinedSessions.has(id) && !sessionNodes.has(id))
1062
+ return false;
1063
+ const bare = config.team + '/' + id8(id);
1064
+ const inferred = localHandleResolver(true, false, true)(bare) === id ? bare : undefined;
1065
+ const qualified = bare + '@' + ownCanvasFingerprint;
1066
+ const sameId8 = new Set([...joinedSessions.list(), ...sessionNodes.keys()].filter(local => id8(local) === id8(id)));
1067
+ const pinned = canvasStore.ownerKeyOf(qualified);
1068
+ const proof = sameId8.size === 1 && (pinned === undefined || pinned === encodePublicKey(createPublicKey(nodePrivateKey))) ? qualified : undefined;
1069
+ return canvasStore.revokeLocal(id, proof, inferred, reason, name);
1070
+ }
1071
+ function canvasMigrationState() {
1072
+ const raw = canvasStore.list().flatMap(name => canvasStore.rawMembersOf(name).filter(legacyBareMember).map(member => ({ name, member })));
1073
+ const resolve = localHandleResolver(false, false);
1074
+ return { canvasLegacyBareMembers: config.canvasLegacyBareMembers !== false, epoch: canvasMigrationEpoch,
1075
+ legacyBareMembers: raw.length,
1076
+ legacyInferredMembers: raw.filter(r => canvasStore.membersOf(r.name).includes(r.member) && resolve(r.member) !== undefined).length,
1077
+ legacyInferenceResolutions, legacyCollisionWarnings, legacyCollisionRecent: [...legacyCollisionRecent],
1078
+ suspendedMembersCount: new Set(canvasStore.state().suspendedMembers.map(r => JSON.stringify([r.team, r.member]))).size,
1079
+ ...canvasStore.state() };
1080
+ }
1032
1081
  function ownCanvasHandleOf(id, includeArchived = false) {
1033
1082
  const handle = `${config.team}/${id8(id)}`;
1034
1083
  return localHandleResolver(includeArchived)(handle) === id ? handle : undefined;
@@ -1056,8 +1105,8 @@ export function apply(ctx, config) {
1056
1105
  * reading that depends on which peer cards are cached must not rewrite
1057
1106
  * the store (review of PR #151 P2-2).
1058
1107
  */
1059
- function canvasLocalMembersOf(name) {
1060
- const resolve = localHandleResolver();
1108
+ function canvasLocalMembersOf(name, count = false) {
1109
+ const resolve = localHandleResolver(false, count);
1061
1110
  return canvasStore.membersOf(name).map(id => (isRemoteMemberRef(id) ? resolve(id) ?? id : id));
1062
1111
  }
1063
1112
  /** The 8-char suffix of one agent's id. */
@@ -1108,7 +1157,7 @@ export function apply(ctx, config) {
1108
1157
  function canvasLiveAgent(name, skipId) {
1109
1158
  if (name === undefined)
1110
1159
  return undefined;
1111
- for (const id of canvasLocalMembersOf(name)) {
1160
+ for (const id of canvasLocalMembersOf(name, true)) {
1112
1161
  if (id === skipId)
1113
1162
  continue;
1114
1163
  const agent = sessionNodes.get(id);
@@ -1128,7 +1177,7 @@ export function apply(ctx, config) {
1128
1177
  if (name === undefined)
1129
1178
  return undefined;
1130
1179
  const isArchived = archivedSessionFilter();
1131
- return canvasLocalMembersOf(name).find(id => id !== skipId && joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
1180
+ return canvasLocalMembersOf(name, true).find(id => id !== skipId && joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
1132
1181
  }
1133
1182
  /**
1134
1183
  * Human-facing node facts for one session: the session title (fallback:
@@ -1593,7 +1642,7 @@ export function apply(ctx, config) {
1593
1642
  whenWebServerSettled((webServer) => {
1594
1643
  // gh#77 B1: the admin faces live in src/faces/admin.ts.
1595
1644
  registerAdminFaces(webServer, {
1596
- ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
1645
+ ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, revokeCanvasMember, canvasMigrationState, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
1597
1646
  indexUrl: import.meta.url, enforceRefused,
1598
1647
  get cardCache() { return cardCache; },
1599
1648
  get remoteRowsCache() { return remoteRowsCache; },
@@ -1655,11 +1704,13 @@ export function apply(ctx, config) {
1655
1704
  // MINIMAL discovery card.
1656
1705
  handler: async (req, res) => {
1657
1706
  const send = (named, body_) => {
1658
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body_) });
1707
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body_), 'Cache-Control': 'private, no-store' });
1659
1708
  res.end(body_);
1660
1709
  void named;
1661
1710
  };
1662
1711
  const claim = await verifyOriginClaim(req);
1712
+ // #312: signed discovery is a reduced face; it never requests a full-card callback.
1713
+ const discoveryOnly = new URL(req.url ?? '/', 'http://x').searchParams.get('discovery') === '1';
1663
1714
  // W3 (#18 half-side, 0.6.25): a VERIFIED envelope proves the
1664
1715
  // caller holds a real node key — it does not prove a relationship
1665
1716
  // with THIS host. The full card (sessionTeams/rosters =
@@ -1670,14 +1721,17 @@ export function apply(ctx, config) {
1670
1721
  // Interaction with the OriginClaim narrowing: the caller is a
1671
1722
  // bare zone; linkage judges that zone face against our canvas
1672
1723
  // remote refs, joined handles, and the caller card's peers.
1673
- const named = claim.ok && callerLinkedToThisHost(claim.caller, claim.ok ? claim.callerUrl : '', claim.ok ? claim.card : undefined);
1724
+ const linkedCaller = claim.ok && callerLinkedToThisHost(claim.caller, claim.callerUrl, claim.card);
1725
+ const showPeers = claim.ok && (configuredSeedCaller(claim.callerUrl) || linkedCaller);
1726
+ const named = !discoveryOnly && linkedCaller;
1674
1727
  // gh#7 G1 (review-seat R2 ruling): the card serves two faces.
1675
1728
  // A NAMED caller — ?caller_session= or the X-A2A-Caller header in
1676
1729
  // the zone or zone/id8 shape — gets the FULL card. An anonymous
1677
1730
  // fetcher (or an invalid shape, fail-closed) gets the MINIMAL
1678
- // card: name/team/session/capabilities/records/peers/signature
1679
- // stay (discovery, gossip, delegation, capability negotiation
1680
- // unbroken), while sessionTeams/teamMemberships/lanIp are
1731
+ // card: name/team/session/capabilities/records/signature stay.
1732
+ // #312/#324 F1: peers requires a verified caller that we explicitly seed
1733
+ // or already link; unrelated self-signed strangers get no topology.
1734
+ // Meanwhile sessionTeams/teamMemberships/lanIp are
1681
1735
  // omitted — session membership and rosters are collaboration
1682
1736
  // facts, not discovery facts. Named determination does NOT
1683
1737
  // require a shared team: demanding roster symmetry before the
@@ -1733,7 +1787,8 @@ export function apply(ctx, config) {
1733
1787
  const memberships = teamMemberships.list().flatMap(entry => entry.teams.map(team => ({ node: `${config.team}/${id8(entry.session)}`, team })));
1734
1788
  const body = JSON.stringify({
1735
1789
  ...currentCard,
1736
- peers: peerStore.list(),
1790
+ // #324 F1: possession of an arbitrary self-signed identity is not a local relationship.
1791
+ ...(showPeers ? { peers: peerStore.list() } : {}),
1737
1792
  ...(named && sessionTeams.length > 0 ? { sessionTeams } : {}),
1738
1793
  ...(named && memberships.length > 0 ? { teamMemberships: memberships } : {}),
1739
1794
  // gh#41 fleet planning (owner ruling: any peer composes and
@@ -2182,8 +2237,21 @@ export function apply(ctx, config) {
2182
2237
  boundMissRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
2183
2238
  boundMissRecent.length = Math.min(boundMissRecent.length, 16);
2184
2239
  };
2185
- function verifyOriginClaim(req, options) {
2186
- return verifyOriginClaimOf(req, {
2240
+ /** Our configured seeds anchor origin claims; mounted claims require exact mount matching. */
2241
+ function configuredSeedCaller(callerUrl) {
2242
+ return config.peers.some(peer => {
2243
+ try {
2244
+ return isRootOriginUrl(callerUrl) ? new URL(peer).origin === new URL(callerUrl).origin : sameMountedUrl(peer, callerUrl);
2245
+ }
2246
+ catch {
2247
+ return false;
2248
+ }
2249
+ });
2250
+ }
2251
+ // #312: topology-only callbacks must never replace a full session/roster card cache entry.
2252
+ const signedDiscoveryPeers = new Map();
2253
+ async function verifyOriginClaim(req, options) {
2254
+ const claim = await verifyOriginClaimOf(req, {
2187
2255
  cardCache,
2188
2256
  fetch: fetchImpl,
2189
2257
  audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
@@ -2195,6 +2263,32 @@ export function apply(ctx, config) {
2195
2263
  ...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
2196
2264
  ...(options?.rawBody !== undefined ? { rawBody: options.rawBody } : {}),
2197
2265
  });
2266
+ // #312: recover bilateral seed evidence from a signed, reduction-only card,
2267
+ // never from a caller-selected unseeded URL and never recursively.
2268
+ const discovery = new URL(req.url ?? '/', 'http://x').searchParams.get('discovery') === '1';
2269
+ if (!discovery && claim.ok && claim.cli !== true && claim.card.peers === undefined && configuredSeedCaller(claim.callerUrl)) {
2270
+ const discoveryKey = new URL(claim.callerUrl).href.replace(/\/$/, '');
2271
+ const known = signedDiscoveryPeers.get(discoveryKey);
2272
+ if (known !== undefined && known.key === claim.card.publicKey && known.expiresAt > Date.now() && Date.now() - known.at < config.cardCacheTtlMs)
2273
+ return { ...claim, card: { ...claim.card, peers: known.peers } };
2274
+ const target = claim.callerUrl.replace(/\/$/, '') + '/.well-known/agent-card.json?discovery=1';
2275
+ try {
2276
+ const response = await peerFetch(target, { method: 'GET', headers: peerRequestHeaders(target, 'GET'), redirect: 'manual', signal: AbortSignal.timeout(5_000) });
2277
+ if (response.ok) {
2278
+ const verified = verifyCard(JSON.parse(await response.text()), Date.now());
2279
+ if (verified.ok && verified.card.publicKey === claim.card.publicKey && verified.card.team === claim.card.team) {
2280
+ if (verified.card.peers !== undefined) {
2281
+ signedDiscoveryPeers.set(discoveryKey, { at: Date.now(), expiresAt: verified.card.expiresAt, key: verified.card.publicKey, peers: verified.card.peers });
2282
+ if (signedDiscoveryPeers.size > 64)
2283
+ signedDiscoveryPeers.delete(signedDiscoveryPeers.keys().next().value);
2284
+ }
2285
+ return { ...claim, card: { ...claim.card, ...(verified.card.peers !== undefined ? { peers: verified.card.peers } : {}) } };
2286
+ }
2287
+ }
2288
+ }
2289
+ catch { /* unavailable refresh keeps bilateral-link checks closed */ }
2290
+ }
2291
+ return claim;
2198
2292
  }
2199
2293
  /**
2200
2294
  * gh#133: the deposited card a CLI claim is judged by, or undefined when
@@ -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