@nelsonlongxiang/dsh-open-a2a-net 0.6.38 → 0.6.40

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 (48) hide show
  1. package/README.md +342 -335
  2. package/README_zh.md +243 -236
  3. package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
  4. package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
  5. package/cli/a2a.mjs +20 -5
  6. package/cli/serve.mjs +132 -0
  7. package/cordis.patch.yml +21 -21
  8. package/lib/a2a-client.js +1 -0
  9. package/lib/canvas-id.js +27 -0
  10. package/lib/canvas-store.js +7 -1
  11. package/lib/cli-deposit.js +2 -2
  12. package/lib/client.js +63 -63
  13. package/lib/config.js +125 -0
  14. package/lib/direct-forward.js +64 -18
  15. package/lib/faces/admin.js +4 -1
  16. package/lib/faces/wire.js +77 -7
  17. package/lib/flood-guard.js +3 -1
  18. package/lib/index.js +84 -172
  19. package/lib/lan-ip.js +20 -0
  20. package/lib/live-roots.js +39 -0
  21. package/lib/origin-claim.js +3 -2
  22. package/lib/peer-link-display.js +3 -2
  23. package/lib/queue-guard.js +3 -1
  24. package/lib/skill/references/cli.md +215 -203
  25. package/lib/skill/references/routing.md +4 -0
  26. package/lib/skill/references/setup.md +156 -153
  27. package/lib/task-auth.js +14 -10
  28. package/lib/types/canvas-id.d.ts +9 -0
  29. package/lib/types/cli-deposit.d.ts +6 -1
  30. package/lib/types/config.d.ts +278 -0
  31. package/lib/types/direct-forward.d.ts +25 -3
  32. package/lib/types/faces/admin.d.ts +2 -0
  33. package/lib/types/faces/wire.d.ts +26 -0
  34. package/lib/types/index.d.ts +3 -277
  35. package/lib/types/lan-ip.d.ts +7 -0
  36. package/lib/types/live-roots.d.ts +35 -0
  37. package/lib/types/origin-claim.d.ts +1 -0
  38. package/lib/types/peer-link-display.d.ts +2 -0
  39. package/lib/types/queue-guard.d.ts +2 -0
  40. package/lib/types/task-auth.d.ts +3 -0
  41. package/lib/types/types.d.ts +7 -0
  42. package/nexus-stage/package-lock.json +2262 -2262
  43. package/nexus-stage/src/edges.ts +72 -72
  44. package/nexus-stage/src/layout-wire.ts +158 -158
  45. package/nexus-stage/src/seat.ts +35 -35
  46. package/nexus-stage/src/stage-name.ts +13 -13
  47. package/nexus-stage/src/tokens.ts +31 -31
  48. package/package.json +219 -218
package/lib/index.js CHANGED
@@ -5,9 +5,10 @@
5
5
  * inbound direct routes into live agent sessions.
6
6
  * @module @nelsonlongxiang/dsh-open-a2a-net
7
7
  */
8
- import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
8
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
10
10
  import { networkInterfaces } from 'node:os';
11
+ import { selectLanIPv4 } from "./lan-ip.js";
11
12
  import { dirname, join } from 'node:path';
12
13
  import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
13
14
  // The package own version, read from package.json at load time so the
@@ -26,7 +27,7 @@ export function resolveCallerLabel(input) {
26
27
  return input.nodeTeam;
27
28
  return input.sessionLabel;
28
29
  }
29
- import { signCard, verifyCard } from "./card.js";
30
+ import { encodePublicKey, signCard, verifyCard } from "./card.js";
30
31
  import { GroupStore } from "./group-store.js";
31
32
  import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
32
33
  import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
@@ -43,20 +44,20 @@ import { createDirectForward, ForwardBirths } from "./direct-forward.js";
43
44
  import { TeamMembershipStore } from "./team-store.js";
44
45
  import { PeerStore } from "./peer-store.js";
45
46
  import { SelfReferralFilter } from "./self-suppress.js";
46
- import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
47
+ import { TaskLedger, SUMMARY_CAP, briefOf } from "./task-ledger.js";
47
48
  import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
48
49
  import { runReceiptLadder } from "./receipt-ladder.js";
49
50
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
50
51
  import { createFloodGuard } from "./flood-guard.js";
51
- import { createQueueGuard, DEFAULT_TARGET_QUEUE_CAP, queueFullRefusal, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
52
+ import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
52
53
  import { resolveZone } from "./zone.js";
53
54
  import { registerA2aNetworkSkill } from "./skill-registration.js";
54
55
  import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
55
56
  import { AuthObserver } from "./auth-observer.js";
56
57
  import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
57
- import s from '@deepseek-ai/schemastery';
58
58
  import { MessageId } from '@deepseek-ai/dsh-llm';
59
59
  import { SessionId } from '@deepseek-ai/dsh-session';
60
+ import { liveRootAgents } from "./live-roots.js";
60
61
  import { A2aClient } from "./a2a-client.js";
61
62
  import { WIRE_ERROR_PAYLOAD_TOO_LARGE } from "./transport-caps.js";
62
63
  import { registerAdminFaces } from "./faces/admin.js";
@@ -80,127 +81,10 @@ export { A2aClient } from "./a2a-client.js";
80
81
  export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
81
82
  export const name = 'a2a';
82
83
  export const inject = ['tools', 'timer', 'systemPrompt'];
83
- /** Schemastery configuration for the A2A client plugin row. */
84
- export const Config = s.object({
85
- apiKey: s.string().default(''),
86
- session: s.string().default(''),
87
- team: s.string().default('dsh'),
88
- /** S4 fix ③: host-side (agent-less) routing calls — audit them (default) or refuse outright. */
89
- gateAgentLessCalls: s.union(['audit', 'refuse']).default('audit'),
90
- settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
91
- announce: s.boolean().default(false),
92
- isolated: s.boolean().default(false),
93
- forwardDirect: s.boolean().default(true),
94
- agentName: s.string().default('DeepSeek Harness A2A node'),
95
- peers: s.array(s.string()).default([]),
96
- delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
97
- dshHome: s.string().default(''),
98
- sessionNodes: s.boolean().default(true),
99
- wakeJoinedOnBoot: s.boolean().default(false),
100
- wakePrewarmDelayMs: s.number().default(10_000),
101
- wakePrewarmQuietMs: s.number().default(5_000),
102
- wakeBootStaggerMs: s.number().default(3_000),
103
- wakeReconcile: s.boolean().default(true),
104
- wakeReconcileIntervalMs: s.number().default(60_000),
105
- wakeReconcileBackoffBaseMs: s.number().default(5_000),
106
- wakeReconcileMaxBackoffMs: s.number().default(600_000),
107
- /**
108
- * S4 (gh#11 N7): the joinable-team curation list. A model calling
109
- * a2a_team_join may only declare membership in a team with an EXACT
110
- * entry here — trailing-`*` patterns are readable but authorize nothing
111
- * (no mint rights: a pattern pre-authorizing every future name under it
112
- * would let a model mint teams nobody created). Default empty: the
113
- * owner's no-self-join ruling, institutionalized as default-deny; a
114
- * refused join carries guidance instead of failing silently.
115
- */
116
- teamJoinAllowlist: s.array(s.string()).default([]),
117
- seatNames: s.dict(s.any()).default({}),
118
- /**
119
- * S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
120
- * caller↔target shared team membership before addressing — a session
121
- * with no declared team has no network (the unteamed-peer defect the
122
- * owner reproduced on 4080 and locally). Fleet compatibility rides the
123
- * roster capability flag (0.5.44 cards advertise roster:true; older
124
- * peers read as legacy). Set false to restore the open outbound seam.
125
- */
126
- teamScopeRouting: s.boolean().default(true),
127
- /**
128
- * S5 (default-on, owner directive 2026-10-20): the information-plane
129
- * tools answer from the joined-team scope only — a2a_teams lists
130
- * [this host] rows plus peer rows serving a joined team (note field
131
- * explains the scope); a2a_status narrows peers / in-flight / activity
132
- * to that scope (local traffic and idempotency aggregates stay);
133
- * a2a_probe sweeps serving peers and refuses a single URL whose
134
- * verified card cannot prove service. A host whose nodes declare no
135
- * teams keeps host-internal facts only. Set false to restore
136
- * fleet-wide disclosure.
137
- */
138
- teamScopeInfo: s.boolean().default(true),
139
- /**
140
- * Peer boundary model — connection layer: an empty list keeps the
141
- * historical open-referral posture; a non-empty list admits a referral
142
- * only when its host matches an entry (exact host[:port], bare domain
143
- * suffix, or full URL). Seeds from `peers` are owner-configured and
144
- * unaffected.
145
- */
146
- peerAllowlist: s.array(s.string()).default([]),
147
- /**
148
- * Peer boundary model — connection layer: when false, referral URLs are
149
- * neither learned nor offered; the tracked set stays the configured seed
150
- * set for the lifetime of the config that named them.
151
- */
152
- referralLearning: s.boolean().default(true),
153
- /**
154
- * gh#8 (N9): team-name resolution rides the operator-established peer
155
- * basis (config seeds + delegates) only; the referral-learned tail's
156
- * self-published card claims do not bind team names. Set true to restore
157
- * the legacy whole-store basis (fleet migration window).
158
- */
159
- referralTeamResolution: s.boolean().default(false),
160
- /**
161
- * #19 D2 (ruling final, owner-level knob): how much of a session team's
162
- * activity EXCERPT the card face publishes in sessionTeams descriptions.
163
- * 'full' (default) keeps the verbatim tail excerpt; 'masked' truncates
164
- * to a shape-only prefix; 'off' omits the description field entirely.
165
- * Titles and team handles stay in every tier — the knob is the excerpt,
166
- * not the listing.
167
- */
168
- exposureExcerpt: s.union(['full', 'masked', 'off']).default('full'),
169
- /**
170
- * gh#9+gh#10 OriginClaim window: absent envelopes still walk N3 during
171
- * the window (counted as legacyInbound); closing it rejects them like
172
- * failed signatures. See docs/protocol/delivery-origin-auth.md §4.
173
- */
174
- originClaimWindow: s.boolean().default(true),
175
- /**
176
- * OriginClaim unbound window: also sign/accept the legacy envelope that
177
- * names no audience (counted as unboundInbound); closing it leaves the
178
- * audience-bound form only. See docs/protocol/delivery-origin-auth.md §2.1.
179
- */
180
- originClaimUnboundWindow: s.boolean().default(true),
181
- /** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
182
- originClaimAudiences: s.array(s.string()).default([]),
183
- /** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
184
- originClaimUrl: s.string().default(''),
185
- /**
186
- * v0.5.23 (async-stall): delay before a delivered-but-unconsumed async
187
- * route re-wakes its target with a one-line nudge (defect
188
- * t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
189
- */
190
- asyncNudgeDelayMs: s.number().default(120_000),
191
- /** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
192
- targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
193
- stateColdRowsTtlMs: s.number().default(5_000),
194
- cardCacheTtlMs: s.number().default(60_000),
195
- cardCacheNegativeTtlMs: s.number().default(30_000),
196
- remoteRowsTtlMs: s.number().default(15_000),
197
- cardTtlMs: s.number().default(172_800_000),
198
- flushTimeoutMs: s.number().default(300_000),
199
- routeTimeoutMs: s.number().default(1_800_000),
200
- taskStaleTtlMs: s.number().default(TASK_STALE_TTL_MS),
201
- nativeTeamsInbound: s.boolean().default(false),
202
- nativeRoundWaitMs: s.number().default(180_000),
203
- });
84
+ import { Config } from "./config.js";
85
+ import { canvasOwnerFingerprint, splitCanvasOwner } from "./canvas-id.js";
86
+ import { forwardReceiptBasis } from "./direct-forward.js";
87
+ export { Config };
204
88
  /**
205
89
  * Register the outbound peer tools, the direct-route endpoint, and (when
206
90
  * composed) the announced card and session-node control surface.
@@ -342,6 +226,11 @@ export function apply(ctx, config) {
342
226
  const site = req.headers['sec-fetch-site'];
343
227
  if (typeof site === 'string' && site !== 'same-origin' && site !== 'none')
344
228
  return false;
229
+ // A reverse proxy or tunnel on this host (Caddy, nginx, a tunnel agent) reaches us over loopback for EVERY
230
+ // remote client: a forwarding header says the real caller is elsewhere, so loopback trust does not apply —
231
+ // otherwise the workbench shell would hand the apiKey to anyone on the internet. Forging one only denies trust.
232
+ if (req.headers['x-forwarded-for'] !== undefined || req.headers.forwarded !== undefined || req.headers['x-real-ip'] !== undefined)
233
+ return false;
345
234
  const remote = req.socket.remoteAddress;
346
235
  return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
347
236
  }
@@ -564,6 +453,8 @@ export function apply(ctx, config) {
564
453
  ...(delegate.publicKey !== '' ? { publicKey: delegate.publicKey } : {}),
565
454
  }));
566
455
  const nodePrivateKey = loadOrCreateNodeKey(join(home, 'a2a', 'node-key.pem'));
456
+ /** #282 S2: this host's canvas owner fingerprint (`<canvas>@<fp8>` names our own canvas). */
457
+ const ownCanvasFingerprint = canvasOwnerFingerprint(encodePublicKey(createPublicKey(nodePrivateKey)));
567
458
  /** Our own card URL once the web server settles — the OriginClaim anchor. */
568
459
  let ownCardUrl = '';
569
460
  /**
@@ -652,20 +543,12 @@ export function apply(ctx, config) {
652
543
  recentActivity.splice(0, recentActivity.length - ACTIVITY_CAP);
653
544
  };
654
545
  /**
655
- * This host's primary LAN IPv4 (first non-internal address), computed once:
546
+ * This host's primary LAN IPv4 (RFC1918 > other external IPv4 > CGNAT), computed once:
656
547
  * a node fact the card and state route publish so peers and operators can
657
548
  * tell machines apart in a same-team fleet. Empty when no external address
658
549
  * exists (loopback-only deployments).
659
550
  */
660
- const lanIp = (() => {
661
- for (const entries of Object.values(networkInterfaces())) {
662
- for (const entry of entries ?? []) {
663
- if (entry.family === 'IPv4' && !entry.internal)
664
- return entry.address;
665
- }
666
- }
667
- return '';
668
- })();
551
+ const lanIp = selectLanIPv4(networkInterfaces());
669
552
  // Session groups: user-named buckets for the panel's session listing,
670
553
  // persisted beside the join intents so they survive restarts.
671
554
  // Deprecated as of 0.6.6 — canvas teams are the grouping face now; this
@@ -1360,7 +1243,8 @@ export function apply(ctx, config) {
1360
1243
  // side-effect-only.
1361
1244
  ctx.on('agent/created', ({ agent }) => {
1362
1245
  const agents = ctx.get('agents');
1363
- if (agents === undefined || !agents.roots().some(root => root.id === agent.id))
1246
+ // #291: hosts without roots() (0.2.2-rc) resolve top-level via list()+delegation depth; never throws.
1247
+ if (agents === undefined || !liveRootAgents(agents).some(root => root.id === agent.id))
1364
1248
  return undefined;
1365
1249
  liveRoots.set(String(agent.id), agent);
1366
1250
  if (joinedSessions.has(String(agent.id)))
@@ -1375,7 +1259,7 @@ export function apply(ctx, config) {
1375
1259
  // the remount above (leave is the only intent remover).
1376
1260
  sessionNodes.delete(String(agent.id));
1377
1261
  });
1378
- for (const agent of ctx.get('agents')?.roots() ?? []) {
1262
+ for (const agent of liveRootAgents(ctx.get('agents'))) {
1379
1263
  liveRoots.set(String(agent.id), agent);
1380
1264
  if (joinedSessions.has(String(agent.id)))
1381
1265
  mountSessionNode(agent);
@@ -1680,7 +1564,7 @@ export function apply(ctx, config) {
1680
1564
  get unboundInboundCount() { return unboundInboundCount; },
1681
1565
  get boundMissInboundCount() { return boundMissInboundCount; },
1682
1566
  get boundMissRecent() { return boundMissRecent; },
1683
- get ownCardUrl() { return ownCardUrl; },
1567
+ get ownCardUrl() { return ownCardUrl; }, ownCanvasFingerprint,
1684
1568
  get ownerSeatNames() { return ownerSeatNames; },
1685
1569
  set ownerSeatNames(v) { ownerSeatNames = v; },
1686
1570
  get ownerAllowlist() { return ownerAllowlist; },
@@ -1852,7 +1736,7 @@ export function apply(ctx, config) {
1852
1736
  return agents.requireInitiator();
1853
1737
  }
1854
1738
  catch {
1855
- const roots = agents.roots();
1739
+ const roots = liveRootAgents(agents);
1856
1740
  return roots.length > 0 ? roots[0] : undefined;
1857
1741
  }
1858
1742
  }
@@ -2189,8 +2073,20 @@ export function apply(ctx, config) {
2189
2073
  */
2190
2074
  function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2191
2075
  return new Promise((resolve) => {
2076
+ const floorAtRegistration = sessionEventsOf(agent).length;
2192
2077
  const waiter = registerFinalWaiter(agent, (text, placeholder) => {
2193
- resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}) });
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`).
2081
+ let ownTurn = false;
2082
+ if (placeholder !== true && taskId !== undefined && taskId !== '') {
2083
+ const events = sessionEventsOf(agent);
2084
+ 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
+ const window = read === undefined ? undefined : readingTurnReply(events, read);
2087
+ ownTurn = window !== undefined && window.completed && window.reply !== '' && window.reply === text;
2088
+ }
2089
+ resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}), ...(ownTurn ? { ownTurn: true } : {}) });
2194
2090
  });
2195
2091
  // The header names the sender first: `caller` is the routing node's
2196
2092
  // own label (the session that issued the route), while `team` is this
@@ -2593,7 +2489,8 @@ export function apply(ctx, config) {
2593
2489
  idempotencyStore,
2594
2490
  verifyOriginClaim,
2595
2491
  remoteCliProof,
2596
- forwardDirect: request => forwardDirect(request),
2492
+ forwardReceiptBasis: (taskId, caller, cliHandle, claim) => forwardReceiptBasis(taskLedger.find(taskId), caller, cliHandle, claim),
2493
+ forwardDirect: request => forwardDirect(request), ownCanvasFingerprint,
2597
2494
  diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
2598
2495
  callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
2599
2496
  recordActivity,
@@ -2875,7 +2772,7 @@ export function apply(ctx, config) {
2875
2772
  */
2876
2773
  function peerLinkState(peerUrl) {
2877
2774
  return peerLinkDisplay(peerUrl, {
2878
- card: cardCache.get(peerUrl)?.card, ownUrl: ownCardUrl, seeds: config.peers,
2775
+ card: cardCache.get(peerUrl)?.card, ownUrl: ownCardUrl, ownAudiences: config.originClaimAudiences, seeds: config.peers,
2879
2776
  refs: canvasStore.list().flatMap(name => [...canvasStore.remoteMembersOf(name)]),
2880
2777
  ownHandles: new Set([...joinedSessions.list(), ...liveRoots.keys()].map(id => `${config.team}/${id8(id)}`)),
2881
2778
  cards: [...cardCache.values()].flatMap(entry => entry.card === undefined ? [] : [entry.card]),
@@ -3297,6 +3194,7 @@ export function apply(ctx, config) {
3297
3194
  };
3298
3195
  }
3299
3196
  const remote = failures.filter(entry => !entry.startsWith('local: '));
3197
+ const queueFull = failures.some(entry => entry.startsWith('local: target-queue-full'));
3300
3198
  const suffix = team.slice(team.indexOf('/') + 1);
3301
3199
  const near = [...new Set([
3302
3200
  ...joinedSessions.list().map(id => `${config.team}/${id8(id)}`),
@@ -3313,9 +3211,10 @@ export function apply(ctx, config) {
3313
3211
  `team "${team}" is not published by any configured peer${remote.length > 0 ? ` (unresolved delegations: ${remote.join('; ')})` : ''}`,
3314
3212
  ...failures.filter(entry => entry.startsWith('local: ')).map(entry => `local reason: ${entry.slice('local: '.length)}`),
3315
3213
  near.length > 0 ? `nearby advertised teams: ${near.join(', ')}` : undefined,
3316
- 'next: enumerate live alternatives with a2a_teams; do not retry unchanged; joining is the target session\'s own gesture (its sidebar) — relay that request to the human',
3214
+ // #282 S1: a full local queue means the target exists and is busy — waiting is the only right next step.
3215
+ queueFull ? `next: ${QUEUE_FULL_NEXT}` : 'next: enumerate live alternatives with a2a_teams; do not retry unchanged; joining is the target session\'s own gesture (its sidebar) — relay that request to the human',
3317
3216
  ].filter((line) => line !== undefined);
3318
- return { ok: false, error: lines.join(' — '), code: -32004 };
3217
+ return queueFull ? { ok: false, error: lines.join(' — '), code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT } : { ok: false, error: lines.join(' — '), code: -32004 };
3319
3218
  };
3320
3219
  actTools.registerRoute();
3321
3220
  // #91 file channel F2: a2a_file_send / a2a_file_fetch (src/tools/files.ts).
@@ -3358,9 +3257,9 @@ export function apply(ctx, config) {
3358
3257
  for (const owner of owners.filter(row => row.team === team && !row.local)) {
3359
3258
  if (!owner.members.includes(caller))
3360
3259
  missing.push(owner.owner + ': caller is not an advertised canvas member');
3361
- for (const member of owner.members.filter(member => member !== caller))
3362
- if (!canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(member)))
3363
- missing.push(owner.owner + ': missing direct operator link to receiver ' + member);
3260
+ // #282 S3: no per-member link here; the owner's signed canvas is the return path.
3261
+ if (owner.members.every(member => member === caller))
3262
+ missing.push(owner.owner + ': the canvas lists no receiving member besides the caller');
3364
3263
  }
3365
3264
  }
3366
3265
  return canvasFailure(team, owners, reason) + (missing.length === 0 ? '' : ' — missing prerequisites: ' + missing.join('; '));
@@ -3368,7 +3267,6 @@ export function apply(ctx, config) {
3368
3267
  const forwardDirect = createDirectForward({
3369
3268
  births: forwardingBirths,
3370
3269
  enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
3371
- memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
3372
3270
  scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
3373
3271
  candidates: async (team, failures, caller) => { const fetch = memoizedCardFetch(); return { urls: await directoryPeerCandidates(fetch, team, failures, { id: caller, handle: caller }), card: fetch }; },
3374
3272
  dispatch: (url, request, guard) => dispatchPeerCandidate(url, request.team, request.message, request.contextId, new AbortController().signal, request.caller, request.noWait, request.taskId, request.caller, guard),
@@ -3400,6 +3298,12 @@ export function apply(ctx, config) {
3400
3298
  // node team when it has joined, else the node label), so the receiver
3401
3299
  // can answer with one a2a_route instead of an unroutable display label.
3402
3300
  a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
3301
+ // #282 S2: an owner-qualified canvas id. Our own fingerprint is our canvas; a foreign one never
3302
+ // lands on a same-named local canvas, and the directory walk keeps only that owner.
3303
+ const ownerQualified = splitCanvasOwner(String(args.team ?? '').trim());
3304
+ const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
3305
+ if (ownerQualified.owner !== undefined)
3306
+ args = { ...args, team: ownerQualified.team };
3403
3307
  // A target-authored receipt attempt answers its own work even if scope/transport later refuses delivery.
3404
3308
  const answer = parseReceipt(args.message);
3405
3309
  if (agent !== undefined && answer !== null)
@@ -3472,7 +3376,7 @@ export function apply(ctx, config) {
3472
3376
  const failures = [];
3473
3377
  if (signal?.aborted)
3474
3378
  return localRoute({ ok: false, error: 'route aborted before dispatch', code: -32000 });
3475
- const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
3379
+ const outcome = foreignOwner !== undefined ? undefined : await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
3476
3380
  const localBirth = localBirths.get(taskId);
3477
3381
  localBirths.delete(taskId);
3478
3382
  if (outcome !== undefined) {
@@ -3493,7 +3397,7 @@ export function apply(ctx, config) {
3493
3397
  // union in member order. Non-canvas teams keep the single-walk shape
3494
3398
  // unchanged. The ledger stays keyed to the canvas team the caller
3495
3399
  // named; the peer request carries the member handle it must resolve.
3496
- const canvasName = parseCanvasTeamName(args.team);
3400
+ const canvasName = foreignOwner !== undefined ? undefined : parseCanvasTeamName(args.team);
3497
3401
  // gh#131: one of this host's own handles is a local member, never a directory target.
3498
3402
  const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
3499
3403
  const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
@@ -3509,7 +3413,7 @@ export function apply(ctx, config) {
3509
3413
  }
3510
3414
  const candidatePool = [];
3511
3415
  for (const target of targets) {
3512
- for (const candidate of await directoryPeerCandidates(fetch, target, failures, scopedCanvasCaller)) {
3416
+ for (const candidate of await directoryPeerCandidates(fetch, ownerQualified.owner !== undefined && target === args.team ? `${target}@${ownerQualified.owner}` : target, failures, scopedCanvasCaller)) {
3513
3417
  candidatePool.push({ candidate, target });
3514
3418
  }
3515
3419
  }
@@ -3593,7 +3497,7 @@ export function apply(ctx, config) {
3593
3497
  const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, cliDeposits.get(team) !== undefined, ownTask);
3594
3498
  if (queueRefusal !== undefined) {
3595
3499
  recordActivity('out', team, callerSession ?? 'local', false);
3596
- return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL };
3500
+ return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT };
3597
3501
  }
3598
3502
  const canvasName = parseCanvasTeamName(team);
3599
3503
  // Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
@@ -3978,14 +3882,18 @@ export function apply(ctx, config) {
3978
3882
  // (gh#8: a squatter two gossip hops away captured routes by naming
3979
3883
  // itself after the target). referralTeamResolution restores the legacy
3980
3884
  // whole-store basis for fleets mid-migration.
3885
+ // #282 S2: `<canvas>@<fp8>` selects only the owner whose signed card key matches; no delegation walk.
3886
+ const qualified = splitCanvasOwner(team);
3887
+ team = qualified.team;
3888
+ const ownerOk = (card) => qualified.owner === undefined || canvasOwnerFingerprint(card?.publicKey) === qualified.owner;
3981
3889
  const peers = resolutionPeers();
3982
3890
  const candidates = [];
3983
- const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
3891
+ const cards = (await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }))).map(entry => ownerOk(entry.card) ? entry : { peer: entry.peer, card: undefined });
3984
3892
  // Check after fetching all cards: a seeded peer publishing the same id8
3985
3893
  // makes the caller handle ambiguous, so do not send a canvas payload.
3986
3894
  const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
3987
3895
  const resolveLocal = sessions === undefined ? undefined : (ref) => cliDeposits.get(ref) !== undefined ? ref : sessions(ref);
3988
- const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
3896
+ const resolutions = qualified.owner !== undefined ? [] : await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
3989
3897
  if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
3990
3898
  failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
3991
3899
  return [];
@@ -4074,7 +3982,7 @@ export function apply(ctx, config) {
4074
3982
  const card = await fetch(candidate);
4075
3983
  peerAsync = card?.capabilities?.async === true;
4076
3984
  }
4077
- const result = await dispatchPeerCandidate(candidate, request.handle, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
3985
+ const result = await dispatchPeerCandidate(candidate, splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
4078
3986
  if (!result.ok) {
4079
3987
  // Idempotency verdicts (B3) are terminal, not failover: a replay
4080
3988
  // says the prior attempt at THIS peer stays authoritative —
@@ -4107,7 +4015,7 @@ export function apply(ctx, config) {
4107
4015
  break;
4108
4016
  continue;
4109
4017
  }
4110
- trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
4018
+ trackOwedTask(taskId, splitCanvasOwner(request.handle).team, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
4111
4019
  const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
4112
4020
  if (result.task_status === 'TASK_STATE_COMPLETED') {
4113
4021
  return { kind: 'completed', text: result.reply, taskId, contextId };
@@ -4260,24 +4168,14 @@ export function apply(ctx, config) {
4260
4168
  entry.scannedTo = events.length;
4261
4169
  continue;
4262
4170
  }
4263
- // The consuming turn's window: up to its own `turn/end`, never into a
4264
- // following turn (back-to-back turns can run with no idle between).
4265
- // Without turn markers, this idle is the turn's end.
4266
- let end = events.length;
4267
- let completed = true;
4268
- if (turnMarked) {
4269
- const close = turnEndIndex(events, entry.consumedAt + 1);
4270
- if (close === undefined) {
4271
- // The consuming turn is still open: wait for its turn/end.
4272
- continue;
4273
- }
4274
- end = close;
4275
- completed = events[close]?.data?.reason?.kind === 'completed';
4276
- }
4171
+ const window = readingTurnReply(events, entry.consumedAt, turnMarked);
4172
+ // The consuming turn is still open: wait for its turn/end.
4173
+ if (window === undefined)
4174
+ continue;
4277
4175
  // The waiter closes HERE with that turn's last text. A turn that said
4278
4176
  // nothing, or ended aborted/failed/blocked, did no work this host can
4279
4177
  // vouch for: close as a placeholder — no receipt, the row stays owed.
4280
- const reply = latestAssistantText(events, entry.consumedAt + 1, end);
4178
+ const { reply, completed } = window;
4281
4179
  answered.push(reply !== '' && completed ? { entry, reply } : { entry, reply: '', placeholder: true });
4282
4180
  continue;
4283
4181
  }
@@ -4300,6 +4198,20 @@ export function apply(ctx, config) {
4300
4198
  entry.answer(reply);
4301
4199
  }
4302
4200
  }
4201
+ /**
4202
+ * #114 S5: the consuming turn's window — from the read at `read` up to its own `turn/end`, never into a
4203
+ * following turn (back-to-back turns can run with no idle between); without turn markers, up to now (the
4204
+ * idle is the turn's end). Undefined while a marked turn is still open. The receipt waiter and the sync
4205
+ * answer's `ownTurn` (#298) both read this one window.
4206
+ */
4207
+ function readingTurnReply(events, read, turnMarked = events.some(event => event?.type === 'turn/start' || event?.type === 'turn/end')) {
4208
+ if (!turnMarked)
4209
+ return { reply: latestAssistantText(events, read + 1), completed: true };
4210
+ const close = turnEndIndex(events, read + 1);
4211
+ if (close === undefined)
4212
+ return undefined;
4213
+ return { reply: latestAssistantText(events, read + 1, close), completed: events[close]?.data?.reason?.kind === 'completed' };
4214
+ }
4303
4215
  /** #114 S5: the index of the first `turn/end` at or after `from`, or undefined while the turn is open. */
4304
4216
  function turnEndIndex(events, from) {
4305
4217
  for (let index = from; index < events.length; index++) {
package/lib/lan-ip.js ADDED
@@ -0,0 +1,20 @@
1
+ /** #311: prefer LAN addresses over overlay CGNAT without depending on adapter names. */
2
+ import { isIPv4 } from 'node:net';
3
+ /** RFC1918 > other external IPv4 > CGNAT (100.64/10); ties retain enumeration order. */
4
+ export function selectLanIPv4(interfaces) {
5
+ let selected = '', best = Infinity;
6
+ for (const entries of Object.values(interfaces)) {
7
+ for (const entry of entries ?? []) {
8
+ if (entry.family !== 'IPv4' || entry.internal || !isIPv4(entry.address))
9
+ continue;
10
+ const [a, b] = entry.address.split('.').map(Number);
11
+ const rank = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
12
+ ? 0 : a === 100 && b >= 64 && b <= 127 ? 2 : 1;
13
+ if (rank < best) {
14
+ selected = entry.address;
15
+ best = rank;
16
+ }
17
+ }
18
+ }
19
+ return selected;
20
+ }
@@ -0,0 +1,39 @@
1
+ function safeDepth(value) {
2
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)
3
+ ? value
4
+ : undefined;
5
+ }
6
+ /**
7
+ * Delegation depth of a live agent: 0 = top-level. Mirrors the harness'
8
+ * authoritative max(persisted session header, runtime options); invalid
9
+ * values read as absent (never throwing) — see the module comment.
10
+ */
11
+ export function delegationDepthOf(agent) {
12
+ const carrier = agent;
13
+ return Math.max(safeDepth(carrier.options?.subagentDepth) ?? 0, safeDepth(carrier.session?.header?.delegationDepth) ?? 0);
14
+ }
15
+ /** Whether a live agent is top-level (not inside any subagent tree). */
16
+ export function isTopLevelAgent(agent) {
17
+ return delegationDepthOf(agent) === 0;
18
+ }
19
+ /**
20
+ * Live top-level agents on this host: `roots()` where the host still has
21
+ * it (exact pre-0.2.2 semantics, so old hosts change nothing), else
22
+ * `list()` filtered by delegation depth. A missing registry, a surface
23
+ * with neither method, or a throwing method all degrade to `[]`.
24
+ */
25
+ export function liveRootAgents(registry) {
26
+ if (registry === undefined || registry === null)
27
+ return [];
28
+ const surface = registry;
29
+ try {
30
+ if (typeof surface.roots === 'function')
31
+ return [...surface.roots()];
32
+ if (typeof surface.list === 'function')
33
+ return surface.list().filter(isTopLevelAgent);
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ return [];
39
+ }
@@ -224,7 +224,7 @@ export async function verifyOriginClaim(req, d) {
224
224
  if (cli.name !== caller)
225
225
  return { ok: false, reason: 'not-derivable' };
226
226
  accepted();
227
- return { ok: true, caller: cli.handle, callerUrl, card: cli.card, bound, cli: true };
227
+ return { ok: true, caller: cli.handle, callerUrl, card: cli.card, bound, cli: true, ...(bound && hasBodySig ? { bodyBound: true } : {}) };
228
228
  }
229
229
  // Fetch the caller's card PLAIN (REVIEW M2 third-round ruling: the
230
230
  // envelope names a BARE ZONE, and derivation is the exact match
@@ -265,5 +265,6 @@ export async function verifyOriginClaim(req, d) {
265
265
  if (card.team !== caller)
266
266
  return { ok: false, reason: 'not-derivable' };
267
267
  accepted();
268
- return { ok: true, caller, callerUrl, card, bound };
268
+ // #282 S3 review: a verified Sig3 means the body itself was signed (stripping it falls back to Sig2, which has no bodyBound).
269
+ return { ok: true, caller, callerUrl, card, bound, ...(bound && hasBodySig ? { bodyBound: true } : {}) };
269
270
  }
@@ -8,8 +8,9 @@ export function peerLinkDisplay(peerUrl, d) {
8
8
  catch {
9
9
  return '';
10
10
  } };
11
- const peerOrigin = origin(peerUrl), ownOrigin = origin(d.ownUrl);
12
- const bilateral = peerOrigin !== '' && ownOrigin !== '' && d.seeds.some(url => origin(url) === peerOrigin) && (card.peers ?? []).some(url => origin(url) === ownOrigin);
11
+ const peerOrigin = origin(peerUrl);
12
+ const ownOrigins = new Set([d.ownUrl, ...d.ownAudiences ?? []].map(origin).filter(value => value !== ''));
13
+ const bilateral = peerOrigin !== '' && d.seeds.some(url => origin(url) === peerOrigin) && (card.peers ?? []).some(url => ownOrigins.has(origin(url)));
13
14
  const exactMember = d.refs.some(ref => !d.ownHandles.has(ref) && (card.sessionTeams ?? []).some(row => row.team === ref) && !d.cards.some(other => other.publicKey !== card.publicKey && (other.sessionTeams ?? []).some(row => row.team === ref)));
14
15
  return { link: bilateral || exactMember ? 'linked' : 'stranger', verified: true };
15
16
  }
@@ -21,6 +21,8 @@ export function queueFullRefusal(rows, team, message, cap, mailbox, ownTask) {
21
21
  return undefined;
22
22
  return `target-queue-full (gh#224): "${team}" already has ${String(queued)} task(s) not yet answered by the target (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
23
  }
24
+ /** #282 S1: the one next step for a full target queue. */
25
+ export const QUEUE_FULL_NEXT = 'wait for a receipt from the target (a2a-net inbox / a2a_tasks), then send again with a new task id; do not resend in a loop';
24
26
  /** Wire-face adapter in the flood guard's shape: a refused fresh key is FAILED, not stranded pending. */
25
27
  export function createQueueGuard(rows, cap, ownTask, outcomes, recordActivity) {
26
28
  return (caller, message, team, taskId, mailbox, res) => {
@@ -29,7 +31,7 @@ export function createQueueGuard(rows, cap, ownTask, outcomes, recordActivity) {
29
31
  return false;
30
32
  outcomes.recordOutcome(taskId, { status: 'failed', error });
31
33
  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' });
34
+ 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', next: QUEUE_FULL_NEXT });
33
35
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
34
36
  res.end(payload);
35
37
  return true;