@nelsonlongxiang/dsh-open-a2a-net 0.6.36 → 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/README.md +7 -4
- package/README_zh.md +7 -4
- package/assets/nexusDist/assets/index-C7HagaGy.css +1 -0
- package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +35 -5
- package/lib/a2a-client.js +3 -3
- package/lib/canvas-discovery.js +29 -0
- package/lib/client.js +63 -63
- package/lib/direct-forward.js +49 -27
- package/lib/faces/admin.js +30 -4
- package/lib/faces/canvas-notify.js +8 -6
- package/lib/faces/wire.js +29 -10
- package/lib/index.js +107 -24
- package/lib/origin-claim.js +31 -8
- package/lib/owner-settings.js +38 -1
- package/lib/peer-directory.js +1 -1
- package/lib/peer-link-display.js +15 -0
- package/lib/skill/references/cli.md +11 -3
- package/lib/skill/references/pitfalls.md +1 -1
- package/lib/skill/references/setup.md +9 -9
- package/lib/task-auth.js +1 -1
- package/lib/task-ledger-rows.js +220 -0
- package/lib/task-ledger.js +13 -196
- package/lib/tools/info.js +14 -5
- package/lib/types/a2a-client.d.ts +1 -0
- package/lib/types/canvas-discovery.d.ts +20 -0
- package/lib/types/direct-forward.d.ts +9 -0
- package/lib/types/faces/admin.d.ts +11 -0
- package/lib/types/faces/canvas-notify.d.ts +3 -1
- package/lib/types/faces/wire.d.ts +4 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/origin-claim.d.ts +16 -4
- package/lib/types/owner-settings.d.ts +5 -0
- package/lib/types/peer-link-display.d.ts +13 -0
- package/lib/types/task-auth.d.ts +2 -0
- package/lib/types/task-ledger-rows.d.ts +352 -0
- package/lib/types/task-ledger.d.ts +7 -288
- package/lib/types/tools/info.d.ts +5 -0
- package/lib/types/types.d.ts +2 -1
- package/nexus-stage/src/planning-member-handles.ts +28 -1
- package/nexus-stage/src/planning-menus.ts +12 -5
- package/nexus-stage/src/planning-view.ts +18 -14
- package/nexus-stage/src/seat-name-editor.ts +31 -0
- package/nexus-stage/src/session-drag.ts +64 -0
- package/nexus-stage/src/wb-directory.ts +2 -0
- package/nexus-stage/src/workbench.css +2 -0
- package/nexus-stage/src/workbench.ts +16 -5
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-DIQNDBy6.js +0 -9
- package/assets/nexusDist/assets/index-z33lHtLw.css +0 -1
package/lib/index.js
CHANGED
|
@@ -31,12 +31,15 @@ import { GroupStore } from "./group-store.js";
|
|
|
31
31
|
import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
|
|
32
32
|
import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
|
|
33
33
|
import { LayoutStore } from "./layout-store.js";
|
|
34
|
+
import { peerLinkDisplay } from "./peer-link-display.js";
|
|
35
|
+
import { canvasOwners, canvasFailure } from "./canvas-discovery.js";
|
|
34
36
|
import { JoinedSessions } from "./joined-store.js";
|
|
35
37
|
import { ExposureStore } from "./exposure-store.js";
|
|
38
|
+
import { loadSeatNames } from "./owner-settings.js";
|
|
36
39
|
import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
|
|
37
40
|
import { cliHomeChoices, decideCliHome } from "./cli-home.js";
|
|
38
41
|
import { outboundScope } from "./outbound-scope.js";
|
|
39
|
-
import { createDirectForward } from "./direct-forward.js";
|
|
42
|
+
import { createDirectForward, ForwardBirths } from "./direct-forward.js";
|
|
40
43
|
import { TeamMembershipStore } from "./team-store.js";
|
|
41
44
|
import { PeerStore } from "./peer-store.js";
|
|
42
45
|
import { SelfReferralFilter } from "./self-suppress.js";
|
|
@@ -87,7 +90,7 @@ export const Config = s.object({
|
|
|
87
90
|
settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
|
|
88
91
|
announce: s.boolean().default(false),
|
|
89
92
|
isolated: s.boolean().default(false),
|
|
90
|
-
forwardDirect: s.boolean().default(
|
|
93
|
+
forwardDirect: s.boolean().default(true),
|
|
91
94
|
agentName: s.string().default('DeepSeek Harness A2A node'),
|
|
92
95
|
peers: s.array(s.string()).default([]),
|
|
93
96
|
delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
|
|
@@ -111,6 +114,7 @@ export const Config = s.object({
|
|
|
111
114
|
* refused join carries guidance instead of failing silently.
|
|
112
115
|
*/
|
|
113
116
|
teamJoinAllowlist: s.array(s.string()).default([]),
|
|
117
|
+
seatNames: s.dict(s.any()).default({}),
|
|
114
118
|
/**
|
|
115
119
|
* S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
|
|
116
120
|
* caller↔target shared team membership before addressing — a session
|
|
@@ -570,7 +574,9 @@ export function apply(ctx, config) {
|
|
|
570
574
|
* request) and the relay/notify faces.
|
|
571
575
|
*/
|
|
572
576
|
function originClaimHeaders(target) {
|
|
573
|
-
|
|
577
|
+
// No local web/card anchor yet: keep the preexisting absent-envelope migration posture, not a malformed Sig3.
|
|
578
|
+
const signedTarget = ownCardUrl === '' ? { url: target.url, method: target.method } : target;
|
|
579
|
+
return signOriginClaim(signedTarget, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
|
|
574
580
|
}
|
|
575
581
|
const client = new A2aClient({
|
|
576
582
|
apiKey: config.apiKey,
|
|
@@ -598,6 +604,8 @@ export function apply(ctx, config) {
|
|
|
598
604
|
// are read at call time, so the panel's write applies WITHOUT a restart;
|
|
599
605
|
// announce is boot-time publishing (restart pending flag for the UI).
|
|
600
606
|
let ownerAllowlist = [...(config.teamJoinAllowlist ?? [])];
|
|
607
|
+
let ownerSeatNames = loadSeatNames(config.seatNames ?? {}, reason => logger.warn(reason));
|
|
608
|
+
ctx.tools.guard(exec => exec.name.startsWith('a2a_') && exec.arguments !== null && typeof exec.arguments === 'object' && (Object.hasOwn(exec.arguments, 'seatName') || Object.hasOwn(exec.arguments, 'seatNames')) ? 'seatName is human-owned; use the workbench owner settings, not model tools' : undefined);
|
|
601
609
|
// Truthiness matches the ORIGINAL read site (`if (config.teamScopeInfo)`):
|
|
602
610
|
// literal compositions without the schema default carry undefined, which
|
|
603
611
|
// reads as the OPEN plane — `!== false` would silently scope them.
|
|
@@ -675,7 +683,13 @@ export function apply(ctx, config) {
|
|
|
675
683
|
// queryable here, and the correlating `[A2A receipt] task <id>` message
|
|
676
684
|
// resolves it — the caller-side half of the receipt contract, persisted so
|
|
677
685
|
// a restart does not orphan the reconciliation.
|
|
678
|
-
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
|
+
}
|
|
679
693
|
// Unjoined-node reporting ingress (design: docs/design/unjoined-report-ingress.md):
|
|
680
694
|
// a one-way signal channel for sessions with no network membership — a
|
|
681
695
|
// fallen-off or never-joined session reports "I exist / I have a task"
|
|
@@ -720,6 +734,12 @@ export function apply(ctx, config) {
|
|
|
720
734
|
* the consumer's own discipline — keep seam listeners synchronous.
|
|
721
735
|
* @param message - the inbound or relayed message text.
|
|
722
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
|
+
}
|
|
723
743
|
function settleAndAnnounce(message, settler) {
|
|
724
744
|
const parsedForJudge = parseReceipt(message);
|
|
725
745
|
const judge = settlementJudgeFor(settler, parsedForJudge);
|
|
@@ -1253,14 +1273,15 @@ export function apply(ctx, config) {
|
|
|
1253
1273
|
const LEGACY_PREWARM_PROBE_TITLE = '[prewarm] 请回复 ok。';
|
|
1254
1274
|
function nodeMetadataOf(agent) {
|
|
1255
1275
|
const title = sessionTitleOf(agent);
|
|
1256
|
-
const cwd = agent.session
|
|
1276
|
+
const cwd = agent.session?.header?.cwd;
|
|
1257
1277
|
// Display-layer fallback (pure presentation, zero data mutation): a title
|
|
1258
1278
|
// that IS the legacy probe reads as machine junk in every directory and
|
|
1259
1279
|
// card — surface the stable seat label instead. The session-title
|
|
1260
1280
|
// service's stored value is untouched.
|
|
1261
1281
|
const displayTitle = title !== undefined && title !== '' && title !== LEGACY_PREWARM_PROBE_TITLE ? title : undefined;
|
|
1262
1282
|
return {
|
|
1263
|
-
name: displayTitle !== undefined ? displayTitle : sessionLabelOf(agent),
|
|
1283
|
+
name: ownerSeatNames[String(agent.id)] ?? (displayTitle !== undefined ? displayTitle : sessionLabelOf(agent)),
|
|
1284
|
+
...(ownerSeatNames[String(agent.id)] !== undefined ? { seatName: ownerSeatNames[String(agent.id)] } : {}),
|
|
1264
1285
|
description: recentActivityOf(agent),
|
|
1265
1286
|
...(cwd !== undefined && cwd !== '' ? { workspace: cwd } : {}),
|
|
1266
1287
|
};
|
|
@@ -1652,13 +1673,16 @@ export function apply(ctx, config) {
|
|
|
1652
1673
|
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1653
1674
|
registerAdminFaces(webServer, {
|
|
1654
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,
|
|
1655
|
-
indexUrl: import.meta.url,
|
|
1676
|
+
indexUrl: import.meta.url, enforceRefused,
|
|
1656
1677
|
get cardCache() { return cardCache; },
|
|
1657
1678
|
get remoteRowsCache() { return remoteRowsCache; },
|
|
1658
1679
|
get legacyInboundCount() { return legacyInboundCount; },
|
|
1659
1680
|
get unboundInboundCount() { return unboundInboundCount; },
|
|
1660
1681
|
get boundMissInboundCount() { return boundMissInboundCount; },
|
|
1682
|
+
get boundMissRecent() { return boundMissRecent; },
|
|
1661
1683
|
get ownCardUrl() { return ownCardUrl; },
|
|
1684
|
+
get ownerSeatNames() { return ownerSeatNames; },
|
|
1685
|
+
set ownerSeatNames(v) { ownerSeatNames = v; },
|
|
1662
1686
|
get ownerAllowlist() { return ownerAllowlist; },
|
|
1663
1687
|
set ownerAllowlist(v) { ownerAllowlist = v; },
|
|
1664
1688
|
get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
|
|
@@ -1776,7 +1800,7 @@ export function apply(ctx, config) {
|
|
|
1776
1800
|
...[...sessionNodes.values()].filter(isJoinedNode).map(agent => exposureRow(sessionTeamOf(agent), nodeMetadataOf(agent))),
|
|
1777
1801
|
...joinedSessions.list()
|
|
1778
1802
|
.filter(id => !liveRoots.has(id) && isArchived?.(id) !== true)
|
|
1779
|
-
.map(id => exposureRow(`${config.team}/${id8(id)}`, { name: `${session}-${id8(id)}`, description: 'cold — not loaded; routing here wakes the session' })),
|
|
1803
|
+
.map(id => exposureRow(`${config.team}/${id8(id)}`, { name: ownerSeatNames[id] ?? `${session}-${id8(id)}`, ...(ownerSeatNames[id] !== undefined ? { seatName: ownerSeatNames[id] } : {}), description: 'cold — not loaded; routing here wakes the session' })),
|
|
1780
1804
|
// gh#45/47: deposited CLI identities publish as directory rows —
|
|
1781
1805
|
// the fleet sees them as member cards (draggable = the operator
|
|
1782
1806
|
// link). Their "zone" is the agent-name. gh#47: an EXPIRED
|
|
@@ -2111,15 +2135,15 @@ export function apply(ctx, config) {
|
|
|
2111
2135
|
* marker instead of a (false) completion.
|
|
2112
2136
|
* @param taskId - the correlation key the steered receipt header carries.
|
|
2113
2137
|
*/
|
|
2114
|
-
async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom) {
|
|
2138
|
+
async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2115
2139
|
const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
|
|
2116
2140
|
if (agent !== undefined) {
|
|
2117
|
-
return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom);
|
|
2141
|
+
return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered);
|
|
2118
2142
|
}
|
|
2119
2143
|
// Wake-on-route: a cold joined team materializes on demand, then steers.
|
|
2120
2144
|
const wake = wakeColdTeam(team);
|
|
2121
2145
|
if (wake.kind === 'flight') {
|
|
2122
|
-
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)}` }));
|
|
2123
2147
|
}
|
|
2124
2148
|
// Native-teams inbound bridge (opt-in): a registry team claims the
|
|
2125
2149
|
// handle and dispatches through its authoritative seam. Checked before
|
|
@@ -2148,7 +2172,7 @@ export function apply(ctx, config) {
|
|
|
2148
2172
|
if (live !== undefined) {
|
|
2149
2173
|
if (callerIsAgent(live, caller))
|
|
2150
2174
|
return { ok: false, error: selfRouteText(team) };
|
|
2151
|
-
return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader);
|
|
2175
|
+
return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered);
|
|
2152
2176
|
}
|
|
2153
2177
|
return { ok: false, error: 'No live DSH agent is available to accept this message.' };
|
|
2154
2178
|
}
|
|
@@ -2163,7 +2187,7 @@ export function apply(ctx, config) {
|
|
|
2163
2187
|
* @param caller - caller label for the steered prefix.
|
|
2164
2188
|
* @returns the agent's final reply, or the explicit failure.
|
|
2165
2189
|
*/
|
|
2166
|
-
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom) {
|
|
2190
|
+
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2167
2191
|
return new Promise((resolve) => {
|
|
2168
2192
|
const waiter = registerFinalWaiter(agent, (text, placeholder) => {
|
|
2169
2193
|
resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}) });
|
|
@@ -2180,7 +2204,11 @@ export function apply(ctx, config) {
|
|
|
2180
2204
|
: `[A2A direct] ${taskId === undefined ? '' : `(task ${taskId}) `}未验证(in-process) ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
|
|
2181
2205
|
const text = `${headerLine}\n\n${message}`;
|
|
2182
2206
|
try {
|
|
2207
|
+
const floor = sessionEventsOf(agent).length;
|
|
2183
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
|
|
2184
2212
|
}
|
|
2185
2213
|
catch (error) {
|
|
2186
2214
|
// The waiter was registered above, so the key always holds it; the
|
|
@@ -2213,6 +2241,15 @@ export function apply(ctx, config) {
|
|
|
2213
2241
|
* closing originClaimUnboundWindow; boundMissInbound: those of them whose
|
|
2214
2242
|
* bound form missed (a peer dials us by a name not in our audiences). */
|
|
2215
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
|
+
};
|
|
2216
2253
|
function verifyOriginClaim(req, options) {
|
|
2217
2254
|
return verifyOriginClaimOf(req, {
|
|
2218
2255
|
cardCache,
|
|
@@ -2220,10 +2257,11 @@ export function apply(ctx, config) {
|
|
|
2220
2257
|
audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
|
|
2221
2258
|
unboundOpen: config.originClaimUnboundWindow !== false,
|
|
2222
2259
|
onUnbound: () => { unboundInboundCount += 1; },
|
|
2223
|
-
onBoundMiss:
|
|
2260
|
+
onBoundMiss: noteBoundMiss,
|
|
2224
2261
|
loopbackArrival: (config.originClaimUrl ?? '') === '',
|
|
2225
2262
|
now: Date.now(),
|
|
2226
2263
|
...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
|
|
2264
|
+
...(options?.rawBody !== undefined ? { rawBody: options.rawBody } : {}),
|
|
2227
2265
|
});
|
|
2228
2266
|
}
|
|
2229
2267
|
/**
|
|
@@ -2556,6 +2594,7 @@ export function apply(ctx, config) {
|
|
|
2556
2594
|
verifyOriginClaim,
|
|
2557
2595
|
remoteCliProof,
|
|
2558
2596
|
forwardDirect: request => forwardDirect(request),
|
|
2597
|
+
diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
|
|
2559
2598
|
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
2560
2599
|
recordActivity,
|
|
2561
2600
|
bumpLegacyInbound: () => { legacyInboundCount += 1; },
|
|
@@ -2569,7 +2608,7 @@ export function apply(ctx, config) {
|
|
|
2569
2608
|
taskLedger,
|
|
2570
2609
|
refuseIfFlooded,
|
|
2571
2610
|
refuseIfQueueFull,
|
|
2572
|
-
settleAndAnnounce,
|
|
2611
|
+
settleAndAnnounce, waitForForwardBirth,
|
|
2573
2612
|
touchWakeDemand: () => { lastWakeDemandAt = Date.now(); },
|
|
2574
2613
|
session,
|
|
2575
2614
|
resolveAgentForTeam,
|
|
@@ -2817,10 +2856,10 @@ export function apply(ctx, config) {
|
|
|
2817
2856
|
}
|
|
2818
2857
|
/**
|
|
2819
2858
|
* gh#20 W-A: THIS host's view of one tracked peer's link state (the
|
|
2820
|
-
*
|
|
2821
|
-
* · linked — bilateral seeding
|
|
2822
|
-
*
|
|
2823
|
-
*
|
|
2859
|
+
* display projection, not an admission decision:
|
|
2860
|
+
* · linked — bilateral URL seeding, or a canvas reference to an exact
|
|
2861
|
+
* nonlocal session handle uniquely advertised by that peer card key;
|
|
2862
|
+
* a shared zone or a minimal card alone never proves host identity;
|
|
2824
2863
|
* · stranger — no operator-established basis (a verified key is not a
|
|
2825
2864
|
* relationship);
|
|
2826
2865
|
* · verified — we hold a signature-valid cached card (the badge's
|
|
@@ -2835,6 +2874,15 @@ export function apply(ctx, config) {
|
|
|
2835
2874
|
* Pure over warm caches and config; never fetches.
|
|
2836
2875
|
*/
|
|
2837
2876
|
function peerLinkState(peerUrl) {
|
|
2877
|
+
return peerLinkDisplay(peerUrl, {
|
|
2878
|
+
card: cardCache.get(peerUrl)?.card, ownUrl: ownCardUrl, seeds: config.peers,
|
|
2879
|
+
refs: canvasStore.list().flatMap(name => [...canvasStore.remoteMembersOf(name)]),
|
|
2880
|
+
ownHandles: new Set([...joinedSessions.list(), ...liveRoots.keys()].map(id => `${config.team}/${id8(id)}`)),
|
|
2881
|
+
cards: [...cardCache.values()].flatMap(entry => entry.card === undefined ? [] : [entry.card]),
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
// Compatibility for existing CLI home/admission semantics: badge projection must not change authorization.
|
|
2885
|
+
function peerAdmissionLinkState(peerUrl) {
|
|
2838
2886
|
const cached = cardCache.get(peerUrl)?.card;
|
|
2839
2887
|
const verified = cached !== undefined;
|
|
2840
2888
|
const zone = cached?.team ?? '';
|
|
@@ -2901,7 +2949,7 @@ export function apply(ctx, config) {
|
|
|
2901
2949
|
const card = await fetchPeerCard(url).catch(() => undefined);
|
|
2902
2950
|
if (card !== undefined)
|
|
2903
2951
|
cardCache.set(url, { at: Date.now(), card });
|
|
2904
|
-
return { url, card, linked: card === undefined ||
|
|
2952
|
+
return { url, card, linked: card === undefined || peerAdmissionLinkState(url).link === 'linked' };
|
|
2905
2953
|
});
|
|
2906
2954
|
}
|
|
2907
2955
|
async function cliHomeForAdd(handle, requested) {
|
|
@@ -2935,7 +2983,7 @@ export function apply(ctx, config) {
|
|
|
2935
2983
|
if (card === undefined || card.publicKey !== claim.card.publicKey)
|
|
2936
2984
|
continue;
|
|
2937
2985
|
cardCache.set(url, { at: Date.now(), card });
|
|
2938
|
-
if (
|
|
2986
|
+
if (peerAdmissionLinkState(url).link !== 'linked')
|
|
2939
2987
|
continue;
|
|
2940
2988
|
const row = card.sessionTeams?.find(item => item.team === handle);
|
|
2941
2989
|
if (row !== undefined)
|
|
@@ -3037,7 +3085,7 @@ export function apply(ctx, config) {
|
|
|
3037
3085
|
};
|
|
3038
3086
|
const SWEEP_CONCURRENCY = 6;
|
|
3039
3087
|
const infoTools = createInfoTools({
|
|
3040
|
-
ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger,
|
|
3088
|
+
ctx, config, logger, session, lanIp, get ownCardUrl() { return ownCardUrl; }, peerStore, taskLedger,
|
|
3041
3089
|
inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
|
|
3042
3090
|
teamMemberships, exposureStore, idempotencyStore, cardCache,
|
|
3043
3091
|
id8, sessionTeamOf,
|
|
@@ -3106,6 +3154,7 @@ export function apply(ctx, config) {
|
|
|
3106
3154
|
team: canvasTeamOf(name),
|
|
3107
3155
|
session,
|
|
3108
3156
|
name,
|
|
3157
|
+
kind: 'canvas', owner: ownCardUrl, memberCount: canvasStore.membersOf(name).length,
|
|
3109
3158
|
description: `canvas team — ${members.length} member${members.length === 1 ? '' : 's'}, ${live} live`,
|
|
3110
3159
|
local: true,
|
|
3111
3160
|
origin: localOrigin,
|
|
@@ -3284,7 +3333,40 @@ export function apply(ctx, config) {
|
|
|
3284
3333
|
* caller label — the "能发即能收" invariant: whatever it addresses can
|
|
3285
3334
|
* answer it with one route.
|
|
3286
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
|
+
};
|
|
3287
3368
|
const forwardDirect = createDirectForward({
|
|
3369
|
+
births: forwardingBirths,
|
|
3288
3370
|
enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
|
|
3289
3371
|
memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
|
|
3290
3372
|
scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
|
|
@@ -3343,7 +3425,7 @@ export function apply(ctx, config) {
|
|
|
3343
3425
|
scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
|
|
3344
3426
|
const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
|
|
3345
3427
|
if (!scope.ok)
|
|
3346
|
-
return localRoute(scope);
|
|
3428
|
+
return localRoute({ ...scope, error: await diagnoseCanvas(args.team, scope.error, callerHandle) });
|
|
3347
3429
|
}
|
|
3348
3430
|
}
|
|
3349
3431
|
let callerSession = resolveCallerLabel({
|
|
@@ -3471,7 +3553,8 @@ export function apply(ctx, config) {
|
|
|
3471
3553
|
}
|
|
3472
3554
|
if (failures.length > 0)
|
|
3473
3555
|
logger.warn(`route to team "${args.team}" exhausted its candidates: ${failures.join('; ')}`);
|
|
3474
|
-
|
|
3556
|
+
const verdict = routeVerdict(args.team, failures, candidatePool.length);
|
|
3557
|
+
return { ...verdict, error: await diagnoseCanvas(args.team, verdict.error, callerSession) };
|
|
3475
3558
|
}
|
|
3476
3559
|
/**
|
|
3477
3560
|
* The route dispatcher's local half: same-host candidates dial in-process,
|
package/lib/origin-claim.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* face (card, /a2a/direct, /a2a/log, /a2a/canvas-op, /a2a/canvas-changed)
|
|
5
5
|
* runs.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Three canonical forms share the base caller headers
|
|
8
8
|
* (X-A2A-Caller / -Url / -Ts):
|
|
9
9
|
* · bound (v2, §2.1): JSON.stringify([tag, caller, callerUrl, ts,
|
|
10
10
|
* audience, METHOD, path]) in X-A2A-Caller-Sig2, the audience origin in
|
|
@@ -14,18 +14,22 @@
|
|
|
14
14
|
* · unbound (v1, legacy): JSON.stringify([caller, callerUrl, ts]) in
|
|
15
15
|
* X-A2A-Caller-Sig. Emitted and accepted only while the unbound window
|
|
16
16
|
* is open (originClaimUnboundWindow); accepted ones are counted.
|
|
17
|
-
*
|
|
17
|
+
* A present Sig3 requires the exact raw body and v3 signature, with no downgrade.
|
|
18
|
+
* Without Sig3, once the window closes a present Sig2 is the only thing verified. While
|
|
18
19
|
* it is open, a Sig2 that misses (audience or signature) falls back to the
|
|
19
20
|
* Sig the envelope also carries — no more than stripping Sig2 would give —
|
|
20
21
|
* and the miss is counted, so a peer dialled by an unlisted name keeps
|
|
21
22
|
* working through the rollout and shows on the gauge.
|
|
22
23
|
*/
|
|
23
|
-
import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
24
|
+
import { createHash, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
24
25
|
import { verifyCard } from "./card.js";
|
|
25
26
|
/** ts skew tolerance (protocol constant, §7). */
|
|
26
27
|
export const ORIGIN_CLAIM_SKEW_MS = 5 * 60_000;
|
|
27
28
|
/** Domain tag of the bound form: a v1 signature can never verify as v2. */
|
|
28
29
|
export const ORIGIN_CLAIM_BOUND_TAG = 'a2a-origin-claim/2';
|
|
30
|
+
export const ORIGIN_CLAIM_BODY_TAG = 'a2a-origin-claim/3';
|
|
31
|
+
/** Digest the exact UTF-8 string/bytes passed to transport, never reserialized JSON. */
|
|
32
|
+
export const originBodySha256 = (body) => createHash('sha256').update(body).digest('base64url');
|
|
29
33
|
/** Only the canonical origin root can claim that origin's identity. */
|
|
30
34
|
export function isRootOriginUrl(value) {
|
|
31
35
|
try {
|
|
@@ -62,6 +66,10 @@ export function unboundCanonical(caller, callerUrl, ts) {
|
|
|
62
66
|
export function boundCanonical(caller, callerUrl, ts, audience, method, path) {
|
|
63
67
|
return JSON.stringify([ORIGIN_CLAIM_BOUND_TAG, caller, callerUrl, ts, audience, method.toUpperCase(), path]);
|
|
64
68
|
}
|
|
69
|
+
/** The body-bound v3 form is domain-separated from v1/v2, with the digest appended. */
|
|
70
|
+
export function bodyBoundCanonical(caller, callerUrl, ts, audience, method, path, bodySha256) {
|
|
71
|
+
return JSON.stringify([ORIGIN_CLAIM_BODY_TAG, caller, callerUrl, ts, audience, method.toUpperCase(), path, bodySha256]);
|
|
72
|
+
}
|
|
65
73
|
/** An origin in URL's normal form, or '' when the text is not an http(s) URL. */
|
|
66
74
|
export function originOf(url) {
|
|
67
75
|
try {
|
|
@@ -98,6 +106,11 @@ export function signOriginClaim(target, signer) {
|
|
|
98
106
|
if (audience !== '') {
|
|
99
107
|
headers['X-A2A-Caller-Aud'] = audience;
|
|
100
108
|
headers['X-A2A-Caller-Sig2'] = sign(boundCanonical(caller, callerUrl, ts, audience, target.method, pathOf(target.url)));
|
|
109
|
+
if (target.body !== undefined) {
|
|
110
|
+
const digest = originBodySha256(target.body);
|
|
111
|
+
headers['X-A2A-Body-Sha256'] = digest;
|
|
112
|
+
headers['X-A2A-Caller-Sig3'] = sign(bodyBoundCanonical(caller, callerUrl, ts, audience, target.method, pathOf(target.url), digest));
|
|
113
|
+
}
|
|
101
114
|
}
|
|
102
115
|
if (signer.unbound)
|
|
103
116
|
headers['X-A2A-Caller-Sig'] = sign(unboundCanonical(caller, callerUrl, ts));
|
|
@@ -153,15 +166,21 @@ export async function verifyOriginClaim(req, d) {
|
|
|
153
166
|
const unboundSig = header('x-a2a-caller-sig');
|
|
154
167
|
const boundSig = header('x-a2a-caller-sig2');
|
|
155
168
|
const audience = header('x-a2a-caller-aud');
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
const hasBodySig = req.headers['x-a2a-caller-sig3'] !== undefined;
|
|
170
|
+
const bodySig = header('x-a2a-caller-sig3');
|
|
171
|
+
const digest = header('x-a2a-body-sha256');
|
|
172
|
+
// Presence, even malformed/empty, opts into v3. It must never become absent/legacy or fall back to Sig2/v1.
|
|
173
|
+
if (hasBodySig && (!bodySig || !/^[A-Za-z0-9_-]{43}$/.test(digest) || d.rawBody === undefined || originBodySha256(d.rawBody) !== digest))
|
|
174
|
+
return { ok: false, reason: 'body-mismatch' };
|
|
175
|
+
if (caller === '' || callerUrl === '' || !Number.isFinite(ts) || ts <= 0 || (unboundSig === '' && boundSig === '' && !hasBodySig))
|
|
176
|
+
return { ok: false, reason: hasBodySig ? 'bad-sig' : 'absent' };
|
|
158
177
|
if (!originClaimCallerShape.test(caller))
|
|
159
178
|
return { ok: false, reason: 'bad-caller-shape' };
|
|
160
179
|
if (Math.abs(d.now - ts) > ORIGIN_CLAIM_SKEW_MS)
|
|
161
180
|
return { ok: false, reason: 'bad-ts' };
|
|
162
|
-
const hasBound = boundSig !== '';
|
|
181
|
+
const hasBound = boundSig !== '' || hasBodySig;
|
|
163
182
|
const audienceOk = hasBound && requestAudiences(req, d.audiences, d.loopbackArrival ?? true).has(originOf(audience));
|
|
164
|
-
const fallback = d.unboundOpen && unboundSig !== '';
|
|
183
|
+
const fallback = !hasBodySig && d.unboundOpen && unboundSig !== '';
|
|
165
184
|
if (hasBound && !audienceOk && !fallback)
|
|
166
185
|
return { ok: false, reason: 'wrong-audience' };
|
|
167
186
|
if (!hasBound && !d.unboundOpen)
|
|
@@ -179,6 +198,10 @@ export async function verifyOriginClaim(req, d) {
|
|
|
179
198
|
// signature is accepted for migration compatibility but remains unbound.
|
|
180
199
|
let bound = false;
|
|
181
200
|
const signedBy = (card) => {
|
|
201
|
+
if (hasBodySig) {
|
|
202
|
+
bound = audienceOk && verifies(card, bodyBoundCanonical(caller, callerUrl, ts, audience, req.method ?? '', pathOf(req.url ?? ''), digest), bodySig);
|
|
203
|
+
return bound;
|
|
204
|
+
}
|
|
182
205
|
bound = audienceOk && verifies(card, boundCanonical(caller, callerUrl, ts, audience, req.method ?? '', pathOf(req.url ?? '')), boundSig);
|
|
183
206
|
return bound || (fallback && verifies(card, unboundCanonical(caller, callerUrl, ts), unboundSig));
|
|
184
207
|
};
|
|
@@ -187,7 +210,7 @@ export async function verifyOriginClaim(req, d) {
|
|
|
187
210
|
return;
|
|
188
211
|
d.onUnbound();
|
|
189
212
|
if (hasBound)
|
|
190
|
-
d.onBoundMiss?.();
|
|
213
|
+
d.onBoundMiss?.({ caller, audience: originOf(audience) });
|
|
191
214
|
};
|
|
192
215
|
// gh#133: a CLI identity deposited HERE, decided from the deposit table
|
|
193
216
|
// before any fetch (the CLI may reach its home through a proxy the host
|
package/lib/owner-settings.js
CHANGED
|
@@ -8,8 +8,39 @@
|
|
|
8
8
|
* else; a missing entry is appended in the canonical short-id form.
|
|
9
9
|
* @module @nelsonlongxiang/dsh-open-a2a-net/owner-settings
|
|
10
10
|
*/
|
|
11
|
+
/** A label shaped like a routable address (`dsh/66abc2cf`, `zone/canvas/x`, `name/<16 hex>`) would pass for another
|
|
12
|
+
* session in the directory and in `a2a-net teams <query>`; labels are display-only, so that shape is refused. */
|
|
13
|
+
const ADDRESS_SHAPED = /^[A-Za-z0-9._-]+\/\S+$/;
|
|
14
|
+
/** Shared validation for boot config and owner POST; labels are plain text, never handles or authority. */
|
|
15
|
+
export function parseSeatNames(value) {
|
|
16
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length > 1024)
|
|
17
|
+
return undefined;
|
|
18
|
+
const entries = Object.entries(value);
|
|
19
|
+
if (entries.some(([id, name]) => !/^session-[a-zA-Z0-9-]{8,128}$/.test(id) || typeof name !== 'string' || name.trim() === '' || [...name.trim()].length > 64 || /[\p{Cc}\p{Cf}\u2028\u2029\ufffd]/u.test(name) || !/[^\p{Z}\p{C}\p{M}]/u.test(name) || ADDRESS_SHAPED.test(name.trim())))
|
|
20
|
+
return undefined;
|
|
21
|
+
return Object.fromEntries(entries.map(([id, name]) => [id, name.trim()]));
|
|
22
|
+
}
|
|
23
|
+
/** Boot config degrades entry-by-entry; owner POST remains all-or-nothing. Never echo label contents in warnings. */
|
|
24
|
+
export function loadSeatNames(value, warn) {
|
|
25
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
26
|
+
warn('seatNames: ignored invalid map');
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
const kept = {};
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const [id, name] of Object.entries(value)) {
|
|
32
|
+
const entry = parseSeatNames({ [id]: name });
|
|
33
|
+
if (entry === undefined || count >= 1024) {
|
|
34
|
+
warn('seatNames: ignored invalid or over-cap entry');
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
Object.assign(kept, entry);
|
|
38
|
+
count += 1;
|
|
39
|
+
}
|
|
40
|
+
return kept;
|
|
41
|
+
}
|
|
11
42
|
/** The exact keys this module may touch, in wire order. */
|
|
12
|
-
const KEYS = ['teamJoinAllowlist', 'teamScopeInfo', 'announce'];
|
|
43
|
+
const KEYS = ['teamJoinAllowlist', 'teamScopeInfo', 'announce', 'seatNames'];
|
|
13
44
|
export function applyOwnerSettings(patchYaml, changes) {
|
|
14
45
|
const lines = patchYaml.split('\n');
|
|
15
46
|
// Find the a2a entry: `- id: a2a` / `- id: 'a2a'` variants.
|
|
@@ -34,6 +65,8 @@ export function applyOwnerSettings(patchYaml, changes) {
|
|
|
34
65
|
writes.push(` teamScopeInfo: ${changes.teamScopeInfo ? 'true' : 'false'}`);
|
|
35
66
|
if (changes.announce !== undefined)
|
|
36
67
|
writes.push(` announce: ${changes.announce ? 'true' : 'false'}`);
|
|
68
|
+
if (changes.seatNames !== undefined)
|
|
69
|
+
writes.push(` seatNames: ${JSON.stringify(changes.seatNames)}`);
|
|
37
70
|
if (writes.length === 0)
|
|
38
71
|
return { yaml: patchYaml, restartNeeded };
|
|
39
72
|
if (entryStart === -1) {
|
|
@@ -81,6 +114,10 @@ export function applyOwnerSettings(patchYaml, changes) {
|
|
|
81
114
|
if (write !== undefined) {
|
|
82
115
|
lines[j] = write;
|
|
83
116
|
found = true;
|
|
117
|
+
// A map may have been authored as block YAML. Replace its nested rows too, not just the heading.
|
|
118
|
+
if (key === 'seatNames')
|
|
119
|
+
while (j + 1 < lines.length && (/^ {5,}\S/.test(lines[j + 1] ?? '') || /^\s*$/.test(lines[j + 1] ?? '')))
|
|
120
|
+
lines.splice(j + 1, 1);
|
|
84
121
|
}
|
|
85
122
|
break;
|
|
86
123
|
}
|
package/lib/peer-directory.js
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function peerLinkDisplay(peerUrl, d) {
|
|
2
|
+
const { card } = d;
|
|
3
|
+
if (card === undefined)
|
|
4
|
+
return { link: 'stranger', verified: false };
|
|
5
|
+
const origin = (url) => { try {
|
|
6
|
+
return new URL(url).origin;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return '';
|
|
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);
|
|
13
|
+
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
|
+
return { link: bilateral || exactMember ? 'linked' : 'stranger', verified: true };
|
|
15
|
+
}
|
|
@@ -42,6 +42,10 @@ model tools.
|
|
|
42
42
|
405 with an empty body. The fix is a host upgrade, not a retry.
|
|
43
43
|
- `a2a-net revoke` deregisters early.
|
|
44
44
|
|
|
45
|
+
## Team display names
|
|
46
|
+
|
|
47
|
+
`a2a-net teams <query>` searches the owner-set seat label as well as the route handle; `--json` exposes the effective display name. Local rows prefer `seatName`, then the current title (`name`), then the legacy label. Only the human workbench owner panel/config can set `seatNames`; the CLI has no naming command.
|
|
48
|
+
|
|
45
49
|
## Tasks: ledger and host observability
|
|
46
50
|
|
|
47
51
|
`a2a-net tasks --json` returns the existing `pending`, `dead`, and `archived` arrays plus `settlement` and `conservation` copied unchanged from `/__dsh_a2a/state`. These blocks include the lifetime `settlement.refusalsTotal`, refusal ring and mode, and the host conservation verdict/counters/duplicate ids. Older hosts omit a block they do not expose; the CLI does not manufacture zero counters or a passing verdict. Plain `tasks` appends a conservation line (`conserved`, `delta`) and a settlement line (`mode`, `refusalsTotal`, `offChain`) when the respective block exists.
|
|
@@ -117,9 +121,9 @@ Files do not go through session messages: use the file channel below, never JPFI
|
|
|
117
121
|
- Reusing an id for a different message, or switching `--sync` on or off,
|
|
118
122
|
is refused (exit 1): drop `--task` to send it as a new task.
|
|
119
123
|
- `report` takes `--task` the same way.
|
|
120
|
-
-
|
|
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.
|
|
121
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.
|
|
122
|
-
- 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.
|
|
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.
|
|
123
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.
|
|
124
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.
|
|
125
129
|
- `a2a-net report <message…>` is a one-way report to the host's process team.
|
|
@@ -165,9 +169,13 @@ Files do not go through session messages: use the file channel below, never JPFI
|
|
|
165
169
|
|
|
166
170
|
## Reading the host
|
|
167
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
|
+
|
|
168
174
|
`whoami`, `teams [query]`, `status`, `tasks`, `probe` read the home host's
|
|
169
175
|
state face (key-gated on a keyed host). `--json` prints raw rows for
|
|
170
|
-
`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.
|
|
171
179
|
|
|
172
180
|
## Local commands
|
|
173
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.**
|
|
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.
|
|
@@ -21,6 +21,10 @@ presenting the operator's gesture on the operator's behalf is exactly the
|
|
|
21
21
|
| Version | `/__dsh_a2a/state` `version`, or the workbench header badge | the same version on every host you will link (0.6.28+) |
|
|
22
22
|
| Clock | `date` on each host | NTP-synced: card signatures carry `expiresAt`, and the OriginClaim envelope allows ±5 min |
|
|
23
23
|
|
|
24
|
+
## Human seat names
|
|
25
|
+
|
|
26
|
+
The owner can open the workbench header ⚙, select a local session under “会话席位名”, and explicitly save a stable display name (up to 64 characters; a name shaped like a route address such as `dsh/66abc2cf` is refused so it cannot pass for another session). Empty input clears the label. The `seatNames` map is stored in the home patch layer and hot-applies; cards and the board prefer it over the session title, and `a2a_whoami` / `a2a-net teams <name>` expose it. It never changes the route handle, join state or admission. Agents must ask the owner rather than self-name: A2A tool arguments containing `seatName`/`seatNames` are refused; no naming model tool exists. The owner control API keeps its existing apiKey/loopback trust model, not a sandbox against local shell or config-file writers.
|
|
27
|
+
|
|
24
28
|
## 1. One host: sessions talking to each other
|
|
25
29
|
|
|
26
30
|
**Owner:**
|
|
@@ -111,14 +115,11 @@ Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
|
|
|
111
115
|
|
|
112
116
|
## 3. Beyond direct peers (A cannot reach C)
|
|
113
117
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
- A session's `a2a_route` dials the host that publishes the team.
|
|
118
|
-
- #57 Plan A (host forwarding) is designed. Its prerequisite #60 hop budget
|
|
119
|
-
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.
|
|
119
|
+
|
|
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.
|
|
120
121
|
|
|
121
|
-
|
|
122
|
+
Other connectivity options (not authority bypasses):
|
|
122
123
|
|
|
123
124
|
1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
|
|
124
125
|
then do section 2 for A↔C. This is the simplest and keeps receipts
|
|
@@ -135,8 +136,7 @@ Until forwarding ships, pick one:
|
|
|
135
136
|
## 4. Callers outside a session
|
|
136
137
|
|
|
137
138
|
Scripts and terminals use the `a2a-net` CLI: deposit on ONE home host, read
|
|
138
|
-
a mailbox, no wake. The
|
|
139
|
-
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).
|
|
140
140
|
|
|
141
141
|
## 5. Symptom → step
|
|
142
142
|
|