@nelsonlongxiang/dsh-open-a2a-net 0.6.32 → 0.6.34

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 (64) hide show
  1. package/README.md +12 -3
  2. package/README_zh.md +12 -3
  3. package/assets/nexusDist/assets/index-D2BGWOeU.js +9 -0
  4. package/assets/nexusDist/assets/index-z33lHtLw.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +5 -2
  7. package/lib/a2a-client.js +9 -2
  8. package/lib/auth-observer.js +1 -1
  9. package/lib/canvas-store.js +50 -6
  10. package/lib/card.js +2 -1
  11. package/lib/cli-home.js +44 -0
  12. package/lib/client/A2aControl.js +1 -1
  13. package/lib/client.js +65 -65
  14. package/lib/client.js.map +1 -1
  15. package/lib/direct-forward.js +61 -0
  16. package/lib/faces/admin.js +84 -9
  17. package/lib/faces/wire.js +57 -16
  18. package/lib/index.js +118 -67
  19. package/lib/layout-store.js +3 -2
  20. package/lib/outbound-scope.js +25 -0
  21. package/lib/queue-guard.js +37 -0
  22. package/lib/roster-op.js +104 -0
  23. package/lib/skill/references/cli.md +6 -5
  24. package/lib/skill/references/pitfalls.md +1 -4
  25. package/lib/skill/references/router.md +1 -1
  26. package/lib/task-auth.js +52 -3
  27. package/lib/task-ledger.js +4 -1
  28. package/lib/team-store.js +49 -1
  29. package/lib/tools/info.js +19 -3
  30. package/lib/types/a2a-client.d.ts +1 -1
  31. package/lib/types/auth-observer.d.ts +2 -2
  32. package/lib/types/canvas-store.d.ts +14 -1
  33. package/lib/types/cli-home.d.ts +24 -0
  34. package/lib/types/client/A2aControl.d.ts +1 -1
  35. package/lib/types/direct-forward.d.ts +32 -0
  36. package/lib/types/faces/admin.d.ts +9 -1
  37. package/lib/types/faces/wire.d.ts +13 -4
  38. package/lib/types/index.d.ts +4 -0
  39. package/lib/types/outbound-scope.d.ts +21 -0
  40. package/lib/types/queue-guard.d.ts +32 -0
  41. package/lib/types/roster-op.d.ts +21 -0
  42. package/lib/types/runtime.d.ts +1 -1
  43. package/lib/types/task-auth.d.ts +17 -2
  44. package/lib/types/task-ledger.d.ts +6 -1
  45. package/lib/types/team-store.d.ts +7 -0
  46. package/lib/types/types.d.ts +8 -0
  47. package/nexus-stage/src/canvas-ops.ts +2 -1
  48. package/nexus-stage/src/cli-home-picker.ts +42 -0
  49. package/nexus-stage/src/layout-doc.ts +6 -4
  50. package/nexus-stage/src/main.ts +36 -4
  51. package/nexus-stage/src/planning-card.ts +61 -0
  52. package/nexus-stage/src/planning-frame-hue.ts +13 -0
  53. package/nexus-stage/src/planning-legend.ts +1 -0
  54. package/nexus-stage/src/planning-menus.ts +63 -12
  55. package/nexus-stage/src/planning-roster.ts +15 -0
  56. package/nexus-stage/src/planning-types.ts +1 -1
  57. package/nexus-stage/src/planning-view.ts +43 -59
  58. package/nexus-stage/src/planning.css +19 -0
  59. package/nexus-stage/src/workbench.css +4 -0
  60. package/nexus-stage/src/workbench.ts +24 -5
  61. package/nexus-stage/src/world.ts +5 -1
  62. package/package.json +4 -2
  63. package/assets/nexusDist/assets/index-D5H8R8de.js +0 -9
  64. package/assets/nexusDist/assets/index-D9GYZyGZ.css +0 -1
package/lib/index.js CHANGED
@@ -28,12 +28,15 @@ export function resolveCallerLabel(input) {
28
28
  }
29
29
  import { signCard, verifyCard } from "./card.js";
30
30
  import { GroupStore } from "./group-store.js";
31
- import { CanvasStore, isRemoteMemberRef } from "./canvas-store.js";
31
+ import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
32
32
  import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
33
33
  import { LayoutStore } from "./layout-store.js";
34
34
  import { JoinedSessions } from "./joined-store.js";
35
35
  import { ExposureStore } from "./exposure-store.js";
36
36
  import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
37
+ import { cliHomeChoices, decideCliHome } from "./cli-home.js";
38
+ import { outboundScope } from "./outbound-scope.js";
39
+ import { createDirectForward } from "./direct-forward.js";
37
40
  import { TeamMembershipStore } from "./team-store.js";
38
41
  import { PeerStore } from "./peer-store.js";
39
42
  import { SelfReferralFilter } from "./self-suppress.js";
@@ -42,11 +45,12 @@ import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
42
45
  import { runReceiptLadder } from "./receipt-ladder.js";
43
46
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
44
47
  import { createFloodGuard } from "./flood-guard.js";
48
+ import { createQueueGuard, DEFAULT_TARGET_QUEUE_CAP, queueFullRefusal, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
45
49
  import { resolveZone } from "./zone.js";
46
50
  import { registerA2aNetworkSkill } from "./skill-registration.js";
47
51
  import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
48
52
  import { AuthObserver } from "./auth-observer.js";
49
- import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission } from "./task-auth.js";
53
+ import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
50
54
  import s from '@deepseek-ai/schemastery';
51
55
  import { MessageId } from '@deepseek-ai/dsh-llm';
52
56
  import { SessionId } from '@deepseek-ai/dsh-session';
@@ -83,6 +87,7 @@ export const Config = s.object({
83
87
  settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
84
88
  announce: s.boolean().default(false),
85
89
  isolated: s.boolean().default(false),
90
+ forwardDirect: s.boolean().default(false),
86
91
  agentName: s.string().default('DeepSeek Harness A2A node'),
87
92
  peers: s.array(s.string()).default([]),
88
93
  delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
@@ -179,6 +184,8 @@ export const Config = s.object({
179
184
  * t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
180
185
  */
181
186
  asyncNudgeDelayMs: s.number().default(120_000),
187
+ /** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
188
+ targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
182
189
  stateColdRowsTtlMs: s.number().default(5_000),
183
190
  cardCacheTtlMs: s.number().default(60_000),
184
191
  cardCacheNegativeTtlMs: s.number().default(30_000),
@@ -799,6 +806,8 @@ export function apply(ctx, config) {
799
806
  // refused-but-idempotent, same-key different-payload answers conflict.
800
807
  const idempotencyStore = new IdempotencyStore(join(home, 'a2a', 'idempotency.json'));
801
808
  const refuseIfFlooded = createFloodGuard(idempotencyStore, recordActivity);
809
+ const ownTask = (taskId) => taskLedger.list().some(r => r.taskId === taskId) || taskLedger.archive().some(r => r.taskId === taskId);
810
+ const refuseIfQueueFull = createQueueGuard(() => taskLedger.list(), () => config.targetQueueCap, ownTask, idempotencyStore, recordActivity);
802
811
  // Unmasked-identity suppression: gossip mirrors peer lists back and forth,
803
812
  // so a URL whose fetch just proved it serves this node — or an already
804
813
  // tracked node (an alias spelling) — keeps coming home as an inbound
@@ -1639,7 +1648,7 @@ export function apply(ctx, config) {
1639
1648
  whenWebServerSettled((webServer) => {
1640
1649
  // gh#77 B1: the admin faces live in src/faces/admin.ts.
1641
1650
  registerAdminFaces(webServer, {
1642
- ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, 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,
1651
+ 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,
1643
1652
  indexUrl: import.meta.url,
1644
1653
  get cardCache() { return cardCache; },
1645
1654
  get remoteRowsCache() { return remoteRowsCache; },
@@ -1771,7 +1780,7 @@ export function apply(ctx, config) {
1771
1780
  // deposit stays as an OFFLINE row (tombstone) so the board keeps
1772
1781
  // the member card grayed-but-present and canvas refs never
1773
1782
  // dangle silently; re-publish (same handle) un-tombstones.
1774
- ...cliDeposits.directoryRows().map(row => exposureRow(row.team, { name: row.name, description: row.description })),
1783
+ ...cliDeposits.directoryRows().map(row => exposureRow(row.team, { name: row.name, description: row.description, offline: row.offline })),
1775
1784
  ];
1776
1785
  const memberships = teamMemberships.list().flatMap(entry => entry.teams.map(team => ({ node: `${config.team}/${id8(entry.session)}`, team })));
1777
1786
  const body = JSON.stringify({
@@ -2043,7 +2052,10 @@ export function apply(ctx, config) {
2043
2052
  // without a log.
2044
2053
  void runReceiptLadder({
2045
2054
  deliver: async (team) => {
2046
- const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true);
2055
+ // gh#217: across hosts the caller admits a session face it linked, never this host's process label; a
2056
+ // task for the bare process team keeps the label, the only host face the settlement judge accepts (N1).
2057
+ const peerCaller = row?.auth?.target.includes('/') === true ? sessionTeamOf(target) : undefined;
2058
+ const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true, undefined, peerCaller);
2047
2059
  // gh#141: the caller lane pays this delivery's inbound row across hosts (a
2048
2060
  // same-host receipt already settled it in-process), unless the caller
2049
2061
  // refused it. The owner tier is no payment here — but an owner mailbox
@@ -2136,7 +2148,7 @@ export function apply(ctx, config) {
2136
2148
  }
2137
2149
  return { ok: false, error: 'No live DSH agent is available to accept this message.' };
2138
2150
  }
2139
- return { ok: false, error: wake.kind === 'no-face' ? noWakeFaceText(team) : wake.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team) };
2151
+ return { ok: false, ...(wake.kind === 'no-match' ? { localMiss: true } : {}), error: wake.kind === 'no-face' ? noWakeFaceText(team) : wake.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team) };
2140
2152
  }
2141
2153
  /**
2142
2154
  * Steer one direct route request into one named agent and resolve with its
@@ -2379,6 +2391,7 @@ export function apply(ctx, config) {
2379
2391
  },
2380
2392
  liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
2381
2393
  cliDeposited: handle => cliDeposits.get(handle) !== undefined,
2394
+ cliHomeOf: handle => canvasStore.cliHomeOf(handle),
2382
2395
  };
2383
2396
  /** W1/W2 shared-team admission (see sameZoneAdmission in src/task-auth.ts). */
2384
2397
  function sameZoneSharedTeamRefusal(caller, targetTeam) {
@@ -2390,8 +2403,8 @@ export function apply(ctx, config) {
2390
2403
  return verdict === undefined ? undefined : refusalOf(verdict);
2391
2404
  }
2392
2405
  /** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
2393
- function a2aCallerAdmission(caller, targetTeam) {
2394
- return callerAdmission(admissionRecords, caller, targetTeam);
2406
+ function a2aCallerAdmission(caller, targetTeam, cliProof) {
2407
+ return callerAdmission(admissionRecords, caller, targetTeam, cliProof);
2395
2408
  }
2396
2409
  function a2aCallerRefusal(caller, targetTeam) {
2397
2410
  return refusalOf(a2aCallerAdmission(caller, targetTeam));
@@ -2415,6 +2428,7 @@ export function apply(ctx, config) {
2415
2428
  // Local members by their handle (design §2.3), remote refs as they are stored.
2416
2429
  canvas: canvasStore.list().map(name => [canvasTeamOf(name), canvasStore.membersOf(name).map(m => (m.includes('/') ? m : handle(m)))]),
2417
2430
  cli: cliDeposits.list().map(row => row.handle),
2431
+ cliHome: canvasStore.pinnedCliHomes(),
2418
2432
  links: config.peers.map(peer => { try {
2419
2433
  return new URL(peer).origin;
2420
2434
  }
@@ -2440,7 +2454,7 @@ export function apply(ctx, config) {
2440
2454
  cause,
2441
2455
  rows: () => taskLedger.list(),
2442
2456
  // A local-candidate record (#139) is judged as the local candidate admits; every other one by the wire face.
2443
- judge: (caller, target, ingress) => (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
2457
+ judge: (caller, target, ingress, auth) => auth.admission_rule === 'remote-ref-cli' ? recheckRemoteCliAuthorization(admissionRecords, auth) : (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
2444
2458
  });
2445
2459
  if (entry === undefined)
2446
2460
  return;
@@ -2531,10 +2545,13 @@ export function apply(ctx, config) {
2531
2545
  config,
2532
2546
  cliDeposits,
2533
2547
  canvasStore,
2548
+ canvasNameReserved: name => canvasNameReserved(name, config.team, ownerAllowlist, teamMemberships),
2534
2549
  sessionNodes,
2535
2550
  joinedSessions,
2536
2551
  idempotencyStore,
2537
2552
  verifyOriginClaim,
2553
+ remoteCliProof,
2554
+ forwardDirect: request => forwardDirect(request),
2538
2555
  callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
2539
2556
  recordActivity,
2540
2557
  bumpLegacyInbound: () => { legacyInboundCount += 1; },
@@ -2547,6 +2564,7 @@ export function apply(ctx, config) {
2547
2564
  readSessionLogTail,
2548
2565
  taskLedger,
2549
2566
  refuseIfFlooded,
2567
+ refuseIfQueueFull,
2550
2568
  settleAndAnnounce,
2551
2569
  touchWakeDemand: () => { lastWakeDemandAt = Date.now(); },
2552
2570
  session,
@@ -2872,6 +2890,55 @@ export function apply(ctx, config) {
2872
2890
  }
2873
2891
  return { link: linked ? 'linked' : 'stranger', verified };
2874
2892
  }
2893
+ async function freshCliHomeCards() {
2894
+ return await mapBounded(peerStore.list(), 6, async (url) => {
2895
+ // A failed read cannot prove a tracked host is unlinked (especially after a cold boot).
2896
+ // Conservatively leave automatic ownership unknown; explicit fresh choices still work.
2897
+ const card = await fetchPeerCard(url).catch(() => undefined);
2898
+ if (card !== undefined)
2899
+ cardCache.set(url, { at: Date.now(), card });
2900
+ return { url, card, linked: card === undefined || peerLinkState(url).link === 'linked' };
2901
+ });
2902
+ }
2903
+ async function cliHomeForAdd(handle, requested) {
2904
+ const localHousehold = () => cliDeposits.directoryRows().some(row => row.team === handle);
2905
+ if (localHousehold())
2906
+ return decideCliHome(handle, requested, [], true);
2907
+ return decideCliHome(handle, requested, CLI_HANDLE_RE.test(handle) ? await freshCliHomeCards() : [], localHousehold());
2908
+ }
2909
+ async function cliHomeOptions(handle) {
2910
+ const locallyHeld = () => cliDeposits.directoryRows().some(row => row.team === handle);
2911
+ if (locallyHeld())
2912
+ return cliHomeChoices(handle, [], true);
2913
+ return cliHomeChoices(handle, await freshCliHomeCards(), locallyHeld());
2914
+ }
2915
+ /** Remote CLI admission never trusts a retained stale cache entry after a failed home read. */
2916
+ async function remoteCliProof(handle, claim) {
2917
+ const home = canvasStore.cliHomeOf(handle);
2918
+ if (home === undefined || !claim.bound || claim.cli === true || !isRootOriginUrl(claim.callerUrl))
2919
+ return undefined;
2920
+ if (new URL(claim.callerUrl).origin !== home)
2921
+ return undefined;
2922
+ for (const url of peerStore.list()) {
2923
+ try {
2924
+ if (new URL(url).origin !== home)
2925
+ continue;
2926
+ }
2927
+ catch {
2928
+ continue;
2929
+ }
2930
+ const card = await fetchPeerCard(url);
2931
+ if (card === undefined || card.publicKey !== claim.card.publicKey)
2932
+ continue;
2933
+ cardCache.set(url, { at: Date.now(), card });
2934
+ if (peerLinkState(url).link !== 'linked')
2935
+ continue;
2936
+ const row = card.sessionTeams?.find(item => item.team === handle);
2937
+ if (row !== undefined)
2938
+ return { origin: home, viaUrl: claim.callerUrl, bound: true, online: row.offline === false };
2939
+ }
2940
+ return { origin: home, viaUrl: claim.callerUrl, bound: true, online: false };
2941
+ }
2875
2942
  function cachedCardFetch(url) {
2876
2943
  const hit = cardCache.get(url);
2877
2944
  const now = Date.now();
@@ -3213,6 +3280,22 @@ export function apply(ctx, config) {
3213
3280
  * caller label — the "能发即能收" invariant: whatever it addresses can
3214
3281
  * answer it with one route.
3215
3282
  */
3283
+ const forwardDirect = createDirectForward({
3284
+ enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
3285
+ memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
3286
+ scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
3287
+ candidates: async (team, failures, caller) => { const fetch = memoizedCardFetch(); return { urls: await directoryPeerCandidates(fetch, team, failures, { id: caller, handle: caller }), card: fetch }; },
3288
+ dispatch: (url, request, guard) => dispatchPeerCandidate(url, request.team, request.message, request.contextId, new AbortController().signal, request.caller, request.noWait, request.taskId, request.caller, guard),
3289
+ ledger: taskLedger, activity: (team, peer, ok) => recordActivity('fwd', team, peer, ok),
3290
+ });
3291
+ const senderScopeRecords = {
3292
+ zone: config.team, id8, teamsOf: id => teamMemberships.teamsOf(id), membersOf: team => teamMemberships.membersOf(team),
3293
+ canvasNames: () => canvasStore.list(), canvasMembers: name => canvasStore.membersOf(name), canvasTeam: canvasTeamOf,
3294
+ peerOrgs: function* () { for (const hit of cardCache.values())
3295
+ if (hit.card?.canvasOrg)
3296
+ yield hit.card.canvasOrg; },
3297
+ warmPeers: () => mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch),
3298
+ };
3216
3299
  async function routeTrunk(args, agent, signal, hostCaller) {
3217
3300
  // Fix ③ (S4 audit): host-side calls carry no agent identity — every one
3218
3301
  // lands in the team audit ring, and the owner can flip the gate from
@@ -3248,61 +3331,11 @@ export function apply(ctx, config) {
3248
3331
  }
3249
3332
  catch { /* no initiator in this fiber */ }
3250
3333
  if (!initiator) {
3251
- const callerTeams = teamMemberships.teamsOf(scopeCaller);
3252
- const team = String(args.team ?? '').trim();
3253
- // gh#71: the SHARED-CANVAS basis and the warm cache's peer-org
3254
- // basis (an org entry naming BOTH the caller and the target
3255
- // handle) count as declared here too — the same link the
3256
- // admission core honors on the receiving side; without this the
3257
- // tool face refused routes the canvas itself had authorized.
3258
- // Canvas members mix two shapes: LOCAL sessions sit by session
3259
- // id, cross-host members by zone/id8 handle — match either.
3260
3334
  const callerHandle = `${config.team}/${id8(scopeCaller)}`;
3261
3335
  scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
3262
- // gh#45 末修 (66abc2cf defect): a CANVAS GROUP NAME target
3263
- // (`<team>/canvas/<name>`) never matches member handles — a
3264
- // whole-group dispatch from a group member was refused. The
3265
- // group's own team handle (canvasTeamOf(name)) is the match
3266
- // (the caller sits in the very group being addressed).
3267
- const sharedCanvas = canvasStore.list().some(name => {
3268
- const members = canvasStore.membersOf(name);
3269
- const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
3270
- return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
3271
- });
3272
- // A direct group route must not require a prior directory call. Fetch
3273
- // only the same eligible peers as resolution; then apply the
3274
- // unchanged sender/receiver admission rules to their verified cards.
3275
- if (team.includes('/canvas/') && !sharedCanvas && !callerTeams.includes(team))
3276
- await mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch);
3277
- let orgShared = false;
3278
- for (const cached of cardCache.values()) {
3279
- const org = cached.card?.canvasOrg;
3280
- if (!Array.isArray(org))
3281
- continue;
3282
- for (const e of org) {
3283
- if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
3284
- continue;
3285
- const inOrg = e.members.includes(scopeCaller) || e.members.includes(callerHandle);
3286
- if (inOrg && (e.members.includes(team) || e.team === team)) {
3287
- orgShared = true;
3288
- break;
3289
- }
3290
- }
3291
- if (orgShared)
3292
- break;
3293
- }
3294
- if (callerTeams.length === 0 && !sharedCanvas && !orgShared) {
3295
- return localRoute({
3296
- ok: false,
3297
- error: `team-scoped routing is on and this session (${id8(scopeCaller)}) has declared no team membership — a teamless node has no network. Declare one with a2a_team_join (subject to the owner's teamJoinAllowlist)`,
3298
- });
3299
- }
3300
- if (!callerTeams.includes(team) && !sharedCanvas && !orgShared) {
3301
- return localRoute({
3302
- ok: false,
3303
- error: `team-scoped routing is on: routing to "${team}" requires declared membership. This session's teams: ${callerTeams.join(', ')}. a2a_team_join can declare it (subject to the owner's teamJoinAllowlist)`,
3304
- });
3305
- }
3336
+ const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
3337
+ if (!scope.ok)
3338
+ return localRoute(scope);
3306
3339
  }
3307
3340
  }
3308
3341
  let callerSession = resolveCallerLabel({
@@ -3465,6 +3498,12 @@ export function apply(ctx, config) {
3465
3498
  recordActivity('out', team, callerSession ?? 'local', false);
3466
3499
  return { ok: false, error: idRefusal, code: -32000 };
3467
3500
  }
3501
+ // gh#224: a full target queue refuses a same-host delivery as the wire face does (receipts, CLI mailboxes pass).
3502
+ const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, cliDeposits.get(team) !== undefined, ownTask);
3503
+ if (queueRefusal !== undefined) {
3504
+ recordActivity('out', team, callerSession ?? 'local', false);
3505
+ return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL };
3506
+ }
3468
3507
  const canvasName = parseCanvasTeamName(team);
3469
3508
  // Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
3470
3509
  // or a session label) and quick-dispatch BORROWS a live session's team
@@ -3734,9 +3773,9 @@ export function apply(ctx, config) {
3734
3773
  * exactly like same-host ones; older peers that ignore the flag simply
3735
3774
  * keep the blocking wait (graceful degradation).
3736
3775
  */
3737
- async function dispatchPeerCandidate(url, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller, callbackAddress) {
3776
+ async function dispatchPeerCandidate(url, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller, callbackAddress, guard) {
3738
3777
  const flight = beginRoute(team, url);
3739
- const result = await client.routeDirect(url, team, message, contextId, signal, callerSession, asyncMode, taskIdFromCaller, callbackAddress);
3778
+ const result = await client.routeDirect(url, team, message, contextId, signal, callerSession, asyncMode, taskIdFromCaller, callbackAddress, guard);
3740
3779
  endRoute(flight);
3741
3780
  recordActivity('out', team, url, result.ok);
3742
3781
  // F1 half-transaction compensation (t-mth5gmxy): the client's 15s budget
@@ -3772,7 +3811,18 @@ export function apply(ctx, config) {
3772
3811
  * receipt ladder can distinguish "delivered" from "no route" and its
3773
3812
  * owner escalation actually fires for cross-node callback addresses.
3774
3813
  */
3775
- async function dispatchAnywhere(team, message, callerSession, contextId, signal, asyncMode, taskId) {
3814
+ async function dispatchAnywhere(team, message, callerSession, contextId, signal, asyncMode, taskId, peerCaller) {
3815
+ // A local online household is an owner-established fact and receives locally.
3816
+ // Otherwise only the pinned home may receive; never fall back to directory claims.
3817
+ if (CLI_HANDLE_RE.test(team) && parseReceipt(message) !== null && cliDeposits.get(team) === undefined) {
3818
+ const home = canvasStore.cliHomeOf(team);
3819
+ if (home === undefined)
3820
+ throw new Error(`CLI receipt home is not pinned for ${team}`);
3821
+ const delivered = await dispatchPeerCandidate(home, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
3822
+ if (!delivered.ok)
3823
+ throw new Error(`CLI receipt to pinned home failed: ${delivered.error}`);
3824
+ return delivered;
3825
+ }
3776
3826
  const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
3777
3827
  // A background send books no outbound row: its birth facts have no row to go on.
3778
3828
  if (taskId !== undefined)
@@ -3789,7 +3839,7 @@ export function apply(ctx, config) {
3789
3839
  }
3790
3840
  let lastError = 'no candidate delivered';
3791
3841
  for (const candidate of candidates) {
3792
- const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, callerSession, asyncMode, taskId);
3842
+ const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
3793
3843
  if (result.ok)
3794
3844
  return result;
3795
3845
  lastError = result.error;
@@ -3839,7 +3889,8 @@ export function apply(ctx, config) {
3839
3889
  const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
3840
3890
  // Check after fetching all cards: a seeded peer publishing the same id8
3841
3891
  // makes the caller handle ambiguous, so do not send a canvas payload.
3842
- const resolveLocal = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
3892
+ const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
3893
+ const resolveLocal = sessions === undefined ? undefined : (ref) => cliDeposits.get(ref) !== undefined ? ref : sessions(ref);
3843
3894
  const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
3844
3895
  if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
3845
3896
  failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
@@ -20,6 +20,8 @@ export const SCALE_MAX = 3;
20
20
  /** Document shape caps: a layout taxonomy, not a database. */
21
21
  export const LAYOUT_NODE_CAP = 256;
22
22
  export const LAYOUT_FRAME_CAP = 96;
23
+ /** Canvas names stay ≤40; roster keys carry an extra seven-character namespace. */
24
+ const validFrame = (name) => name !== '' && !name.includes('/') && (name.startsWith('roster:') ? name.length > 7 && name.length <= 47 : name.length <= 40);
23
25
  function clampCoord(v) {
24
26
  const n = typeof v === 'number' ? v : Number.NaN;
25
27
  if (!Number.isFinite(n))
@@ -79,8 +81,7 @@ function clampDoc(raw) {
79
81
  const frames = {};
80
82
  if (rec.frames !== null && typeof rec.frames === 'object') {
81
83
  for (const [name, rect] of Object.entries(rec.frames)) {
82
- // Frame keys mirror canvas team names: same discipline (no '/', cap 40).
83
- if (typeof name !== 'string' || name === '' || name.includes('/') || name.length > 40)
84
+ if (!validFrame(name))
84
85
  continue;
85
86
  const r = clampRect(rect);
86
87
  if (r !== undefined)
@@ -0,0 +1,25 @@
1
+ export async function outboundScope(r, id, handle, team) {
2
+ const teams = r.teamsOf(id);
3
+ const sharedRoster = teams.some(roster => r.membersOf(roster).some(member => r.zone + '/' + r.id8(member) === team));
4
+ const sharedCanvas = r.canvasNames().some(name => {
5
+ const members = r.canvasMembers(name);
6
+ return (members.includes(id) || members.includes(handle)) && (members.includes(team) || r.canvasTeam(name) === team);
7
+ });
8
+ if (team.includes('/canvas/') && !sharedCanvas && !teams.includes(team))
9
+ await r.warmPeers();
10
+ let orgShared = false;
11
+ for (const org of r.peerOrgs())
12
+ for (const row of org) {
13
+ if (typeof row.team !== 'string' || row.team === '' || !Array.isArray(row.members))
14
+ continue;
15
+ if ((row.members.includes(id) || row.members.includes(handle)) && (row.members.includes(team) || row.team === team)) {
16
+ orgShared = true;
17
+ break;
18
+ }
19
+ }
20
+ if (teams.length === 0 && !sharedRoster && !sharedCanvas && !orgShared)
21
+ return { ok: false, error: `team-scoped routing is on and this session (${r.id8(id)}) has declared no team membership — a teamless node has no network. Declare one with a2a_team_join (subject to the owner's teamJoinAllowlist)` };
22
+ if (!teams.includes(team) && !sharedRoster && !sharedCanvas && !orgShared)
23
+ return { ok: false, error: `team-scoped routing is on: routing to "${team}" requires declared membership. This session's teams: ${teams.join(', ')}. a2a_team_join can declare it (subject to the owner's teamJoinAllowlist)` };
24
+ return { ok: true };
25
+ }
@@ -0,0 +1,37 @@
1
+ import { parseReceipt } from "./receipt.js";
2
+ export const WIRE_ERROR_QUEUE_FULL = -32009;
3
+ export const DEFAULT_TARGET_QUEUE_CAP = 3;
4
+ /** How many receipt-owing tasks this host holds pending for `team`. */
5
+ export function queuedFor(rows, team) {
6
+ return rows.filter(row => row.status === 'pending' && row.receiptExpected !== false &&
7
+ (row.direction === 'inbound' || row.peer === 'local') &&
8
+ (row.team === team || row.deliveredTo === team)).length;
9
+ }
10
+ /** The refusal text when `team`'s queue is full, else undefined. `cap <= 0` disables the guard. */
11
+ export function queueFullRefusal(rows, team, message, cap, mailbox, ownTask) {
12
+ // A config without the key (old patch layer, test harness) gets the shipped default, not "no cap".
13
+ const limit = typeof cap === 'number' && Number.isFinite(cap) ? cap : DEFAULT_TARGET_QUEUE_CAP;
14
+ if (limit <= 0 || mailbox)
15
+ return undefined;
16
+ const receipt = parseReceipt(message);
17
+ if (receipt !== null && receipt.taskId !== '' && ownTask(receipt.taskId))
18
+ return undefined;
19
+ const queued = queuedFor(rows, team);
20
+ if (queued < limit)
21
+ return undefined;
22
+ return `target-queue-full (gh#224): "${team}" already has ${String(queued)} task(s) waiting for a receipt (cap ${String(limit)}) — nothing was delivered. Wait for a receipt from it (a2a inbox / a2a_tasks), or send again later with a NEW task id (this one is recorded as failed); do not resend in a loop. A task that never gets a receipt turns dead after taskStaleTtlMs and stops counting.`;
23
+ }
24
+ /** Wire-face adapter in the flood guard's shape: a refused fresh key is FAILED, not stranded pending. */
25
+ export function createQueueGuard(rows, cap, ownTask, outcomes, recordActivity) {
26
+ return (caller, message, team, taskId, mailbox, res) => {
27
+ const error = queueFullRefusal(rows(), team, message, cap(), mailbox, ownTask);
28
+ if (error === undefined)
29
+ return false;
30
+ outcomes.recordOutcome(taskId, { status: 'failed', error });
31
+ recordActivity('in', team, caller, false);
32
+ const payload = JSON.stringify({ error, reason: 'target-queue-full', code: WIRE_ERROR_QUEUE_FULL, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED' });
33
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
34
+ res.end(payload);
35
+ return true;
36
+ };
37
+ }
@@ -0,0 +1,104 @@
1
+ /** Owner-controlled roster mutations from the planning board. */
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { applyOwnerSettings } from "./owner-settings.js";
5
+ import { TEAM_NAME_MAX } from "./team-store.js";
6
+ /** Writes complete snapshots; on a failed second write, rolls the first back. */
7
+ export function rosterOp(d, raw) {
8
+ const b = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
9
+ const op = b.op;
10
+ const name = b.name;
11
+ const team = typeof name === 'string' ? d.zone + '/' + name : '';
12
+ const action = op === 'remove-member' || op === 'dissolve' ? 'leave' : 'join';
13
+ const actor = 'human:owner';
14
+ const audit = (id, ok, why) => d.audit(action, team, id, ok, why);
15
+ const reject = (error) => { audit(typeof b.id === 'string' ? b.id : actor, false, error); return { ok: false, error }; };
16
+ if (typeof name !== 'string' || name !== name.trim() || name.length < 1 || name.length > 40 || name.includes('/') || name.includes('*') || name.startsWith('roster:') || /^\d+$/.test(name) || /[\r\n]/.test(name) || team.length > TEAM_NAME_MAX)
17
+ return reject('name must be one exact local roster name (no slash or canvas path)');
18
+ if (op === 'create' && d.canvasExists(name))
19
+ return reject('a canvas team already uses this name');
20
+ if (op !== 'create' && op !== 'add-member' && op !== 'remove-member' && op !== 'dissolve')
21
+ return reject('invalid roster operation');
22
+ const current = d.store.membersOf(team);
23
+ const allow = [...d.allowlist()];
24
+ const allowed = allow.includes(team);
25
+ if (op === 'add-member' && !allowed)
26
+ return reject('roster name is no longer in the exact allowlist');
27
+ if (op !== 'create' && !allowed && current.length === 0)
28
+ return reject('no such roster team');
29
+ if (op === 'create' && (!Array.isArray(b.ids) || b.ids.length < 2 || b.ids.some(x => typeof x !== 'string')))
30
+ return reject('create needs at least two joined local session ids');
31
+ if ((op === 'add-member' || op === 'remove-member') && (typeof b.id !== 'string' || b.id === ''))
32
+ return reject('local session id is required');
33
+ const add = op === 'create' ? b.ids : op === 'add-member' ? [b.id] : [];
34
+ if (new Set(add).size !== add.length || add.some(id => id === '' || !d.joined(id)))
35
+ return reject('only distinct joined local sessions can join a roster');
36
+ const members = op === 'create' || op === 'add-member' ? [...new Set([...current, ...add])] : op === 'remove-member' ? current.filter(id => id !== b.id) : [];
37
+ const nextAllow = op === 'create' && !allowed ? [...allow, team] : op === 'dissolve' ? allow.filter(x => x !== team) : allow;
38
+ const patch = join(d.home, 'cordis.patch.yml');
39
+ let before;
40
+ if ((op === 'create' && !allowed) || (op === 'dissolve' && allowed)) {
41
+ try {
42
+ before = existsSync(patch) ? readFileSync(patch, 'utf8') : undefined;
43
+ }
44
+ catch (error) {
45
+ return reject('owner patch unavailable: ' + String(error));
46
+ }
47
+ }
48
+ const savePatch = (content) => {
49
+ mkdirSync(dirname(patch), { recursive: true });
50
+ const tmp = patch + '.' + String(process.pid) + '.tmp';
51
+ try {
52
+ writeFileSync(tmp, content, { mode: 0o600 });
53
+ renameSync(tmp, patch);
54
+ }
55
+ catch (error) {
56
+ try {
57
+ unlinkSync(tmp);
58
+ }
59
+ catch { /* original remains */ }
60
+ ;
61
+ throw error;
62
+ }
63
+ };
64
+ let patchWritten = false;
65
+ try {
66
+ if (op === 'create' && !allowed) {
67
+ savePatch(applyOwnerSettings(before ?? '', { teamJoinAllowlist: nextAllow }).yaml);
68
+ patchWritten = true;
69
+ }
70
+ const done = d.store.replaceTeam(team, members);
71
+ if (!done.ok)
72
+ throw new Error(done.error);
73
+ if (op === 'dissolve' && allowed)
74
+ savePatch(applyOwnerSettings(before ?? '', { teamJoinAllowlist: nextAllow }).yaml);
75
+ d.setAllowlist(nextAllow);
76
+ }
77
+ catch (error) {
78
+ try {
79
+ if (patchWritten) {
80
+ if (before === undefined) {
81
+ if (existsSync(patch))
82
+ unlinkSync(patch);
83
+ }
84
+ else
85
+ savePatch(before);
86
+ }
87
+ if (op === 'dissolve') {
88
+ const undone = d.store.replaceTeam(team, current);
89
+ if (!undone.ok)
90
+ throw new Error(undone.error);
91
+ }
92
+ }
93
+ catch (rollback) {
94
+ return reject(String(error) + '; rollback failed: ' + String(rollback));
95
+ }
96
+ return reject(String(error));
97
+ }
98
+ const delta = op === 'dissolve' ? current : op === 'remove-member' ? current.filter(id => !members.includes(id)) : members.filter(id => !current.includes(id));
99
+ if ((op === 'create' && !allowed) || (op === 'dissolve' && allowed))
100
+ audit(actor, true, op === 'create' ? 'created roster name' : 'dissolved roster name');
101
+ for (const id of delta)
102
+ audit(id, true, 'by human:owner');
103
+ return { ok: true, team, members };
104
+ }
@@ -5,7 +5,7 @@ agent harness — joins the network through the `a2a-net` command (bin of
5
5
  `@nelsonlongxiang/dsh-open-a2a-net`, 0.6.27+). It has its own key, deposits
6
6
  itself on ONE home host, and receives through a mailbox; nothing wakes.
7
7
  `a2a-net help` prints the command map, `a2a-net tools` the alignment with the
8
- ten model tools.
8
+ model tools.
9
9
 
10
10
  ## Identity and home host
11
11
 
@@ -111,10 +111,11 @@ ten model tools.
111
111
  - Reusing an id for a different message, or switching `--sync` on or off,
112
112
  is refused (exit 1): drop `--task` to send it as a new task.
113
113
  - `report` takes `--task` the same way.
114
- - Direct delivery does not relay: only teams on the home host are reachable.
115
- A team on another host answers "No live DSH session node accepts team".
116
- - Admission: a deposited handle is a node fact of its home host (N3). It
117
- carries no roster or canvas membership of its own.
114
+ - Home-host forwarding is opt-in: `forwardDirect: true` forwards one hop only after a local target miss. H must not be isolated, the CLI must be online and sign a bound envelope, the inbound request must have no Guard, and the target must pass H’s sender scope.
115
+ - Both operators establish links: at A, put the CLI and B receiving member handles in an operator canvas; at B, link the CLI handle and pin its home to A. A remote canvas target requires A to directly link every advertised receiving member; a peer-org claim alone is insufficient for the return receipt.
116
+ - B signs its auto-receipt as the member that did the work. Its destination prefers an online CLI household on that host (an owner-controlled fact); otherwise it returns only to the pinned home, never another directory advertiser. In the normal single-home setup it settles A’s single forwarded outbound row on-chain under `enforce` and enters the CLI mailbox (`a2a-net inbox`). `--sync` returns B’s final reply. A 15s transport-budget expiry is `-32005`; reconcile by task id rather than resend blindly.
117
+ - With forwarding off or any prerequisite missing, the original "No live DSH session node accepts team" error includes the reason it was not forwarded. Receiving hosts never forward the forwarded request again.
118
+ - Admission: a deposited handle is a node fact of its home host (N3); that fact alone grants no remote scope or admission. Operator canvas membership and B’s home pin are still required. Global self-description is never a link.
118
119
  - `a2a-net report <message…>` is a one-way report to the host's process team.
119
120
  - A message that starts with `-` goes after `--`. Flag values never become
120
121
  part of the message.
@@ -20,10 +20,7 @@
20
20
  The `a2a-net` CLI (0.6.27+) is the supported exception: a DEPOSITED handle
21
21
  is routable on its home host and receives into its mailbox
22
22
  ([cli.md](cli.md)).
23
- - **Direct delivery does not relay.** `POST /a2a/direct` only serves teams on
24
- the host that receives it; a team on another host answers "No live DSH
25
- session node accepts team". `a2a_route` resolves peers and forwards;
26
- direct callers (the CLI included) must target their home host's teams.
23
+ - **"No live DSH session node accepts team" may name a forwarding prerequisite.** Direct delivery remains local by default. `forwardDirect: true` permits one hop only for an online, bound-signed local CLI, on a non-isolated home, with no inbound Guard and a target inside sender scope. A must link B’s receiving members; B must link the CLI and pin A as its home. Remote canvas org alone cannot authorize the receipt return path. Read the appended "not forwarded" reason; do not treat a missing link, expired deposit or already-forwarded request as a transport outage. The target host never forwards again.
27
24
  - A sync route to a remote peer rides a 15s transport budget (a target on
28
25
  this host gets 180s) — both are shorter than many model turns. For
29
26
  anything that runs long, prefer `async: true` and the receipt contract.
@@ -87,7 +87,7 @@
87
87
  ### 已知陷阱
88
88
  - held 状态超过 72 小时就判为不通过:观察期里要及时 release 或撤回。
89
89
  - a2a CLI 户口 4 小时过期,发送前先执行 `publish`。
90
- - `verify-probe.mjs` 会清空固定的 TEMP 目录 `dsh-a2a-submission`,跑之前确认里面没有别人的东西。
90
+ - `verify-probe.mjs` 每轮把提交树导出到一个新建的唯一目录 `<TEMP>/dsh-a2a-submission-XXXXXX`,并把路径写进 `verify/probe-summary.json`;`verify-recursive.mjs` 只认这个路径,不再读、也不再清空旧的固定目录 `dsh-a2a-submission`(#213)。这些导出目录不会自动删除,需要时手动清理自己的。
91
91
 
92
92
  ### 当前重点
93
93
  - 跑通第一条真实的人类事件。需要三件事: