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

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.
package/lib/index.js CHANGED
@@ -32,13 +32,14 @@ import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-sto
32
32
  import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
33
33
  import { LayoutStore } from "./layout-store.js";
34
34
  import { peerLinkDisplay } from "./peer-link-display.js";
35
+ import { canvasOwners, canvasFailure } from "./canvas-discovery.js";
35
36
  import { JoinedSessions } from "./joined-store.js";
36
37
  import { ExposureStore } from "./exposure-store.js";
37
38
  import { loadSeatNames } from "./owner-settings.js";
38
39
  import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
39
40
  import { cliHomeChoices, decideCliHome } from "./cli-home.js";
40
41
  import { outboundScope } from "./outbound-scope.js";
41
- import { createDirectForward } from "./direct-forward.js";
42
+ import { createDirectForward, ForwardBirths } from "./direct-forward.js";
42
43
  import { TeamMembershipStore } from "./team-store.js";
43
44
  import { PeerStore } from "./peer-store.js";
44
45
  import { SelfReferralFilter } from "./self-suppress.js";
@@ -89,7 +90,7 @@ export const Config = s.object({
89
90
  settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
90
91
  announce: s.boolean().default(false),
91
92
  isolated: s.boolean().default(false),
92
- forwardDirect: s.boolean().default(false),
93
+ forwardDirect: s.boolean().default(true),
93
94
  agentName: s.string().default('DeepSeek Harness A2A node'),
94
95
  peers: s.array(s.string()).default([]),
95
96
  delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
@@ -682,7 +683,13 @@ export function apply(ctx, config) {
682
683
  // queryable here, and the correlating `[A2A receipt] task <id>` message
683
684
  // resolves it — the caller-side half of the receipt contract, persisted so
684
685
  // a restart does not orphan the reconciliation.
685
- const taskLedger = new TaskLedger(join(home, 'a2a', 'tasks.json'), { staleTtlMs: config.taskStaleTtlMs });
686
+ const taskLedger = new TaskLedger(join(home, 'a2a', 'tasks.json'), { staleTtlMs: config.taskStaleTtlMs, warn: message => logger.warn(message) });
687
+ const enforceBlockers = taskLedger.settlementEnforceBlockers();
688
+ const enforceRefused = config.settlementAuth === 'enforce' && enforceBlockers.length > 0 ? { reason: 'legacy forwarded canvas debts lack receiver facts', blockers: enforceBlockers } : undefined;
689
+ if (enforceRefused !== undefined) {
690
+ logger.error('settlementAuth enforce refused; running observe (#125/#275): %s', JSON.stringify(enforceRefused));
691
+ config = { ...config, settlementAuth: 'observe' };
692
+ }
686
693
  // Unjoined-node reporting ingress (design: docs/design/unjoined-report-ingress.md):
687
694
  // a one-way signal channel for sessions with no network membership — a
688
695
  // fallen-off or never-joined session reports "I exist / I have a task"
@@ -727,6 +734,12 @@ export function apply(ctx, config) {
727
734
  * the consumer's own discipline — keep seam listeners synchronous.
728
735
  * @param message - the inbound or relayed message text.
729
736
  */
737
+ const forwardingBirths = new ForwardBirths();
738
+ async function waitForForwardBirth(message) {
739
+ const receipt = parseReceipt(message);
740
+ if (receipt !== null)
741
+ await forwardingBirths.wait(receipt.taskId);
742
+ }
730
743
  function settleAndAnnounce(message, settler) {
731
744
  const parsedForJudge = parseReceipt(message);
732
745
  const judge = settlementJudgeFor(settler, parsedForJudge);
@@ -1660,12 +1673,13 @@ export function apply(ctx, config) {
1660
1673
  // gh#77 B1: the admin faces live in src/faces/admin.ts.
1661
1674
  registerAdminFaces(webServer, {
1662
1675
  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,
1663
- indexUrl: import.meta.url,
1676
+ indexUrl: import.meta.url, enforceRefused,
1664
1677
  get cardCache() { return cardCache; },
1665
1678
  get remoteRowsCache() { return remoteRowsCache; },
1666
1679
  get legacyInboundCount() { return legacyInboundCount; },
1667
1680
  get unboundInboundCount() { return unboundInboundCount; },
1668
1681
  get boundMissInboundCount() { return boundMissInboundCount; },
1682
+ get boundMissRecent() { return boundMissRecent; },
1669
1683
  get ownCardUrl() { return ownCardUrl; },
1670
1684
  get ownerSeatNames() { return ownerSeatNames; },
1671
1685
  set ownerSeatNames(v) { ownerSeatNames = v; },
@@ -2121,15 +2135,15 @@ export function apply(ctx, config) {
2121
2135
  * marker instead of a (false) completion.
2122
2136
  * @param taskId - the correlation key the steered receipt header carries.
2123
2137
  */
2124
- async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom) {
2138
+ async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2125
2139
  const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
2126
2140
  if (agent !== undefined) {
2127
- return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom);
2141
+ return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered);
2128
2142
  }
2129
2143
  // Wake-on-route: a cold joined team materializes on demand, then steers.
2130
2144
  const wake = wakeColdTeam(team);
2131
2145
  if (wake.kind === 'flight') {
2132
- return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2146
+ return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2133
2147
  }
2134
2148
  // Native-teams inbound bridge (opt-in): a registry team claims the
2135
2149
  // handle and dispatches through its authoritative seam. Checked before
@@ -2158,7 +2172,7 @@ export function apply(ctx, config) {
2158
2172
  if (live !== undefined) {
2159
2173
  if (callerIsAgent(live, caller))
2160
2174
  return { ok: false, error: selfRouteText(team) };
2161
- return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader);
2175
+ return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered);
2162
2176
  }
2163
2177
  return { ok: false, error: 'No live DSH agent is available to accept this message.' };
2164
2178
  }
@@ -2173,7 +2187,7 @@ export function apply(ctx, config) {
2173
2187
  * @param caller - caller label for the steered prefix.
2174
2188
  * @returns the agent's final reply, or the explicit failure.
2175
2189
  */
2176
- function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom) {
2190
+ function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2177
2191
  return new Promise((resolve) => {
2178
2192
  const waiter = registerFinalWaiter(agent, (text, placeholder) => {
2179
2193
  resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}) });
@@ -2190,7 +2204,11 @@ export function apply(ctx, config) {
2190
2204
  : `[A2A direct] ${taskId === undefined ? '' : `(task ${taskId}) `}未验证(in-process) ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
2191
2205
  const text = `${headerLine}\n\n${message}`;
2192
2206
  try {
2207
+ const floor = sessionEventsOf(agent).length;
2193
2208
  steerRelay(agent, text, caller === '' ? undefined : caller, localFrom);
2209
+ onSteered?.(agent, floor);
2210
+ if (onSteered !== undefined && sessionEventsOf(agent).slice(floor).some(event => event?.type === 'assistant/message'))
2211
+ queueMicrotask(() => flushFinals(String(agent.id))); // synchronous final may already have emitted idle
2194
2212
  }
2195
2213
  catch (error) {
2196
2214
  // The waiter was registered above, so the key always holds it; the
@@ -2223,6 +2241,15 @@ export function apply(ctx, config) {
2223
2241
  * closing originClaimUnboundWindow; boundMissInbound: those of them whose
2224
2242
  * bound form missed (a peer dials us by a name not in our audiences). */
2225
2243
  let unboundInboundCount = 0, boundMissInboundCount = 0;
2244
+ /** Which verified callers missed, and the origin they dialled: what to add to originClaimAudiences (#64). */
2245
+ const boundMissRecent = [];
2246
+ const noteBoundMiss = (miss) => {
2247
+ boundMissInboundCount += 1;
2248
+ const at = boundMissRecent.findIndex(row => row.caller === miss.caller && row.audience === miss.audience);
2249
+ const row = at >= 0 ? boundMissRecent.splice(at, 1)[0] : { ...miss, count: 0, lastAt: 0 };
2250
+ boundMissRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
2251
+ boundMissRecent.length = Math.min(boundMissRecent.length, 16);
2252
+ };
2226
2253
  function verifyOriginClaim(req, options) {
2227
2254
  return verifyOriginClaimOf(req, {
2228
2255
  cardCache,
@@ -2230,7 +2257,7 @@ export function apply(ctx, config) {
2230
2257
  audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
2231
2258
  unboundOpen: config.originClaimUnboundWindow !== false,
2232
2259
  onUnbound: () => { unboundInboundCount += 1; },
2233
- onBoundMiss: () => { boundMissInboundCount += 1; },
2260
+ onBoundMiss: noteBoundMiss,
2234
2261
  loopbackArrival: (config.originClaimUrl ?? '') === '',
2235
2262
  now: Date.now(),
2236
2263
  ...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
@@ -2567,6 +2594,7 @@ export function apply(ctx, config) {
2567
2594
  verifyOriginClaim,
2568
2595
  remoteCliProof,
2569
2596
  forwardDirect: request => forwardDirect(request),
2597
+ diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
2570
2598
  callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
2571
2599
  recordActivity,
2572
2600
  bumpLegacyInbound: () => { legacyInboundCount += 1; },
@@ -2580,7 +2608,7 @@ export function apply(ctx, config) {
2580
2608
  taskLedger,
2581
2609
  refuseIfFlooded,
2582
2610
  refuseIfQueueFull,
2583
- settleAndAnnounce,
2611
+ settleAndAnnounce, waitForForwardBirth,
2584
2612
  touchWakeDemand: () => { lastWakeDemandAt = Date.now(); },
2585
2613
  session,
2586
2614
  resolveAgentForTeam,
@@ -3057,7 +3085,7 @@ export function apply(ctx, config) {
3057
3085
  };
3058
3086
  const SWEEP_CONCURRENCY = 6;
3059
3087
  const infoTools = createInfoTools({
3060
- ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger,
3088
+ ctx, config, logger, session, lanIp, get ownCardUrl() { return ownCardUrl; }, peerStore, taskLedger,
3061
3089
  inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
3062
3090
  teamMemberships, exposureStore, idempotencyStore, cardCache,
3063
3091
  id8, sessionTeamOf,
@@ -3126,6 +3154,7 @@ export function apply(ctx, config) {
3126
3154
  team: canvasTeamOf(name),
3127
3155
  session,
3128
3156
  name,
3157
+ kind: 'canvas', owner: ownCardUrl, memberCount: canvasStore.membersOf(name).length,
3129
3158
  description: `canvas team — ${members.length} member${members.length === 1 ? '' : 's'}, ${live} live`,
3130
3159
  local: true,
3131
3160
  origin: localOrigin,
@@ -3304,7 +3333,40 @@ export function apply(ctx, config) {
3304
3333
  * caller label — the "能发即能收" invariant: whatever it addresses can
3305
3334
  * answer it with one route.
3306
3335
  */
3336
+ const knownCanvasOwners = () => canvasOwners(canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: canvasStore.membersOf(name) })), ownCardUrl, [...cardCache.entries()].flatMap(([url, entry]) => entry.card === undefined ? [] : [{ url, card: entry.card }]));
3337
+ const diagnoseCanvas = async (team, reason, caller) => {
3338
+ if (!new RegExp('^.+/canvas/[^/]+$').test(team))
3339
+ return reason;
3340
+ try {
3341
+ await listDirectoryTeams(false, true);
3342
+ }
3343
+ catch (error) {
3344
+ return reason + ' — canvas discovery unavailable: ' + String(error);
3345
+ }
3346
+ const resolve = localHandleResolver();
3347
+ const localId = caller === undefined ? undefined : resolve(caller);
3348
+ const owners = knownCanvasOwners().filter(row => !ownerTeamScopeInfo || (caller !== undefined && cliDeposits.get(caller) !== undefined) || (row.local ? localId !== undefined && row.members.some(ref => ref === localId || resolve(ref) === localId) : (row.scopeMembers ?? row.members).some(ref => { const id = resolve(ref); return id !== undefined && joinedSessions.has(id); })));
3349
+ const missing = [];
3350
+ if (caller !== undefined && cliDeposits.get(caller) !== undefined) {
3351
+ if (!config.forwardDirect)
3352
+ missing.push('home forwardDirect is off');
3353
+ if (config.isolated)
3354
+ missing.push('home is isolated');
3355
+ const scope = config.teamScopeRouting ? await outboundScope(senderScopeRecords, caller, caller, team) : { ok: true };
3356
+ if (!scope.ok)
3357
+ missing.push('sender scope: ' + scope.error);
3358
+ for (const owner of owners.filter(row => row.team === team && !row.local)) {
3359
+ if (!owner.members.includes(caller))
3360
+ 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);
3364
+ }
3365
+ }
3366
+ return canvasFailure(team, owners, reason) + (missing.length === 0 ? '' : ' — missing prerequisites: ' + missing.join('; '));
3367
+ };
3307
3368
  const forwardDirect = createDirectForward({
3369
+ births: forwardingBirths,
3308
3370
  enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
3309
3371
  memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
3310
3372
  scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
@@ -3363,7 +3425,7 @@ export function apply(ctx, config) {
3363
3425
  scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
3364
3426
  const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
3365
3427
  if (!scope.ok)
3366
- return localRoute(scope);
3428
+ return localRoute({ ...scope, error: await diagnoseCanvas(args.team, scope.error, callerHandle) });
3367
3429
  }
3368
3430
  }
3369
3431
  let callerSession = resolveCallerLabel({
@@ -3491,7 +3553,8 @@ export function apply(ctx, config) {
3491
3553
  }
3492
3554
  if (failures.length > 0)
3493
3555
  logger.warn(`route to team "${args.team}" exhausted its candidates: ${failures.join('; ')}`);
3494
- return routeVerdict(args.team, failures, candidatePool.length);
3556
+ const verdict = routeVerdict(args.team, failures, candidatePool.length);
3557
+ return { ...verdict, error: await diagnoseCanvas(args.team, verdict.error, callerSession) };
3495
3558
  }
3496
3559
  /**
3497
3560
  * The route dispatcher's local half: same-host candidates dial in-process,
@@ -210,7 +210,7 @@ export async function verifyOriginClaim(req, d) {
210
210
  return;
211
211
  d.onUnbound();
212
212
  if (hasBound)
213
- d.onBoundMiss?.();
213
+ d.onBoundMiss?.({ caller, audience: originOf(audience) });
214
214
  };
215
215
  // gh#133: a CLI identity deposited HERE, decided from the deposit table
216
216
  // before any fetch (the CLI may reach its home through a proxy the host
@@ -109,7 +109,7 @@ export function peerDirectoryRows(card, peer, includeCanvas) {
109
109
  if (includeCanvas)
110
110
  for (const entry of advertisedCanvasTeams(card)) {
111
111
  const declared = rosterOf.get(entry.team);
112
- rows.push({ team: entry.team, session: card.session, name: entry.name, description: 'canvas team', origin, via: peer, ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}), canvasMembers: entry.members.filter(ref => !publishedSessions.has(ref)) });
112
+ rows.push({ team: entry.team, session: card.session, name: entry.name, description: 'canvas team', kind: 'canvas', owner: peer, memberCount: entry.members.length, origin, via: peer, ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}), canvasMembers: entry.members.filter(ref => !publishedSessions.has(ref)) });
113
113
  }
114
114
  return rows;
115
115
  }
@@ -121,9 +121,9 @@ Files do not go through session messages: use the file channel below, never JPFI
121
121
  - Reusing an id for a different message, or switching `--sync` on or off,
122
122
  is refused (exit 1): drop `--task` to send it as a new task.
123
123
  - `report` takes `--task` the same way.
124
- - 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.
124
+ - From 0.6.38 home-host forwarding defaults on (`forwardDirect: true`); explicit `false` restores local-only delivery. It 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.
125
125
  - 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.
126
- - 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.
126
+ - 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. The transport still has a 15s synchronous patience window, not a full model-turn budget. For forwarded or long work, prefer `a2a-net route <team> <message>` (async by default) then `a2a-net inbox --wait --renew`. A `--sync` transport expiry is `-32005`; the target still auto-receipts the original task after finishing. Reconcile by task id/inbox instead of treating a new direct pong as its receipt or resending blindly.
127
127
  - 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.
128
128
  - 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.
129
129
  - `a2a-net report <message…>` is a one-way report to the host's process team.
@@ -169,9 +169,13 @@ Files do not go through session messages: use the file channel below, never JPFI
169
169
 
170
170
  ## Reading the host
171
171
 
172
+ If configured enforcement is refused because old forwarded canvas debts lack receiver facts, the plugin stays online in observe. `tasks --json` preserves `settlement.mode` and `settlement.enforceRefused:{reason,blockers}`; tasks/status text also shows the refusal. This boot-time audit remains after reconciliation; a later owner-controlled reapplication may enable enforce once blockers are cleared. Configuration on disk is unchanged.
173
+
172
174
  `whoami`, `teams [query]`, `status`, `tasks`, `probe` read the home host's
173
175
  state face (key-gated on a keyed host). `--json` prints raw rows for
174
- `teams`, `tasks`, `probe`, `route`, `report`, `inbox` and `skills`.
176
+ `whoami`, `teams`, `tasks`, `probe`, `route`, `report`, `inbox` and `skills`.
177
+
178
+ `teams` includes local canvas and peerOrg rows with qualified route name, owner URL and memberCount; same names on different hosts stay separate. `whoami --json` is pure JSON and includes only canvases naming this CLI identity, local and remote. An unreadable optional state face reports canvasError/UNKNOWN, never falsely empty membership. Canvas failures retain the original refusal and add visible-directory not-found, owner prerequisites (including each missing direct receiver link), or conflicting owner candidates. Owner URLs do not require direct dialing or a home override and grant no admission.
175
179
 
176
180
  ## Local commands
177
181
 
@@ -24,7 +24,7 @@
24
24
  The `a2a-net` CLI (0.6.27+) is the supported exception: a DEPOSITED handle
25
25
  is routable on its home host and receives into its mailbox
26
26
  ([cli.md](cli.md)).
27
- - **"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
+ - **"No live DSH session node accepts team" may name a forwarding prerequisite.** From 0.6.38 `forwardDirect` defaults on; explicit `false` disables it. It 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.
28
28
  - A sync route to a remote peer rides a 15s transport budget (a target on
29
29
  this host gets 180s) — both are shorter than many model turns. For
30
30
  anything that runs long, prefer `async: true` and the receipt contract.
@@ -115,14 +115,11 @@ Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
115
115
 
116
116
  ## 3. Beyond direct peers (A cannot reach C)
117
117
 
118
- Host-to-host forwarding is **not shipped yet**. Today a delivery reaches only
119
- the host it is sent to:
120
- - `/a2a/direct` never relays.
121
- - A session's `a2a_route` dials the host that publishes the team.
122
- - #57 Plan A (host forwarding) is designed. Its prerequisite #60 hop budget
123
- has landed at ingress.
118
+ Home-host one-hop forwarding shipped in 0.6.34 and defaults **on from 0.6.38**. Explicit `forwardDirect: false` restores local-only CLI delivery. It only follows a local miss for a bound-signed, online CLI deposited on that home, inside the unchanged sender scope. A must directly link B's receiving member (all advertised potential receivers for a canvas), and B must link the CLI and pin A as home. The home must not be isolated and any inbound Guard prevents a second forward. The outbound hop budget remains DEFAULT-1. No links are auto-created or relaxed.
124
119
 
125
- Until forwarding ships, pick one:
120
+ Long forwarded work should use async `route` plus `inbox --wait --renew`, not expect a 15s `--sync` transport window to cover an entire model turn. A late final still produces the original task's receipt, and the forwarded row keeps selected-peer receiver facts for enforced settlement.
121
+
122
+ Other connectivity options (not authority bypasses):
126
123
 
127
124
  1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
128
125
  then do section 2 for A↔C. This is the simplest and keeps receipts
@@ -139,8 +136,7 @@ Until forwarding ships, pick one:
139
136
  ## 4. Callers outside a session
140
137
 
141
138
  Scripts and terminals use the `a2a-net` CLI: deposit on ONE home host, read
142
- a mailbox, no wake. The same rule applies: the CLI reaches only teams on its
143
- home host. See [cli.md](cli.md).
139
+ a mailbox, no wake. The home can forward eligible requests one hop under section 3; an online deposit alone grants no remote scope. See [cli.md](cli.md).
144
140
 
145
141
  ## 5. Symptom → step
146
142
 
package/lib/task-auth.js CHANGED
@@ -487,7 +487,7 @@ export function judgeSettlement(row, settler, facts) {
487
487
  return { ok: true, role: 'legacy' };
488
488
  const target = inbound ? row.auth.target : row.team;
489
489
  // #139: once the member that received the task is known, it alone answers for the team.
490
- const members = row.deliveredTo !== undefined ? [row.deliveredTo] : facts.membersOf(target) ?? [];
490
+ const members = row.deliveredTo !== undefined ? [row.deliveredTo] : row.forwarded === true ? row.receiverMembers ?? [] : facts.membersOf(target) ?? [];
491
491
  const local = inbound || row.peer === 'local' || row.peer.startsWith('local:');
492
492
  const peerOrigin = local ? undefined : originOfUrl(row.peer);
493
493
  // #139: the peer is its signing key, not one spelling of its address — a host
@@ -91,12 +91,17 @@ export function withAuth(raw, entry, now) {
91
91
  return {};
92
92
  if (entry.direction === 'inbound')
93
93
  return auth.ingress === 'wire' && `local:${auth.receipt_target}` === entry.peer ? { auth } : {};
94
- // #139: an outbound row carries only its local-candidate admission.
94
+ // Forwarded births retain the verified local CLI wire admission for audit, never borrowed remote authority.
95
+ if (entry.forwarded === true && typeof entry.peer === 'string' && !entry.peer.startsWith('local')) {
96
+ const sender = parseSender(entry.sender);
97
+ return auth.ingress === 'wire' && auth.proof === 'cli-identity' && sender?.kind === 'cli' && sender.id === auth.caller && auth.receipt_target === auth.caller ? { auth } : {};
98
+ }
95
99
  return auth.ingress === 'local' && entry.peer === 'local' ? { auth } : {};
96
100
  }
97
101
  /** Routing and answer facts copied unchanged between a row and its archive entry. */
98
102
  export function routingOf(row) {
99
103
  return {
104
+ ...(row.receiverMembers !== undefined ? { receiverMembers: [...row.receiverMembers] } : {}),
100
105
  ...(row.answeredAt !== undefined ? { answeredAt: row.answeredAt } : {}),
101
106
  ...(row.forwarded === true ? { forwarded: true } : {}),
102
107
  ...(row.peerKey !== undefined ? { peerKey: row.peerKey } : {}),
@@ -105,13 +110,15 @@ export function routingOf(row) {
105
110
  };
106
111
  }
107
112
  /** The facts of one outbound birth that fit the row (see {@link withRouting} for the same shape on restore). */
108
- export function birthFacts(peer, birth) {
113
+ export function birthFacts(peer, birth, team = '', sender, now = Date.now(), warn = console.warn) {
109
114
  if (birth === undefined)
110
115
  return {};
111
116
  const fact = (v) => v !== undefined && v !== '' && v.length <= ROUTING_FACT_CAP;
112
117
  const remote = peer !== 'local' && !peer.startsWith('local:');
118
+ const receiverMembers = remote && birth.forwarded === true && fact(birth.peerKey) ? parseReceiverMembers(birth.receiverMembers, warn) : undefined;
113
119
  return {
114
- ...(!remote && birth.auth !== undefined && birth.auth.ingress === 'local' ? { auth: birth.auth } : {}),
120
+ ...withAuth(birth.auth, { direction: 'outbound', team, peer, sender, forwarded: birth.forwarded }, now),
121
+ ...(receiverMembers !== undefined ? { receiverMembers } : {}),
115
122
  ...(remote && birth.forwarded === true ? { forwarded: true } : {}),
116
123
  ...(remote && fact(birth.peerKey) ? { peerKey: birth.peerKey } : {}),
117
124
  ...(remote && fact(birth.peerHost) ? { peerHost: birth.peerHost } : {}),
@@ -120,17 +127,38 @@ export function birthFacts(peer, birth) {
120
127
  }
121
128
  /** Bound on a persisted routing fact; anything longer (or empty, or not a string) is dropped on restore. */
122
129
  export const ROUTING_FACT_CAP = 256;
130
+ export function parseReceiverMembers(value, warn = console.warn) {
131
+ if (value === undefined)
132
+ return undefined;
133
+ if (!Array.isArray(value)) {
134
+ warn('a2a: receiverMembers is not an array; ignored');
135
+ return undefined;
136
+ }
137
+ const members = new Set();
138
+ if (value.length > 256)
139
+ warn('a2a: receiverMembers exceeds 256 entries; tail ignored');
140
+ for (const [index, member] of value.slice(0, 256).entries()) {
141
+ if (typeof member !== 'string' || !new RegExp('^[a-z0-9-]+/[0-9a-f]{8}(?:[0-9a-f]{8})?$', 'i').test(member)) {
142
+ warn('a2a: invalid receiverMembers entry at index ' + index + '; ignored');
143
+ continue;
144
+ }
145
+ members.add(member);
146
+ }
147
+ return members.size === 0 ? undefined : Object.freeze([...members]);
148
+ }
123
149
  /**
124
150
  * #139: restore-side allowlist for the routing facts. Each is kept only in
125
151
  * its shape — a non-empty string within the cap — and the peer facts only on
126
152
  * a remote outbound row; anything else is dropped (the row survives as one
127
153
  * born before the fields, judged by current membership and origin).
128
154
  */
129
- export function withRouting(entry) {
155
+ export function withRouting(entry, warn = console.warn) {
130
156
  const fact = (v) => typeof v === 'string' && v !== '' && v.length <= ROUTING_FACT_CAP;
131
157
  const remoteOutbound = entry.direction === 'outbound' && typeof entry.peer === 'string' && entry.peer !== 'local' && !entry.peer.startsWith('local:');
158
+ const receiverMembers = remoteOutbound && entry.forwarded === true && fact(entry.peerKey) ? parseReceiverMembers(entry.receiverMembers, warn) : undefined;
132
159
  return {
133
160
  ...(typeof entry.answeredAt === 'number' && Number.isFinite(entry.answeredAt) && entry.answeredAt >= 0 ? { answeredAt: entry.answeredAt } : {}),
161
+ ...(receiverMembers !== undefined ? { receiverMembers } : {}),
134
162
  ...(remoteOutbound && entry.forwarded === true ? { forwarded: true } : {}),
135
163
  ...(remoteOutbound && fact(entry.peerKey) ? { peerKey: entry.peerKey } : {}),
136
164
  ...(remoteOutbound && fact(entry.peerHost) ? { peerHost: entry.peerHost } : {}),
@@ -46,6 +46,7 @@ export class TaskLedger {
46
46
  archiveCap;
47
47
  /** Injectable clock (option `now`). */
48
48
  now;
49
+ warn;
49
50
  /**
50
51
  * @param path - persistence file (`<dsh-home>/a2a/tasks.json`); empty = no persistence.
51
52
  * @param options - TTL/capacity overrides; defaults come from the exported constants.
@@ -55,6 +56,7 @@ export class TaskLedger {
55
56
  this.staleTtlMs = options?.staleTtlMs ?? TASK_STALE_TTL_MS;
56
57
  this.archiveCap = options?.archiveCap ?? ARCHIVE_CAP;
57
58
  this.now = options?.now ?? (() => Date.now());
59
+ this.warn = options?.warn ?? console.warn;
58
60
  this.restore();
59
61
  }
60
62
  /**
@@ -66,6 +68,10 @@ export class TaskLedger {
66
68
  this.sweep(this.now());
67
69
  return [...this.tasks];
68
70
  }
71
+ /** #275: old forwarded canvas debts cannot be safely upgraded by inventing receiver authority. */
72
+ settlementEnforceBlockers() {
73
+ return this.list().filter(row => row.direction === 'outbound' && row.forwarded === true && row.receiptExpected !== false && row.team.includes('/canvas/') && (row.deliveredTo === row.team || (row.deliveredTo === undefined && (row.receiverMembers === undefined || row.receiverMembers.length === 0)))).map(row => row.taskId);
74
+ }
69
75
  /**
70
76
  * The settled archive, most recently resolved first. Rows land here the
71
77
  * moment their receipt correlates and stay until the archive cap evicts
@@ -113,7 +119,7 @@ export class TaskLedger {
113
119
  ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
114
120
  ...(brief !== undefined && brief !== '' ? { brief } : {}),
115
121
  ...(sender !== undefined ? { sender } : {}),
116
- ...birthFacts(peer, birth),
122
+ ...birthFacts(peer, birth, team, sender, this.now(), this.warn),
117
123
  status: 'pending',
118
124
  }, ...this.tasks];
119
125
  this.trimOwed();
@@ -137,7 +143,7 @@ export class TaskLedger {
137
143
  ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
138
144
  ...(brief !== undefined && brief !== '' ? { brief } : {}),
139
145
  ...(sender !== undefined ? { sender } : {}),
140
- ...birthFacts(peer, birth),
146
+ ...birthFacts(peer, birth, team, sender, this.now(), this.warn),
141
147
  status: 'pending',
142
148
  }, ...this.tasks];
143
149
  this.trimOwed();
@@ -629,7 +635,7 @@ export class TaskLedger {
629
635
  ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
630
636
  ...withSender(entry.sender, entry.direction),
631
637
  ...withAuth(entry.auth, entry, this.now()),
632
- ...withRouting(entry),
638
+ ...withRouting(entry, this.warn),
633
639
  ...withSettledBy(entry.settledBy),
634
640
  });
635
641
  continue;
@@ -648,7 +654,7 @@ export class TaskLedger {
648
654
  // #114 S3: a malformed sender is dropped whole; the row survives.
649
655
  ...withSender(entry.sender, entry.direction),
650
656
  ...withAuth(entry.auth, entry, this.now()),
651
- ...withRouting(entry),
657
+ ...withRouting(entry, this.warn),
652
658
  status: entry.status === 'dead' ? 'dead' : 'pending',
653
659
  ...(entry.status === 'dead'
654
660
  ? {
@@ -686,7 +692,7 @@ export class TaskLedger {
686
692
  ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
687
693
  ...withSender(entry.sender, entry.direction),
688
694
  ...withAuth(entry.auth, entry, this.now()),
689
- ...withRouting(entry),
695
+ ...withRouting(entry, this.warn),
690
696
  ...withSettledBy(entry.settledBy),
691
697
  });
692
698
  }
package/lib/tools/info.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { defineTool } from '@deepseek-ai/dsh-tools';
11
11
  import { peerDirectoryRows } from "../peer-directory.js";
12
+ import { canvasMemberships, canvasOwners } from "../canvas-discovery.js";
12
13
  import { peerContentMeta } from "../node-self.js";
13
14
  /**
14
15
  * Build the five registrars. Call createInfoTools after its deps exist, then
@@ -16,7 +17,7 @@ import { peerContentMeta } from "../node-self.js";
16
17
  * registry order the model sees must not change.
17
18
  */
18
19
  export function createInfoTools(deps) {
19
- const { ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger, joinedSessions, teamMemberships, canvasStore, exposureStore, idempotencyStore, cardCache, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, id8, sessionTeamOf, nodeMetadataOf, canvasTeamOf, canvasTeamsOfSession, localHandleResolver, memoizedCardFetch, mapBounded, SWEEP_CONCURRENCY, a2aJoinGateRefusal, observeAuthorization, settlePeerCard, client, listDirectoryTeams, } = deps;
20
+ const { ctx, config, logger, session, lanIp, peerStore, taskLedger, joinedSessions, teamMemberships, canvasStore, exposureStore, idempotencyStore, cardCache, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, id8, sessionTeamOf, nodeMetadataOf, canvasTeamOf, canvasTeamsOfSession, localHandleResolver, memoizedCardFetch, mapBounded, SWEEP_CONCURRENCY, a2aJoinGateRefusal, observeAuthorization, settlePeerCard, client, listDirectoryTeams, } = deps;
20
21
  // S5 (owner directive 2026-10-20): information-plane scoping. The host's
21
22
  // joined-team scope is the union of every local node's roster
22
23
  // declarations — a host-level set, not per-session: supervisor seats need
@@ -104,7 +105,7 @@ export function createInfoTools(deps) {
104
105
  teams: {
105
106
  type: 'object',
106
107
  additionalProperties: true,
107
- description: 'roster: allowlisted teams declared via a2a_team_join; canvas: workbench-assigned teams.',
108
+ description: 'roster: allowlisted teams declared via a2a_team_join; canvas: local workbench-assigned names (compatibility); canvasDetails: local/remote membership with qualified team, owner URL and memberCount.',
108
109
  },
109
110
  allowlist: {
110
111
  type: 'object',
@@ -124,7 +125,7 @@ export function createInfoTools(deps) {
124
125
  type: 'text',
125
126
  text: value.face === 'host'
126
127
  ? `Host face on team ${String(value.node?.team ?? '?')}: routing rides the bare node team (能发即能收)`
127
- : `Node ${String(value.node?.name ?? '')} ${String(value.node?.id ?? '?')}: joined=${String(value.node?.joined)}, live=${String(value.node?.live)}; roster teams: ${(value.teams?.roster ?? []).join(', ') || 'none'}; canvas: ${(value.teams?.canvas ?? []).join(', ') || 'none'}; allowlist effect: ${String(value.allowlist?.effect ?? '?')}`,
128
+ : `Node ${String(value.node?.name ?? '')} ${String(value.node?.id ?? '?')}: joined=${String(value.node?.joined)}, live=${String(value.node?.live)}; roster teams: ${(value.teams?.roster ?? []).join(', ') || 'none'}; canvas: ${(value.teams?.canvas ?? []).join(', ') || 'none'}; canvas owners: ${JSON.stringify(value.teams?.canvasDetails ?? [])}; allowlist effect: ${String(value.allowlist?.effect ?? '?')}`,
128
129
  }],
129
130
  },
130
131
  presentCall: () => ({ card: 'generic', title: 'A2A whoami', kind: 'other', rawInput: null }),
@@ -140,6 +141,8 @@ export function createInfoTools(deps) {
140
141
  }
141
142
  const joined = joinedSessions.has(agentId);
142
143
  const live = sessionNodes.has(agentId);
144
+ await listDirectoryTeams(false, true); // refresh through the existing bounded directory, not a second discovery path
145
+ const canvasDetails = canvasMemberships(canvasOwners(canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: canvasStore.membersOf(name) })), deps.ownCardUrl, [...cardCache.entries()].flatMap(([url, entry]) => entry.card === undefined ? [] : [{ url, card: entry.card }])), `${config.team}/${id8(agentId)}`, agentId, localHandleResolver());
143
146
  const refusals = teamAuditRing
144
147
  .filter(entry => entry.id === agentId && !entry.ok)
145
148
  .slice(-3)
@@ -159,6 +162,7 @@ export function createInfoTools(deps) {
159
162
  teams: {
160
163
  roster: [...teamMemberships.teamsOf(agentId)],
161
164
  canvas: [...canvasTeamsOfSession(agentId)],
165
+ canvasDetails,
162
166
  },
163
167
  allowlist: {
164
168
  patterns,
@@ -211,6 +215,9 @@ export function createInfoTools(deps) {
211
215
  session: { type: 'string', required: true },
212
216
  name: { type: 'string', required: true },
213
217
  seatName: { type: 'string', description: 'Human-owned stable display label, never a route identity.' },
218
+ kind: { type: 'string', enum: ['canvas'] },
219
+ owner: { type: 'string', description: 'Canvas owner host URL; discovery metadata, not a --home requirement.' },
220
+ memberCount: { type: 'number' },
214
221
  description: { type: 'string', required: true },
215
222
  local: { type: 'boolean', description: 'true when the team is served by this host (loopback candidate).' },
216
223
  origin: { type: 'string', description: 'The publishing host (node session label, LAN IP when known) — the natural grouping for fleet rows.' },
@@ -228,7 +235,7 @@ export function createInfoTools(deps) {
228
235
  type: 'text',
229
236
  text: (value.teams.length === 0
230
237
  ? `No A2A teams${(value.query ?? '') === '' ? '' : ` matching "${String(args.query)}"`} are currently reachable.`
231
- : value.teams.map(team => `- ${team.team}${team.local === true ? ' [this host]' : ''}${team.name === '' ? '' : ` (${team.name})`}${team.description === '' ? '' : ` — ${team.description}`}${typeof team.origin === 'string' && team.origin !== '' && team.local !== true ? ` @ ${team.origin}` : ''}${typeof team.workspace === 'string' && team.workspace !== '' ? ` [${team.workspace}]` : ''}`).join('\n'))
238
+ : value.teams.map(team => `- ${team.team}${team.local === true ? ' [this host]' : ''}${team.name === '' ? '' : ` (${team.name})`}${team.kind === 'canvas' ? ` [canvas owner=${team.owner ?? '?'} members=${String(team.memberCount ?? '?')}]` : ''}${team.description === '' ? '' : ` — ${team.description}`}${typeof team.origin === 'string' && team.origin !== '' && team.local !== true ? ` @ ${team.origin}` : ''}${typeof team.workspace === 'string' && team.workspace !== '' ? ` [${team.workspace}]` : ''}`).join('\n'))
232
239
  + (value.note === undefined ? '' : `\n${value.note}`),
233
240
  }],
234
241
  },
@@ -301,7 +308,7 @@ export function createInfoTools(deps) {
301
308
  return { ok: true, query: '', teams, ...(note !== undefined ? { note } : {}) };
302
309
  const matches = (team) => team.team.toLowerCase().includes(query) || team.name.toLowerCase().includes(query) || team.description.toLowerCase().includes(query)
303
310
  || team.session.toLowerCase().includes(query)
304
- || (team.origin ?? '').toLowerCase().includes(query) || (team.workspace ?? '').toLowerCase().includes(query) || (team.via ?? '').toLowerCase().includes(query);
311
+ || (team.owner ?? '').toLowerCase().includes(query) || (team.origin ?? '').toLowerCase().includes(query) || (team.workspace ?? '').toLowerCase().includes(query) || (team.via ?? '').toLowerCase().includes(query);
305
312
  return { ok: true, query, teams: teams.filter(matches), ...(note !== undefined ? { note } : {}) };
306
313
  },
307
314
  }));
@@ -0,0 +1,20 @@
1
+ import type { A2aPeerCard } from './types.ts';
2
+ export interface CanvasOwner {
3
+ team: string;
4
+ name: string;
5
+ owner: string;
6
+ memberCount: number;
7
+ local: boolean;
8
+ members: readonly string[];
9
+ scopeMembers?: readonly string[];
10
+ }
11
+ export declare function canvasOwners(local: readonly {
12
+ name: string;
13
+ team: string;
14
+ members: readonly string[];
15
+ }[], ownUrl: string, peers: readonly {
16
+ url: string;
17
+ card: A2aPeerCard;
18
+ }[]): CanvasOwner[];
19
+ export declare function canvasMemberships(owners: readonly CanvasOwner[], handle: string, localId?: string, resolveLocal?: (ref: string) => string | undefined): Omit<CanvasOwner, 'members' | 'scopeMembers'>[];
20
+ export declare function canvasFailure(team: string, owners: readonly CanvasOwner[], reason: string): string;
@@ -1,4 +1,5 @@
1
1
  import { type TaskLedger } from './task-ledger.ts';
2
+ import type { TaskAuthorization } from './task-auth.ts';
2
3
  import type { A2aPeerCard, A2aRouteResult } from './types.ts';
3
4
  export interface ForwardRequest {
4
5
  team: string;
@@ -9,8 +10,16 @@ export interface ForwardRequest {
9
10
  noWait: boolean;
10
11
  localCliProof: boolean;
11
12
  guarded: boolean;
13
+ auth?: TaskAuthorization;
14
+ }
15
+ /** Birth barrier exists only for eligible, actively dispatched tasks; receipts never reserve a slot. */
16
+ export declare class ForwardBirths {
17
+ private readonly pending;
18
+ wait(taskId: string): Promise<void> | undefined;
19
+ begin(taskId: string): (() => void) | undefined;
12
20
  }
13
21
  export interface DirectForwardDeps {
22
+ births: ForwardBirths;
14
23
  enabled(): boolean;
15
24
  isolated(): boolean;
16
25
  deposited(handle: string): boolean;