@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/config.js ADDED
@@ -0,0 +1,125 @@
1
+ /** The plugin row's configuration: the `Config` interface and its Schemastery schema (moved out of index.ts, #77). */
2
+ import s from '@deepseek-ai/schemastery';
3
+ import { TASK_STALE_TTL_MS } from "./task-ledger.js";
4
+ import { DEFAULT_TARGET_QUEUE_CAP } from "./queue-guard.js";
5
+ /** Schemastery configuration for the A2A client plugin row. */
6
+ export const Config = s.object({
7
+ apiKey: s.string().default(''),
8
+ session: s.string().default(''),
9
+ team: s.string().default('dsh'),
10
+ /** S4 fix ③: host-side (agent-less) routing calls — audit them (default) or refuse outright. */
11
+ gateAgentLessCalls: s.union(['audit', 'refuse']).default('audit'),
12
+ settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
13
+ announce: s.boolean().default(false),
14
+ isolated: s.boolean().default(false),
15
+ forwardDirect: s.boolean().default(true),
16
+ agentName: s.string().default('DeepSeek Harness A2A node'),
17
+ peers: s.array(s.string()).default([]),
18
+ delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
19
+ dshHome: s.string().default(''),
20
+ sessionNodes: s.boolean().default(true),
21
+ wakeJoinedOnBoot: s.boolean().default(false),
22
+ wakePrewarmDelayMs: s.number().default(10_000),
23
+ wakePrewarmQuietMs: s.number().default(5_000),
24
+ wakeBootStaggerMs: s.number().default(3_000),
25
+ wakeReconcile: s.boolean().default(true),
26
+ wakeReconcileIntervalMs: s.number().default(60_000),
27
+ wakeReconcileBackoffBaseMs: s.number().default(5_000),
28
+ wakeReconcileMaxBackoffMs: s.number().default(600_000),
29
+ /**
30
+ * S4 (gh#11 N7): the joinable-team curation list. A model calling
31
+ * a2a_team_join may only declare membership in a team with an EXACT
32
+ * entry here — trailing-`*` patterns are readable but authorize nothing
33
+ * (no mint rights: a pattern pre-authorizing every future name under it
34
+ * would let a model mint teams nobody created). Default empty: the
35
+ * owner's no-self-join ruling, institutionalized as default-deny; a
36
+ * refused join carries guidance instead of failing silently.
37
+ */
38
+ teamJoinAllowlist: s.array(s.string()).default([]),
39
+ seatNames: s.dict(s.any()).default({}),
40
+ /**
41
+ * S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
42
+ * caller↔target shared team membership before addressing — a session
43
+ * with no declared team has no network (the unteamed-peer defect the
44
+ * owner reproduced on 4080 and locally). Fleet compatibility rides the
45
+ * roster capability flag (0.5.44 cards advertise roster:true; older
46
+ * peers read as legacy). Set false to restore the open outbound seam.
47
+ */
48
+ teamScopeRouting: s.boolean().default(true),
49
+ /**
50
+ * S5 (default-on, owner directive 2026-10-20): the information-plane
51
+ * tools answer from the joined-team scope only — a2a_teams lists
52
+ * [this host] rows plus peer rows serving a joined team (note field
53
+ * explains the scope); a2a_status narrows peers / in-flight / activity
54
+ * to that scope (local traffic and idempotency aggregates stay);
55
+ * a2a_probe sweeps serving peers and refuses a single URL whose
56
+ * verified card cannot prove service. A host whose nodes declare no
57
+ * teams keeps host-internal facts only. Set false to restore
58
+ * fleet-wide disclosure.
59
+ */
60
+ teamScopeInfo: s.boolean().default(true),
61
+ /**
62
+ * Peer boundary model — connection layer: an empty list keeps the
63
+ * historical open-referral posture; a non-empty list admits a referral
64
+ * only when its host matches an entry (exact host[:port], bare domain
65
+ * suffix, or full URL). Seeds from `peers` are owner-configured and
66
+ * unaffected.
67
+ */
68
+ peerAllowlist: s.array(s.string()).default([]),
69
+ /**
70
+ * Peer boundary model — connection layer: when false, referral URLs are
71
+ * neither learned nor offered; the tracked set stays the configured seed
72
+ * set for the lifetime of the config that named them.
73
+ */
74
+ referralLearning: s.boolean().default(true),
75
+ /**
76
+ * gh#8 (N9): team-name resolution rides the operator-established peer
77
+ * basis (config seeds + delegates) only; the referral-learned tail's
78
+ * self-published card claims do not bind team names. Set true to restore
79
+ * the legacy whole-store basis (fleet migration window).
80
+ */
81
+ referralTeamResolution: s.boolean().default(false),
82
+ /**
83
+ * #19 D2 (ruling final, owner-level knob): how much of a session team's
84
+ * activity EXCERPT the card face publishes in sessionTeams descriptions.
85
+ * 'full' (default) keeps the verbatim tail excerpt; 'masked' truncates
86
+ * to a shape-only prefix; 'off' omits the description field entirely.
87
+ * Titles and team handles stay in every tier — the knob is the excerpt,
88
+ * not the listing.
89
+ */
90
+ exposureExcerpt: s.union(['full', 'masked', 'off']).default('full'),
91
+ /**
92
+ * gh#9+gh#10 OriginClaim window: absent envelopes still walk N3 during
93
+ * the window (counted as legacyInbound); closing it rejects them like
94
+ * failed signatures. See docs/protocol/delivery-origin-auth.md §4.
95
+ */
96
+ originClaimWindow: s.boolean().default(true),
97
+ /**
98
+ * OriginClaim unbound window: also sign/accept the legacy envelope that
99
+ * names no audience (counted as unboundInbound); closing it leaves the
100
+ * audience-bound form only. See docs/protocol/delivery-origin-auth.md §2.1.
101
+ */
102
+ originClaimUnboundWindow: s.boolean().default(true),
103
+ /** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
104
+ originClaimAudiences: s.array(s.string()).default([]),
105
+ /** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
106
+ originClaimUrl: s.string().default(''),
107
+ /**
108
+ * v0.5.23 (async-stall): delay before a delivered-but-unconsumed async
109
+ * route re-wakes its target with a one-line nudge (defect
110
+ * t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
111
+ */
112
+ asyncNudgeDelayMs: s.number().default(120_000),
113
+ /** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
114
+ targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
115
+ stateColdRowsTtlMs: s.number().default(5_000),
116
+ cardCacheTtlMs: s.number().default(60_000),
117
+ cardCacheNegativeTtlMs: s.number().default(30_000),
118
+ remoteRowsTtlMs: s.number().default(15_000),
119
+ cardTtlMs: s.number().default(172_800_000),
120
+ flushTimeoutMs: s.number().default(300_000),
121
+ routeTimeoutMs: s.number().default(1_800_000),
122
+ taskStaleTtlMs: s.number().default(TASK_STALE_TTL_MS),
123
+ nativeTeamsInbound: s.boolean().default(false),
124
+ nativeRoundWaitMs: s.number().default(180_000),
125
+ });
@@ -2,10 +2,29 @@
2
2
  import { DEFAULT_HOP_BUDGET, guardHeader, mintGuard } from "./recursion-guard.js";
3
3
  import { briefOf } from "./task-ledger.js";
4
4
  import { parseReceipt } from "./receipt.js";
5
+ import { splitCanvasOwner } from "./canvas-id.js";
6
+ import { claimCovers } from "./task-auth.js";
7
+ /** An early receipt waits at most this long for its forward row to be born (#298). */
8
+ export const BIRTH_WAIT_CAP_MS = 60_000;
5
9
  /** Birth barrier exists only for eligible, actively dispatched tasks; receipts never reserve a slot. */
6
10
  export class ForwardBirths {
11
+ waitCapMs;
7
12
  pending = new Map();
8
- wait(taskId) { return this.pending.get(taskId); }
13
+ constructor(waitCapMs = BIRTH_WAIT_CAP_MS) {
14
+ this.waitCapMs = waitCapMs;
15
+ }
16
+ /**
17
+ * Resolves when the birth is released or after the cap, whichever is first. After the cap the receipt
18
+ * is judged by the normal rules against whatever row exists then — never accepted in advance (#298).
19
+ */
20
+ wait(taskId) {
21
+ const birth = this.pending.get(taskId);
22
+ if (birth === undefined)
23
+ return undefined;
24
+ let timer;
25
+ const cap = new Promise(resolve => { timer = setTimeout(resolve, this.waitCapMs); timer.unref?.(); });
26
+ return Promise.race([birth, cap]).finally(() => clearTimeout(timer));
27
+ }
9
28
  begin(taskId) {
10
29
  if (this.pending.size >= 64 || this.pending.has(taskId))
11
30
  return undefined;
@@ -16,29 +35,33 @@ export class ForwardBirths {
16
35
  }
17
36
  export function createDirectForward(d) {
18
37
  return async (request) => {
19
- const refuse = (why) => ({ ok: false, code: -32000, task_id: request.taskId, error: 'not forwarded: ' + why });
38
+ const refuse = (why, next) => ({ ok: false, code: -32000, task_id: request.taskId, error: 'not forwarded: ' + why, ...(next !== undefined ? { next } : {}) });
39
+ const publish = 'run `a2a-net publish` to renew this CLI deposit on its home host, then send again with a new task id';
20
40
  if (!d.enabled())
21
- return refuse('forwardDirect is off');
41
+ return refuse('forwardDirect is off', 'ask this host\'s operator to set forwardDirect: true (the default since 0.6.38)');
22
42
  if (d.isolated())
23
- return refuse('host is isolated');
43
+ return refuse('host is isolated', 'this host forwards nothing while isolated; deposit the CLI on another home host');
24
44
  if (request.guarded)
25
45
  return refuse('an inbound X-A2A-Guard is present; already forwarded');
26
46
  if (!request.localCliProof || !d.deposited(request.caller))
27
- return refuse('caller is not a proven online local CLI deposit');
28
- const scope = await d.scope(request.caller, request.team);
47
+ return refuse('caller is not a proven online local CLI deposit', publish);
48
+ // #282 S2: candidates honour an owner-qualified id; scope, the peer, the receivers and the ledger see the plain team.
49
+ const plainTeam = splitCanvasOwner(request.team).team;
50
+ const scope = await d.scope(request.caller, plainTeam);
29
51
  if (!scope.ok)
30
- return refuse('target is outside sender scope: ' + scope.error);
52
+ return refuse('target is outside sender scope: ' + scope.error, `ask this host's operator to link ${request.team} here: drag it (or one of its members) into a canvas team on this host`);
31
53
  const failures = [];
32
54
  const directory = await d.candidates(request.team, failures, request.caller);
33
55
  const guard = guardHeader({ ...mintGuard(), hopBudget: DEFAULT_HOP_BUDGET - 1 });
34
56
  for (const url of directory.urls) {
35
57
  if (!d.deposited(request.caller))
36
- return refuse('local CLI deposit expired before dispatch');
58
+ return refuse('local CLI deposit expired before dispatch', publish);
37
59
  const peerCard = await directory.card(url);
38
- const receivers = request.team.includes('/canvas/') ? peerCard?.canvasOrg?.find(row => row.team === request.team)?.members.filter(member => member !== request.caller) : undefined;
39
- if (request.team.includes('/canvas/')) {
40
- if (receivers === undefined || receivers.length === 0 || receivers.some(member => !d.memberLinked(member))) {
41
- failures.push(url + ': forwarding a canvas requires a direct operator link to every receiving member for its receipt return path');
60
+ const receivers = plainTeam.includes('/canvas/') ? peerCard?.canvasOrg?.find(row => row.team === plainTeam)?.members.filter(member => member !== request.caller) : undefined;
61
+ if (plainTeam.includes('/canvas/')) {
62
+ // #282 S3 (same team authorizes): the owner's signed canvas is the return path; receipts settle only from these frozen receivers on this peer key (#274).
63
+ if (receivers === undefined || receivers.length === 0) {
64
+ failures.push(url + ': the owner card lists no receiving member of this canvas');
42
65
  continue;
43
66
  }
44
67
  }
@@ -46,12 +69,12 @@ export function createDirectForward(d) {
46
69
  const receipt = parseReceipt(request.message) !== null;
47
70
  const release = receipt ? () => { } : d.births.begin(request.taskId);
48
71
  if (release === undefined)
49
- return refuse('forward receipt correlation capacity is full');
72
+ return refuse('forward receipt correlation capacity is full', 'wait for in-flight forwards to settle, then send again with a new task id');
50
73
  try {
51
74
  // The same selected-peer snapshot supplies both return-link gate and settlement birth.
52
- const answer = await d.dispatch(url, request, guard);
75
+ const answer = await d.dispatch(url, plainTeam === request.team ? request : { ...request, team: plainTeam }, guard);
53
76
  const result = !answer.ok && answer.ownBudgetExhausted === true ? { ...answer, code: -32005 } : answer;
54
- d.activity(request.team, url, result.ok);
77
+ d.activity(plainTeam, url, result.ok);
55
78
  if (result.ok || result.code === -32005) {
56
79
  // A receipt owes no receipt; native bridge rounds have no A2A receipt contract.
57
80
  // Match routeTrunk/trackOwedTask rather than manufacturing an unpayable debt.
@@ -63,9 +86,9 @@ export function createDirectForward(d) {
63
86
  const birth = { forwarded: true, ...(request.auth !== undefined ? { auth: request.auth } : {}), ...(peerCard?.publicKey ? { peerKey: peerCard.publicKey } : {}), ...(peerCard?.session ? { peerHost: peerCard.session } : {}), ...(deliveredTo !== undefined ? { deliveredTo } : {}), ...(receivers !== undefined ? { receiverMembers: receivers } : {}) };
64
87
  // Exactly one outbound row. A completed sync response is receiptless bookkeeping.
65
88
  if (result.ok && (result.task_status === 'TASK_STATE_COMPLETED' || result.receipt === 'none'))
66
- d.ledger.trackReceiptless(request.taskId, request.team, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
89
+ d.ledger.trackReceiptless(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
67
90
  else
68
- d.ledger.track(request.taskId, request.team, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
91
+ d.ledger.track(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
69
92
  return result;
70
93
  }
71
94
  // The shared dispatcher's sync recovery query may prove a definitive failed
@@ -78,6 +101,29 @@ export function createDirectForward(d) {
78
101
  release();
79
102
  }
80
103
  }
81
- return { ok: false, code: -32004, task_id: request.taskId, error: 'forwarding to "' + request.team + '" failed: ' + (failures.join('; ') || 'no eligible peer candidate') };
104
+ const noReceiver = failures.some(f => f.includes('lists no receiving member'));
105
+ const next = noReceiver ? `the owner's canvas ${plainTeam} lists no member besides you; ask its owner to add one, or route to a member's own handle` : failures.length === 0 ? `no linked peer lists ${request.team}; check the name with \`a2a-net teams\`` : undefined;
106
+ return { ok: false, code: -32004, task_id: request.taskId, error: 'forwarding to "' + request.team + '" failed: ' + (failures.join('; ') || 'no eligible peer candidate'), ...(next !== undefined ? { next } : {}) };
82
107
  };
83
108
  }
109
+ /**
110
+ * #282 S3 (same team authorizes): the admission basis for a receipt delivered to a local CLI mailbox
111
+ * from a member the home never linked — only for that CLI's own pending forward, only from a receiver
112
+ * frozen at birth, only when the body-bound (Sig3) claim's card key is the frozen peer key. Anything else:
113
+ * undefined. Trust assumption (design D2): the selected owner host is honest about its own canvas — it may
114
+ * list any label as a receiver, including one of this host's session labels in the same zone.
115
+ */
116
+ export function forwardReceiptBasis(row, caller, cliHandle, claim) {
117
+ // The signer's zone must cover the caller label: B never signs for another zone's member (review of #287 M4).
118
+ if (!claimCovers(caller, claim.caller))
119
+ return undefined;
120
+ if (row === undefined || row.status !== 'pending' || row.forwarded !== true || row.sender?.kind !== 'cli' || row.sender.id !== cliHandle)
121
+ return undefined;
122
+ // Body-bound (Sig3) only: a stripped Sig3 would let a captured envelope carry another body (review of #287 P3-1).
123
+ if (!claim.bound || claim.bodyBound !== true || row.peerKey === undefined || claim.card.publicKey !== row.peerKey)
124
+ return undefined;
125
+ // A legacy row's deliveredTo may be the canvas handle itself (#272/#275): never a receiver.
126
+ if (row.deliveredTo !== undefined && (row.deliveredTo === row.team || row.deliveredTo.includes('/canvas/')))
127
+ return undefined;
128
+ return (row.deliveredTo !== undefined ? [row.deliveredTo] : row.receiverMembers ?? []).includes(caller) ? `peer-org:${row.team}` : undefined;
129
+ }
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { canvasNameReserved as reservedCanvasName, isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
22
+ import { qualifiedCanvasId } from "../canvas-id.js";
22
23
  import { refreshPeerCard, registerCanvasNotify } from "./canvas-notify.js";
23
24
  import { CLI_HANDLE_RE } from "../cli-deposit.js";
24
25
  import { applyOwnerSettings, parseSeatNames } from "../owner-settings.js";
@@ -135,6 +136,8 @@ export function registerAdminFaces(webServer, d) {
135
136
  teams: canvasStore.list().map(name => ({
136
137
  name,
137
138
  team: canvasTeamOf(name),
139
+ // #282 S2: the owner-qualified routing id.
140
+ id: `${canvasTeamOf(name)}@${d.ownCanvasFingerprint}`,
138
141
  // gh#152: members as the local tiers read them (gh#131). One
139
142
  // of this host's own handles renders as the local session it
140
143
  // names — join/live facts instead of an opaque remote chip — and
@@ -321,7 +324,7 @@ export function registerAdminFaces(webServer, d) {
321
324
  const unreachable = card !== undefined && entry?.missAt !== undefined ? { unreachable: true } : {};
322
325
  return Array.isArray(org)
323
326
  ? { url, ...unreachable, org: org.filter((e) => e !== null && typeof e === 'object')
324
- .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
327
+ .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', id: typeof e.team === 'string' ? qualifiedCanvasId(e.team, card?.publicKey) : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
325
328
  .filter(e => e.team !== '') }
326
329
  : { url, ...unreachable, org: [] };
327
330
  }),
package/lib/faces/wire.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
2
+ import { splitCanvasOwner } from "../canvas-id.js";
2
3
  import { CLI_HANDLE_RE } from "../cli-deposit.js";
3
4
  import { TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_LIMIT } from "../session-transcript.js";
4
5
  import { refuseIfBudgetExhausted } from "../recursion-guard.js";
5
6
  import { peerPayloadFingerprint, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED } from "../idempotency-store.js";
6
7
  import { briefOf } from "../task-ledger.js";
7
8
  import { parseReceipt, taskIdRefusal } from "../receipt.js";
8
- import { buildAuthorization, claimCovers, refusalOf } from "../task-auth.js";
9
+ import { buildAuthorization, claimCovers, nextOf, refusalOf } from "../task-auth.js";
9
10
  import { isRootOriginUrl } from "../origin-claim.js";
10
11
  import { withResponseFields } from "../response-fields.js";
11
12
  import { WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "../transport-caps.js";
@@ -52,8 +53,47 @@ export function settlerClaimFields(claim, originOf) {
52
53
  * @param webServer - the settled host web server.
53
54
  * @param d - the captures listed on {@link WireDeps}.
54
55
  */
56
+ /** #298: how long, and how many, sync-answered forwards are remembered for marking their duplicate receipt. */
57
+ export const SYNC_ANSWERED_TTL_MS = 3_600_000;
58
+ export const SYNC_ANSWERED_CAP = 256;
59
+ /**
60
+ * #298: forwards whose final sync answer — the member's own reply, not a delivered/mailbox placeholder — was
61
+ * fully written back to the CLI that sent them. That member's receipt for the same task, landing in that CLI's
62
+ * mailbox unrefused, is marked a duplicate — never dropped. In memory only: after a restart the receipt arrives
63
+ * unmarked, which is today's behaviour.
64
+ */
65
+ export class SyncAnswered {
66
+ now;
67
+ rows = new Map();
68
+ constructor(now = Date.now) {
69
+ this.now = now;
70
+ }
71
+ note(taskId, handle, answeredBy) {
72
+ this.sweep();
73
+ this.rows.delete(taskId);
74
+ this.rows.set(taskId, { handle, answeredBy, at: this.now() });
75
+ while (this.rows.size > SYNC_ANSWERED_CAP)
76
+ this.rows.delete(this.rows.keys().next().value);
77
+ }
78
+ /** True once for a task answered to exactly this handle by exactly this sender; the row is consumed. */
79
+ take(taskId, handle, sender) {
80
+ this.sweep();
81
+ const row = this.rows.get(taskId);
82
+ if (row === undefined || row.handle !== handle || row.answeredBy !== sender)
83
+ return false;
84
+ this.rows.delete(taskId);
85
+ return true;
86
+ }
87
+ sweep() {
88
+ const cutoff = this.now() - SYNC_ANSWERED_TTL_MS;
89
+ for (const [taskId, row] of this.rows)
90
+ if (row.at <= cutoff)
91
+ this.rows.delete(taskId);
92
+ }
93
+ }
55
94
  export function registerWireFaces(webServer, d) {
56
95
  const { ctx, logger, config, cliDeposits, canvasStore, canvasNameReserved, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, remoteCliProof, callerLinkedToThisHost, recordActivity, secretEqual, a2aCallerRefusal, a2aCallerAdmission, allowlistVersionNow, observeAuthorization, scanSessionLogList, readSessionLogTail, taskLedger, refuseIfFlooded, refuseIfQueueFull, settleAndAnnounce, waitForForwardBirth, session, resolveAgentForTeam, parseCanvasTeamName, canvasLiveAgent, wakeColdTeam, probeConsumption, sessionTeamOf, localSessionOfHandle, steerRelay, fromClause, sanitizeFromClause, armReceiptAutosend, eventCountOf, receiptExpectationOf, armAsyncNudge, liveAgent, callerIsAgent, selfRouteText, noWakeFaceText, archivedColdMatchText, noColdMatchText, nativeTeamsPrepare, nativeTeamsRound, routeIntoAgent, } = d;
96
+ const syncAnswered = new SyncAnswered();
57
97
  // gh#45 CLI 户口卡面 (peer face, unsigned card serve): the deposited
58
98
  // CLI's SIGNED card is served verbatim under its own base URL — an
59
99
  // OriginClaim verification of a CLI envelope fetches exactly here
@@ -434,7 +474,9 @@ export function registerWireFaces(webServer, d) {
434
474
  return;
435
475
  }
436
476
  }
437
- const team = typeof body.team === 'string' ? body.team : '';
477
+ // #282 S2: our own `<canvas>@<fp8>` is our canvas; a foreign suffix stays for the forward walk.
478
+ const ownerQualified = splitCanvasOwner(typeof body.team === 'string' ? body.team : '');
479
+ const team = ownerQualified.owner !== undefined && ownerQualified.owner === d.ownCanvasFingerprint ? ownerQualified.team : typeof body.team === 'string' ? body.team : '';
438
480
  const message = typeof body.message === 'string' ? body.message : '';
439
481
  const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
440
482
  // P2 receipt-callback: where THIS node's receipt should route on
@@ -482,11 +524,25 @@ export function registerWireFaces(webServer, d) {
482
524
  const foreignCli = config.teamScopeRouting && CLI_HANDLE_RE.test(caller) && cliDeposits.get(caller) === undefined;
483
525
  const earlyClaim = foreignCli ? await verifyOriginClaim(req, { cli: true, rawBody }) : undefined;
484
526
  const cliProof = foreignCli && earlyClaim?.ok ? await remoteCliProof(caller, earlyClaim).catch(() => undefined) : undefined;
485
- const admission = a2aCallerAdmission(caller, team, cliProof);
527
+ let admission = a2aCallerAdmission(caller, team, cliProof);
528
+ // #282 S3 (same team authorizes): a receipt for this host's own forward, to the CLI that sent it, from a
529
+ // receiver frozen at birth on the selected peer's key — the owner's signed canvas is the return path.
530
+ const echoed = !admission.ok && cliDeposits.get(team) !== undefined ? parseReceipt(message) : null;
531
+ if (echoed !== null) {
532
+ // Verify first, so an unsigned or Sig2-only caller cannot hold the connection on an in-flight forward (#287 review P3);
533
+ // then a body-bound receipt may beat the dispatch's HTTP answer: wait for this forward's row to be born (#287 P2).
534
+ const receiptClaim = earlyClaim ?? await verifyOriginClaim(req, { cli: true, rawBody });
535
+ if (receiptClaim.ok && receiptClaim.bodyBound === true)
536
+ await waitForForwardBirth(message);
537
+ const basis = receiptClaim.ok ? d.forwardReceiptBasis(echoed.taskId, caller, team, receiptClaim) : undefined;
538
+ if (basis !== undefined)
539
+ admission = { ok: true, via: 'peer-org', rule: 'peer-org', basis: [basis] };
540
+ }
486
541
  const callerRefusal = refusalOf(admission);
487
542
  if (callerRefusal !== undefined || !admission.ok) {
488
543
  recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
489
- const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
544
+ const next = nextOf(admission);
545
+ const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED', ...(next !== undefined ? { next } : {}) });
490
546
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
491
547
  res.end(payload);
492
548
  return;
@@ -571,7 +627,10 @@ export function registerWireFaces(webServer, d) {
571
627
  // keeps the idempotency claim (above) exactly as for sessions.
572
628
  const cliRow = cliDeposits.get(team);
573
629
  if (cliRow !== undefined) {
574
- cliDeposits.mail(team, caller, `${directHeader(caller)}\n\n${message}`);
630
+ const answered = parseReceipt(message);
631
+ // #298: only the answering member's own, unrefused receipt is the duplicate of a sync answer.
632
+ const duplicate = answered !== null && refusedSettlement === undefined && syncAnswered.take(answered.taskId, team, caller);
633
+ cliDeposits.mail(team, caller, `${directHeader(caller)}\n\n${message}`, duplicate ? 'sync-answered' : undefined);
575
634
  recordActivity('in', team, caller, true);
576
635
  logger.info(`a2a: cli mailbox delivery for ${team} (from ${caller})`);
577
636
  const note = `delivered to the mailbox of ${team} — the CLI polls while online`;
@@ -589,7 +648,8 @@ export function registerWireFaces(webServer, d) {
589
648
  // quiet window around it.
590
649
  d.touchWakeDemand();
591
650
  const forwardMiss = async (original) => {
592
- const result = await d.forwardDirect({ team, message, caller, taskId, contextId, noWait,
651
+ // #282 S2 review P1: forward with the id the caller sent — our own `@fp8` must find no other owner.
652
+ const result = await d.forwardDirect({ team: typeof body.team === 'string' && ownerQualified.owner !== undefined ? body.team : team, message, caller, taskId, contextId, noWait,
593
653
  auth: buildAuthorization(admission, { caller, proof: callerProof, ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}), ingress: 'wire', allowlistVersion: admittedAt.version, target: team, receiptTarget: caller, receivedAt: admittedAt.at }),
594
654
  localCliProof: claim.ok && claim.cli === true && claim.bound && claim.caller === caller, guarded: req.headers['x-a2a-guard'] !== undefined }).catch((error) => ({ ok: false, error: `forwarding unavailable: ${String(error)}`, code: -32000, task_id: taskId }));
595
655
  const failure = result.ok ? '' : await d.diagnoseCanvas(team, original + ' — ' + result.error, claim.ok && claim.cli === true && claim.bound && claim.caller === caller ? caller : undefined);
@@ -597,7 +657,14 @@ export function registerWireFaces(webServer, d) {
597
657
  idempotencyStore.recordOutcome(taskId, { status: 'failed', error: failure });
598
658
  else if (result.ok && result.task_status === 'TASK_STATE_COMPLETED')
599
659
  idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: result.reply });
600
- const payload = JSON.stringify(result.ok ? { routed: true, ...(result.task_status === 'TASK_STATE_DELIVERED' ? { delivered: true } : { result: { text: result.reply } }), team, task_id: taskId, context_id: contextId, task_status: result.task_status, consumed: result.consumed, bridge: result.bridge, receipt: result.receipt, forwarded: true } : { error: failure, code: result.code ?? -32000, receipt: result.receipt, task_id: taskId, team, task_status: result.code === -32005 ? 'TASK_STATE_DELIVERED' : 'TASK_STATE_FAILED' });
660
+ const payload = JSON.stringify(result.ok ? { routed: true, ...(result.task_status === 'TASK_STATE_DELIVERED' ? { delivered: true } : { result: { text: result.reply } }), team, task_id: taskId, context_id: contextId, task_status: result.task_status, consumed: result.consumed, bridge: result.bridge, receipt: result.receipt, forwarded: true } : { error: failure, code: result.code ?? -32000, receipt: result.receipt, task_id: taskId, team, task_status: result.code === -32005 ? 'TASK_STATE_DELIVERED' : 'TASK_STATE_FAILED', ...(result.next !== undefined ? { next: result.next } : {}) });
661
+ // #298: only a reply the peer itself declares final (`reply_final`; never a host placeholder, review of
662
+ // #305 F3), handed to the CLI in full, marks that member's later receipt a duplicate — never a delivered,
663
+ // mailbox or bridge shape (F1), and never when the CLI gave up and closed the socket ('finish' does not fire).
664
+ const answeredBy = result.ok ? result.deliveredTo : undefined;
665
+ if (!noWait && result.ok && result.replyFinal === true && result.task_status === 'TASK_STATE_COMPLETED' && result.receipt === undefined && result.bridge === undefined && answeredBy !== undefined
666
+ && claim.ok && claim.cli === true && claim.bound && claim.caller === caller && cliDeposits.get(caller) !== undefined)
667
+ res.once('finish', () => syncAnswered.note(taskId, caller, answeredBy));
601
668
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
602
669
  res.end(payload);
603
670
  };
@@ -854,6 +921,9 @@ export function registerWireFaces(webServer, d) {
854
921
  session,
855
922
  result: { text: outcome.reply },
856
923
  ...(syncTarget !== undefined ? { delivered_to: syncTarget } : {}),
924
+ // #298: a positive, cross-host statement that this text is the member's own final reply — the turn
925
+ // that read this task's steer (`ownTurn`), never a placeholder, a bridge round or another turn (#305 F3/F4).
926
+ ...(outcome.ownTurn === true && outcome.placeholder !== true && outcome.bridge === undefined && (outcome.status ?? 'TASK_STATE_COMPLETED') === 'TASK_STATE_COMPLETED' ? { reply_final: true } : {}),
857
927
  task_id: taskId,
858
928
  context_id: contextId,
859
929
  task_status: outcome.status ?? 'TASK_STATE_COMPLETED',
@@ -104,7 +104,9 @@ export function createFloodGuard(outcomes, recordActivity) {
104
104
  const error = 'flood-guard: ' + reason + ' (gh#91): file data must use an independent channel; no delivery occurred' + (advice === undefined ? '' : '; use-file-channel: ' + advice.message);
105
105
  outcomes.recordOutcome(taskId, { status: 'failed', error });
106
106
  recordActivity('in', team, caller, false);
107
- const payload = JSON.stringify({ error, reason, code: WIRE_ERROR_RATE_LIMITED, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED' });
107
+ // #282 S1: one next step — file bytes go through the file channel, never chat messages.
108
+ const next = 'send file data with a2a_file_send / `a2a-net file send <path> <team>` instead of message chunks; resend other text as one message with a new task id';
109
+ const payload = JSON.stringify({ error, reason, code: WIRE_ERROR_RATE_LIMITED, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED', next });
108
110
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
109
111
  res.end(payload);
110
112
  return true;