@nelsonlongxiang/dsh-open-a2a-net 0.6.31 → 0.6.32
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 +4 -1
- package/README_zh.md +3 -0
- package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +445 -57
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/card.js +4 -2
- package/lib/cli-deposit.js +122 -7
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +133 -43
- package/lib/faces/canvas-notify.js +12 -3
- package/lib/faces/files.js +155 -0
- package/lib/faces/nodes.js +171 -0
- package/lib/faces/session-log.js +162 -0
- package/lib/faces/wire.js +206 -39
- package/lib/index.js +809 -1720
- package/lib/joined-store.js +23 -0
- package/lib/ledger-conservation.js +97 -0
- package/lib/legacy/teams-bridge-inbound.js +156 -0
- package/lib/node-bind.js +534 -0
- package/lib/node-github.js +72 -0
- package/lib/node-self.js +453 -0
- package/lib/node-store.js +484 -0
- package/lib/node-tasks.js +342 -0
- package/lib/origin-claim.js +246 -0
- package/lib/peer-directory.js +134 -0
- package/lib/receipt.js +26 -1
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +82 -3
- package/lib/skill/references/collaboration.md +126 -74
- package/lib/skill/references/discovery.md +29 -0
- package/lib/skill/references/intake.md +107 -0
- package/lib/skill/references/router.md +96 -0
- package/lib/skill/references/setup.md +2 -1
- package/lib/task-auth.js +483 -0
- package/lib/task-ledger.js +324 -12
- package/lib/team-store.js +24 -1
- package/lib/tools/act.js +383 -0
- package/lib/tools/files.js +201 -0
- package/lib/tools/info.js +684 -0
- package/lib/types/a2a-client.d.ts +10 -4
- package/lib/types/auth-observer.d.ts +110 -0
- package/lib/types/blob-store.d.ts +83 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +14 -2
- package/lib/types/faces/canvas-notify.d.ts +4 -1
- package/lib/types/faces/files.d.ts +53 -0
- package/lib/types/faces/nodes.d.ts +69 -0
- package/lib/types/faces/session-log.d.ts +44 -0
- package/lib/types/faces/wire.d.ts +50 -5
- package/lib/types/index.d.ts +27 -0
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/ledger-conservation.d.ts +77 -0
- package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
- package/lib/types/node-bind.d.ts +220 -0
- package/lib/types/node-github.d.ts +35 -0
- package/lib/types/node-self.d.ts +178 -0
- package/lib/types/node-store.d.ts +180 -0
- package/lib/types/node-tasks.d.ts +121 -0
- package/lib/types/origin-claim.d.ts +119 -0
- package/lib/types/peer-directory.d.ts +39 -0
- package/lib/types/receipt.d.ts +12 -0
- package/lib/types/task-auth.d.ts +271 -0
- package/lib/types/task-ledger.d.ts +143 -6
- package/lib/types/team-store.d.ts +9 -0
- package/lib/types/tools/act.d.ts +52 -0
- package/lib/types/tools/files.d.ts +38 -0
- package/lib/types/tools/info.d.ts +102 -0
- package/lib/types/types.d.ts +6 -0
- package/nexus-stage/src/canvas-ops.ts +1 -1
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +41 -18
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +29 -26
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +5 -1
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
- package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
package/lib/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* inbound direct routes into live agent sessions.
|
|
6
6
|
* @module @nelsonlongxiang/dsh-open-a2a-net
|
|
7
7
|
*/
|
|
8
|
-
import { createHash, createPrivateKey,
|
|
8
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { networkInterfaces } from 'node:os';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
@@ -29,29 +29,45 @@ export function resolveCallerLabel(input) {
|
|
|
29
29
|
import { signCard, verifyCard } from "./card.js";
|
|
30
30
|
import { GroupStore } from "./group-store.js";
|
|
31
31
|
import { CanvasStore, isRemoteMemberRef } from "./canvas-store.js";
|
|
32
|
-
import { decodeSessionLog, extractTranscript,
|
|
32
|
+
import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
|
|
33
33
|
import { LayoutStore } from "./layout-store.js";
|
|
34
34
|
import { JoinedSessions } from "./joined-store.js";
|
|
35
35
|
import { ExposureStore } from "./exposure-store.js";
|
|
36
|
-
import { CliDepositStore } from "./cli-deposit.js";
|
|
36
|
+
import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
|
|
37
37
|
import { TeamMembershipStore } from "./team-store.js";
|
|
38
38
|
import { PeerStore } from "./peer-store.js";
|
|
39
39
|
import { SelfReferralFilter } from "./self-suppress.js";
|
|
40
40
|
import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
|
|
41
|
-
import { formatReceipt, parseReceipt } from "./receipt.js";
|
|
41
|
+
import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
|
|
42
42
|
import { runReceiptLadder } from "./receipt-ladder.js";
|
|
43
43
|
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
|
|
44
44
|
import { createFloodGuard } from "./flood-guard.js";
|
|
45
45
|
import { resolveZone } from "./zone.js";
|
|
46
46
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
47
|
+
import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
|
|
48
|
+
import { AuthObserver } from "./auth-observer.js";
|
|
49
|
+
import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission } from "./task-auth.js";
|
|
47
50
|
import s from '@deepseek-ai/schemastery';
|
|
48
|
-
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
49
51
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
50
52
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
51
53
|
import { A2aClient } from "./a2a-client.js";
|
|
52
54
|
import { WIRE_ERROR_PAYLOAD_TOO_LARGE } from "./transport-caps.js";
|
|
53
55
|
import { registerAdminFaces } from "./faces/admin.js";
|
|
56
|
+
import { registerNodeFaces } from "./faces/nodes.js";
|
|
57
|
+
import { NodeStore } from "./node-store.js";
|
|
58
|
+
import { createNodeBinder, NodeBindings } from "./node-bind.js";
|
|
59
|
+
import { NodeTasks, settledReminder } from "./node-tasks.js";
|
|
60
|
+
import { githubProbe } from "./node-github.js";
|
|
61
|
+
import { NodeReviewQueue, SessionTaint, localRoute, toolTaint } from "./node-self.js";
|
|
54
62
|
import { registerWireFaces } from "./faces/wire.js";
|
|
63
|
+
import { createInfoTools } from "./tools/info.js";
|
|
64
|
+
import { registerSessionLogFaces } from "./faces/session-log.js";
|
|
65
|
+
import { registerFileFaces } from "./faces/files.js";
|
|
66
|
+
import { createFileTools } from "./tools/files.js";
|
|
67
|
+
import { BlobStore } from "./blob-store.js";
|
|
68
|
+
import { canvasCandidateMatches, canvasTargetAmbiguous, peerDirectoryRows, routableCallerLabel, sessionCandidateMatches, unroutableCallerNote } from "./peer-directory.js";
|
|
69
|
+
import { createTeamsBridgeInbound } from "./legacy/teams-bridge-inbound.js";
|
|
70
|
+
import { createActTools } from "./tools/act.js";
|
|
55
71
|
import { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
56
72
|
export { A2aClient } from "./a2a-client.js";
|
|
57
73
|
export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
@@ -64,7 +80,9 @@ export const Config = s.object({
|
|
|
64
80
|
team: s.string().default('dsh'),
|
|
65
81
|
/** S4 fix ③: host-side (agent-less) routing calls — audit them (default) or refuse outright. */
|
|
66
82
|
gateAgentLessCalls: s.union(['audit', 'refuse']).default('audit'),
|
|
83
|
+
settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
|
|
67
84
|
announce: s.boolean().default(false),
|
|
85
|
+
isolated: s.boolean().default(false),
|
|
68
86
|
agentName: s.string().default('DeepSeek Harness A2A node'),
|
|
69
87
|
peers: s.array(s.string()).default([]),
|
|
70
88
|
delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
|
|
@@ -145,6 +163,14 @@ export const Config = s.object({
|
|
|
145
163
|
* failed signatures. See docs/protocol/delivery-origin-auth.md §4.
|
|
146
164
|
*/
|
|
147
165
|
originClaimWindow: s.boolean().default(true),
|
|
166
|
+
/**
|
|
167
|
+
* OriginClaim unbound window: also sign/accept the legacy envelope that
|
|
168
|
+
* names no audience (counted as unboundInbound); closing it leaves the
|
|
169
|
+
* audience-bound form only. See docs/protocol/delivery-origin-auth.md §2.1.
|
|
170
|
+
*/
|
|
171
|
+
originClaimUnboundWindow: s.boolean().default(true),
|
|
172
|
+
/** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
|
|
173
|
+
originClaimAudiences: s.array(s.string()).default([]),
|
|
148
174
|
/** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
|
|
149
175
|
originClaimUrl: s.string().default(''),
|
|
150
176
|
/**
|
|
@@ -164,17 +190,6 @@ export const Config = s.object({
|
|
|
164
190
|
nativeTeamsInbound: s.boolean().default(false),
|
|
165
191
|
nativeRoundWaitMs: s.number().default(180_000),
|
|
166
192
|
});
|
|
167
|
-
/** Model-facing text for one route result. */
|
|
168
|
-
function renderRoute(_args, value) {
|
|
169
|
-
const result = value;
|
|
170
|
-
const text = result.ok
|
|
171
|
-
? [
|
|
172
|
-
`Team ${result.team} replied (task ${result.task_id}, status ${result.task_status}, context ${result.context_id}):`,
|
|
173
|
-
result.reply,
|
|
174
|
-
].join('\n')
|
|
175
|
-
: `A2A route failed${result.code === undefined ? '' : ` (code ${String(result.code)})`}: ${result.error}`;
|
|
176
|
-
return [{ type: 'text', text }];
|
|
177
|
-
}
|
|
178
193
|
/**
|
|
179
194
|
* Register the outbound peer tools, the direct-route endpoint, and (when
|
|
180
195
|
* composed) the announced card and session-node control surface.
|
|
@@ -288,7 +303,9 @@ export function apply(ctx, config) {
|
|
|
288
303
|
return () => { };
|
|
289
304
|
}
|
|
290
305
|
};
|
|
291
|
-
|
|
306
|
+
// gh#193: the one door to peers — while isolated nothing leaves this host.
|
|
307
|
+
const peerFetch = (url, init) => config.isolated ? Promise.reject(new Error('isolated: outbound peer traffic is off on this host')) : globalThis.fetch(url, init);
|
|
308
|
+
const fetchImpl = (url, init) => peerFetch(url, init);
|
|
292
309
|
/** Constant-time secret comparison: hash both sides to equal-length digests. */
|
|
293
310
|
function secretEqual(a, b) {
|
|
294
311
|
const digestA = createHash('sha256').update(a).digest();
|
|
@@ -426,6 +443,8 @@ export function apply(ctx, config) {
|
|
|
426
443
|
let body;
|
|
427
444
|
try {
|
|
428
445
|
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
446
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
|
447
|
+
throw new TypeError('control body must be a JSON object'); // `null` crashed every route reading body.id
|
|
429
448
|
}
|
|
430
449
|
catch {
|
|
431
450
|
const payload = JSON.stringify({ error: 'malformed body' });
|
|
@@ -446,7 +465,18 @@ export function apply(ctx, config) {
|
|
|
446
465
|
res.end(payload);
|
|
447
466
|
return;
|
|
448
467
|
}
|
|
449
|
-
|
|
468
|
+
try {
|
|
469
|
+
use(body);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
logger.warn('control route failed: %s', String(error));
|
|
473
|
+
if (!res.headersSent) {
|
|
474
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
475
|
+
res.end('{"error":"internal error"}');
|
|
476
|
+
}
|
|
477
|
+
else if (!res.writableEnded)
|
|
478
|
+
res.end();
|
|
479
|
+
} // runs in the reader's 'end' callback, outside the web server's guard: a throw here would exit the host
|
|
450
480
|
});
|
|
451
481
|
}
|
|
452
482
|
/**
|
|
@@ -482,6 +512,8 @@ export function apply(ctx, config) {
|
|
|
482
512
|
// the same home as the node key.
|
|
483
513
|
const home = config.dshHome === '' ? resolveDshHome() : resolveDshHome(config.dshHome);
|
|
484
514
|
const peerStore = new PeerStore(config.peers, join(home, 'a2a', 'peers.json'));
|
|
515
|
+
// Node identity (docs/design/node-identity.md, N1): human-named nodes and their prompt versions.
|
|
516
|
+
const nodeStore = new NodeStore(join(home, 'a2a'));
|
|
485
517
|
// Peer boundary model (docs/design/peer-boundary-model.md) — connection
|
|
486
518
|
// layer. referralLearning off freezes the seed set: referrals are neither
|
|
487
519
|
// learned nor offered. A non-empty peerAllowlist admits a referral only
|
|
@@ -524,32 +556,23 @@ export function apply(ctx, config) {
|
|
|
524
556
|
/** Our own card URL once the web server settles — the OriginClaim anchor. */
|
|
525
557
|
let ownCardUrl = '';
|
|
526
558
|
/**
|
|
527
|
-
* The OriginClaim envelope headers (gh#9+gh#10):
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* caller derives from the minimal card's team either way).
|
|
559
|
+
* The OriginClaim envelope headers (gh#9+gh#10, src/origin-claim.ts): the
|
|
560
|
+
* bound form names the target's origin, method and path; the unbound
|
|
561
|
+
* legacy form rides along only while originClaimUnboundWindow is open.
|
|
562
|
+
* Signed with the node card key; shared by the a2a client (every peer
|
|
563
|
+
* request) and the relay/notify faces.
|
|
533
564
|
*/
|
|
534
|
-
function originClaimHeaders() {
|
|
535
|
-
|
|
536
|
-
const signature = edSign(undefined, Buffer.from(JSON.stringify([config.team, ownCardUrl, ts]), 'utf8'), nodePrivateKey).toString('base64');
|
|
537
|
-
return {
|
|
538
|
-
'X-A2A-Caller': config.team,
|
|
539
|
-
'X-A2A-Caller-Url': ownCardUrl,
|
|
540
|
-
'X-A2A-Caller-Ts': String(ts),
|
|
541
|
-
'X-A2A-Caller-Sig': signature,
|
|
542
|
-
};
|
|
565
|
+
function originClaimHeaders(target) {
|
|
566
|
+
return signOriginClaim(target, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
|
|
543
567
|
}
|
|
544
568
|
const client = new A2aClient({
|
|
545
569
|
apiKey: config.apiKey,
|
|
546
570
|
sessionId: session,
|
|
547
571
|
cardCaller: config.team,
|
|
548
572
|
// gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md): every
|
|
549
|
-
// peer request carries a verifiable origin
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
// is unknown; the signer returns plain naming for that brief window.
|
|
573
|
+
// peer request carries a verifiable origin bound to its target, signed
|
|
574
|
+
// with the node card key (single identity anchor). Until the server
|
|
575
|
+
// settles the URL is unknown; receivers read that as an absent claim.
|
|
553
576
|
originClaim: originClaimHeaders,
|
|
554
577
|
schedule,
|
|
555
578
|
fetch: fetchImpl,
|
|
@@ -637,6 +660,8 @@ export function apply(ctx, config) {
|
|
|
637
660
|
// may sit in many teams; routing to <team>/canvas/<name> resolves the
|
|
638
661
|
// first live member or wakes the first cold one (member order = priority).
|
|
639
662
|
const canvasStore = new CanvasStore(join(home, 'a2a', 'canvas.json'));
|
|
663
|
+
// #91 file channel: the sender host's blob store (docs/design/file-channel.md).
|
|
664
|
+
const blobStore = new BlobStore(join(home, 'a2a'));
|
|
640
665
|
// Spatial presentation state for the full-page stage (contract v2).
|
|
641
666
|
const layoutStore = new LayoutStore(join(home, 'a2a', 'canvas-layout.json'));
|
|
642
667
|
// The async-task ledger: every route that leaves a task owed a receipt is
|
|
@@ -650,13 +675,34 @@ export function apply(ctx, config) {
|
|
|
650
675
|
// here. It grants no presence: reports land in a bounded ring the state
|
|
651
676
|
// route serves, never in any routable surface.
|
|
652
677
|
const reportRing = [];
|
|
653
|
-
const reportLastAt = new Map();
|
|
654
678
|
// Quick-dispatch face (0.6.9): the workbench POSTs /__dsh_a2a/dispatch as a
|
|
655
679
|
// human gesture; the window keeps a runaway page (or a stuck autorefresh)
|
|
656
680
|
// from flooding the mesh. Keyed by client address, mirror of reportLastAt.
|
|
657
681
|
const dispatchLastAt = new Map();
|
|
658
|
-
/**
|
|
659
|
-
|
|
682
|
+
/**
|
|
683
|
+
* #114 S4: the judge of one settler against each correlated row's
|
|
684
|
+
* authorization chain (design §4.2). `observe` records what enforce would
|
|
685
|
+
* refuse and settles as before; `enforce` keeps a refused row owed. No
|
|
686
|
+
* settler (a path that knows none), or settlementAuth off, judges nothing.
|
|
687
|
+
*/
|
|
688
|
+
function settlementJudgeFor(settler, parsedForJudge, alwaysEnforce = false) {
|
|
689
|
+
return settler === undefined || (config.settlementAuth === 'off' && !alwaysEnforce) ? undefined : {
|
|
690
|
+
enforce: alwaysEnforce || config.settlementAuth === 'enforce',
|
|
691
|
+
settler: { caller: settler.caller, proof: settler.proof, ingress: settler.ingress },
|
|
692
|
+
check: (row, echo) => judgeSettlement(row, settler, {
|
|
693
|
+
hostLabel: session,
|
|
694
|
+
membersOf: (team) => {
|
|
695
|
+
const name = parseCanvasTeamName(team);
|
|
696
|
+
if (name === undefined)
|
|
697
|
+
return undefined;
|
|
698
|
+
// Canvas members by session id and by handle, as the faces store them.
|
|
699
|
+
return canvasStore.membersOf(name).flatMap(m => (m.includes('/') ? [m] : [m, `${config.team}/${id8(m)}`]));
|
|
700
|
+
},
|
|
701
|
+
...(parsedForJudge?.envelope?.onBehalfOf !== undefined ? { onBehalfOf: parsedForJudge.envelope.onBehalfOf } : {}),
|
|
702
|
+
echo,
|
|
703
|
+
}),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
660
706
|
/**
|
|
661
707
|
* Settle one message through the ledger and announce what it correlated
|
|
662
708
|
* on `a2a/receipt-resolved` — the seam peer plugins (native-teams)
|
|
@@ -667,7 +713,9 @@ export function apply(ctx, config) {
|
|
|
667
713
|
* the consumer's own discipline — keep seam listeners synchronous.
|
|
668
714
|
* @param message - the inbound or relayed message text.
|
|
669
715
|
*/
|
|
670
|
-
function settleAndAnnounce(message) {
|
|
716
|
+
function settleAndAnnounce(message, settler) {
|
|
717
|
+
const parsedForJudge = parseReceipt(message);
|
|
718
|
+
const judge = settlementJudgeFor(settler, parsedForJudge);
|
|
671
719
|
// W7 slice-2 hook 3: a receipt that correlates an id THIS node's
|
|
672
720
|
// idempotency ledger claimed records the receipt's human line as that
|
|
673
721
|
// key's outcome. Parsed DIRECTLY off the receipt codec — a claimed key
|
|
@@ -677,24 +725,41 @@ export function apply(ctx, config) {
|
|
|
677
725
|
// the envelope's controlled-vocabulary outcome is never a product and
|
|
678
726
|
// must not be recorded as one. First-write wins inside the store, so a
|
|
679
727
|
// late receipt never overwrites a precise sync-hook record.
|
|
680
|
-
const parsed =
|
|
728
|
+
const parsed = parsedForJudge;
|
|
681
729
|
if (parsed !== null && parsed.taskId !== '') {
|
|
682
730
|
const line = parsed.summary.trim();
|
|
683
|
-
|
|
731
|
+
// #114 S4 (design §4.2.2): under enforce, the receipt hook writes the
|
|
732
|
+
// outcome a /a2a/query answers only for a row THIS ledger owes and
|
|
733
|
+
// whose settler is on the chain — anyone sending a receipt line could
|
|
734
|
+
// otherwise rewrite another caller's answer.
|
|
735
|
+
const row = judge?.enforce === true ? taskLedger.find(parsed.taskId) : undefined;
|
|
736
|
+
const allowed = judge?.enforce !== true || (row !== undefined && judge.check(row, false).ok);
|
|
737
|
+
if (line !== '' && allowed) {
|
|
684
738
|
idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
|
|
685
739
|
}
|
|
686
740
|
}
|
|
687
|
-
|
|
741
|
+
// A header that names an owed row and comes back unresolved was refused
|
|
742
|
+
// (enforce): that is the verdict — the echo fallback must not judge the
|
|
743
|
+
// same message a second time.
|
|
744
|
+
const owedRow = parsed !== null && parsed.taskId !== '' ? taskLedger.find(parsed.taskId) : undefined;
|
|
745
|
+
const owedByHeader = owedRow !== undefined;
|
|
746
|
+
// #139 (design §4.4): an enforce refusal of the row the header names is
|
|
747
|
+
// told to the sender on the wire response, not only in /state.
|
|
748
|
+
const headerVerdict = judge?.enforce === true && owedRow !== undefined ? judge.check(owedRow, false) : undefined;
|
|
749
|
+
const resolved = taskLedger.resolveFromMessage(message, judge);
|
|
750
|
+
if (resolved === undefined && owedByHeader) {
|
|
751
|
+
return headerVerdict !== undefined && !headerVerdict.ok ? { status: 'refused', reason: headerVerdict.reason, task_id: parsed.taskId } : undefined;
|
|
752
|
+
}
|
|
688
753
|
if (resolved === undefined) {
|
|
689
754
|
// F5 (settlement integrity): the formal envelope missed — an inbound
|
|
690
755
|
// message that still echoes a pending task id means the peer
|
|
691
756
|
// demonstrably responded to it, which settles the debt even when the
|
|
692
757
|
// receipt formality never rode the reply.
|
|
693
|
-
const echoed = taskLedger.resolveEcho(message);
|
|
758
|
+
const echoed = taskLedger.resolveEcho(message, judge);
|
|
694
759
|
if (echoed.length > 0) {
|
|
695
760
|
logger.info(`a2a: ${String(echoed.length)} pending task(s) settled by content echo (${echoed.join(', ')})`);
|
|
696
761
|
}
|
|
697
|
-
return;
|
|
762
|
+
return undefined;
|
|
698
763
|
}
|
|
699
764
|
try {
|
|
700
765
|
ctx.emit('a2a/receipt-resolved', resolved);
|
|
@@ -702,6 +767,32 @@ export function apply(ctx, config) {
|
|
|
702
767
|
catch (error) {
|
|
703
768
|
logger.warn(`a2a: receipt-resolved listener rejected: ${String(error)}`);
|
|
704
769
|
}
|
|
770
|
+
return undefined;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* gh#141: a receipt this node sent across hosts pays its own debt — the
|
|
774
|
+
* inbound row the answered delivery was born with (F2'). The same-host
|
|
775
|
+
* path settles in settleAndAnnounce, as the receipt lands in-process; a
|
|
776
|
+
* receipt that leaves over the wire never passed this ledger, so the row
|
|
777
|
+
* stayed owed (and kept the async nudge armed) until its dead TTL. Only
|
|
778
|
+
* an inbound row whose receipt target is the team the receipt reached
|
|
779
|
+
* settles, and only for an on-chain payer, whatever settlementAuth says:
|
|
780
|
+
* this path is new, so "observe settles as before" has no before to keep
|
|
781
|
+
* (review of PR #149 P2-1) — another session on this host never pays a
|
|
782
|
+
* row it was not delivered. A settled row records the receipt's line as
|
|
783
|
+
* its claimed key's outcome, as the in-process path does (hook 3).
|
|
784
|
+
* @param message - the receipt text as delivered.
|
|
785
|
+
* @param creditor - the team the receipt was delivered to.
|
|
786
|
+
* @param payer - who sent it on this host.
|
|
787
|
+
*/
|
|
788
|
+
function settleOwnInbound(message, creditor, payer) {
|
|
789
|
+
const parsed = parseReceipt(message);
|
|
790
|
+
if (parsed === null || parsed.taskId === '')
|
|
791
|
+
return;
|
|
792
|
+
const settled = taskLedger.resolveFromMessage(message, settlementJudgeFor(payer, parsed, true), row => row.direction === 'inbound' && row.peer === `local:${creditor}`);
|
|
793
|
+
const line = parsed.summary.trim();
|
|
794
|
+
if (settled !== undefined && line !== '')
|
|
795
|
+
idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
|
|
705
796
|
}
|
|
706
797
|
// Server-side idempotency keys (work-order P3): a caller-born task id
|
|
707
798
|
// executes at most once inside the 24h window — same-key replays answer
|
|
@@ -721,6 +812,37 @@ export function apply(ctx, config) {
|
|
|
721
812
|
// Live top-level agents: the join surface's candidates and the cold-row
|
|
722
813
|
// complement (apply scope — the direct-route wake below reads it too).
|
|
723
814
|
const liveRoots = new Map();
|
|
815
|
+
// Node identity N2+N3: a joined session that carries a node gets that node's layers on its own scope.
|
|
816
|
+
const nodeBindings = new NodeBindings(join(home, 'a2a'));
|
|
817
|
+
const nodeReviews = new NodeReviewQueue(join(home, 'a2a'));
|
|
818
|
+
const nodeTaint = new SessionTaint(join(home, 'a2a'));
|
|
819
|
+
// N4: a SUBAGENT's mark climbs to every ancestor it reports to (session.header: origin 'subagent' + parentSession). A
|
|
820
|
+
// fork is not climbed: its source never sees the fork's text, and the fork's inherited log carries its own evidence.
|
|
821
|
+
const markLineage = (agent, via) => {
|
|
822
|
+
const seen = new Set();
|
|
823
|
+
for (let a = agent; a !== undefined && !seen.has(String(a.id));) {
|
|
824
|
+
seen.add(String(a.id));
|
|
825
|
+
nodeTaint.mark(String(a.id), via);
|
|
826
|
+
const header = a.session?.header;
|
|
827
|
+
if (header?.origin !== 'subagent' || typeof header.parentSession !== 'string' || header.parentSession === '')
|
|
828
|
+
return;
|
|
829
|
+
const next = ctx.get('agents')?.get(header.parentSession);
|
|
830
|
+
if (next === undefined) {
|
|
831
|
+
nodeTaint.mark(header.parentSession, via);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
a = next;
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
ctx.on('tools/post-execute', (exec, result, next) => { const via = result.isError ? (/^a2a_(route|tasks|teams|status|probe|file_send|file_fetch)$/.test(exec.name) ? `${exec.name}:error` : '') : toolTaint(exec.name, result.value); if (via !== '')
|
|
838
|
+
markLineage(exec.agent, via); return next(); });
|
|
839
|
+
const nodeTasks = new NodeTasks(join(home, 'a2a'), { probe: githubProbe(), logger, onSettled: (node, p) => nodeBinder.remind(node, settledReminder(p)) });
|
|
840
|
+
nodeTasks.schedule(ctx);
|
|
841
|
+
const nodeBinder = createNodeBinder({ store: nodeStore, bindings: nodeBindings, logger, joined: id => joinedSessions.has(id), joinedList: () => joinedSessions.list(), archived: id => archivedSessionFilter()?.(id) === true, live: id => liveRoots.get(id), tasksText: node => nodeTasks.render(node), pendingReminders: node => nodeTasks.takePending(node).map(settledReminder), selfUpdate: { store: nodeStore, queue: nodeReviews, taint: nodeTaint, events: agent => sessionEventsOf(agent), localSession: id => liveRoots.has(id) || joinedSessions.has(id) } });
|
|
842
|
+
// No boot reconcile: a joined.json that failed to parse reads as empty, and ending every binding on that
|
|
843
|
+
// reading would destroy them (the 3081 lesson). A stale row is harmless — mount checks joined itself.
|
|
844
|
+
ctx.effect(() => joinedSessions.onChange(() => { nodeBinder.reconcile(); }), 'a2a: node bindings follow join');
|
|
845
|
+
ctx.effect(() => () => { nodeBinder.disposeAll(); }, 'a2a: node mounts');
|
|
724
846
|
// Last foreground wake/route demand (epoch ms): boot prewarm steps yield
|
|
725
847
|
// while demand keeps arriving inside the quiet window. Panel polls never
|
|
726
848
|
// touch this — they are constant and would starve warming forever.
|
|
@@ -841,7 +963,10 @@ export function apply(ctx, config) {
|
|
|
841
963
|
for (const id of joinedSessions.list()) {
|
|
842
964
|
if (!isArchived(id))
|
|
843
965
|
continue;
|
|
966
|
+
const ownHandle = ownCanvasHandleOf(id, true);
|
|
844
967
|
sessionNodes.delete(id);
|
|
968
|
+
// Sections come back at the next mount (re-publish) if this archived reading was transient; the row stays.
|
|
969
|
+
nodeBinder.suspend(id);
|
|
845
970
|
// The archived session's JOIN INTENT is retained: deleting it was the
|
|
846
971
|
// 3081 wake-defect root cause (a transient archived-window reading
|
|
847
972
|
// wiped intents at boot, and cold sessions then never woke — "no cold
|
|
@@ -849,6 +974,8 @@ export function apply(ctx, config) {
|
|
|
849
974
|
// archived intents (:804), card assembly and the state cold rows filter
|
|
850
975
|
// them, the reconciler skips them, and JOIN_CAP bounds the file.
|
|
851
976
|
canvasStore.dropMember(id);
|
|
977
|
+
if (ownHandle !== undefined)
|
|
978
|
+
canvasStore.dropMember(ownHandle);
|
|
852
979
|
// S2 consistency: an archived node's roster declarations die with it
|
|
853
980
|
// — a left node keeps no team membership.
|
|
854
981
|
teamMemberships.dropSession(id);
|
|
@@ -900,6 +1027,87 @@ export function apply(ctx, config) {
|
|
|
900
1027
|
return `import-${imported[1].slice(0, 8)}`;
|
|
901
1028
|
return bare.slice(0, 8);
|
|
902
1029
|
}
|
|
1030
|
+
/**
|
|
1031
|
+
* gh#131: the local session one of this host's own handles names — the
|
|
1032
|
+
* `<zone>/<id8>` of a joined or live, unarchived session here — when that
|
|
1033
|
+
* is the only reading. An id8 shared by two local sessions names neither,
|
|
1034
|
+
* and a handle a configured peer's card also publishes (an id8 collision
|
|
1035
|
+
* across hosts: the operator may mean the peer's session) stays remote.
|
|
1036
|
+
* Only the peers the directory resolves through count (seeded and
|
|
1037
|
+
* delegates, review of PR #151 P3-1): a host learned by referral cannot
|
|
1038
|
+
* veto the local reading with an unsigned sessionTeams row.
|
|
1039
|
+
* @param ref - a remote-shaped member ref.
|
|
1040
|
+
* @returns the local session id, or undefined.
|
|
1041
|
+
*/
|
|
1042
|
+
function localSessionOfHandle(ref) {
|
|
1043
|
+
return localHandleResolver()(ref);
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* One snapshot of {@link localSessionOfHandle} for many lookups: the
|
|
1047
|
+
* archived filter, the local id8 index and the seeded peers' cards are
|
|
1048
|
+
* read once (review of PR #151 P3-e).
|
|
1049
|
+
* @param includeArchived - count archived sessions too (the archive prune
|
|
1050
|
+
* drops an archived session's own handle).
|
|
1051
|
+
*/
|
|
1052
|
+
function localHandleResolver(includeArchived = false) {
|
|
1053
|
+
const isArchived = includeArchived ? undefined : archivedSessionFilter();
|
|
1054
|
+
const byHandle = new Map();
|
|
1055
|
+
for (const id of new Set([...joinedSessions.list(), ...sessionNodes.keys()])) {
|
|
1056
|
+
if (isArchived?.(id) === true)
|
|
1057
|
+
continue;
|
|
1058
|
+
const handle = `${config.team}/${id8(id)}`;
|
|
1059
|
+
byHandle.set(handle, [...(byHandle.get(handle) ?? []), id]);
|
|
1060
|
+
}
|
|
1061
|
+
const published = new Set();
|
|
1062
|
+
for (const url of new Set([...peerStore.listSeeded(), ...config.delegates.map(delegate => delegate.url)])) {
|
|
1063
|
+
const card = cardCache.get(url)?.card;
|
|
1064
|
+
if (card !== undefined && card.session !== session)
|
|
1065
|
+
for (const row of card.sessionTeams ?? [])
|
|
1066
|
+
published.add(row.team);
|
|
1067
|
+
}
|
|
1068
|
+
return (ref) => {
|
|
1069
|
+
const hits = byHandle.get(ref);
|
|
1070
|
+
return hits?.length === 1 && !published.has(ref) ? hits[0] : undefined;
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* gh#131: the handle a canvas may hold for one local session — its own
|
|
1075
|
+
* `<zone>/<id8>` when that handle reads as this session — so leave,
|
|
1076
|
+
* archive and removal drop it with the id (review of PR #151 P2: a
|
|
1077
|
+
* handle-stored membership must not outlive the session's consent).
|
|
1078
|
+
* @param id - the local session id (still joined when this is read).
|
|
1079
|
+
*/
|
|
1080
|
+
function ownCanvasHandleOf(id, includeArchived = false) {
|
|
1081
|
+
const handle = `${config.team}/${id8(id)}`;
|
|
1082
|
+
return localHandleResolver(includeArchived)(handle) === id ? handle : undefined;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* gh#152: a local session's canvas teams as every READ face sees them — the
|
|
1086
|
+
* stored member id, or one of this host's own handles that reads as this
|
|
1087
|
+
* session (a fleet edit stored it by handle; routing has read that shape
|
|
1088
|
+
* through canvasLocalMembersOf since gh#131). Visibility now matches
|
|
1089
|
+
* routability: whoami, the scoped teams listing and the state face show the
|
|
1090
|
+
* canvas a handle-stored membership puts the session in.
|
|
1091
|
+
*/
|
|
1092
|
+
function canvasTeamsOfSession(id) {
|
|
1093
|
+
const teams = new Set(canvasStore.teamsOf(id));
|
|
1094
|
+
const own = ownCanvasHandleOf(id);
|
|
1095
|
+
if (own !== undefined)
|
|
1096
|
+
for (const name of canvasStore.teamsOf(own))
|
|
1097
|
+
teams.add(name);
|
|
1098
|
+
return [...teams];
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* gh#131: a canvas team's members as the local routing tiers read them —
|
|
1102
|
+
* one of this host's own handles stands for the session it names (a fleet
|
|
1103
|
+
* edit stored it by handle). Read at route time, never written back: a
|
|
1104
|
+
* reading that depends on which peer cards are cached must not rewrite
|
|
1105
|
+
* the store (review of PR #151 P2-2).
|
|
1106
|
+
*/
|
|
1107
|
+
function canvasLocalMembersOf(name) {
|
|
1108
|
+
const resolve = localHandleResolver();
|
|
1109
|
+
return canvasStore.membersOf(name).map(id => (isRemoteMemberRef(id) ? resolve(id) ?? id : id));
|
|
1110
|
+
}
|
|
903
1111
|
/** The 8-char suffix of one agent's id. */
|
|
904
1112
|
function agentId8(agent) {
|
|
905
1113
|
return id8(String(agent.id));
|
|
@@ -948,7 +1156,7 @@ export function apply(ctx, config) {
|
|
|
948
1156
|
function canvasLiveAgent(name, skipId) {
|
|
949
1157
|
if (name === undefined)
|
|
950
1158
|
return undefined;
|
|
951
|
-
for (const id of
|
|
1159
|
+
for (const id of canvasLocalMembersOf(name)) {
|
|
952
1160
|
if (id === skipId)
|
|
953
1161
|
continue;
|
|
954
1162
|
const agent = sessionNodes.get(id);
|
|
@@ -968,7 +1176,7 @@ export function apply(ctx, config) {
|
|
|
968
1176
|
if (name === undefined)
|
|
969
1177
|
return undefined;
|
|
970
1178
|
const isArchived = archivedSessionFilter();
|
|
971
|
-
return
|
|
1179
|
+
return canvasLocalMembersOf(name).find(id => id !== skipId && joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
|
|
972
1180
|
}
|
|
973
1181
|
/**
|
|
974
1182
|
* Human-facing node facts for one session: the session title (fallback:
|
|
@@ -1124,10 +1332,12 @@ export function apply(ctx, config) {
|
|
|
1124
1332
|
liveRoots.set(String(agent.id), agent);
|
|
1125
1333
|
if (joinedSessions.has(String(agent.id)))
|
|
1126
1334
|
mountSessionNode(agent);
|
|
1335
|
+
nodeBinder.mount(agent);
|
|
1127
1336
|
return undefined;
|
|
1128
1337
|
});
|
|
1129
1338
|
ctx.on('agent/disposed', ({ agent }) => {
|
|
1130
1339
|
liveRoots.delete(String(agent.id));
|
|
1340
|
+
nodeBinder.forget(String(agent.id));
|
|
1131
1341
|
// Disposal unmounts the runtime node; the persisted intent stays for
|
|
1132
1342
|
// the remount above (leave is the only intent remover).
|
|
1133
1343
|
sessionNodes.delete(String(agent.id));
|
|
@@ -1136,6 +1346,7 @@ export function apply(ctx, config) {
|
|
|
1136
1346
|
liveRoots.set(String(agent.id), agent);
|
|
1137
1347
|
if (joinedSessions.has(String(agent.id)))
|
|
1138
1348
|
mountSessionNode(agent);
|
|
1349
|
+
nodeBinder.mount(agent, { atFrozen: true });
|
|
1139
1350
|
}
|
|
1140
1351
|
/** F-observability: the boot prewarm's lifecycle, surfaced on the state route (F: prewarm not running was invisible). */
|
|
1141
1352
|
const prewarmStatus = { state: 'off', attempted: 0, woken: 0, failed: [] };
|
|
@@ -1417,7 +1628,7 @@ export function apply(ctx, config) {
|
|
|
1417
1628
|
at: now,
|
|
1418
1629
|
rows: all
|
|
1419
1630
|
.filter(row => row.local !== true)
|
|
1420
|
-
.map(row => ({ team: row.team, name: row.name, ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.via !== undefined ? { via: row.via } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}), ...(row.legacy === true ? { legacy: true } : {}), ...(row.teams !== undefined && row.teams.length > 0 ? { teams: [...row.teams] } : {}) })),
|
|
1631
|
+
.map(row => ({ team: row.team, name: row.name, ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.via !== undefined ? { via: row.via } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}), ...(row.legacy === true ? { legacy: true } : {}), ...(row.unreachable === true ? { unreachable: true } : {}), ...(row.teams !== undefined && row.teams.length > 0 ? { teams: [...row.teams] } : {}) })),
|
|
1421
1632
|
};
|
|
1422
1633
|
})
|
|
1423
1634
|
.catch(() => { })
|
|
@@ -1428,11 +1639,13 @@ export function apply(ctx, config) {
|
|
|
1428
1639
|
whenWebServerSettled((webServer) => {
|
|
1429
1640
|
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1430
1641
|
registerAdminFaces(webServer, {
|
|
1431
|
-
ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch,
|
|
1642
|
+
ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
|
|
1432
1643
|
indexUrl: import.meta.url,
|
|
1433
1644
|
get cardCache() { return cardCache; },
|
|
1434
1645
|
get remoteRowsCache() { return remoteRowsCache; },
|
|
1435
1646
|
get legacyInboundCount() { return legacyInboundCount; },
|
|
1647
|
+
get unboundInboundCount() { return unboundInboundCount; },
|
|
1648
|
+
get boundMissInboundCount() { return boundMissInboundCount; },
|
|
1436
1649
|
get ownCardUrl() { return ownCardUrl; },
|
|
1437
1650
|
get ownerAllowlist() { return ownerAllowlist; },
|
|
1438
1651
|
set ownerAllowlist(v) { ownerAllowlist = v; },
|
|
@@ -1441,6 +1654,7 @@ export function apply(ctx, config) {
|
|
|
1441
1654
|
get ownerAnnounceRestartPending() { return ownerAnnounceRestartPending; },
|
|
1442
1655
|
set ownerAnnounceRestartPending(v) { ownerAnnounceRestartPending = v; },
|
|
1443
1656
|
});
|
|
1657
|
+
registerNodeFaces(webServer, { ctx, logger, nodeStore, bindings: nodeBindings, binder: nodeBinder, tasks: nodeTasks, reviews: nodeReviews, apiKey: () => config.apiKey, secretEqual, controlRoute, readJsonBody });
|
|
1444
1658
|
});
|
|
1445
1659
|
}
|
|
1446
1660
|
if (config.announce) {
|
|
@@ -1573,7 +1787,10 @@ export function apply(ctx, config) {
|
|
|
1573
1787
|
// zone/id8 handles (local session ids translate to the node's
|
|
1574
1788
|
// session-team handle) — one form on the wire, so readers never
|
|
1575
1789
|
// mix id/handle shapes (owner: 数据对不上).
|
|
1576
|
-
|
|
1790
|
+
// gh#152: members are read through the local tiers' lens — one of
|
|
1791
|
+
// this host's own handles resolves to the session it names, and
|
|
1792
|
+
// the two stored shapes of one session publish as ONE handle.
|
|
1793
|
+
...(named ? { canvasOrg: canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: [...new Set(canvasLocalMembersOf(name))].map(id => sessionNodes.has(id) || joinedSessions.has(id) ? `${config.team}/${id8(id)}` : id) })) } : {}),
|
|
1577
1794
|
...(named && lanIp !== '' ? { lanIp } : {}),
|
|
1578
1795
|
...(PLUGIN_VERSION !== '' ? { version: PLUGIN_VERSION } : {}),
|
|
1579
1796
|
description: `A2A node exposing team ${config.team}${named ? '' : ' (minimal card — OriginClaim envelope required for the full card: X-A2A-Caller/-Url/-Ts/-Sig signed with your card key)'}`,
|
|
@@ -1625,12 +1842,15 @@ export function apply(ctx, config) {
|
|
|
1625
1842
|
* so ui-chat renders the relay body ("来自 X" + full text) instead of an
|
|
1626
1843
|
* opaque row; omitted when the sender is not meaningfully addressable.
|
|
1627
1844
|
*/
|
|
1628
|
-
function steerRelay(agent, text, sender) {
|
|
1845
|
+
function steerRelay(agent, text, sender, localFrom) {
|
|
1846
|
+
// N4/gh#198: a relay steer carries content from outside this host — unless it is an in-process delivery from a clean local session (localFrom), whose taint the target then takes.
|
|
1847
|
+
if (localFrom === undefined || nodeTaint.of(localFrom) !== '')
|
|
1848
|
+
markLineage(agent, 'relay');
|
|
1629
1849
|
agent.steer({
|
|
1630
1850
|
id: MessageId(`a2a-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`),
|
|
1631
1851
|
role: 'user',
|
|
1632
1852
|
content: [{ type: 'text', text }],
|
|
1633
|
-
source: { kind: 'a2a-relay', form: 'relay', ...(sender === undefined ? {} : { senderSessionId: sender }) },
|
|
1853
|
+
source: { kind: 'a2a-relay', form: 'relay', ...(sender === undefined ? {} : { senderSessionId: sender }), ...(localFrom === undefined ? {} : { localSender: localFrom }) },
|
|
1634
1854
|
});
|
|
1635
1855
|
}
|
|
1636
1856
|
/** The cooperative stop-notice text, shared by the tasks/cancel control
|
|
@@ -1697,19 +1917,20 @@ export function apply(ctx, config) {
|
|
|
1697
1917
|
* visible for /a2a/query follow-ups.
|
|
1698
1918
|
*/
|
|
1699
1919
|
const nudgeBudget = new Map();
|
|
1920
|
+
const nudgeRef = (taskId) => createHash('sha256').update(taskId, 'utf8').digest('hex').slice(0, 16);
|
|
1700
1921
|
const escalateQueuePrompt = (agent, team, taskId) => {
|
|
1701
|
-
// Queue-mode prompt seam
|
|
1702
|
-
// (successor of the removed api gateway's prompt face).
|
|
1922
|
+
// Queue-mode prompt seam (the session controller owns prompt admission); #183: it stamps kind:'user', so nudgeRef stands in for the caller's task id.
|
|
1703
1923
|
const controller = ctx.get('sessionController');
|
|
1704
1924
|
if (typeof controller?.prompt !== 'function')
|
|
1705
1925
|
return;
|
|
1706
|
-
|
|
1926
|
+
markLineage(agent, 'nudge');
|
|
1927
|
+
logger.info(`a2a: async nudge escalates to queue-mode prompt for ${team} (task ${taskId}, ref ${nudgeRef(taskId)})`);
|
|
1707
1928
|
try {
|
|
1708
1929
|
void controller.prompt({
|
|
1709
|
-
requestId: `a2a-nudge-${taskId}`,
|
|
1930
|
+
requestId: `a2a-nudge-${nudgeRef(taskId)}`,
|
|
1710
1931
|
sessionId: agent.id,
|
|
1711
1932
|
mode: 'queue',
|
|
1712
|
-
content: [{ type: 'text', text: `[A2A nudge] (
|
|
1933
|
+
content: [{ type: 'text', text: `[A2A nudge] (ref ${nudgeRef(taskId)}) an earlier A2A delivery reached this session while it could not start a turn — please consume the inbox backlog now (ignore if there is nothing pending).` }],
|
|
1713
1934
|
}, new AbortController().signal).catch(() => { });
|
|
1714
1935
|
}
|
|
1715
1936
|
catch {
|
|
@@ -1749,8 +1970,8 @@ export function apply(ctx, config) {
|
|
|
1749
1970
|
// the current live node, or the wake path materializes the session
|
|
1750
1971
|
// afresh.
|
|
1751
1972
|
const fresh = resolveAgentForTeam(team) ?? agent;
|
|
1752
|
-
logger.info(`a2a: async nudge re-waking ${team} (task ${taskId} delivered but not consumed)`);
|
|
1753
|
-
steerRelay(fresh, `[A2A nudge] (
|
|
1973
|
+
logger.info(`a2a: async nudge re-waking ${team} (task ${taskId}, ref ${nudgeRef(taskId)}, delivered but not consumed)`);
|
|
1974
|
+
steerRelay(fresh, `[A2A nudge] (ref ${nudgeRef(taskId)}) an earlier A2A delivery reached this session while it could not start a turn — please consume the inbox backlog now.`);
|
|
1754
1975
|
}, delayMs);
|
|
1755
1976
|
ctx.effect(() => () => clearTimeout(timer), `a2a: async nudge ${taskId}`);
|
|
1756
1977
|
}
|
|
@@ -1767,7 +1988,7 @@ export function apply(ctx, config) {
|
|
|
1767
1988
|
* receipts themselves are never tracked as new debts.
|
|
1768
1989
|
*/
|
|
1769
1990
|
const receiptWarnAt = new Map();
|
|
1770
|
-
function armReceiptAutosend(target, taskId, callerTeam, deliveredText) {
|
|
1991
|
+
function armReceiptAutosend(target, taskId, callerTeam, deliveredText, steerFloor) {
|
|
1771
1992
|
if (!callerTeam || callerTeam === '')
|
|
1772
1993
|
return;
|
|
1773
1994
|
// Never synthesize a receipt for a receipt: answering an envelope by mail
|
|
@@ -1781,6 +2002,10 @@ export function apply(ctx, config) {
|
|
|
1781
2002
|
// even if a future path reopens it.
|
|
1782
2003
|
if (callerTeam === `${config.team}/${agentId8(target)}`)
|
|
1783
2004
|
return;
|
|
2005
|
+
// #114 S5: the waiter is tied to THIS task's steer — only a final the
|
|
2006
|
+
// target produced after consuming `[A2A direct] (task <id>)` answers it
|
|
2007
|
+
// (flushFinals). A busy target's other work line can no longer settle
|
|
2008
|
+
// a task it has not read yet (the #61 direct-bb27ec94 shape).
|
|
1784
2009
|
registerFinalWaiter(target, (finalText, placeholder) => {
|
|
1785
2010
|
// W7 slice-2: a host-authored placeholder (flush timeout, dead
|
|
1786
2011
|
// session) must not synthesize a COMPLETION receipt — a wedged
|
|
@@ -1798,7 +2023,15 @@ export function apply(ctx, config) {
|
|
|
1798
2023
|
const summary = `${finalText.trim().replace(/\s+/g, ' ').slice(0, SUMMARY_CAP) || 'done'} (auto)`;
|
|
1799
2024
|
// v2 envelope projection: header stays byte-compatible for every
|
|
1800
2025
|
// legacy correlator; the machine JSON rides exactly one following line.
|
|
1801
|
-
|
|
2026
|
+
// #114 S5 binding: onBehalfOf names the session that did the work (the
|
|
2027
|
+
// host is only its courier); authRef pins the inbound authorization
|
|
2028
|
+
// record the delivery was born with — recorded for audit, judged by S4.
|
|
2029
|
+
const receipt = formatReceipt(taskId, summary, {
|
|
2030
|
+
outcome: 'completed',
|
|
2031
|
+
idempotencyKey: taskId,
|
|
2032
|
+
onBehalfOf: sessionTeamOf(target),
|
|
2033
|
+
...(row?.auth !== undefined ? { authRef: authorizationRef(row.auth) } : {}),
|
|
2034
|
+
});
|
|
1802
2035
|
// Three-tier hard order (work-order P2): caller lane first; on failure
|
|
1803
2036
|
// escalate ONCE to the owner mailbox — the outcome never evaporates
|
|
1804
2037
|
// just because the original waiter died. Archive holds truth regardless.
|
|
@@ -1809,7 +2042,15 @@ export function apply(ctx, config) {
|
|
|
1809
2042
|
// settled undefined as tier-1 success, so the receipt would vanish
|
|
1810
2043
|
// without a log.
|
|
1811
2044
|
void runReceiptLadder({
|
|
1812
|
-
deliver: (team) =>
|
|
2045
|
+
deliver: async (team) => {
|
|
2046
|
+
const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true);
|
|
2047
|
+
// gh#141: the caller lane pays this delivery's inbound row across hosts (a
|
|
2048
|
+
// same-host receipt already settled it in-process), unless the caller
|
|
2049
|
+
// refused it. The owner tier is no payment here — but an owner mailbox
|
|
2050
|
+
// on THIS host settles the row in-process, as it always has.
|
|
2051
|
+
if (team === callerTeam && delivered.settlementRefused !== true)
|
|
2052
|
+
settleOwnInbound(receipt, callerTeam, { caller: session, proof: 'in-process', ingress: 'local' });
|
|
2053
|
+
},
|
|
1813
2054
|
ownerTeam: config.team,
|
|
1814
2055
|
log: (stage, error) => {
|
|
1815
2056
|
// N2 (observability): throttled per task to avoid ping-pong noise.
|
|
@@ -1822,17 +2063,26 @@ export function apply(ctx, config) {
|
|
|
1822
2063
|
}, callerTeam).catch(() => {
|
|
1823
2064
|
// Ladder exhausted: the throttled breadcrumbs above already said why.
|
|
1824
2065
|
});
|
|
1825
|
-
});
|
|
2066
|
+
}, { taskId, steerFloor });
|
|
1826
2067
|
}
|
|
1827
2068
|
/**
|
|
1828
2069
|
* Register one final-reply waiter for the agent and arm its flush timeout.
|
|
1829
2070
|
* @param agent - the agent whose next assistant message answers the waiter.
|
|
1830
2071
|
* @param answer - how the waiter's reply is delivered.
|
|
2072
|
+
* @param attribution - #114 S5: attribute by consumption — only the turn
|
|
2073
|
+
* that consumed this task's `[A2A direct] (task <id>)` user message
|
|
2074
|
+
* answers the waiter; the search starts at `steerFloor`, the log length
|
|
2075
|
+
* read just before the steer, so an older steer of a reused id never
|
|
2076
|
+
* anchors it.
|
|
1831
2077
|
* @returns the registered waiter (for callers that may retract it).
|
|
1832
2078
|
*/
|
|
1833
|
-
function registerFinalWaiter(agent, answer) {
|
|
2079
|
+
function registerFinalWaiter(agent, answer, attribution) {
|
|
1834
2080
|
const key = String(agent.id);
|
|
1835
|
-
const waiter = {
|
|
2081
|
+
const waiter = {
|
|
2082
|
+
answer,
|
|
2083
|
+
sinceEvents: sessionEventsOf(agent).length,
|
|
2084
|
+
...(attribution !== undefined && attribution.taskId !== '' ? { taskId: attribution.taskId, scannedTo: attribution.steerFloor } : {}),
|
|
2085
|
+
};
|
|
1836
2086
|
waiter.timeoutDisposer = armFlushTimeout(key, waiter);
|
|
1837
2087
|
pendingFinals.set(key, [...(pendingFinals.get(key) ?? []), waiter]);
|
|
1838
2088
|
return waiter;
|
|
@@ -1845,15 +2095,15 @@ export function apply(ctx, config) {
|
|
|
1845
2095
|
* marker instead of a (false) completion.
|
|
1846
2096
|
* @param taskId - the correlation key the steered receipt header carries.
|
|
1847
2097
|
*/
|
|
1848
|
-
async function routeIntoAgent(team, message, caller, taskId) {
|
|
2098
|
+
async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom) {
|
|
1849
2099
|
const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
|
|
1850
2100
|
if (agent !== undefined) {
|
|
1851
|
-
return routeIntoAgentFor(agent, team, message, caller, taskId);
|
|
2101
|
+
return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom);
|
|
1852
2102
|
}
|
|
1853
2103
|
// Wake-on-route: a cold joined team materializes on demand, then steers.
|
|
1854
2104
|
const wake = wakeColdTeam(team);
|
|
1855
2105
|
if (wake.kind === 'flight') {
|
|
1856
|
-
return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
|
|
2106
|
+
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)}` }));
|
|
1857
2107
|
}
|
|
1858
2108
|
// Native-teams inbound bridge (opt-in): a registry team claims the
|
|
1859
2109
|
// handle and dispatches through its authoritative seam. Checked before
|
|
@@ -1862,7 +2112,7 @@ export function apply(ctx, config) {
|
|
|
1862
2112
|
if (config.nativeTeamsInbound === true) {
|
|
1863
2113
|
const prepared = await nativeTeamsPrepare(team);
|
|
1864
2114
|
if (prepared.ok) {
|
|
1865
|
-
const bridged = await nativeTeamsRound(prepared, team, caller, message, taskId);
|
|
2115
|
+
const bridged = await nativeTeamsRound(prepared, team, caller, message, taskId, proofClause ?? '未验证(in-process)', undefined, buildHeader === undefined ? undefined : (_team, _taskId, from) => buildHeader(from));
|
|
1866
2116
|
if (bridged.ok) {
|
|
1867
2117
|
if (bridged.settled)
|
|
1868
2118
|
return { ok: true, reply: bridged.reply };
|
|
@@ -1882,7 +2132,7 @@ export function apply(ctx, config) {
|
|
|
1882
2132
|
if (live !== undefined) {
|
|
1883
2133
|
if (callerIsAgent(live, caller))
|
|
1884
2134
|
return { ok: false, error: selfRouteText(team) };
|
|
1885
|
-
return routeIntoAgentFor(live, team, message, caller, taskId);
|
|
2135
|
+
return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader);
|
|
1886
2136
|
}
|
|
1887
2137
|
return { ok: false, error: 'No live DSH agent is available to accept this message.' };
|
|
1888
2138
|
}
|
|
@@ -1897,7 +2147,7 @@ export function apply(ctx, config) {
|
|
|
1897
2147
|
* @param caller - caller label for the steered prefix.
|
|
1898
2148
|
* @returns the agent's final reply, or the explicit failure.
|
|
1899
2149
|
*/
|
|
1900
|
-
function routeIntoAgentFor(agent, team, message, caller, taskId) {
|
|
2150
|
+
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom) {
|
|
1901
2151
|
return new Promise((resolve) => {
|
|
1902
2152
|
const waiter = registerFinalWaiter(agent, (text, placeholder) => {
|
|
1903
2153
|
resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}) });
|
|
@@ -1909,12 +2159,12 @@ export function apply(ctx, config) {
|
|
|
1909
2159
|
// the caller supplied one, rides the header so an "[A2A receipt] task
|
|
1910
2160
|
// <id>" answer correlates with the caller's own route result.
|
|
1911
2161
|
const from = caller === '' ? 'an unknown node' : caller;
|
|
1912
|
-
const
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
${message}`;
|
|
2162
|
+
const headerLine = buildHeader !== undefined && taskId !== undefined
|
|
2163
|
+
? buildHeader(from)
|
|
2164
|
+
: `[A2A direct] ${taskId === undefined ? '' : `(task ${taskId}) `}未验证(in-process) ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
|
|
2165
|
+
const text = `${headerLine}\n\n${message}`;
|
|
1916
2166
|
try {
|
|
1917
|
-
steerRelay(agent, text, caller === '' ? undefined : caller);
|
|
2167
|
+
steerRelay(agent, text, caller === '' ? undefined : caller, localFrom);
|
|
1918
2168
|
}
|
|
1919
2169
|
catch (error) {
|
|
1920
2170
|
// The waiter was registered above, so the key always holds it; the
|
|
@@ -1931,227 +2181,75 @@ ${message}`;
|
|
|
1931
2181
|
}
|
|
1932
2182
|
});
|
|
1933
2183
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
2184
|
+
// gh#77 G-in (S23 repayment): the native-teams inbound bridge moved verbatim
|
|
2185
|
+
// to src/legacy/teams-bridge-inbound.ts — the registry probe, claim memo,
|
|
2186
|
+
// prepare and the bounded round. The outbound bridgeFace stays here until
|
|
2187
|
+
// the G-out slice (it submits through the dispatch trunk).
|
|
2188
|
+
const { teamsRegistry, nativeTeamsClaims, nativeTeamsPrepare, nativeTeamsRound } = createTeamsBridgeInbound({ ctx, config, liveAgent, fromClause, sanitizeFromClause, proofHeader: hostDirectHeader, taint: agent => { markLineage(agent, 'native-teams'); } });
|
|
2189
|
+
/* ── gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md,
|
|
2190
|
+
src/origin-claim.ts): the caller's card key signs the envelope; the
|
|
2191
|
+
bound form must name one of OUR origins and the route it hits. ts skew
|
|
2192
|
+
±5 min, no receiver-side state. Fail-closed everywhere. */
|
|
2193
|
+
/** legacyInbound warn metric (M1): envelope-absent deliveries during the
|
|
2194
|
+
* window — the operator watches the tail drain before closing it. */
|
|
2195
|
+
let legacyInboundCount = 0;
|
|
2196
|
+
/** unboundInbound: accepted unbound (v1) envelopes — the drain gauge for
|
|
2197
|
+
* closing originClaimUnboundWindow; boundMissInbound: those of them whose
|
|
2198
|
+
* bound form missed (a peer dials us by a name not in our audiences). */
|
|
2199
|
+
let unboundInboundCount = 0, boundMissInboundCount = 0;
|
|
2200
|
+
function verifyOriginClaim(req, options) {
|
|
2201
|
+
return verifyOriginClaimOf(req, {
|
|
2202
|
+
cardCache,
|
|
2203
|
+
fetch: fetchImpl,
|
|
2204
|
+
audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
|
|
2205
|
+
unboundOpen: config.originClaimUnboundWindow !== false,
|
|
2206
|
+
onUnbound: () => { unboundInboundCount += 1; },
|
|
2207
|
+
onBoundMiss: () => { boundMissInboundCount += 1; },
|
|
2208
|
+
loopbackArrival: (config.originClaimUrl ?? '') === '',
|
|
2209
|
+
now: Date.now(),
|
|
2210
|
+
...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
|
|
2211
|
+
});
|
|
1941
2212
|
}
|
|
1942
|
-
/** Reply-wait parity with the steer path's 180s deadline: native-teams
|
|
1943
|
-
* rounds settle on their own cadence, but a wedged seam must not park a
|
|
1944
|
-
* caller past a bounded window (config.nativeRoundWaitMs). */
|
|
1945
|
-
/** The claim table changes at registry-edit cadence: a short memo dedupes
|
|
1946
|
-
* the double probe (callers pre-check, the round re-verifies) without
|
|
1947
|
-
* pretending the answer is permanent. */
|
|
1948
|
-
const BRIDGE_CLAIM_TTL_MS = 5_000;
|
|
1949
|
-
const bridgeClaimCache = new Map();
|
|
1950
|
-
const bridgeTimers = new Set();
|
|
1951
|
-
ctx.effect(() => () => {
|
|
1952
|
-
for (const timer of bridgeTimers)
|
|
1953
|
-
clearTimeout(timer);
|
|
1954
|
-
});
|
|
1955
2213
|
/**
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
2214
|
+
* gh#133: the deposited card a CLI claim is judged by, or undefined when
|
|
2215
|
+
* the claim URL is not a deposit face of a handle held here (the ordinary
|
|
2216
|
+
* zone path then decides). The URL's last path segments must be
|
|
2217
|
+
* `/a2a/cli/<handle>` (any prefix or origin: the CLI dials its home under
|
|
2218
|
+
* whatever address it can — the deposited key, not the address, is the
|
|
2219
|
+
* proof). origin-claim.ts verifies the signature under that card and the
|
|
2220
|
+
* signed name against the handle's own; the claim's caller is the full
|
|
2221
|
+
* HANDLE, so it covers exactly that handle — never a namesake CLI, a zone
|
|
2222
|
+
* session, or the host face (review of PR #135 P1).
|
|
1961
2223
|
*/
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
if (cached !== undefined && Date.now() - cached.at < BRIDGE_CLAIM_TTL_MS)
|
|
1967
|
-
return cached.claimed;
|
|
1968
|
-
const teams = teamsRegistry();
|
|
1969
|
-
let claimed = false;
|
|
1970
|
-
if (teams !== undefined) {
|
|
1971
|
-
try {
|
|
1972
|
-
const descriptor = await teams.describeTarget(team);
|
|
1973
|
-
claimed = descriptor.plane === 'local' && descriptor.ambiguous !== true && descriptor.localLabel !== undefined;
|
|
1974
|
-
}
|
|
1975
|
-
catch {
|
|
1976
|
-
claimed = false;
|
|
1977
|
-
}
|
|
2224
|
+
function cliDepositCard(callerUrl) {
|
|
2225
|
+
let path;
|
|
2226
|
+
try {
|
|
2227
|
+
path = new URL(callerUrl).pathname;
|
|
1978
2228
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
// generous cap, drop the whole table (a cold re-probe costs one local
|
|
1982
|
-
// describeTarget call).
|
|
1983
|
-
if (bridgeClaimCache.size > 512)
|
|
1984
|
-
bridgeClaimCache.clear();
|
|
1985
|
-
return claimed;
|
|
1986
|
-
}
|
|
1987
|
-
/**
|
|
1988
|
-
* Resolve everything the round needs BEFORE any caller is told the
|
|
1989
|
-
* dispatch happened: the claim, the seam, and a parent initiator. The
|
|
1990
|
-
* `error` variant is the only fast-failure shape — `not-claimed` simply
|
|
1991
|
-
* hands the address back to the standard resolution chain.
|
|
1992
|
-
*/
|
|
1993
|
-
async function nativeTeamsPrepare(team) {
|
|
1994
|
-
if (!await nativeTeamsClaims(team))
|
|
1995
|
-
return { ok: false, reason: 'not-claimed' };
|
|
1996
|
-
const teams = teamsRegistry();
|
|
1997
|
-
if (teams === undefined)
|
|
1998
|
-
return { ok: false, reason: 'not-claimed' };
|
|
1999
|
-
const parent = liveAgent();
|
|
2000
|
-
if (parent === undefined)
|
|
2001
|
-
return { ok: false, reason: 'error', error: 'No live DSH initiator is available to parent the native-teams round.' };
|
|
2002
|
-
// The alpha.4 Session face dropped the events member (the widened
|
|
2003
|
-
// BridgeTeamsRegistry accepts it optional) — the raw Agent carries the
|
|
2004
|
-
// ambient Session whose shape no longer overlaps, so project through the
|
|
2005
|
-
// widened structural face explicitly (type-level only; the runtime value
|
|
2006
|
-
// is the same live agent).
|
|
2007
|
-
const parentFace = parent;
|
|
2008
|
-
return { ok: true, teams, parent: parentFace };
|
|
2009
|
-
}
|
|
2010
|
-
/** The A2A envelope rides the round message so the dispatcher sees the network origin. */
|
|
2011
|
-
function bridgeEnvelope(team, caller, message, taskId) {
|
|
2012
|
-
const from = caller === '' ? 'an unknown node' : caller;
|
|
2013
|
-
const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
|
|
2014
|
-
return `[A2A direct] ${taskPrefix}${fromClause(from)} (routed to ${team}) sent:\n\n${message}`;
|
|
2015
|
-
}
|
|
2016
|
-
/** The projection for a round that outlived the reply window: unlike the
|
|
2017
|
-
* steer deadline's text, this must NOT promise a receipt — native-teams
|
|
2018
|
-
* rounds route none in this slice. */
|
|
2019
|
-
function bridgeUnsettledReply(team) {
|
|
2020
|
-
return `The native-teams round for "${team}" is still running past the reply window; it was delivered and keeps going. Native-teams rounds route no A2A receipt in this slice — reconcile with a follow-up route (continuity rides the team's own durable round chain).`;
|
|
2021
|
-
}
|
|
2022
|
-
/**
|
|
2023
|
-
* Fire the round and wait, bounded. The caller's signal (when it has one)
|
|
2024
|
-
* cancels the round itself; the deadline releases the caller with the
|
|
2025
|
-
* unsettled projection while the round keeps running detached.
|
|
2026
|
-
* @param prepared - a ready dispatch (claim, seam, parent) from {@link nativeTeamsPrepare}.
|
|
2027
|
-
*/
|
|
2028
|
-
async function nativeTeamsRound(prepared, team, caller, message, taskId, external) {
|
|
2029
|
-
const controller = new AbortController();
|
|
2030
|
-
// One listener, always removed after the race: an external abort cancels
|
|
2031
|
-
// the round through its own controller.
|
|
2032
|
-
const onExternalAbort = () => { controller.abort(); };
|
|
2033
|
-
if (external !== undefined) {
|
|
2034
|
-
if (external.aborted)
|
|
2035
|
-
controller.abort();
|
|
2036
|
-
else
|
|
2037
|
-
external.addEventListener('abort', onExternalAbort);
|
|
2229
|
+
catch {
|
|
2230
|
+
return undefined;
|
|
2038
2231
|
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
let
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
if (deadlineTimer !== undefined)
|
|
2046
|
-
bridgeTimers.delete(deadlineTimer);
|
|
2047
|
-
resolve({ kind: 'deadline' });
|
|
2048
|
-
}, config.nativeRoundWaitMs ?? 180_000);
|
|
2049
|
-
deadlineTimer.unref?.();
|
|
2050
|
-
bridgeTimers.add(deadlineTimer);
|
|
2051
|
-
});
|
|
2052
|
-
const verdict = await Promise.race([
|
|
2053
|
-
prepared.teams.startRound({ team, message: bridgeEnvelope(team, caller, message, taskId) }, prepared.parent, controller.signal).then((reply) => ({ kind: 'settled', reply }), (error) => { roundError = error; return { kind: 'failed' }; }),
|
|
2054
|
-
deadline,
|
|
2055
|
-
new Promise(resolve => {
|
|
2056
|
-
if (controller.signal.aborted)
|
|
2057
|
-
resolve({ kind: 'aborted' });
|
|
2058
|
-
else
|
|
2059
|
-
controller.signal.addEventListener('abort', () => resolve({ kind: 'aborted' }), { once: true });
|
|
2060
|
-
}),
|
|
2061
|
-
]);
|
|
2062
|
-
// Hygiene: the losing deadline timer stops here (not 180s later), and
|
|
2063
|
-
// the external-signal listener does not outlive the round.
|
|
2064
|
-
if (deadlineTimer !== undefined) {
|
|
2065
|
-
clearTimeout(deadlineTimer);
|
|
2066
|
-
bridgeTimers.delete(deadlineTimer);
|
|
2232
|
+
const segment = /\/a2a\/cli\/([^/]+)\/?$/.exec(path)?.[1];
|
|
2233
|
+
if (segment === undefined)
|
|
2234
|
+
return undefined;
|
|
2235
|
+
let handle;
|
|
2236
|
+
try {
|
|
2237
|
+
handle = decodeURIComponent(segment);
|
|
2067
2238
|
}
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
if (verdict.kind === 'settled')
|
|
2071
|
-
return { ok: true, reply: verdict.reply, settled: true };
|
|
2072
|
-
if (verdict.kind === 'deadline')
|
|
2073
|
-
return { ok: true, reply: bridgeUnsettledReply(team), settled: false };
|
|
2074
|
-
if (verdict.kind === 'aborted')
|
|
2075
|
-
return { ok: false, error: `the wait for the native-teams round "${team}" was aborted (the round was cancelled with it)`, aborted: true };
|
|
2076
|
-
return { ok: false, error: `the native-teams round for "${team}" failed: ${String(roundError)}` };
|
|
2077
|
-
}
|
|
2078
|
-
/* ── gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md).
|
|
2079
|
-
Envelope: X-A2A-Caller / -Url / -Ts / -Sig — canonical form is
|
|
2080
|
-
JSON.stringify([caller, callerUrl, ts]) (R2), signed with the CALLER'S
|
|
2081
|
-
CARD KEY; verification fetches the caller's card at callerUrl through
|
|
2082
|
-
our own enveloped memoized fetch (R1: our fetches are full-form by
|
|
2083
|
-
construction — the client stamps the envelope on every GET), then
|
|
2084
|
-
verifies the signature against THAT card's key and derives the caller
|
|
2085
|
-
name from the card (team or a published session team). ts skew ±5 min,
|
|
2086
|
-
stateless (no nonce ring — ruling refinement). Fail-closed everywhere. */
|
|
2087
|
-
const ORIGIN_CLAIM_SKEW_MS = 5 * 60_000;
|
|
2088
|
-
/** legacyInbound warn metric (M1): envelope-absent deliveries during the
|
|
2089
|
-
* window — the operator watches the tail drain before closing it. */
|
|
2090
|
-
let legacyInboundCount = 0;
|
|
2091
|
-
const originClaimCallerShape = /^[a-z0-9-]+$/i;
|
|
2092
|
-
async function verifyOriginClaim(req) {
|
|
2093
|
-
const caller = String(req.headers['x-a2a-caller'] ?? '');
|
|
2094
|
-
const callerUrl = String(req.headers['x-a2a-caller-url'] ?? '');
|
|
2095
|
-
const ts = Number(req.headers['x-a2a-caller-ts'] ?? '');
|
|
2096
|
-
const signature = String(req.headers['x-a2a-caller-sig'] ?? '');
|
|
2097
|
-
if (caller === '' || callerUrl === '' || !Number.isFinite(ts) || ts <= 0 || signature === '')
|
|
2098
|
-
return { ok: false, reason: 'absent' };
|
|
2099
|
-
if (!originClaimCallerShape.test(caller))
|
|
2100
|
-
return { ok: false, reason: 'bad-caller-shape' };
|
|
2101
|
-
if (Math.abs(Date.now() - ts) > ORIGIN_CLAIM_SKEW_MS)
|
|
2102
|
-
return { ok: false, reason: 'bad-ts' };
|
|
2103
|
-
// Fetch the caller's card PLAIN (REVIEW M2 third-round ruling: the
|
|
2104
|
-
// envelope names a BARE ZONE, and derivation is the exact match
|
|
2105
|
-
// card.team === caller below — both ride the minimal card's signed
|
|
2106
|
-
// core, so a single envelope-less fetch suffices). An envelope-less
|
|
2107
|
-
// request returns immediately without recursing (the enveloped variant
|
|
2108
|
-
// re-opened mutual recursion); a warm full-form cache entry (R1)
|
|
2109
|
-
// short-circuits the fetch entirely.
|
|
2110
|
-
let card = cardCache.get(callerUrl)?.card;
|
|
2111
|
-
if (card === undefined) {
|
|
2112
|
-
try {
|
|
2113
|
-
// callerUrl is the caller's BASE URL — the card lives at the
|
|
2114
|
-
// well-known path under it.
|
|
2115
|
-
const cardUrl = callerUrl.endsWith('/') ? `${callerUrl}.well-known/agent-card.json` : `${callerUrl}/.well-known/agent-card.json`;
|
|
2116
|
-
const response = await fetchImpl(cardUrl, { method: 'GET', headers: {} });
|
|
2117
|
-
const text = await response.text();
|
|
2118
|
-
const verdict = verifyCard(JSON.parse(text), Date.now());
|
|
2119
|
-
if (verdict.ok)
|
|
2120
|
-
card = verdict.card;
|
|
2121
|
-
else
|
|
2122
|
-
req.__claimDbg = { stage: 'verify', reason: verdict.reason, head: text.slice(0, 100) };
|
|
2123
|
-
}
|
|
2124
|
-
catch (error) {
|
|
2125
|
-
;
|
|
2126
|
-
req.__claimDbg = { stage: 'fetch', error: error instanceof Error ? error.message : String(error) };
|
|
2127
|
-
card = undefined;
|
|
2128
|
-
}
|
|
2239
|
+
catch {
|
|
2240
|
+
return undefined;
|
|
2129
2241
|
}
|
|
2130
|
-
|
|
2131
|
-
|
|
2242
|
+
const shape = CLI_HANDLE_RE.exec(handle);
|
|
2243
|
+
const deposit = shape === null ? undefined : cliDeposits.get(handle);
|
|
2244
|
+
if (shape === null || deposit === undefined)
|
|
2245
|
+
return undefined;
|
|
2132
2246
|
try {
|
|
2133
|
-
const
|
|
2134
|
-
|
|
2135
|
-
if (!valid)
|
|
2136
|
-
return { ok: false, reason: 'bad-sig' };
|
|
2247
|
+
const verdict = verifyCard(JSON.parse(deposit.cardJson), Date.now());
|
|
2248
|
+
return { handle, name: shape[1], card: verdict.ok && verdict.card.team === handle ? verdict.card : undefined };
|
|
2137
2249
|
}
|
|
2138
2250
|
catch {
|
|
2139
|
-
return {
|
|
2140
|
-
}
|
|
2141
|
-
// Derivation (REVIEW M2, third-round ruling — shape narrowed to bare
|
|
2142
|
-
// zone): card.team === caller EXACT match. A zone/id8 envelope is
|
|
2143
|
-
// rejected as bad-caller-shape at the gate — session-level identity
|
|
2144
|
-
// rides the body's caller_session, judged by N3/S3 against the PUBLISHED
|
|
2145
|
-
// roster. (The prefix-derivation variant admitted an impersonation
|
|
2146
|
-
// chain: a same-zone foreign host signs the target's session handle
|
|
2147
|
-
// with its own key — prefix matches, signature verifies, full card
|
|
2148
|
-
// serves, and N3 then judges the body by the impersonated roster.
|
|
2149
|
-
// Fleet senders stamp bare zone only; the narrowed shape kills the
|
|
2150
|
-
// false rejections AND the impersonation vector with zero recursion.)
|
|
2151
|
-
if (card.team !== caller) {
|
|
2152
|
-
return { ok: false, reason: 'not-derivable' };
|
|
2251
|
+
return { handle, name: shape[1], card: undefined };
|
|
2153
2252
|
}
|
|
2154
|
-
return { ok: true, caller, callerUrl, card };
|
|
2155
2253
|
}
|
|
2156
2254
|
/**
|
|
2157
2255
|
* W3 (#18 half-side): is a VERIFIED caller LINKED to this host? Key
|
|
@@ -2177,6 +2275,9 @@ ${message}`;
|
|
|
2177
2275
|
* Our operator's config anchors; the card shadow corroborates.
|
|
2178
2276
|
*/
|
|
2179
2277
|
function callerLinkedToThisHost(caller, callerUrl, callerCard) {
|
|
2278
|
+
// gh#133 review: a CLI identity's card (team = its handle) is never a host link, whatever face asks.
|
|
2279
|
+
if (callerCard !== undefined && CLI_HANDLE_RE.test(callerCard.team))
|
|
2280
|
+
return false;
|
|
2180
2281
|
// Basis 1, same-host membership (review second-round M-fix): "self" is
|
|
2181
2282
|
// an ORIGIN equality, not a zone-name equality. `caller === config.team`
|
|
2182
2283
|
// granted unconditionally in a single-zone fleet (the deployment
|
|
@@ -2184,7 +2285,7 @@ ${message}`;
|
|
|
2184
2285
|
// shared zone string was "us". True self is the loopback self-fetch
|
|
2185
2286
|
// face: the caller's card URL origin equals OUR card URL origin.
|
|
2186
2287
|
try {
|
|
2187
|
-
if (callerUrl !== '' && new URL(callerUrl).origin === new URL(ownCardUrl).origin)
|
|
2288
|
+
if (callerUrl !== '' && isRootOriginUrl(callerUrl) && new URL(callerUrl).origin === new URL(ownCardUrl).origin)
|
|
2188
2289
|
return true;
|
|
2189
2290
|
}
|
|
2190
2291
|
catch { /* malformed callerUrl — falls through to the record bases */ }
|
|
@@ -2213,12 +2314,13 @@ ${message}`;
|
|
|
2213
2314
|
// served-fresh field, exactly the self-description trust class
|
|
2214
2315
|
// N3/V5 abolished. Our operator's own config anchors; the card
|
|
2215
2316
|
// shadow only corroborates.
|
|
2317
|
+
// #189: a ROOT claim URL speaks for its origin; a path-prefixed one only for the exact peer URL we seeded (a mounted card).
|
|
2216
2318
|
if (callerCard !== undefined && ownCardUrl !== '') {
|
|
2217
2319
|
const ourOrigin = new URL(ownCardUrl).origin;
|
|
2218
2320
|
const callerOrigin = new URL(callerUrl).origin;
|
|
2219
2321
|
const weSeedThem = config.peers.some(peer => {
|
|
2220
2322
|
try {
|
|
2221
|
-
return new URL(peer).origin === callerOrigin;
|
|
2323
|
+
return isRootOriginUrl(callerUrl) ? new URL(peer).origin === callerOrigin : sameMountedUrl(peer, callerUrl);
|
|
2222
2324
|
}
|
|
2223
2325
|
catch {
|
|
2224
2326
|
return false;
|
|
@@ -2253,218 +2355,112 @@ ${message}`;
|
|
|
2253
2355
|
* sessions' declarations. Used by both the inbound faces and the local
|
|
2254
2356
|
* dispatch candidate so the two paths can never diverge.
|
|
2255
2357
|
*/
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
break;
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
if (callerSessionId !== undefined) {
|
|
2277
|
-
for (const name of canvasStore.teamsOf(callerSessionId))
|
|
2278
|
-
declared.add(canvasTeamOf(name));
|
|
2279
|
-
}
|
|
2280
|
-
else {
|
|
2281
|
-
// Issue #43: a same-zone handle that resolves to NO local session is a
|
|
2282
|
-
// CROSS-HOST member (two nodes sharing one zone name — the fleet's
|
|
2283
|
-
// common `dsh` spelling). The canvas link record exists on this host
|
|
2284
|
-
// as a REMOTE MEMBER REF the operator added; the foreign-zone branch
|
|
2285
|
-
// (a2aCallerRefusal) already honors that basis — the same-zone path
|
|
2286
|
-
// must too, or a linked remote member is refused as teamless. The
|
|
2287
|
-
// trust basis is unchanged: this host's operator-composed canvas,
|
|
2288
|
-
// never the caller's self-description (V5).
|
|
2289
|
-
for (const name of canvasStore.list()) {
|
|
2290
|
-
if (canvasStore.remoteMembersOf(name).includes(caller))
|
|
2291
|
-
declared.add(canvasTeamOf(name));
|
|
2292
|
-
}
|
|
2293
|
-
// gh#71 fix ②: the warm card cache's peer canvasOrg is the same-zone
|
|
2294
|
-
// cross-host basis too — an org entry naming BOTH the caller's handle
|
|
2295
|
-
// and one of THIS host's own handles is a shared team (双向识别: the
|
|
2296
|
-
// composition gesture happened on the other host's board; both ends
|
|
2297
|
-
// are named). Read-only over the cache; a caller org that never names
|
|
2298
|
-
// an own handle still refuses (V5 preserved).
|
|
2299
|
-
const ownHandles = new Set([config.team, ...joinedSessions.list().map(id => `${config.team}/${id8(id)}`)]);
|
|
2358
|
+
// gh#114 S1: the admission rules live in src/task-auth.ts as one pure
|
|
2359
|
+
// decision over these records; the closures below are thin wrappers, so
|
|
2360
|
+
// the faces' refusal texts and the authorization record (S2) can never
|
|
2361
|
+
// disagree. Records are read at decision time, never copied.
|
|
2362
|
+
const admissionRecords = {
|
|
2363
|
+
zone: config.team,
|
|
2364
|
+
get teamScopeRouting() { return config.teamScopeRouting; },
|
|
2365
|
+
id8,
|
|
2366
|
+
canvasTeamOf,
|
|
2367
|
+
memberships: () => teamMemberships.list(),
|
|
2368
|
+
joined: () => joinedSessions.list(),
|
|
2369
|
+
canvasNames: () => canvasStore.list(),
|
|
2370
|
+
canvasTeamsOf: sessionId => canvasStore.teamsOf(sessionId),
|
|
2371
|
+
canvasMembersOf: name => canvasStore.membersOf(name),
|
|
2372
|
+
canvasRemoteMembersOf: name => canvasStore.remoteMembersOf(name),
|
|
2373
|
+
peerOrgs: function* () {
|
|
2300
2374
|
for (const cached of cardCache.values()) {
|
|
2301
2375
|
const org = cached.card?.canvasOrg;
|
|
2302
|
-
if (
|
|
2303
|
-
|
|
2304
|
-
for (const e of org) {
|
|
2305
|
-
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
2306
|
-
continue;
|
|
2307
|
-
if (e.members.includes(caller) && e.members.some(m => typeof m === 'string' && ownHandles.has(m)))
|
|
2308
|
-
declared.add(e.team);
|
|
2309
|
-
}
|
|
2376
|
+
if (Array.isArray(org))
|
|
2377
|
+
yield org;
|
|
2310
2378
|
}
|
|
2311
|
-
}
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
for (const entry of teamMemberships.list()) {
|
|
2319
|
-
if (`${config.team}/${id8(entry.session)}` === caller)
|
|
2320
|
-
continue;
|
|
2321
|
-
for (const t of entry.teams)
|
|
2322
|
-
otherLocalDeclarations.add(t);
|
|
2323
|
-
}
|
|
2324
|
-
const shared = [...declared].some(t => routable.has(t) || otherLocalDeclarations.has(t));
|
|
2325
|
-
// F5 (gh#26, attribute-owner ruling): same-host admission needs a
|
|
2326
|
-
// TARGET-level intersection — a canvas member used to reach ANY session
|
|
2327
|
-
// team on this host through a host-level link alone. The target's link
|
|
2328
|
-
// set = its roster declarations ∪ its canvas teams. EXCEPTION (ruling,
|
|
2329
|
-
// 成文): the PROCESS face (target === config.team) is the owner face,
|
|
2330
|
-
// not a member face — any shared team clears it.
|
|
2331
|
-
if (targetTeam !== undefined && targetTeam !== config.team) {
|
|
2332
|
-
let targetLinks;
|
|
2333
|
-
const targetId8 = targetTeam.startsWith(`${config.team}/`) ? targetTeam.slice(config.team.length + 1) : undefined;
|
|
2334
|
-
if (targetId8 !== undefined) {
|
|
2335
|
-
// Only a KNOWN local session earns target-level judgement; an
|
|
2336
|
-
// unresolvable handle (archived/foreign spelling) keeps the
|
|
2337
|
-
// host-level basis — an empty target set must not refuse everyone.
|
|
2338
|
-
// The handle carries the id8 SLICE, so resolve it back to the
|
|
2339
|
-
// session id before asking the stores (canvas teamsOf keys by id).
|
|
2340
|
-
const targetSession = joinedSessions.list().find(sessionId => id8(sessionId) === targetId8)
|
|
2341
|
-
?? teamMemberships.list().find(entry => id8(entry.session) === targetId8)?.session;
|
|
2342
|
-
if (targetSession !== undefined) {
|
|
2343
|
-
targetLinks = new Set();
|
|
2344
|
-
for (const entry of teamMemberships.list()) {
|
|
2345
|
-
if (entry.session === targetSession)
|
|
2346
|
-
for (const t of entry.teams)
|
|
2347
|
-
targetLinks.add(t);
|
|
2348
|
-
}
|
|
2349
|
-
for (const name of canvasStore.teamsOf(targetSession))
|
|
2350
|
-
targetLinks.add(canvasTeamOf(name));
|
|
2351
|
-
// gh#71 fix ②: the warm cache's org basis at target level too —
|
|
2352
|
-
// an org entry naming BOTH the caller and the target handle is
|
|
2353
|
-
// the shared team (same 双向识别 rule as the host-level basis).
|
|
2354
|
-
for (const cached of cardCache.values()) {
|
|
2355
|
-
const org = cached.card?.canvasOrg;
|
|
2356
|
-
if (!Array.isArray(org))
|
|
2357
|
-
continue;
|
|
2358
|
-
for (const e of org) {
|
|
2359
|
-
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
2360
|
-
continue;
|
|
2361
|
-
if (e.members.includes(caller) && e.members.includes(targetTeam))
|
|
2362
|
-
targetLinks.add(e.team);
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
}
|
|
2367
|
-
else {
|
|
2368
|
-
const canvasName = canvasStore.list().find(name => canvasTeamOf(name) === targetTeam);
|
|
2369
|
-
if (canvasName !== undefined) {
|
|
2370
|
-
targetLinks = new Set([targetTeam]);
|
|
2371
|
-
for (const memberId of canvasStore.membersOf(canvasName)) {
|
|
2372
|
-
for (const entry of teamMemberships.list())
|
|
2373
|
-
if (entry.session === memberId)
|
|
2374
|
-
for (const t of entry.teams)
|
|
2375
|
-
targetLinks.add(t);
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
}
|
|
2379
|
-
if (targetLinks !== undefined) {
|
|
2380
|
-
if ([...declared].some(t => targetLinks.has(t)))
|
|
2381
|
-
return undefined;
|
|
2382
|
-
return declared.size === 0
|
|
2383
|
-
? `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`
|
|
2384
|
-
: `caller "${caller}" shares no team with target "${targetTeam}" — declared [${[...declared].join(', ')}], target links [${[...targetLinks].join(', ')}] (F5 target-level admission: a host-level link alone does not open every session team).`;
|
|
2385
|
-
}
|
|
2386
|
-
// Unresolvable target (foreign/zone delegation): host-level basis.
|
|
2387
|
-
}
|
|
2388
|
-
if (shared)
|
|
2389
|
-
return undefined;
|
|
2390
|
-
return declared.size === 0
|
|
2391
|
-
? `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`
|
|
2392
|
-
: `caller "${caller}" declares teams [${[...declared].join(', ')}] — none is routable on this node (shared-team admission). Declare a shared team on both ends, or route through a teamed member.`;
|
|
2379
|
+
},
|
|
2380
|
+
liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
|
|
2381
|
+
cliDeposited: handle => cliDeposits.get(handle) !== undefined,
|
|
2382
|
+
};
|
|
2383
|
+
/** W1/W2 shared-team admission (see sameZoneAdmission in src/task-auth.ts). */
|
|
2384
|
+
function sameZoneSharedTeamRefusal(caller, targetTeam) {
|
|
2385
|
+
return refusalOf(sameZoneAdmission(admissionRecords, caller, targetTeam));
|
|
2393
2386
|
}
|
|
2394
|
-
/**
|
|
2395
|
-
* W1: does a caller label fall under the joined-face admission gate? The
|
|
2396
|
-
* zone/id8 shape with THIS zone names a joined session node — the face
|
|
2397
|
-
* the boundary-hardening wave closes. The initiator face (bare node
|
|
2398
|
-
* team) and foreign-zone labels are judged elsewhere (F1b host-face
|
|
2399
|
-
* rules; the foreign branch below) and stay untouched.
|
|
2400
|
-
*/
|
|
2401
|
-
const joinedFaceCallerShape = /^[a-z0-9-]+\/[0-9a-f]{8}$/i;
|
|
2387
|
+
/** W1: the joined-face gate of the local dispatch candidate (see joinedFaceAdmission). */
|
|
2402
2388
|
function joinedFaceAdmissionRefusal(callerSession, targetTeam) {
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
return sameZoneSharedTeamRefusal(callerSession, targetTeam);
|
|
2389
|
+
const verdict = joinedFaceAdmission(admissionRecords, callerSession, targetTeam);
|
|
2390
|
+
return verdict === undefined ? undefined : refusalOf(verdict);
|
|
2391
|
+
}
|
|
2392
|
+
/** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
|
|
2393
|
+
function a2aCallerAdmission(caller, targetTeam) {
|
|
2394
|
+
return callerAdmission(admissionRecords, caller, targetTeam);
|
|
2410
2395
|
}
|
|
2411
2396
|
function a2aCallerRefusal(caller, targetTeam) {
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
// card is the CALLER'S own claim about itself (V5: any host could
|
|
2436
|
-
// declare the target's team on its own card and self-serve
|
|
2437
|
-
// admission), so it no longer decides anything here. First-contact
|
|
2438
|
-
// learn-and-admit dies with it.
|
|
2439
|
-
for (const name of canvasStore.list()) {
|
|
2440
|
-
if (canvasStore.remoteMembersOf(name).includes(caller))
|
|
2441
|
-
return undefined;
|
|
2397
|
+
return refusalOf(a2aCallerAdmission(caller, targetTeam));
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* #114 S2: the allowlist version over the operator-controlled records the
|
|
2401
|
+
* admission rules read (design §2.3). The peer-card canvasOrg is left out:
|
|
2402
|
+
* a record admitted by it carries `peer-org:<team>` in its basis instead.
|
|
2403
|
+
*/
|
|
2404
|
+
function allowlistVersionNow() {
|
|
2405
|
+
return allowlistVersion(allowlistInputsNow());
|
|
2406
|
+
}
|
|
2407
|
+
/** The records allowlistVersionNow hashes (design §2.3), read live. */
|
|
2408
|
+
function allowlistInputsNow() {
|
|
2409
|
+
const handle = (session) => `${config.team}/${id8(session)}`;
|
|
2410
|
+
return {
|
|
2411
|
+
zone: config.team,
|
|
2412
|
+
scope: [config.teamScopeRouting, config.originClaimWindow !== false],
|
|
2413
|
+
joined: joinedSessions.list().map(handle),
|
|
2414
|
+
roster: teamMemberships.list().flatMap(entry => entry.teams.map(team => [handle(entry.session), team])),
|
|
2415
|
+
// Local members by their handle (design §2.3), remote refs as they are stored.
|
|
2416
|
+
canvas: canvasStore.list().map(name => [canvasTeamOf(name), canvasStore.membersOf(name).map(m => (m.includes('/') ? m : handle(m)))]),
|
|
2417
|
+
cli: cliDeposits.list().map(row => row.handle),
|
|
2418
|
+
links: config.peers.map(peer => { try {
|
|
2419
|
+
return new URL(peer).origin;
|
|
2442
2420
|
}
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2421
|
+
catch {
|
|
2422
|
+
return peer;
|
|
2423
|
+
} }),
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
/*
|
|
2427
|
+
* #114 S6 (design §5): when the version moves, the unsettled tasks born
|
|
2428
|
+
* under another one are re-judged against today's records and the
|
|
2429
|
+
* affected ones recorded — OBSERVE ONLY: nothing is cancelled, no row's
|
|
2430
|
+
* state changes, no session is nudged, no receipt is blocked. Called
|
|
2431
|
+
* lazily (state/tasks reads, inbound admission) and at the known change
|
|
2432
|
+
* points (the stores' onChange hooks below); never on a timer.
|
|
2433
|
+
*/
|
|
2434
|
+
const authObserver = new AuthObserver(join(home, 'a2a', 'auth-versions.json'));
|
|
2435
|
+
function observeAuthorization(cause) {
|
|
2436
|
+
const inputs = allowlistInputsNow();
|
|
2437
|
+
const entry = authObserver.observe({
|
|
2438
|
+
version: allowlistVersion(inputs),
|
|
2439
|
+
inputs: () => JSON.parse(canonicalAllowlist(inputs)),
|
|
2440
|
+
cause,
|
|
2441
|
+
rows: () => taskLedger.list(),
|
|
2442
|
+
// A local-candidate record (#139) is judged as the local candidate admits; every other one by the wire face.
|
|
2443
|
+
judge: (caller, target, ingress) => (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
|
|
2444
|
+
});
|
|
2445
|
+
if (entry === undefined)
|
|
2446
|
+
return;
|
|
2447
|
+
const revoke = entry.tasks.filter(task => task.verdict === 'would-revoke').length;
|
|
2448
|
+
logger.info(`a2a: allowlist ${entry.from_version ?? '(none)'} → ${entry.to_version} (${entry.cause}): ${String(entry.tasks.length)} affected task(s), ${String(revoke)} would-revoke — observe only, nothing cancelled`);
|
|
2449
|
+
try {
|
|
2450
|
+
ctx.emit('a2a/authorization-affected', entry);
|
|
2455
2451
|
}
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
return undefined;
|
|
2452
|
+
catch (error) {
|
|
2453
|
+
logger.warn(`a2a: authorization-affected listener rejected: ${String(error)}`);
|
|
2459
2454
|
}
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2455
|
+
}
|
|
2456
|
+
/** The /state `authorization` block: the version history (newest first, inputs omitted) and the affected entries. */
|
|
2457
|
+
function authorizationView() {
|
|
2458
|
+
observeAuthorization('lazy');
|
|
2459
|
+
return {
|
|
2460
|
+
version: allowlistVersionNow(),
|
|
2461
|
+
history: authObserver.history().map(({ version, at, cause }) => ({ version, at, cause })),
|
|
2462
|
+
affected: authObserver.entries(),
|
|
2463
|
+
};
|
|
2468
2464
|
}
|
|
2469
2465
|
/* ── Session-log read faces (shared by the local control route and the
|
|
2470
2466
|
peer /a2a/log face): both answer from <dshHome>/sessions with
|
|
@@ -2539,11 +2535,14 @@ ${message}`;
|
|
|
2539
2535
|
joinedSessions,
|
|
2540
2536
|
idempotencyStore,
|
|
2541
2537
|
verifyOriginClaim,
|
|
2542
|
-
callerLinkedToThisHost,
|
|
2538
|
+
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
2543
2539
|
recordActivity,
|
|
2544
2540
|
bumpLegacyInbound: () => { legacyInboundCount += 1; },
|
|
2545
2541
|
secretEqual,
|
|
2546
2542
|
a2aCallerRefusal,
|
|
2543
|
+
a2aCallerAdmission,
|
|
2544
|
+
allowlistVersionNow,
|
|
2545
|
+
observeAuthorization,
|
|
2547
2546
|
scanSessionLogList,
|
|
2548
2547
|
readSessionLogTail,
|
|
2549
2548
|
taskLedger,
|
|
@@ -2556,9 +2555,13 @@ ${message}`;
|
|
|
2556
2555
|
canvasLiveAgent,
|
|
2557
2556
|
wakeColdTeam,
|
|
2558
2557
|
probeConsumption,
|
|
2558
|
+
sessionTeamOf,
|
|
2559
|
+
localSessionOfHandle,
|
|
2559
2560
|
steerRelay,
|
|
2560
2561
|
fromClause,
|
|
2562
|
+
sanitizeFromClause,
|
|
2561
2563
|
armReceiptAutosend,
|
|
2564
|
+
eventCountOf: (agent) => sessionEventsOf(agent).length,
|
|
2562
2565
|
receiptExpectationOf,
|
|
2563
2566
|
armAsyncNudge,
|
|
2564
2567
|
liveAgent,
|
|
@@ -2685,160 +2688,9 @@ ${message}`;
|
|
|
2685
2688
|
});
|
|
2686
2689
|
}),
|
|
2687
2690
|
}), 'a2a: quick-dispatch route');
|
|
2688
|
-
//
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
// serve. The id gate is artifact existence under this host's sessions
|
|
2692
|
-
// tree — reading is a host-workbench concern, not a network-membership
|
|
2693
|
-
// concern, so archived and never-joined sessions stay readable while the
|
|
2694
|
-
// network faces keep their own visibility rules. Path shape is still
|
|
2695
|
-
// checked (no '/', '\', '..') and the tree never leaves <dshHome>/sessions.
|
|
2696
|
-
// Without an id the same face lists readable sessions (newest activity
|
|
2697
|
-
// first, capped) so the workbench directory can offer log rows even when
|
|
2698
|
-
// the network directory shows none. Caps mirror session-transcript.ts.
|
|
2699
|
-
ctx.effect(() => webServer.register({
|
|
2700
|
-
kind: 'exact',
|
|
2701
|
-
path: '/__dsh_a2a/session-log',
|
|
2702
|
-
handler: controlRoute((req, res) => {
|
|
2703
|
-
const url = new URL(req.url ?? '/', 'http://x');
|
|
2704
|
-
const id = url.searchParams.get('id') ?? '';
|
|
2705
|
-
const limitArg = Number(url.searchParams.get('limit') ?? TRANSCRIPT_DEFAULT_LIMIT);
|
|
2706
|
-
const limit = Number.isFinite(limitArg) ? Math.max(1, Math.min(TRANSCRIPT_MAX_LIMIT, Math.floor(limitArg))) : TRANSCRIPT_DEFAULT_LIMIT;
|
|
2707
|
-
let result;
|
|
2708
|
-
if (id.includes('/') || id.includes('\\') || id.includes('..')) {
|
|
2709
|
-
result = { ok: false, error: 'id is required' };
|
|
2710
|
-
}
|
|
2711
|
-
else if (id === '') {
|
|
2712
|
-
// Listing face: every session directory holding a log artifact.
|
|
2713
|
-
try {
|
|
2714
|
-
const rows = scanSessionLogList();
|
|
2715
|
-
result = { ok: true, count: rows.length, sessions: rows.slice(0, 50) };
|
|
2716
|
-
}
|
|
2717
|
-
catch (error) {
|
|
2718
|
-
result = { ok: false, error: `transcript list failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
2719
|
-
}
|
|
2720
|
-
}
|
|
2721
|
-
else {
|
|
2722
|
-
try {
|
|
2723
|
-
const tail = readSessionLogTail(id, limit);
|
|
2724
|
-
if (!tail.ok)
|
|
2725
|
-
result = { ok: false, error: tail.error };
|
|
2726
|
-
else
|
|
2727
|
-
result = { ok: true, id: tail.id, count: tail.messages.length, messages: tail.messages };
|
|
2728
|
-
}
|
|
2729
|
-
catch (error) {
|
|
2730
|
-
result = { ok: false, error: `transcript failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
2731
|
-
}
|
|
2732
|
-
}
|
|
2733
|
-
const payload = JSON.stringify(result);
|
|
2734
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2735
|
-
res.end(payload);
|
|
2736
|
-
}),
|
|
2737
|
-
}), 'a2a: session-log route');
|
|
2738
|
-
// 0.6.15: the local proxy for REMOTE session logs. The workbench runs
|
|
2739
|
-
// in this host's browser page, so cross-host reads must originate from
|
|
2740
|
-
// this host (no CORS, one trust boundary). ?team=<zone/id8> resolves
|
|
2741
|
-
// directory candidates that publish that team (same walk as routing);
|
|
2742
|
-
// ?peer=<base-url> targets one tracked peer directly and MUST name a
|
|
2743
|
-
// URL already in the peer store — the proxy never fetches an
|
|
2744
|
-
// arbitrary address. The caller label we present is config.team (this
|
|
2745
|
-
// node's own zone handle, the same host face quick-dispatch uses).
|
|
2746
|
-
ctx.effect(() => webServer.register({
|
|
2747
|
-
kind: 'exact',
|
|
2748
|
-
path: '/__dsh_a2a/remote-session-log',
|
|
2749
|
-
handler: controlRoute(async (req, res) => {
|
|
2750
|
-
const url = new URL(req.url ?? '/', 'http://x');
|
|
2751
|
-
const team = url.searchParams.get('team') ?? '';
|
|
2752
|
-
const peer = url.searchParams.get('peer') ?? '';
|
|
2753
|
-
const id = url.searchParams.get('id') ?? '';
|
|
2754
|
-
const limitArg = Number(url.searchParams.get('limit') ?? TRANSCRIPT_DEFAULT_LIMIT);
|
|
2755
|
-
const limit = Number.isFinite(limitArg) ? Math.max(1, Math.min(TRANSCRIPT_MAX_LIMIT, Math.floor(limitArg))) : TRANSCRIPT_DEFAULT_LIMIT;
|
|
2756
|
-
const finish = (body) => {
|
|
2757
|
-
const payload = JSON.stringify(body);
|
|
2758
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2759
|
-
res.end(payload);
|
|
2760
|
-
};
|
|
2761
|
-
if (id.includes('/') || id.includes('\\') || id.includes('..')) {
|
|
2762
|
-
finish({ ok: false, error: 'id is required' });
|
|
2763
|
-
return;
|
|
2764
|
-
}
|
|
2765
|
-
const trackedPeers = new Set(peerStore.list().map(entry => entry.replace(/\/+$/, '')));
|
|
2766
|
-
let targets;
|
|
2767
|
-
if (peer !== '') {
|
|
2768
|
-
const normalized = peer.replace(/\/+$/, '');
|
|
2769
|
-
if (!trackedPeers.has(normalized)) {
|
|
2770
|
-
finish({ ok: false, error: 'unknown peer on this node — the proxy only reads tracked peers' });
|
|
2771
|
-
return;
|
|
2772
|
-
}
|
|
2773
|
-
targets = [normalized];
|
|
2774
|
-
}
|
|
2775
|
-
else if (team !== '') {
|
|
2776
|
-
const failures = [];
|
|
2777
|
-
targets = await directoryPeerCandidates(memoizedCardFetch(), team, failures);
|
|
2778
|
-
if (targets.length === 0) {
|
|
2779
|
-
finish({ ok: false, error: `no directory candidate publishes team "${team}"${failures.length > 0 ? ` (${failures.slice(0, 3).join('; ')})` : ''}` });
|
|
2780
|
-
return;
|
|
2781
|
-
}
|
|
2782
|
-
}
|
|
2783
|
-
else {
|
|
2784
|
-
finish({ ok: false, error: 'team or peer is required' });
|
|
2785
|
-
return;
|
|
2786
|
-
}
|
|
2787
|
-
// 0.6.16: a team-handle read without an explicit id names a NODE, not
|
|
2788
|
-
// a session. The handle suffix (zone/<id8>) is the first 8 hex of the
|
|
2789
|
-
// peer-side session id (sessionTeamOf), so resolve the listing once
|
|
2790
|
-
// and pick that session — bare zone teams fall back to the newest.
|
|
2791
|
-
// An explicit id (peer drill) short-circuits this.
|
|
2792
|
-
let resolvedId = id;
|
|
2793
|
-
if (resolvedId === '' && team !== '') {
|
|
2794
|
-
const suffix = team.includes('/') ? team.slice(team.lastIndexOf('/') + 1) : '';
|
|
2795
|
-
for (const base of targets) {
|
|
2796
|
-
const listUrl = new URL('/a2a/log', base);
|
|
2797
|
-
listUrl.searchParams.set('caller_session', config.team);
|
|
2798
|
-
try {
|
|
2799
|
-
const listResponse = await globalThis.fetch(listUrl, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
|
|
2800
|
-
const listBody = await listResponse.json().catch(() => ({ ok: false, error: `peer answered HTTP ${listResponse.status}` }));
|
|
2801
|
-
if (listResponse.ok && listBody.ok === true && Array.isArray(listBody.sessions)) {
|
|
2802
|
-
const picked = suffix !== '' ? listBody.sessions.find(row => row.id.includes(suffix)) : listBody.sessions[0];
|
|
2803
|
-
if (picked !== undefined) {
|
|
2804
|
-
resolvedId = picked.id;
|
|
2805
|
-
break;
|
|
2806
|
-
}
|
|
2807
|
-
}
|
|
2808
|
-
}
|
|
2809
|
-
catch {
|
|
2810
|
-
// listing failed on this candidate — the tail attempt below
|
|
2811
|
-
// will surface the honest error for the same base
|
|
2812
|
-
}
|
|
2813
|
-
}
|
|
2814
|
-
if (resolvedId === '') {
|
|
2815
|
-
finish({ ok: false, error: `no readable session on this peer matches "${team}"` });
|
|
2816
|
-
return;
|
|
2817
|
-
}
|
|
2818
|
-
}
|
|
2819
|
-
const attempts = [];
|
|
2820
|
-
for (const base of targets) {
|
|
2821
|
-
const target = new URL('/a2a/log', base);
|
|
2822
|
-
target.searchParams.set('caller_session', config.team);
|
|
2823
|
-
if (resolvedId !== '')
|
|
2824
|
-
target.searchParams.set('id', resolvedId);
|
|
2825
|
-
target.searchParams.set('limit', String(limit));
|
|
2826
|
-
try {
|
|
2827
|
-
const response = await globalThis.fetch(target, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
|
|
2828
|
-
const body = await response.json().catch(() => ({ ok: false, error: `peer answered HTTP ${response.status}` }));
|
|
2829
|
-
if (response.ok && body.ok === true) {
|
|
2830
|
-
finish({ ...body, via: base });
|
|
2831
|
-
return;
|
|
2832
|
-
}
|
|
2833
|
-
attempts.push(`${base}: ${body.error ?? `HTTP ${response.status}`}`);
|
|
2834
|
-
}
|
|
2835
|
-
catch (error) {
|
|
2836
|
-
attempts.push(`${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2837
|
-
}
|
|
2838
|
-
}
|
|
2839
|
-
finish({ ok: false, error: `remote log unavailable — ${attempts.join(' | ')}` });
|
|
2840
|
-
}),
|
|
2841
|
-
}), 'a2a: remote-session-log proxy route');
|
|
2691
|
+
// gh#77: the session-log faces (local transcript reader + remote-log proxy) live in src/faces/session-log.ts.
|
|
2692
|
+
registerSessionLogFaces(webServer, { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, directoryPeerCandidates, memoizedCardFetch });
|
|
2693
|
+
registerFileFaces(webServer, { ctx, config, blobStore, canvasStore, verifyOriginClaim, secretEqual, controlRoute, recordActivity });
|
|
2842
2694
|
});
|
|
2843
2695
|
/**
|
|
2844
2696
|
* Settle one card-fetch outcome into the peer store and the plain card:
|
|
@@ -2852,7 +2704,10 @@ ${message}`;
|
|
|
2852
2704
|
*/
|
|
2853
2705
|
function settlePeerCard(peer, outcome) {
|
|
2854
2706
|
if (!outcome.ok) {
|
|
2855
|
-
|
|
2707
|
+
// gh#193: a fetch the isolation switch refused says nothing about the
|
|
2708
|
+
// peer — scoring it would evict learned peers and rewrite peers.json.
|
|
2709
|
+
if (!config.isolated)
|
|
2710
|
+
peerStore.noteFailure(peer);
|
|
2856
2711
|
return undefined;
|
|
2857
2712
|
}
|
|
2858
2713
|
// A self-referral learned from a peer's card (the peer lists this node's
|
|
@@ -2919,58 +2774,24 @@ ${message}`;
|
|
|
2919
2774
|
*/
|
|
2920
2775
|
const cardCache = new Map();
|
|
2921
2776
|
const cardCacheInFlight = new Map();
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
* 3. a remote zone/id8 caller → the cached card whose sessionTeams row
|
|
2932
|
-
* matches the handle → that row's name;
|
|
2933
|
-
* 4. nothing resolves → '' (the from line stays bare — no false names).
|
|
2934
|
-
* Read-only over warm caches: never fetches, so a cold cache degrades to
|
|
2935
|
-
* the bare handle, never to a stall.
|
|
2936
|
-
*/
|
|
2937
|
-
function callerDisplayName(caller) {
|
|
2938
|
-
if (caller === '' || caller === 'an unknown node')
|
|
2939
|
-
return '';
|
|
2940
|
-
// Local: joined/live session facts on THIS host.
|
|
2941
|
-
for (const [id, agent] of sessionNodes) {
|
|
2942
|
-
if (id === caller || sessionLabelOf(agent) === caller || `${config.team}/${id8(id)}` === caller) {
|
|
2943
|
-
return nodeMetadataOf(agent).name;
|
|
2944
|
-
}
|
|
2945
|
-
}
|
|
2946
|
-
for (const [id, agent] of liveRoots) {
|
|
2947
|
-
if (id === caller || sessionLabelOf(agent) === caller || `${config.team}/${id8(id)}` === caller) {
|
|
2948
|
-
return nodeMetadataOf(agent).name;
|
|
2949
|
-
}
|
|
2950
|
-
}
|
|
2951
|
-
// Remote: warm card cache only.
|
|
2952
|
-
const zone = caller.includes('/') ? caller.slice(0, caller.indexOf('/')) : caller;
|
|
2953
|
-
let zoneName = '';
|
|
2954
|
-
for (const entry of cardCache.values()) {
|
|
2955
|
-
const card = entry.card;
|
|
2956
|
-
if (card === undefined)
|
|
2957
|
-
continue;
|
|
2958
|
-
if (card.team === zone && zoneName === '')
|
|
2959
|
-
zoneName = card.name;
|
|
2960
|
-
if (caller.includes('/')) {
|
|
2961
|
-
const row = (card.sessionTeams ?? []).find(item => item.team === caller);
|
|
2962
|
-
if (row !== undefined && row.name !== '')
|
|
2963
|
-
return row.name;
|
|
2964
|
-
}
|
|
2965
|
-
}
|
|
2966
|
-
if (!caller.includes('/') && zoneName !== '')
|
|
2967
|
-
return zoneName;
|
|
2968
|
-
return '';
|
|
2969
|
-
}
|
|
2970
|
-
/** The from clause with the display name in full-width parens when resolved. */
|
|
2777
|
+
// #114 S6: the change hooks and the boot observation start only once every
|
|
2778
|
+
// record admission reads exists (the peer-org basis reads cardCache above).
|
|
2779
|
+
// The cache is still empty here, so the observer skips cache-resting rows
|
|
2780
|
+
// on 'boot' (#138 R1); the first lazy read rechecks them.
|
|
2781
|
+
ctx.effect(() => canvasStore.onChange(() => observeAuthorization('canvas')), 'a2a: S6 canvas observer');
|
|
2782
|
+
ctx.effect(() => joinedSessions.onChange(() => observeAuthorization('joined')), 'a2a: S6 joined observer');
|
|
2783
|
+
ctx.effect(() => teamMemberships.onChange(() => observeAuthorization('roster')), 'a2a: S6 roster observer');
|
|
2784
|
+
observeAuthorization('boot');
|
|
2785
|
+
/** Security headers omit all unsigned peer display metadata. */
|
|
2971
2786
|
function fromClause(from) {
|
|
2972
|
-
const
|
|
2973
|
-
return `from
|
|
2787
|
+
const caller = from.replace(/[\x00-\x1f\x7f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff\uff08\uff09\ufe59\ufe5a]/g, '');
|
|
2788
|
+
return `from ${JSON.stringify(caller)}`;
|
|
2789
|
+
}
|
|
2790
|
+
function sanitizeFromClause(from) {
|
|
2791
|
+
return fromClause(from);
|
|
2792
|
+
}
|
|
2793
|
+
function hostDirectHeader(team, taskId, from, proofClause) {
|
|
2794
|
+
return `[A2A direct] (task ${taskId}) ${proofClause} ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
|
|
2974
2795
|
}
|
|
2975
2796
|
/**
|
|
2976
2797
|
* gh#20 W-A: THIS host's view of one tracked peer's link state (the
|
|
@@ -3143,544 +2964,54 @@ ${message}`;
|
|
|
3143
2964
|
// loud, structured, and schema-free (no success shape is produced).
|
|
3144
2965
|
throw new Error('你被禁止使用 a2a 网络:本会话未加入 A2A 网络(join 是用户手势)。Join via the sidebar control to enable, then retry.');
|
|
3145
2966
|
};
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
// remain readable (whoami shows them) but carry no mint rights.
|
|
3162
|
-
const teamJoinExactAllowed = (team) => ownerAllowlist.includes(team);
|
|
3163
|
-
const teamJoinPatternMatches = (team) => ownerAllowlist.some(pattern => {
|
|
3164
|
-
if (pattern === '*')
|
|
3165
|
-
return true;
|
|
3166
|
-
if (pattern.endsWith('*')) {
|
|
3167
|
-
// Boundary: the wildcard consumes at most the remainder of one
|
|
3168
|
-
// '/'-delimited segment — `dsh*` matches `dsh/agent-1`, never
|
|
3169
|
-
// `dshield`; `dsh/canvas/*` matches the canvas subtree only.
|
|
3170
|
-
const prefix = pattern.slice(0, -1);
|
|
3171
|
-
return team.startsWith(prefix) && (prefix.endsWith('/') || (team.length > prefix.length && team[prefix.length] === '/'));
|
|
3172
|
-
}
|
|
3173
|
-
return team === pattern;
|
|
2967
|
+
const SWEEP_CONCURRENCY = 6;
|
|
2968
|
+
const infoTools = createInfoTools({
|
|
2969
|
+
ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger,
|
|
2970
|
+
inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
|
|
2971
|
+
teamMemberships, exposureStore, idempotencyStore, cardCache,
|
|
2972
|
+
id8, sessionTeamOf,
|
|
2973
|
+
nodeMetadataOf, canvasTeamOf, memoizedCardFetch, mapBounded,
|
|
2974
|
+
// Hot-applied owner settings: getters keep the tools' call-time read live.
|
|
2975
|
+
// Passing these by value snapshots the apply-scope lets and silently
|
|
2976
|
+
// disables owner-settings hot-apply (whoami allowlist / teams scoping).
|
|
2977
|
+
get ownerAllowlist() { return ownerAllowlist; },
|
|
2978
|
+
get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
|
|
2979
|
+
teamAuditRing, reportRing, a2aJoinGateRefusal, observeAuthorization,
|
|
2980
|
+
settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver,
|
|
2981
|
+
SWEEP_CONCURRENCY,
|
|
3174
2982
|
});
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
output: {
|
|
3190
|
-
schema: {
|
|
3191
|
-
type: 'object',
|
|
3192
|
-
additionalProperties: false,
|
|
3193
|
-
properties: {
|
|
3194
|
-
ok: { type: 'boolean', required: true },
|
|
3195
|
-
face: { type: 'string', required: true, description: 'session | host' },
|
|
3196
|
-
node: {
|
|
3197
|
-
type: 'object',
|
|
3198
|
-
additionalProperties: true,
|
|
3199
|
-
description: 'id, joined, live, and the routable team handle (zone/id8) once joined.',
|
|
3200
|
-
},
|
|
3201
|
-
teams: {
|
|
3202
|
-
type: 'object',
|
|
3203
|
-
additionalProperties: true,
|
|
3204
|
-
description: 'roster: allowlisted teams declared via a2a_team_join; canvas: workbench-assigned teams.',
|
|
3205
|
-
},
|
|
3206
|
-
allowlist: {
|
|
3207
|
-
type: 'object',
|
|
3208
|
-
additionalProperties: true,
|
|
3209
|
-
description: 'The owner-curated teamJoinAllowlist: exact entries (the only join authority, gh#11) plus pattern entries kept readable but carrying no mint rights (deny-all / curated; allow-all only via explicit exact entries).',
|
|
3210
|
-
},
|
|
3211
|
-
teamScopeRouting: { type: 'boolean' },
|
|
3212
|
-
note: { type: 'string', description: 'What join does and does not gate, in one sentence.' },
|
|
3213
|
-
lastRefusals: {
|
|
3214
|
-
type: 'array',
|
|
3215
|
-
description: 'Most recent refused join/leave attempts for this node (newest first, ≤3), with reason codes.',
|
|
3216
|
-
items: { type: 'object', additionalProperties: true },
|
|
3217
|
-
},
|
|
3218
|
-
},
|
|
3219
|
-
},
|
|
3220
|
-
render: (_args, value) => [{
|
|
3221
|
-
type: 'text',
|
|
3222
|
-
text: value.face === 'host'
|
|
3223
|
-
? `Host face on team ${String(value.node?.team ?? '?')}: routing rides the bare node team (能发即能收)`
|
|
3224
|
-
: `Node ${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 ?? '?')}`,
|
|
3225
|
-
}],
|
|
3226
|
-
},
|
|
3227
|
-
presentCall: () => ({ card: 'generic', title: 'A2A whoami', kind: 'other', rawInput: null }),
|
|
3228
|
-
execute: async (_args, exec) => {
|
|
3229
|
-
const agentId = exec?.agent !== undefined ? String(exec.agent.id) : '';
|
|
3230
|
-
if (agentId === '') {
|
|
3231
|
-
return {
|
|
3232
|
-
ok: true,
|
|
3233
|
-
face: 'host',
|
|
3234
|
-
node: { team: config.team },
|
|
3235
|
-
note: 'host face — routing rides the bare node team (能发即能收); joins are session-node concerns',
|
|
3236
|
-
};
|
|
3237
|
-
}
|
|
3238
|
-
const joined = joinedSessions.has(agentId);
|
|
3239
|
-
const live = sessionNodes.has(agentId);
|
|
3240
|
-
const refusals = teamAuditRing
|
|
3241
|
-
.filter(entry => entry.id === agentId && !entry.ok)
|
|
3242
|
-
.slice(-3)
|
|
3243
|
-
.reverse()
|
|
3244
|
-
.map(entry => ({ at: entry.at, action: entry.action, team: entry.team, reason: entry.reason ?? '' }));
|
|
3245
|
-
const patterns = [...ownerAllowlist];
|
|
3246
|
-
return {
|
|
3247
|
-
ok: true,
|
|
3248
|
-
face: 'session',
|
|
3249
|
-
node: {
|
|
3250
|
-
id: agentId,
|
|
3251
|
-
...(joined ? { team: `${config.team}/${id8(agentId)}` } : {}),
|
|
3252
|
-
joined,
|
|
3253
|
-
live,
|
|
3254
|
-
},
|
|
3255
|
-
teams: {
|
|
3256
|
-
roster: [...teamMemberships.teamsOf(agentId)],
|
|
3257
|
-
canvas: [...canvasStore.teamsOf(agentId)],
|
|
3258
|
-
},
|
|
3259
|
-
allowlist: {
|
|
3260
|
-
patterns,
|
|
3261
|
-
effect: patterns.length === 0 ? 'deny-all (empty allowlist refuses every join)' : patterns.includes('*') ? 'allow-all' : 'curated',
|
|
3262
|
-
},
|
|
3263
|
-
teamScopeRouting: config.teamScopeRouting === true,
|
|
3264
|
-
...(refusals.length > 0 ? { lastRefusals: refusals } : {}),
|
|
3265
|
-
note: joined
|
|
3266
|
-
? 'join buys presence + being routed to; outbound routing needs no join (initiator face is exempt)'
|
|
3267
|
-
: 'NOT joined: no presence/discovery and cannot be routed to; outbound routing still works (initiator face), and joining is a host-side human control (GUI sidebar A2A panel)',
|
|
3268
|
-
};
|
|
3269
|
-
},
|
|
3270
|
-
}));
|
|
3271
|
-
ctx.tools.register(defineTool({
|
|
3272
|
-
name: 'a2a_team_join',
|
|
3273
|
-
description: 'Declare one of this host\'s joined session nodes a member of a routable team '
|
|
3274
|
-
+ '(node-declared roster; the host card publishes it and peers rebuild team rosters as the union of '
|
|
3275
|
-
+ 'member declarations). Gated by the owner\'s teamJoinAllowlist — an empty list denies every join; '
|
|
3276
|
-
+ 'patterns are exact names or trailing-`*` prefixes. Canvas teams are out of scope here: their '
|
|
3277
|
-
+ 'membership is assigned by hand in the A2A workbench, and the tool refuses `/canvas/` names. '
|
|
3278
|
-
+ 'Collaboration itself stays within teams (team-scoped routing is the separate teamScopeRouting switch).',
|
|
3279
|
-
parameters: {
|
|
3280
|
-
team: { type: 'string', description: 'The routable team to join (full form, e.g. dsh/review-gate).' },
|
|
3281
|
-
id: { type: 'string', description: 'The joined session node id on this host that declares the membership.' },
|
|
3282
|
-
},
|
|
3283
|
-
output: {
|
|
3284
|
-
schema: {
|
|
3285
|
-
type: 'object',
|
|
3286
|
-
additionalProperties: false,
|
|
3287
|
-
properties: {
|
|
3288
|
-
ok: { type: 'boolean', required: true },
|
|
3289
|
-
team: { type: 'string' },
|
|
3290
|
-
id: { type: 'string' },
|
|
3291
|
-
teams: { type: 'array', items: { type: 'string' } },
|
|
3292
|
-
error: { type: 'string', description: 'Refusal reason when ok is false: unknown node or team not allowlisted.' },
|
|
3293
|
-
},
|
|
3294
|
-
},
|
|
3295
|
-
render: (_args, value) => [{
|
|
3296
|
-
type: 'text',
|
|
3297
|
-
text: value.ok === true
|
|
3298
|
-
? `Node ${String(value.id)} joined team ${String(value.team)}; its teams: ${Array.isArray(value.teams) ? value.teams.join(', ') : String(value.team)}`
|
|
3299
|
-
: `Join refused: ${String(value.error)}`,
|
|
3300
|
-
}],
|
|
3301
|
-
},
|
|
3302
|
-
presentCall: () => ({ card: 'generic', title: 'A2A team join', kind: 'other', rawInput: null }),
|
|
3303
|
-
execute: async (args, exec) => {
|
|
3304
|
-
a2aJoinGateRefusal(exec);
|
|
3305
|
-
const team = String(args.team ?? '').trim();
|
|
3306
|
-
const id = String(args.id ?? '').trim();
|
|
3307
|
-
if (team === '' || id === '') {
|
|
3308
|
-
const reason = '[BAD_ARGUMENTS] both team and id are required';
|
|
3309
|
-
recordTeamAudit('join', team, id, false, reason);
|
|
3310
|
-
return { ok: false, error: reason };
|
|
3311
|
-
}
|
|
3312
|
-
if (joinedNodeOrNone(id) === undefined) {
|
|
3313
|
-
// K3 feedback (2026-10-20): reason code + a step the MODEL can take.
|
|
3314
|
-
// The old text pointed at a sidebar control and a CLI — neither is
|
|
3315
|
-
// executable by a session agent. Joining is a host-side human
|
|
3316
|
-
// control; the model's executable step is to ask its operator and
|
|
3317
|
-
// to self-check state with a2a_whoami.
|
|
3318
|
-
const reason = `[NOT_JOINED] session ${id} is not a joined node on this host — network joining is a host-side human control (the GUI sidebar A2A panel), so relay the request to your human operator; once the node is joined, retry this declaration. Your current state (joined/live, teams, allowlist) is one a2a_whoami call away`;
|
|
3319
|
-
recordTeamAudit('join', team, id, false, reason);
|
|
3320
|
-
return { ok: false, error: reason };
|
|
3321
|
-
}
|
|
3322
|
-
// V4 (gh#7, N2): a model tool changes membership for the CALLING
|
|
3323
|
-
// session only — agent-1 declaring agent-2 into a team was an agent
|
|
3324
|
-
// creating a link for another node, published to the whole fleet via
|
|
3325
|
-
// the card. The host face (agent-less, operator surface) may still
|
|
3326
|
-
// act for any joined node.
|
|
3327
|
-
if (exec?.agent !== undefined && String(exec.agent.id) !== id) {
|
|
3328
|
-
const reason = `[NOT_SELF] a model tool may only declare membership for its own session node (${String(exec.agent.id)}) — membership for other nodes is your operator's gesture (the workbench or the host face); N2: agents use links, they do not create them for others`;
|
|
3329
|
-
recordTeamAudit('join', team, id, false, reason);
|
|
3330
|
-
return { ok: false, error: reason };
|
|
3331
|
-
}
|
|
3332
|
-
// Unified teaming (0.6.6): canvas teams are the human-composed surface.
|
|
3333
|
-
// A session may not self-join one through the roster tool — membership
|
|
3334
|
-
// there is assigned by hand, so the refusal names the surface where the
|
|
3335
|
-
// assignment happens. Any `/canvas/` name (this zone's or a peer's) is
|
|
3336
|
-
// refused: remote canvas membership is the owning host's store, and a
|
|
3337
|
-
// roster declaration here would neither feed it nor be read by it.
|
|
3338
|
-
if (team.includes('/canvas/')) {
|
|
3339
|
-
const reason = `[CANVAS_TEAM] team "${team}" is a canvas team — canvas membership is assigned by hand in the A2A workbench (open /__dsh_a2a_nexus/, pick the team, add or remove members there); the roster tool manages allowlisted teams only. If this membership must change now, relay the request to your human operator`;
|
|
3340
|
-
recordTeamAudit('join', team, id, false, reason);
|
|
3341
|
-
return { ok: false, error: reason };
|
|
3342
|
-
}
|
|
3343
|
-
if (!teamJoinExactAllowed(team)) {
|
|
3344
|
-
const reason = teamJoinPatternMatches(team)
|
|
3345
|
-
? `[PATTERN_NO_MINT] team "${team}" matches only a wildcard/pattern entry of this host's teamJoinAllowlist — patterns carry no mint rights (gh#11/N7: team creation is a human act); ask the owner to add the EXACT team name, which is the act of creating it`
|
|
3346
|
-
: `[ALLOWLIST_DENY] team "${team}" is not in this host's teamJoinAllowlist — the owner curates joinable teams with exact names (patterns match nothing at join time; human-composed groups live in the workbench's canvas teams); ask them to add the exact name. The live list is visible in a2a_whoami`;
|
|
3347
|
-
recordTeamAudit('join', team, id, false, reason);
|
|
3348
|
-
return { ok: false, error: reason };
|
|
3349
|
-
}
|
|
3350
|
-
teamMemberships.add(id, team);
|
|
3351
|
-
recordTeamAudit('join', team, id, true);
|
|
3352
|
-
logger.info(`a2a: ${id8(id)} declared membership in ${team}`);
|
|
3353
|
-
return { ok: true, team, id, teams: [...teamMemberships.teamsOf(id)] };
|
|
3354
|
-
},
|
|
3355
|
-
}));
|
|
3356
|
-
ctx.tools.register(defineTool({
|
|
3357
|
-
name: 'a2a_team_leave',
|
|
3358
|
-
description: 'Retract one team-membership declaration previously made by a2a_team_join. Always allowed — leaving only '
|
|
3359
|
-
+ 'shrinks the node\'s visible collaboration surface.',
|
|
3360
|
-
parameters: {
|
|
3361
|
-
team: { type: 'string', description: 'The routable team to leave.' },
|
|
3362
|
-
id: { type: 'string', description: 'The session node id retracting the membership.' },
|
|
3363
|
-
},
|
|
3364
|
-
output: {
|
|
3365
|
-
schema: {
|
|
3366
|
-
type: 'object',
|
|
3367
|
-
additionalProperties: false,
|
|
3368
|
-
properties: {
|
|
3369
|
-
ok: { type: 'boolean', required: true },
|
|
3370
|
-
team: { type: 'string' },
|
|
3371
|
-
id: { type: 'string' },
|
|
3372
|
-
teams: { type: 'array', items: { type: 'string' } },
|
|
3373
|
-
error: { type: 'string', description: 'Refusal reason when ok is false: node unknown or membership absent.' },
|
|
3374
|
-
},
|
|
3375
|
-
},
|
|
3376
|
-
render: (_args, value) => [{
|
|
3377
|
-
type: 'text',
|
|
3378
|
-
text: value.ok === true
|
|
3379
|
-
? `Node ${String(value.id)} left team ${String(value.team)}; remaining teams: ${Array.isArray(value.teams) && value.teams.length > 0 ? value.teams.join(', ') : 'none'}`
|
|
3380
|
-
: `Leave refused: ${String(value.error)}`,
|
|
3381
|
-
}],
|
|
3382
|
-
},
|
|
3383
|
-
presentCall: () => ({ card: 'generic', title: 'A2A team leave', kind: 'other', rawInput: null }),
|
|
3384
|
-
execute: async (args, exec) => {
|
|
3385
|
-
a2aJoinGateRefusal(exec);
|
|
3386
|
-
const team = String(args.team ?? '').trim();
|
|
3387
|
-
const id = String(args.id ?? '').trim();
|
|
3388
|
-
if (team === '' || id === '') {
|
|
3389
|
-
recordTeamAudit('leave', team, id, false, 'both team and id are required');
|
|
3390
|
-
return { ok: false, error: 'both team and id are required' };
|
|
3391
|
-
}
|
|
3392
|
-
if (joinedNodeOrNone(id) === undefined) {
|
|
3393
|
-
const reason = `[NOT_JOINED] session ${id} is not a joined node on this host — check current state with a2a_whoami`;
|
|
3394
|
-
recordTeamAudit('leave', team, id, false, reason);
|
|
3395
|
-
return { ok: false, error: reason };
|
|
3396
|
-
}
|
|
3397
|
-
// V4 (gh#7, N2): same self-only rule as join — narrowing another
|
|
3398
|
-
// node's surface is still a link change by a third party.
|
|
3399
|
-
if (exec?.agent !== undefined && String(exec.agent.id) !== id) {
|
|
3400
|
-
const reason = `[NOT_SELF] a model tool may only retract membership for its own session node (${String(exec.agent.id)}); N2 — use links, do not reshape another node's`;
|
|
3401
|
-
recordTeamAudit('leave', team, id, false, reason);
|
|
3402
|
-
return { ok: false, error: reason };
|
|
3403
|
-
}
|
|
3404
|
-
if (!teamMemberships.teamsOf(id).includes(team)) {
|
|
3405
|
-
const reason = `session ${id} declares no membership in "${team}" — nothing to leave`;
|
|
3406
|
-
recordTeamAudit('leave', team, id, false, reason);
|
|
3407
|
-
return { ok: false, error: reason };
|
|
3408
|
-
}
|
|
3409
|
-
teamMemberships.remove(id, team);
|
|
3410
|
-
recordTeamAudit('leave', team, id, true);
|
|
3411
|
-
logger.info(`a2a: ${id8(id)} retracted membership in ${team}`);
|
|
3412
|
-
return { ok: true, team, id, teams: [...teamMemberships.teamsOf(id)] };
|
|
3413
|
-
},
|
|
3414
|
-
}));
|
|
3415
|
-
ctx.tools.register(defineTool({
|
|
3416
|
-
name: 'a2a_exposure',
|
|
3417
|
-
description: '#19 exposure tiers: set THIS session\'s network visibility. \'linked\' (the default '
|
|
3418
|
-
+ 'floor) — visible to linked callers per the card-face link gate; \'fleet\' — a voluntary '
|
|
3419
|
-
+ 'opt-UP making the session\'s team visible to any fleet peer. Members may raise and lower '
|
|
3420
|
-
+ 'their own tier freely; nothing exists below linked (no private tier in v1), and group '
|
|
3421
|
-
+ 'visibility always derives from the strictest member (no group knobs). Requires the '
|
|
3422
|
-
+ 'session to be joined to the network.',
|
|
3423
|
-
parameters: {
|
|
3424
|
-
tier: { type: 'string', enum: ['linked', 'fleet'], required: true, description: 'The exposure tier to set for this session.' },
|
|
3425
|
-
},
|
|
3426
|
-
output: {
|
|
3427
|
-
schema: {
|
|
3428
|
-
type: 'object',
|
|
3429
|
-
additionalProperties: false,
|
|
3430
|
-
properties: {
|
|
3431
|
-
ok: { type: 'boolean', required: true },
|
|
3432
|
-
id: { type: 'string' },
|
|
3433
|
-
tier: { type: 'string' },
|
|
3434
|
-
error: { type: 'string', description: 'Refusal reason when ok is false (not joined, or no agent context).' },
|
|
3435
|
-
},
|
|
3436
|
-
},
|
|
3437
|
-
render: (_args, value) => [{
|
|
3438
|
-
type: 'text',
|
|
3439
|
-
text: value.ok === true
|
|
3440
|
-
? `Session ${String(value.id)} exposure tier set to ${String(value.tier)}`
|
|
3441
|
-
: `Exposure refused: ${String(value.error)}`,
|
|
3442
|
-
}],
|
|
3443
|
-
},
|
|
3444
|
-
presentCall: () => ({ card: 'generic', title: 'A2A exposure tier', kind: 'other', rawInput: null }),
|
|
3445
|
-
execute: async (args, exec) => {
|
|
3446
|
-
const agent = exec.agent;
|
|
3447
|
-
if (agent === undefined) {
|
|
3448
|
-
return { ok: false, error: 'no agent context — the exposure tier is a per-session fact; call from a session' };
|
|
3449
|
-
}
|
|
3450
|
-
const id = String(agent.id);
|
|
3451
|
-
if (!joinedSessions.has(id)) {
|
|
3452
|
-
return { ok: false, error: 'session is not joined to the A2A network — join first (the sidebar control or the host join route)' };
|
|
3453
|
-
}
|
|
3454
|
-
const tier = args.tier === 'fleet' ? 'fleet' : 'linked';
|
|
3455
|
-
exposureStore.setTier(id, tier);
|
|
3456
|
-
logger.info(`a2a: ${id8(id)} exposure tier -> ${tier}`);
|
|
3457
|
-
return { ok: true, id, tier };
|
|
3458
|
-
},
|
|
3459
|
-
}));
|
|
3460
|
-
ctx.tools.register(defineTool({
|
|
3461
|
-
name: 'a2a_report',
|
|
3462
|
-
description: 'Report a task or status to this host\'s A2A network — the ONE ingress a session WITHOUT network '
|
|
3463
|
-
+ 'membership may use (deliberately exempt from the join gate: one-way reporting grants no presence, '
|
|
3464
|
-
+ 'no discovery, no routing rights; the report lands in the state face\'s reports ring for '
|
|
3465
|
-
+ 'supervision to read and act on, e.g. adopt or re-join the reporter). Use when you are off the '
|
|
3466
|
-
+ 'mesh and need to say so. Rate limited to one report per 30s per session; messages cap at 4000 chars.',
|
|
3467
|
-
parameters: {
|
|
3468
|
-
message: { type: 'string', required: true, description: 'The report text (≤4000 chars).' },
|
|
3469
|
-
level: { type: 'string', enum: ['info', 'warning', 'error'], description: 'Severity; error also warns the host log.' },
|
|
3470
|
-
task: { type: 'string', description: 'Optional task reference (id/link).' },
|
|
3471
|
-
},
|
|
3472
|
-
output: {
|
|
3473
|
-
schema: {
|
|
3474
|
-
type: 'object',
|
|
3475
|
-
additionalProperties: false,
|
|
3476
|
-
properties: {
|
|
3477
|
-
ok: { type: 'boolean', required: true },
|
|
3478
|
-
reportId: { type: 'string' },
|
|
3479
|
-
error: { type: 'string', description: 'Refusal reason when ok is false (empty message, over-cap, or rate limited).' },
|
|
3480
|
-
},
|
|
3481
|
-
},
|
|
3482
|
-
render: (_args, value) => [{
|
|
3483
|
-
type: 'text',
|
|
3484
|
-
text: value.ok === true ? `Report registered (${String(value.reportId)})` : `Report refused: ${String(value.error)}`,
|
|
3485
|
-
}],
|
|
3486
|
-
},
|
|
3487
|
-
presentCall: () => ({ card: 'generic', title: 'A2A report', kind: 'other', rawInput: null }),
|
|
3488
|
-
execute: async (args, exec) => {
|
|
3489
|
-
// Deliberately NO a2aJoinGateRefusal: reporting is the one thing an
|
|
3490
|
-
// unjoined session may do — that is the tool's entire purpose.
|
|
3491
|
-
const message = String(args.message ?? '').trim();
|
|
3492
|
-
if (message === '')
|
|
3493
|
-
return { ok: false, error: 'message is required' };
|
|
3494
|
-
if (message.length > 4000)
|
|
3495
|
-
return { ok: false, error: 'message exceeds the 4000-char cap' };
|
|
3496
|
-
const session = exec?.agent !== undefined ? String(exec.agent.id) : 'host';
|
|
3497
|
-
const now = Date.now();
|
|
3498
|
-
const last = reportLastAt.get(session) ?? 0;
|
|
3499
|
-
if (now - last < 30_000)
|
|
3500
|
-
return { ok: false, error: 'rate limited: one report per 30s per session' };
|
|
3501
|
-
// Fix ④ (loop circuit-breaker, d44efa9f pattern): the SAME session
|
|
3502
|
-
// reporting the SAME text ≥3 times inside a 10-minute window is a
|
|
3503
|
-
// loop, not a situation — refuse with a named reason and audit the
|
|
3504
|
-
// ring (the first copies go through; the bounded ring self-expires).
|
|
3505
|
-
const loopKey = `${session}:${createHash('sha256').update(message).digest('hex').slice(0, 16)}`;
|
|
3506
|
-
const loopWindow = (reportLoopRing.get(loopKey) ?? []).filter(at => now - at < 600_000);
|
|
3507
|
-
if (loopWindow.length >= 3) {
|
|
3508
|
-
recordTeamAudit('agent-less', 'a2a_report', session, false, 'loop detected: 3 identical reports in 10min');
|
|
3509
|
-
return { ok: false, error: '循环检测:同会话同文 10 分钟内已上报 3 次,本 report 熔断拒绝。若确需再报请变更内容或等窗口滑出' };
|
|
3510
|
-
}
|
|
3511
|
-
loopWindow.push(now);
|
|
3512
|
-
reportLoopRing.set(loopKey, loopWindow);
|
|
3513
|
-
reportLastAt.set(session, now);
|
|
3514
|
-
const level = args.level === 'error' || args.level === 'warning' ? args.level : 'info';
|
|
3515
|
-
const reportId = `report-${Math.random().toString(16).slice(2, 10)}`;
|
|
3516
|
-
reportRing.push({
|
|
3517
|
-
at: now,
|
|
3518
|
-
level,
|
|
3519
|
-
message: message.slice(0, 4000),
|
|
3520
|
-
session,
|
|
3521
|
-
...(typeof args.task === 'string' && args.task.trim() !== '' ? { task: args.task.trim() } : {}),
|
|
3522
|
-
});
|
|
3523
|
-
if (reportRing.length > 64)
|
|
3524
|
-
reportRing.splice(0, reportRing.length - 64);
|
|
3525
|
-
if (level === 'error')
|
|
3526
|
-
logger.warn(`a2a report (${id8(session)}): ${message.slice(0, 200)}`);
|
|
3527
|
-
return { ok: true, reportId };
|
|
3528
|
-
},
|
|
3529
|
-
}));
|
|
3530
|
-
// S5 (owner directive 2026-10-20): information-plane scoping. The host's
|
|
3531
|
-
// joined-team scope is the union of every local node's roster
|
|
3532
|
-
// declarations — a host-level set, not per-session: supervisor seats need
|
|
3533
|
-
// the cross-team overview, and 「加入的团队」 reads as the teams this
|
|
3534
|
-
// host's nodes joined. The information-plane tools (a2a_teams /
|
|
3535
|
-
// a2a_status / a2a_probe) disclose remote facts only inside that scope;
|
|
3536
|
-
// a2a_tasks — this host's own dispatch ledger and receipt-reconciliation
|
|
3537
|
-
// surface, including initiator-exempt dispatches — stays unscoped by
|
|
3538
|
-
// ruling (its rows name teams this host itself routed to, never a fleet
|
|
3539
|
-
// census).
|
|
3540
|
-
const joinedTeamScope = () => {
|
|
3541
|
-
const joined = new Set();
|
|
3542
|
-
for (const entry of teamMemberships.list()) {
|
|
3543
|
-
for (const team of entry.teams)
|
|
3544
|
-
joined.add(team);
|
|
3545
|
-
}
|
|
3546
|
-
// G4 (review artifact 688c0354d3 v2): canvas membership is join intent
|
|
3547
|
-
// too. This host's canvas teams — and every member handle they name
|
|
3548
|
-
// (remote zone-qualified handles as-is, local session ids as their
|
|
3549
|
-
// session-team handle) — belong to the scope, so canvas teammates stay
|
|
3550
|
-
// visible to each other under teamScopeInfo instead of the roster-only
|
|
3551
|
-
// reading splitting them apart.
|
|
3552
|
-
for (const name of canvasStore.list()) {
|
|
3553
|
-
joined.add(canvasTeamOf(name));
|
|
3554
|
-
for (const member of canvasStore.membersOf(name)) {
|
|
3555
|
-
joined.add(member.includes('/') ? member : `${config.team}/${id8(member)}`);
|
|
3556
|
-
}
|
|
3557
|
-
}
|
|
3558
|
-
return joined;
|
|
3559
|
-
};
|
|
2983
|
+
infoTools.registerWhoami();
|
|
2984
|
+
const actTools = createActTools({
|
|
2985
|
+
ctx, config, logger, sessionNodes, joinedSessions,
|
|
2986
|
+
teamMemberships, exposureStore, reportRing, recordTeamAudit,
|
|
2987
|
+
a2aJoinGateRefusal, id8, routeTrunk,
|
|
2988
|
+
// Owner-settings hot-apply REBINDS this let: the getter keeps the tool
|
|
2989
|
+
// module's call-time read live instead of snapshotting at construction.
|
|
2990
|
+
get ownerAllowlist() { return ownerAllowlist; },
|
|
2991
|
+
});
|
|
2992
|
+
actTools.registerTeamJoin();
|
|
2993
|
+
actTools.registerTeamLeave();
|
|
2994
|
+
actTools.registerExposure();
|
|
2995
|
+
actTools.registerReport();
|
|
2996
|
+
infoTools.registerTeams();
|
|
3560
2997
|
/**
|
|
3561
|
-
*
|
|
3562
|
-
*
|
|
3563
|
-
*
|
|
3564
|
-
*
|
|
3565
|
-
*
|
|
2998
|
+
* The directory's listing: this host's own teams lead (the loopback
|
|
2999
|
+
* candidates a2a_route dials first) — the process team, then every joined
|
|
3000
|
+
* live session node with its title and activity facts; then every tracked
|
|
3001
|
+
* peer card's own team plus its joined session teams. Delegated names are
|
|
3002
|
+
* not listed — only names a tracked zone publishes directly appear;
|
|
3003
|
+
* delegations resolve at route time (a resolvable delegation always
|
|
3004
|
+
* aliases a listed team, so rows for them would repeat).
|
|
3005
|
+
*
|
|
3006
|
+
* Discovery is gossip: each fetch offers the card's referrals to the
|
|
3007
|
+
* store, and mid-iteration offers are normally fetched on the next call.
|
|
3008
|
+
* A search pass chases one extra hop within the call — after the first
|
|
3009
|
+
* sweep, peers that were newly offered get their own card fetched — so a
|
|
3010
|
+
* keyword hunt reaches sessions the previous call had not yet gossiped,
|
|
3011
|
+
* while staying bounded (one extra sweep, still under the store cap).
|
|
3012
|
+
* @param expand - chase one referral hop beyond the current store walk.
|
|
3566
3013
|
*/
|
|
3567
|
-
|
|
3568
|
-
|| (card.sessionTeams ?? []).some(entry => joined.has(entry.team))
|
|
3569
|
-
|| (card.teamMemberships ?? []).some(membership => joined.has(membership.team));
|
|
3570
|
-
ctx.tools.register(defineTool({
|
|
3571
|
-
name: 'a2a_teams',
|
|
3572
|
-
description: 'List or search reachable A2A teams: this host\'s own process team and joined session nodes first (marked [this host], '
|
|
3573
|
-
+ 'routable over loopback — same-host collaboration needs no peers), then teams across the tracked peer network '
|
|
3574
|
-
+ 'with owner label, a recent-activity excerpt, the publishing host (origin: node label + LAN IP — the natural '
|
|
3575
|
-
+ 'grouping when a fleet spans machines), and the session\'s working directory when shared. Pass query to filter '
|
|
3576
|
-
+ 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
|
|
3577
|
-
+ 'discovers one extra referral hop within the call. Canvas teams (<team>/canvas/<name>, user-composed multi-member groups) '
|
|
3578
|
-
+ 'list as local rows; routing to one resolves the first live member or wakes the first cold one — the dispatching session '
|
|
3579
|
-
+ 'itself is skipped on the local tiers, falling through to the other members (issue #41: a member routing to its own group '
|
|
3580
|
-
+ 'never loops home). '
|
|
3581
|
-
+ 'While teamScopeInfo is on (default), the listing follows YOUR collaboration circle: remote rows serve only teams joined by this host\'s nodes, and [this host] session rows show only your own row, your canvas teams, and sessions sharing a team with you — an exposed node id invites mis-routing, so the directory does not advertise strangers. The owner\'s GUI panel keeps the full plane; teamScopeInfo: false opens it for tools too. '
|
|
3582
|
-
+ 'Call this before a2a_route to pick a target team.',
|
|
3583
|
-
parameters: {
|
|
3584
|
-
query: { type: 'string', description: 'Optional keyword filter (case-insensitive substring over team/name/description).' },
|
|
3585
|
-
},
|
|
3586
|
-
output: {
|
|
3587
|
-
schema: {
|
|
3588
|
-
type: 'object',
|
|
3589
|
-
additionalProperties: false,
|
|
3590
|
-
properties: {
|
|
3591
|
-
ok: { type: 'boolean', required: true },
|
|
3592
|
-
query: { type: 'string', description: 'The filter applied, empty when unfiltered.' },
|
|
3593
|
-
teams: {
|
|
3594
|
-
type: 'array',
|
|
3595
|
-
required: true,
|
|
3596
|
-
items: {
|
|
3597
|
-
type: 'object',
|
|
3598
|
-
additionalProperties: false,
|
|
3599
|
-
properties: {
|
|
3600
|
-
team: { type: 'string', required: true },
|
|
3601
|
-
session: { type: 'string', required: true },
|
|
3602
|
-
name: { type: 'string', required: true },
|
|
3603
|
-
description: { type: 'string', required: true },
|
|
3604
|
-
local: { type: 'boolean', description: 'true when the team is served by this host (loopback candidate).' },
|
|
3605
|
-
origin: { type: 'string', description: 'The publishing host (node session label, LAN IP when known) — the natural grouping for fleet rows.' },
|
|
3606
|
-
via: { type: 'string', description: 'The peer URL this row was discovered through (host:port), when it came from a peer card.' },
|
|
3607
|
-
workspace: { type: 'string', description: 'The session\'s working directory, when shared.' },
|
|
3608
|
-
legacy: { type: 'boolean', description: 'true when the publishing node advertises no roster capability (pre-roster peer).' },
|
|
3609
|
-
teams: { type: 'array', items: { type: 'string' }, description: 'Roster teams the node declared for this handle (card teamMemberships).' },
|
|
3610
|
-
},
|
|
3611
|
-
},
|
|
3612
|
-
},
|
|
3613
|
-
note: { type: 'string', description: 'Present while teamScopeInfo scoping is on: the joined-team scope plus how many rows outside YOUR collaboration circle were withheld (remote/local split; the process team, your own row, and your canvas teams always show).' },
|
|
3614
|
-
},
|
|
3615
|
-
},
|
|
3616
|
-
render: (args, value) => [{
|
|
3617
|
-
type: 'text',
|
|
3618
|
-
text: (value.teams.length === 0
|
|
3619
|
-
? `No A2A teams${(value.query ?? '') === '' ? '' : ` matching "${String(args.query)}"`} are currently reachable.`
|
|
3620
|
-
: 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'))
|
|
3621
|
-
+ (value.note === undefined ? '' : `\n${value.note}`),
|
|
3622
|
-
}],
|
|
3623
|
-
},
|
|
3624
|
-
presentCall: args => ({ card: 'generic', title: args.query === undefined || args.query === '' ? 'List reachable A2A teams' : `Search A2A teams: ${args.query}`, kind: 'other', rawInput: args }),
|
|
3625
|
-
execute: async (args, exec) => {
|
|
3626
|
-
a2aJoinGateRefusal(exec);
|
|
3627
|
-
const query = args.query?.trim().toLowerCase() ?? '';
|
|
3628
|
-
let teams = await listDirectoryTeams(query !== '');
|
|
3629
|
-
// S5: remote rows survive only when they serve a joined team (the
|
|
3630
|
-
// row's own name or any roster declaration it carries); local rows
|
|
3631
|
-
// are host-internal facts and always survive. The note keeps the
|
|
3632
|
-
// model from reading a scoped listing as an empty fleet.
|
|
3633
|
-
let note;
|
|
3634
|
-
if (ownerTeamScopeInfo) {
|
|
3635
|
-
const joined = joinedTeamScope();
|
|
3636
|
-
const remoteBefore = teams.filter(row => row.local !== true).length;
|
|
3637
|
-
teams = teams.filter(row => row.local === true || joined.has(row.team) || (row.teams ?? []).some(name => joined.has(name)));
|
|
3638
|
-
const hiddenRemote = remoteBefore - teams.filter(row => row.local !== true).length;
|
|
3639
|
-
// gh#39 (owner ruling: the listing ADVERTISES targets — an exposed
|
|
3640
|
-
// node id invites a session with no collaboration relationship to
|
|
3641
|
-
// route at it): [this host] session rows follow the CALLER's
|
|
3642
|
-
// collaboration circle. The host's own GUI/panel keeps the full
|
|
3643
|
-
// plane (the owner's eyes); the model-tool face is scoped.
|
|
3644
|
-
let hiddenLocal = 0;
|
|
3645
|
-
const callerAgent = exec.agent;
|
|
3646
|
-
if (callerAgent !== undefined) {
|
|
3647
|
-
const callerId = String(callerAgent.id);
|
|
3648
|
-
const ownHandle = sessionTeamOf(callerAgent);
|
|
3649
|
-
const myCanvas = new Set(canvasStore.teamsOf(callerId));
|
|
3650
|
-
const myRoster = new Set(teamMemberships.teamsOf(callerId));
|
|
3651
|
-
const before = teams.length;
|
|
3652
|
-
teams = teams.filter(row => {
|
|
3653
|
-
if (row.local !== true)
|
|
3654
|
-
return true;
|
|
3655
|
-
if (row.team === config.team || row.team === ownHandle)
|
|
3656
|
-
return true;
|
|
3657
|
-
if (row.team.startsWith(`${config.team}/canvas/`))
|
|
3658
|
-
return myCanvas.has(row.name);
|
|
3659
|
-
// Another session's team row: visible only when we share a
|
|
3660
|
-
// canvas or a declared team with that session.
|
|
3661
|
-
const targetId8 = row.team.slice(config.team.length + 1);
|
|
3662
|
-
const target = [...sessionNodes.values()].find(agent => id8(String(agent.id)) === targetId8);
|
|
3663
|
-
if (target === undefined)
|
|
3664
|
-
return true; // unresolvable: keep (host fact)
|
|
3665
|
-
const targetId = String(target.id);
|
|
3666
|
-
const shares = [...canvasStore.teamsOf(targetId)].some(name => myCanvas.has(name))
|
|
3667
|
-
|| [...teamMemberships.teamsOf(targetId)].some(name => myRoster.has(name));
|
|
3668
|
-
return shares;
|
|
3669
|
-
});
|
|
3670
|
-
hiddenLocal = before - teams.length;
|
|
3671
|
-
}
|
|
3672
|
-
const hidden = hiddenRemote + hiddenLocal;
|
|
3673
|
-
note = `teamScopeInfo on: ${String(joined.size)} joined team${joined.size === 1 ? '' : 's'} on this host — ${String(hidden)} row${hidden === 1 ? '' : 's'} outside your collaboration circle withheld (${String(hiddenRemote)} remote, ${String(hiddenLocal)} local [this host] session rows; the process team, your own row, and your canvas teams always show). To widen the scope: a human composes the shared canvas team (the board drag), the owner adds the EXACT team name to teamJoinAllowlist (patterns carry no mint rights, gh#11), or the owner sets teamScopeInfo: false for the open plane`;
|
|
3674
|
-
}
|
|
3675
|
-
if (query === '')
|
|
3676
|
-
return { ok: true, query: '', teams, ...(note !== undefined ? { note } : {}) };
|
|
3677
|
-
const matches = (team) => team.team.toLowerCase().includes(query) || team.name.toLowerCase().includes(query) || team.description.toLowerCase().includes(query)
|
|
3678
|
-
|| team.session.toLowerCase().includes(query)
|
|
3679
|
-
|| (team.origin ?? '').toLowerCase().includes(query) || (team.workspace ?? '').toLowerCase().includes(query) || (team.via ?? '').toLowerCase().includes(query);
|
|
3680
|
-
return { ok: true, query, teams: teams.filter(matches), ...(note !== undefined ? { note } : {}) };
|
|
3681
|
-
},
|
|
3682
|
-
}));
|
|
3683
|
-
async function listDirectoryTeams(expand) {
|
|
3014
|
+
async function listDirectoryTeams(expand, includeCanvas = false) {
|
|
3684
3015
|
const localOrigin = lanIp === '' ? `${session} [this host]` : `${session} [this host, ${lanIp}]`;
|
|
3685
3016
|
const teams = [
|
|
3686
3017
|
{ team: config.team, session, name: config.agentName, description: '', local: true, origin: localOrigin },
|
|
@@ -3695,7 +3026,10 @@ ${message}`;
|
|
|
3695
3026
|
// count are the facts a caller needs before routing — resolution picks
|
|
3696
3027
|
// the first live member at route time.
|
|
3697
3028
|
...canvasStore.list().map(name => {
|
|
3698
|
-
|
|
3029
|
+
// gh#152: members as the local tiers read them (gh#131) — a handle
|
|
3030
|
+
// naming one of this host's own sessions counts as that member, and
|
|
3031
|
+
// the two stored shapes of one session collapse to one.
|
|
3032
|
+
const members = [...new Set(canvasLocalMembersOf(name))];
|
|
3699
3033
|
const live = members.filter(id => sessionNodes.has(id)).length;
|
|
3700
3034
|
return {
|
|
3701
3035
|
team: canvasTeamOf(name),
|
|
@@ -3728,35 +3062,12 @@ ${message}`;
|
|
|
3728
3062
|
const card = await fetch(peer);
|
|
3729
3063
|
if (card === undefined)
|
|
3730
3064
|
return [];
|
|
3731
|
-
//
|
|
3732
|
-
//
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
//
|
|
3736
|
-
|
|
3737
|
-
// to its publishing endpoint at a glance.
|
|
3738
|
-
// legacy: the publisher's card carries no roster capability — a
|
|
3739
|
-
// pre-roster node (0.5.43-). Rows from it are marked so the fleet
|
|
3740
|
-
// audit (and the phase-2 teamed-only default flip) knows which peers
|
|
3741
|
-
// never grow the roster face.
|
|
3742
|
-
const legacy = card.capabilities?.roster !== true;
|
|
3743
|
-
const rows = [
|
|
3744
|
-
{ team: card.team, session: card.session, name: card.name, description: '', origin, via: peer, ...(legacy ? { legacy } : {}) },
|
|
3745
|
-
];
|
|
3746
|
-
// Roster reader half: the card's unsigned teamMemberships map each
|
|
3747
|
-
// published session team to its declared roster teams, so a remote
|
|
3748
|
-
// node's teamed/teamless state is visible without visiting its host.
|
|
3749
|
-
const rosterOf = new Map();
|
|
3750
|
-
for (const membership of card.teamMemberships ?? []) {
|
|
3751
|
-
const list = rosterOf.get(membership.node) ?? [];
|
|
3752
|
-
list.push(membership.team);
|
|
3753
|
-
rosterOf.set(membership.node, list);
|
|
3754
|
-
}
|
|
3755
|
-
for (const entry of card.sessionTeams ?? []) {
|
|
3756
|
-
const declared = rosterOf.get(entry.team);
|
|
3757
|
-
rows.push({ team: entry.team, session: card.session, name: entry.name, description: entry.description, origin, via: peer, ...(entry.workspace !== undefined ? { workspace: entry.workspace } : {}), ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}) });
|
|
3758
|
-
}
|
|
3759
|
-
return rows;
|
|
3065
|
+
// Keep the GUI's remote node projection session-only; the model tool
|
|
3066
|
+
// opts into the additional canvas-team rows (#159).
|
|
3067
|
+
const rows = peerDirectoryRows(card, peer, includeCanvas);
|
|
3068
|
+
// gh#106: a card kept through a miss still lists (until its own
|
|
3069
|
+
// expiresAt), but the row says the peer did not answer the last read.
|
|
3070
|
+
return cardCache.get(peer)?.missAt !== undefined ? rows.map(row => ({ ...row, unreachable: true })) : rows;
|
|
3760
3071
|
};
|
|
3761
3072
|
// Bounded-concurrency sweep (mapBounded keeps row order by index, so the
|
|
3762
3073
|
// listing still follows the peer store's preference order): a cold cache
|
|
@@ -3777,25 +3088,6 @@ ${message}`;
|
|
|
3777
3088
|
}
|
|
3778
3089
|
return teams;
|
|
3779
3090
|
}
|
|
3780
|
-
/**
|
|
3781
|
-
* Whether one caller label can receive network traffic back. Composite
|
|
3782
|
-
* `<zone>/<id>` shapes route anywhere (N1); bare zone names resolve to a
|
|
3783
|
-
* live initiator per host, so cross-host they would steer whichever node
|
|
3784
|
-
* shares the name — treated as unroutable everywhere.
|
|
3785
|
-
*/
|
|
3786
|
-
function routableCallerLabel(callerSession) {
|
|
3787
|
-
return callerSession !== undefined &&
|
|
3788
|
-
(/[a-z0-9-]+\/[0-9a-f]{8}$/i.test(callerSession) || /[a-z0-9-]+\/[a-z0-9]{4,}$/i.test(callerSession));
|
|
3789
|
-
}
|
|
3790
|
-
/**
|
|
3791
|
-
* Why an async route from such a caller cannot expect its receipt: the
|
|
3792
|
-
* autosend arm skips it and the steered hint is omitted, so the debt has
|
|
3793
|
-
* no paying path — say so at dispatch time instead of silently stranding
|
|
3794
|
-
* a pending row.
|
|
3795
|
-
*/
|
|
3796
|
-
function unroutableCallerNote(label) {
|
|
3797
|
-
return `\n(Note: caller label "${label}" is not network-routable, so the target cannot auto-send a receipt back to you — reconcile via context_id follow-ups instead.)`;
|
|
3798
|
-
}
|
|
3799
3091
|
/**
|
|
3800
3092
|
* Track one routed task in the ledger when its result leaves it owed a
|
|
3801
3093
|
* receipt: ONLY an explicitly async dispatch (fire-and-forget) owes one —
|
|
@@ -3805,8 +3097,38 @@ ${message}`;
|
|
|
3805
3097
|
* decision seat: sync Q&A must not enter pending; only real
|
|
3806
3098
|
* receipt-awaiting rows stay on the book). A synchronous completion
|
|
3807
3099
|
* already carries its answer and owes nothing.
|
|
3100
|
+
* #114 S3: `sender` is the real originator the row is attributed to.
|
|
3101
|
+
*/
|
|
3102
|
+
/**
|
|
3103
|
+
* #139: what an in-process async delivery learned about its own birth —
|
|
3104
|
+
* the admission it ran under (design §2.4) and the member that received it
|
|
3105
|
+
* — handed from dispatchLocalCandidate to the caller's outbound row, keyed
|
|
3106
|
+
* by task id and taken (deleted) by whoever dispatched.
|
|
3808
3107
|
*/
|
|
3809
|
-
const
|
|
3108
|
+
const localBirths = new Map();
|
|
3109
|
+
const localBirthOf = (team, callerSession, receiptTarget, deliveredTo) => {
|
|
3110
|
+
const admission = localCandidateAdmission(admissionRecords, callerSession, team);
|
|
3111
|
+
const caller = callerSession ?? session;
|
|
3112
|
+
const auth = admission.ok && caller !== ''
|
|
3113
|
+
? buildAuthorization(admission, { caller, proof: 'in-process', ingress: 'local', allowlistVersion: allowlistVersionNow(), target: team, receiptTarget: receiptTarget ?? caller, receivedAt: Date.now() })
|
|
3114
|
+
: undefined;
|
|
3115
|
+
return { ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
|
|
3116
|
+
};
|
|
3117
|
+
/**
|
|
3118
|
+
* #139: the peer facts a remote outbound row is born with — the signed
|
|
3119
|
+
* card's key and session label (the host identity whatever spelling of its
|
|
3120
|
+
* address dialed it) and, for a canvas row, the member handle the request
|
|
3121
|
+
* carried. The memo makes the card read free after the directory walk.
|
|
3122
|
+
*/
|
|
3123
|
+
const peerBirth = async (fetch, candidate, member) => {
|
|
3124
|
+
const card = await fetch(candidate).catch(() => undefined);
|
|
3125
|
+
return {
|
|
3126
|
+
...(card !== undefined && card.publicKey !== '' ? { peerKey: card.publicKey } : {}),
|
|
3127
|
+
...(card !== undefined && card.session !== '' ? { peerHost: card.session } : {}),
|
|
3128
|
+
...(member !== undefined ? { deliveredTo: member } : {}),
|
|
3129
|
+
};
|
|
3130
|
+
};
|
|
3131
|
+
const trackOwedTask = (taskId, team, peer, result, callbackAddress, brief, sender, birth) => {
|
|
3810
3132
|
if (!result.ok)
|
|
3811
3133
|
return;
|
|
3812
3134
|
// Native-teams bridge rounds emit no A2A receipt in this slice: booking
|
|
@@ -3822,10 +3144,10 @@ ${message}`;
|
|
|
3822
3144
|
// row whose only exit was the dead-letter sweep (57 dead letters, half
|
|
3823
3145
|
// this shape).
|
|
3824
3146
|
if (callbackAddress === undefined) {
|
|
3825
|
-
taskLedger.trackReceiptless(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief);
|
|
3147
|
+
taskLedger.trackReceiptless(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief, sender, birth);
|
|
3826
3148
|
return;
|
|
3827
3149
|
}
|
|
3828
|
-
taskLedger.track(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief);
|
|
3150
|
+
taskLedger.track(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief, sender, birth);
|
|
3829
3151
|
};
|
|
3830
3152
|
/**
|
|
3831
3153
|
* Build the -32004 verdict. The error string is the calling model's ONLY
|
|
@@ -3844,6 +3166,9 @@ ${message}`;
|
|
|
3844
3166
|
* routable; the dispatch registers receiptless per fix 2).
|
|
3845
3167
|
*/
|
|
3846
3168
|
const routeVerdict = (team, failures, candidateCount) => {
|
|
3169
|
+
const ambiguous = failures.find(failure => failure.startsWith('ambiguous: '));
|
|
3170
|
+
if (candidateCount === 0 && ambiguous !== undefined)
|
|
3171
|
+
return { ok: false, error: ambiguous, code: -32004 };
|
|
3847
3172
|
if (candidateCount > 0) {
|
|
3848
3173
|
return {
|
|
3849
3174
|
ok: false,
|
|
@@ -3872,49 +3197,11 @@ ${message}`;
|
|
|
3872
3197
|
].filter((line) => line !== undefined);
|
|
3873
3198
|
return { ok: false, error: lines.join(' — '), code: -32004 };
|
|
3874
3199
|
};
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
+ 'to continue that conversation. The call blocks until the remote team responds, which may take minutes — '
|
|
3881
|
-
+ 'pass async for long-running tasks: delivery returns immediately and the target routes a receipt '
|
|
3882
|
-
+ '(message starting "[A2A receipt] task <task_id> <outcome summary>") back to your team.',
|
|
3883
|
-
parameters: {
|
|
3884
|
-
team: { type: 'string', required: true, description: 'Target team name, from a2a_teams.' },
|
|
3885
|
-
message: { type: 'string', required: true, description: 'The request text to send. For long verdict-style payloads, split into parts and label each "1/N", "2/N"… so the receiver can reassemble.' },
|
|
3886
|
-
context_id: { type: 'string', description: 'Context id from a previous a2a_route reply, to continue that conversation.' },
|
|
3887
|
-
async: { type: 'boolean', description: 'Fire-and-forget: deliver and return immediately (delivered:true + task_id) without waiting for the target\'s reply. The target answers by routing a receipt — a message starting "[A2A receipt] task <task_id> <outcome summary>" — back to your team (visible in a2a_status activity), or follow up with the context_id. Prefer async for long-running tasks (minutes to hours).' },
|
|
3888
|
-
},
|
|
3889
|
-
output: {
|
|
3890
|
-
schema: {
|
|
3891
|
-
type: 'object',
|
|
3892
|
-
additionalProperties: true,
|
|
3893
|
-
},
|
|
3894
|
-
render: renderRoute,
|
|
3895
|
-
},
|
|
3896
|
-
timeoutMs: config.routeTimeoutMs,
|
|
3897
|
-
presentCall: args => ({ card: 'generic', title: `A2A route → ${args.team}`, kind: 'other', rawInput: args }),
|
|
3898
|
-
execute: async (args, exec) => {
|
|
3899
|
-
// Owner ruling (tool-layer interception): the PROCESS face (bare
|
|
3900
|
-
// zone === config.team) keeps its host-level EXEMPTION on the network
|
|
3901
|
-
// admission layer — receipts, quick dispatch and host-side routing
|
|
3902
|
-
// still target it internally — but a MODEL TOOL call must not address
|
|
3903
|
-
// it: the machine's own mailbox is not a member face, and letting a
|
|
3904
|
-
// session route there would turn the host entrance into a group-free
|
|
3905
|
-
// hop. Host-face entry (agent-less control routes, receipt ladders)
|
|
3906
|
-
// never passes through this tool and stays untouched.
|
|
3907
|
-
const requestedTeam = String(args.team ?? '').trim();
|
|
3908
|
-
if (exec.agent !== undefined && requestedTeam === config.team) {
|
|
3909
|
-
return {
|
|
3910
|
-
ok: false,
|
|
3911
|
-
error: `the process face "${config.team}" is the host's own mailbox — model tools must target a member face. Route to a session team handle ("${config.team}/<id8>" from a2a_teams) or a canvas team; receipts and quick dispatch reach the host face on their own paths.`,
|
|
3912
|
-
code: -32000,
|
|
3913
|
-
};
|
|
3914
|
-
}
|
|
3915
|
-
return routeTrunk(args, exec.agent, exec.signal);
|
|
3916
|
-
},
|
|
3917
|
-
}));
|
|
3200
|
+
actTools.registerRoute();
|
|
3201
|
+
// #91 file channel F2: a2a_file_send / a2a_file_fetch (src/tools/files.ts).
|
|
3202
|
+
const fileTools = createFileTools({ ctx, config, blobStore, canvasMembersOf: name => canvasStore.membersOf(name), ownCardUrl: () => ownCardUrl, sessionTeamOf, a2aJoinGateRefusal, routeTrunk, peerFetch, originClaimHeaders });
|
|
3203
|
+
fileTools.registerFileSend();
|
|
3204
|
+
fileTools.registerFileFetch();
|
|
3918
3205
|
/**
|
|
3919
3206
|
* The routing trunk (0.6.9): the one failover sequence behind both the
|
|
3920
3207
|
* a2a_route tool (agent face) and the quick-dispatch control route (host
|
|
@@ -3952,6 +3239,7 @@ ${message}`;
|
|
|
3952
3239
|
// calls (no agent)
|
|
3953
3240
|
// and the initiator face stay exempt, like the join gate; discovery
|
|
3954
3241
|
// (a2a_teams) stays open so a teamless node can still see what to join.
|
|
3242
|
+
let scopedCanvasCaller;
|
|
3955
3243
|
if (config.teamScopeRouting && agent !== undefined) {
|
|
3956
3244
|
const scopeCaller = String(agent.id);
|
|
3957
3245
|
let initiator = false;
|
|
@@ -3970,6 +3258,7 @@ ${message}`;
|
|
|
3970
3258
|
// Canvas members mix two shapes: LOCAL sessions sit by session
|
|
3971
3259
|
// id, cross-host members by zone/id8 handle — match either.
|
|
3972
3260
|
const callerHandle = `${config.team}/${id8(scopeCaller)}`;
|
|
3261
|
+
scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
|
|
3973
3262
|
// gh#45 末修 (66abc2cf defect): a CANVAS GROUP NAME target
|
|
3974
3263
|
// (`<team>/canvas/<name>`) never matches member handles — a
|
|
3975
3264
|
// whole-group dispatch from a group member was refused. The
|
|
@@ -3980,6 +3269,11 @@ ${message}`;
|
|
|
3980
3269
|
const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
|
|
3981
3270
|
return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
|
|
3982
3271
|
});
|
|
3272
|
+
// A direct group route must not require a prior directory call. Fetch
|
|
3273
|
+
// only the same eligible peers as resolution; then apply the
|
|
3274
|
+
// unchanged sender/receiver admission rules to their verified cards.
|
|
3275
|
+
if (team.includes('/canvas/') && !sharedCanvas && !callerTeams.includes(team))
|
|
3276
|
+
await mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch);
|
|
3983
3277
|
let orgShared = false;
|
|
3984
3278
|
for (const cached of cardCache.values()) {
|
|
3985
3279
|
const org = cached.card?.canvasOrg;
|
|
@@ -3998,16 +3292,16 @@ ${message}`;
|
|
|
3998
3292
|
break;
|
|
3999
3293
|
}
|
|
4000
3294
|
if (callerTeams.length === 0 && !sharedCanvas && !orgShared) {
|
|
4001
|
-
return {
|
|
3295
|
+
return localRoute({
|
|
4002
3296
|
ok: false,
|
|
4003
3297
|
error: `team-scoped routing is on and this session (${id8(scopeCaller)}) has declared no team membership — a teamless node has no network. Declare one with a2a_team_join (subject to the owner's teamJoinAllowlist)`,
|
|
4004
|
-
};
|
|
3298
|
+
});
|
|
4005
3299
|
}
|
|
4006
3300
|
if (!callerTeams.includes(team) && !sharedCanvas && !orgShared) {
|
|
4007
|
-
return {
|
|
3301
|
+
return localRoute({
|
|
4008
3302
|
ok: false,
|
|
4009
3303
|
error: `team-scoped routing is on: routing to "${team}" requires declared membership. This session's teams: ${callerTeams.join(', ')}. a2a_team_join can declare it (subject to the owner's teamJoinAllowlist)`,
|
|
4010
|
-
};
|
|
3304
|
+
});
|
|
4011
3305
|
}
|
|
4012
3306
|
}
|
|
4013
3307
|
}
|
|
@@ -4032,21 +3326,39 @@ ${message}`;
|
|
|
4032
3326
|
const callbackAddress = routableCallerLabel(callerSession) || callerSession === config.team
|
|
4033
3327
|
? callerSession
|
|
4034
3328
|
: undefined;
|
|
3329
|
+
// #114 S3: the outbound row records who really sent it instead of
|
|
3330
|
+
// every row reading as "home". A session is always its own node team
|
|
3331
|
+
// (<zone>/<id8>) — joined or not, initiator or not — so one seat keeps
|
|
3332
|
+
// one bucket. An agent-less call is the host process itself; the label
|
|
3333
|
+
// it borrowed on the wire (quick dispatch rides a live joined session's
|
|
3334
|
+
// team) is kept apart as `face`, so a human's workbench gesture never
|
|
3335
|
+
// lands in that session's seat.
|
|
3336
|
+
const sender = agent !== undefined
|
|
3337
|
+
? { kind: 'session', id: sessionTeamOf(agent) }
|
|
3338
|
+
: { kind: 'host', id: session, ...(callerSession !== undefined && callerSession !== session ? { face: callerSession } : {}) };
|
|
4035
3339
|
const fetch = memoizedCardFetch();
|
|
4036
3340
|
// The task id is born at the caller (idempotency key semantics): both
|
|
4037
3341
|
// dispatchers carry the one id, the peer request echoes it, and the
|
|
4038
3342
|
// steered receipt header names it — so "[A2A receipt] task <id>" from
|
|
4039
3343
|
// the target correlates with the route's own result verbatim.
|
|
4040
3344
|
const taskId = `direct-${Math.random().toString(16).slice(2, 10)}`;
|
|
3345
|
+
const routedReceipt = parseReceipt(args.message) !== null;
|
|
4041
3346
|
// Candidate order: this host's own teams first (an in-process steer —
|
|
4042
3347
|
// cheapest possible and immune to nested-signal aborts), then direct
|
|
4043
3348
|
// publishers, then zone delegations, deduplicated by URL.
|
|
4044
3349
|
const failures = [];
|
|
3350
|
+
if (signal?.aborted)
|
|
3351
|
+
return localRoute({ ok: false, error: 'route aborted before dispatch', code: -32000 });
|
|
4045
3352
|
const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
|
|
3353
|
+
const localBirth = localBirths.get(taskId);
|
|
3354
|
+
localBirths.delete(taskId);
|
|
4046
3355
|
if (outcome !== undefined) {
|
|
4047
3356
|
if (outcome.ok) {
|
|
4048
|
-
|
|
4049
|
-
|
|
3357
|
+
// gh#141: a receipt owes no receipt — booking it would strand a row nothing settles.
|
|
3358
|
+
if (!routedReceipt)
|
|
3359
|
+
trackOwedTask(taskId, args.team, 'local', outcome, callbackAddress, briefOf(args.message), sender, localBirth);
|
|
3360
|
+
// gh#198: host-built delivery notes, or a reply from a clean local session (resolved as routeIntoAgent does), are this host's alone.
|
|
3361
|
+
return ((outcome.bridge === undefined && (outcome.task_status === 'TASK_STATE_DELIVERED' || outcome.task_status === 'TASK_STATE_ABORTED_WAIT' || (t => t !== undefined && nodeTaint.of(String(t.id)) === '')(resolveAgentForTeam(args.team) ?? canvasLiveAgent(parseCanvasTeamName(args.team))))) ? localRoute(outcome) : outcome);
|
|
4050
3362
|
}
|
|
4051
3363
|
failures.push(`local: ${outcome.error}`);
|
|
4052
3364
|
}
|
|
@@ -4059,7 +3371,8 @@ ${message}`;
|
|
|
4059
3371
|
// unchanged. The ledger stays keyed to the canvas team the caller
|
|
4060
3372
|
// named; the peer request carries the member handle it must resolve.
|
|
4061
3373
|
const canvasName = parseCanvasTeamName(args.team);
|
|
4062
|
-
|
|
3374
|
+
// gh#131: one of this host's own handles is a local member, never a directory target.
|
|
3375
|
+
const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
|
|
4063
3376
|
const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
|
|
4064
3377
|
if (remoteTargets.length > 0) {
|
|
4065
3378
|
failures.push(`local: canvas team "${args.team}" has no live or wakeable local member`);
|
|
@@ -4073,7 +3386,7 @@ ${message}`;
|
|
|
4073
3386
|
}
|
|
4074
3387
|
const candidatePool = [];
|
|
4075
3388
|
for (const target of targets) {
|
|
4076
|
-
for (const candidate of await directoryPeerCandidates(fetch, target, failures)) {
|
|
3389
|
+
for (const candidate of await directoryPeerCandidates(fetch, target, failures, scopedCanvasCaller)) {
|
|
4077
3390
|
candidatePool.push({ candidate, target });
|
|
4078
3391
|
}
|
|
4079
3392
|
}
|
|
@@ -4094,8 +3407,13 @@ ${message}`;
|
|
|
4094
3407
|
}
|
|
4095
3408
|
const result = await dispatchPeerCandidate(candidate, target, args.message, args.context_id, signal, callerSession, peerAsync, taskId, callbackAddress);
|
|
4096
3409
|
if (result.ok || signal.aborted) {
|
|
4097
|
-
|
|
4098
|
-
|
|
3410
|
+
// A receipt the creditor refused (its enforce verdict) pays nothing here either (review of PR #149 P2-2).
|
|
3411
|
+
if (result.ok && routedReceipt) {
|
|
3412
|
+
if (result.settlementRefused !== true)
|
|
3413
|
+
settleOwnInbound(args.message, args.team, { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' });
|
|
3414
|
+
}
|
|
3415
|
+
else if (result.ok)
|
|
3416
|
+
trackOwedTask(taskId, args.team, candidate, result, callbackAddress, briefOf(args.message), sender, await peerBirth(fetch, candidate, target === args.team ? undefined : target));
|
|
4099
3417
|
const notes = [];
|
|
4100
3418
|
if (result.ok && args.async === true && result.task_status === 'TASK_STATE_DELIVERED' && !routableCallerLabel(callerSession)) {
|
|
4101
3419
|
notes.push(unroutableCallerNote(String(callerSession ?? session)));
|
|
@@ -4141,6 +3459,12 @@ ${message}`;
|
|
|
4141
3459
|
recordActivity('out', team, callerSession ?? 'local', false);
|
|
4142
3460
|
return { ok: false, error: joinedRefusal, code: -32000 };
|
|
4143
3461
|
}
|
|
3462
|
+
// #114 S5: the same header-safe id rule the wire face enforces.
|
|
3463
|
+
const idRefusal = taskIdFromCaller === undefined ? undefined : taskIdRefusal(taskIdFromCaller);
|
|
3464
|
+
if (idRefusal !== undefined) {
|
|
3465
|
+
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3466
|
+
return { ok: false, error: idRefusal, code: -32000 };
|
|
3467
|
+
}
|
|
4144
3468
|
const canvasName = parseCanvasTeamName(team);
|
|
4145
3469
|
// Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
|
|
4146
3470
|
// or a session label) and quick-dispatch BORROWS a live session's team
|
|
@@ -4159,8 +3483,14 @@ ${message}`;
|
|
|
4159
3483
|
// gh#45 CLI mailbox: a delivery targeting a DEPOSITED CLI handle is
|
|
4160
3484
|
// local by definition — it lands in the deposit's mailbox (the CLI
|
|
4161
3485
|
// polls while alive; receipts and messages never wake anything).
|
|
3486
|
+
// The settler of any receipt on this path: this host's own caller (in-process).
|
|
3487
|
+
const localSettler = { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' };
|
|
4162
3488
|
const cliRow = cliDeposits.get(team);
|
|
4163
3489
|
if (cliRow !== undefined) {
|
|
3490
|
+
// #114 S4 (design §4.5): a receipt dropped into a local CLI mailbox is
|
|
3491
|
+
// exactly the settlement a CLI-originated task waits for — settle it,
|
|
3492
|
+
// as the wire face settles before ITS mailbox split.
|
|
3493
|
+
settleAndAnnounce(message, localSettler);
|
|
4164
3494
|
cliDeposits.mail(team, callerSession ?? 'unknown', message);
|
|
4165
3495
|
recordActivity('in', team, callerSession ?? 'peer', true);
|
|
4166
3496
|
logger.info(`a2a: cli mailbox delivery for ${team} (from ${callerSession ?? 'unknown'})`);
|
|
@@ -4176,7 +3506,7 @@ ${message}`;
|
|
|
4176
3506
|
// A receipt relayed between same-host sessions (the answering session
|
|
4177
3507
|
// routing its `[A2A receipt]` back over the in-process candidate)
|
|
4178
3508
|
// correlates here too — same contract, no HTTP on the path.
|
|
4179
|
-
settleAndAnnounce(message);
|
|
3509
|
+
settleAndAnnounce(message, localSettler);
|
|
4180
3510
|
const taskId = taskIdFromCaller ?? `direct-${Math.random().toString(16).slice(2, 10)}`;
|
|
4181
3511
|
const flight = beginRoute(team, 'local');
|
|
4182
3512
|
if (asyncMode) {
|
|
@@ -4205,7 +3535,7 @@ ${message}`;
|
|
|
4205
3535
|
if (prepared.ok) {
|
|
4206
3536
|
endRoute(flight);
|
|
4207
3537
|
recordActivity('out', team, 'local', true);
|
|
4208
|
-
void nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId)
|
|
3538
|
+
void nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', undefined, hostDirectHeader)
|
|
4209
3539
|
.then(outcome => {
|
|
4210
3540
|
if (!outcome.ok) {
|
|
4211
3541
|
recordActivity('out', team, 'local', false);
|
|
@@ -4254,8 +3584,13 @@ ${message}`;
|
|
|
4254
3584
|
? callbackAddress
|
|
4255
3585
|
: routable ? callerSession : undefined;
|
|
4256
3586
|
const receiptHint = callerTeam === undefined ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${callerTeam}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
|
|
4257
|
-
|
|
4258
|
-
|
|
3587
|
+
// #114 S5: the consumption search starts at the pre-steer log length.
|
|
3588
|
+
const steerFloor = sessionEventsOf(target).length;
|
|
3589
|
+
steerRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId);
|
|
3590
|
+
armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
|
|
3591
|
+
// #139: the caller's outbound row is born with this admission and the member that received it.
|
|
3592
|
+
if (taskIdFromCaller !== undefined)
|
|
3593
|
+
localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target)));
|
|
4259
3594
|
endRoute(flight);
|
|
4260
3595
|
recordActivity('out', team, 'local', true);
|
|
4261
3596
|
return {
|
|
@@ -4289,7 +3624,7 @@ ${message}`;
|
|
|
4289
3624
|
if (!steerable && team !== config.team) {
|
|
4290
3625
|
const prepared = await nativeTeamsPrepare(team);
|
|
4291
3626
|
if (prepared.ok) {
|
|
4292
|
-
const outcome = await nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, signal);
|
|
3627
|
+
const outcome = await nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', signal, hostDirectHeader);
|
|
4293
3628
|
endRoute(flight);
|
|
4294
3629
|
if (!outcome.ok) {
|
|
4295
3630
|
// The aborted-wait shape is earned only by an actual round
|
|
@@ -4330,7 +3665,7 @@ ${message}`;
|
|
|
4330
3665
|
}
|
|
4331
3666
|
}
|
|
4332
3667
|
}
|
|
4333
|
-
const wait = routeIntoAgent(team, message, callerSession ?? session, taskId);
|
|
3668
|
+
const wait = routeIntoAgent(team, message, callerSession ?? session, taskId, undefined, undefined, originAgentId);
|
|
4334
3669
|
const aborted = new Promise((_, reject) => {
|
|
4335
3670
|
signal.addEventListener('abort', () => { reject(new Error('aborted')); }, { once: true });
|
|
4336
3671
|
});
|
|
@@ -4439,10 +3774,13 @@ ${message}`;
|
|
|
4439
3774
|
*/
|
|
4440
3775
|
async function dispatchAnywhere(team, message, callerSession, contextId, signal, asyncMode, taskId) {
|
|
4441
3776
|
const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
|
|
3777
|
+
// A background send books no outbound row: its birth facts have no row to go on.
|
|
3778
|
+
if (taskId !== undefined)
|
|
3779
|
+
localBirths.delete(taskId);
|
|
4442
3780
|
if (local !== undefined) {
|
|
4443
3781
|
if (!local.ok)
|
|
4444
3782
|
throw new Error(local.error);
|
|
4445
|
-
return;
|
|
3783
|
+
return local;
|
|
4446
3784
|
}
|
|
4447
3785
|
const failures = [];
|
|
4448
3786
|
const candidates = await directoryPeerCandidates(memoizedCardFetch(), team, failures);
|
|
@@ -4453,7 +3791,7 @@ ${message}`;
|
|
|
4453
3791
|
for (const candidate of candidates) {
|
|
4454
3792
|
const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, callerSession, asyncMode, taskId);
|
|
4455
3793
|
if (result.ok)
|
|
4456
|
-
return;
|
|
3794
|
+
return result;
|
|
4457
3795
|
lastError = result.error;
|
|
4458
3796
|
if (signal.aborted)
|
|
4459
3797
|
break;
|
|
@@ -4471,7 +3809,6 @@ ${message}`;
|
|
|
4471
3809
|
*/
|
|
4472
3810
|
const CANDIDATE_CONCURRENCY = 6;
|
|
4473
3811
|
/** Concurrent card fetches in one directory sweep (cold-cache burst bound). */
|
|
4474
|
-
const SWEEP_CONCURRENCY = 6;
|
|
4475
3812
|
async function mapBounded(items, limit, task) {
|
|
4476
3813
|
const results = new Array(items.length);
|
|
4477
3814
|
let next = 0;
|
|
@@ -4485,7 +3822,10 @@ ${message}`;
|
|
|
4485
3822
|
await Promise.all(workers);
|
|
4486
3823
|
return results;
|
|
4487
3824
|
}
|
|
4488
|
-
|
|
3825
|
+
function resolutionPeers() {
|
|
3826
|
+
return config.referralTeamResolution === true ? peerStore.list() : [...new Set([...peerStore.listSeeded(), ...config.delegates.map(delegate => delegate.url)])];
|
|
3827
|
+
}
|
|
3828
|
+
async function directoryPeerCandidates(fetch, team, failures, scopedCanvasCaller) {
|
|
4489
3829
|
// gh#8 (N9): a team name resolves only to hosts an operator linked —
|
|
4490
3830
|
// the seeded subset of the peer store (config seeds, eviction-immune)
|
|
4491
3831
|
// plus the delegate records (config-time operator acts). The
|
|
@@ -4494,16 +3834,23 @@ ${message}`;
|
|
|
4494
3834
|
// (gh#8: a squatter two gossip hops away captured routes by naming
|
|
4495
3835
|
// itself after the target). referralTeamResolution restores the legacy
|
|
4496
3836
|
// whole-store basis for fleets mid-migration.
|
|
4497
|
-
const peers =
|
|
3837
|
+
const peers = resolutionPeers();
|
|
4498
3838
|
const candidates = [];
|
|
4499
3839
|
const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
|
|
3840
|
+
// Check after fetching all cards: a seeded peer publishing the same id8
|
|
3841
|
+
// makes the caller handle ambiguous, so do not send a canvas payload.
|
|
3842
|
+
const resolveLocal = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
|
|
3843
|
+
const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
|
|
3844
|
+
if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
|
|
3845
|
+
failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
|
|
3846
|
+
return [];
|
|
3847
|
+
}
|
|
4500
3848
|
for (const entry of cards) {
|
|
4501
3849
|
if (entry.card === undefined)
|
|
4502
3850
|
continue;
|
|
4503
|
-
if (entry.card.team === team || (entry.card
|
|
3851
|
+
if (entry.card.team === team || sessionCandidateMatches(entry.card, team) || canvasCandidateMatches(entry.card, team, scopedCanvasCaller, resolveLocal))
|
|
4504
3852
|
candidates.push(entry.peer);
|
|
4505
3853
|
}
|
|
4506
|
-
const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
|
|
4507
3854
|
for (const { peer, outcome } of resolutions) {
|
|
4508
3855
|
if (outcome.ok) {
|
|
4509
3856
|
if (!candidates.includes(outcome.url))
|
|
@@ -4564,6 +3911,14 @@ ${message}`;
|
|
|
4564
3911
|
if (sessionNodes.has(parent) || joinedSessions.has(parent))
|
|
4565
3912
|
callbackAddress = `${config.team}/${id8(parent)}`;
|
|
4566
3913
|
}
|
|
3914
|
+
// #114 S3: the submitting session's node team, but only when the
|
|
3915
|
+
// transport names a session this host knows (a node, a join, or a live
|
|
3916
|
+
// root) — an unchecked id would let any submitter mint a seat. Anything
|
|
3917
|
+
// else (absent, unknown, non-string) is attributed to the host label.
|
|
3918
|
+
const parentSession = request.callbackTarget?.parentSessionId;
|
|
3919
|
+
const knownParent = typeof parentSession === 'string' && parentSession !== ''
|
|
3920
|
+
&& (sessionNodes.has(parentSession) || joinedSessions.has(parentSession) || liveRoots.has(parentSession));
|
|
3921
|
+
const sender = { kind: 'bridge', id: knownParent ? `${config.team}/${id8(parentSession)}` : session };
|
|
4567
3922
|
for (const candidate of candidates) {
|
|
4568
3923
|
// Capability gate per candidate, like a2a_route's loop: a
|
|
4569
3924
|
// delivery:async submit never dials a peer whose signed card does
|
|
@@ -4607,7 +3962,7 @@ ${message}`;
|
|
|
4607
3962
|
break;
|
|
4608
3963
|
continue;
|
|
4609
3964
|
}
|
|
4610
|
-
trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message));
|
|
3965
|
+
trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
|
|
4611
3966
|
const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
|
|
4612
3967
|
if (result.task_status === 'TASK_STATE_COMPLETED') {
|
|
4613
3968
|
return { kind: 'completed', text: result.reply, taskId, contextId };
|
|
@@ -4712,368 +4067,15 @@ ${message}`;
|
|
|
4712
4067
|
},
|
|
4713
4068
|
};
|
|
4714
4069
|
ctx.reflect.provide(NATIVE_TEAMS_A2A_FACE_KEY, bridgeFace);
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
+ '(who is waiting on whom), and the recent routing activity ring (inbound and outbound outcomes). '
|
|
4719
|
-
+ 'Includes the idempotency window occupancy (claims, replays, conflicts) behind the UNIQUE-ization '
|
|
4720
|
-
+ 'traffic watch. '
|
|
4721
|
-
+ 'While teamScopeInfo is on (default), peers / in-flight / activity narrow to the joined-team scope — local traffic and idempotency aggregates stay. '
|
|
4722
|
-
+ 'Use it to observe an ongoing collaboration or diagnose delivery instead of re-routing.',
|
|
4723
|
-
parameters: {},
|
|
4724
|
-
output: {
|
|
4725
|
-
schema: {
|
|
4726
|
-
type: 'object',
|
|
4727
|
-
additionalProperties: false,
|
|
4728
|
-
properties: {
|
|
4729
|
-
ok: { type: 'boolean', required: true },
|
|
4730
|
-
peers: {
|
|
4731
|
-
type: 'array',
|
|
4732
|
-
required: true,
|
|
4733
|
-
items: {
|
|
4734
|
-
type: 'object',
|
|
4735
|
-
additionalProperties: false,
|
|
4736
|
-
properties: {
|
|
4737
|
-
url: { type: 'string', required: true },
|
|
4738
|
-
score: { type: 'number' },
|
|
4739
|
-
},
|
|
4740
|
-
},
|
|
4741
|
-
},
|
|
4742
|
-
inFlight: {
|
|
4743
|
-
type: 'array',
|
|
4744
|
-
required: true,
|
|
4745
|
-
items: {
|
|
4746
|
-
type: 'object',
|
|
4747
|
-
additionalProperties: false,
|
|
4748
|
-
properties: {
|
|
4749
|
-
team: { type: 'string', required: true },
|
|
4750
|
-
peer: { type: 'string', required: true },
|
|
4751
|
-
startedAt: { type: 'number', required: true },
|
|
4752
|
-
},
|
|
4753
|
-
},
|
|
4754
|
-
},
|
|
4755
|
-
activity: {
|
|
4756
|
-
type: 'array',
|
|
4757
|
-
required: true,
|
|
4758
|
-
items: {
|
|
4759
|
-
type: 'object',
|
|
4760
|
-
additionalProperties: false,
|
|
4761
|
-
properties: {
|
|
4762
|
-
ts: { type: 'number', required: true },
|
|
4763
|
-
dir: { type: 'string', required: true },
|
|
4764
|
-
team: { type: 'string', required: true },
|
|
4765
|
-
peer: { type: 'string', required: true },
|
|
4766
|
-
ok: { type: 'boolean', required: true },
|
|
4767
|
-
},
|
|
4768
|
-
},
|
|
4769
|
-
},
|
|
4770
|
-
idempotency: {
|
|
4771
|
-
type: 'object',
|
|
4772
|
-
additionalProperties: false,
|
|
4773
|
-
required: true,
|
|
4774
|
-
properties: {
|
|
4775
|
-
window: { type: 'number', required: true },
|
|
4776
|
-
cap: { type: 'number', required: true },
|
|
4777
|
-
pending: { type: 'number', required: true },
|
|
4778
|
-
settled: { type: 'number', required: true },
|
|
4779
|
-
claimsFresh: { type: 'number', required: true },
|
|
4780
|
-
replays: { type: 'number', required: true },
|
|
4781
|
-
conflicts: { type: 'number', required: true },
|
|
4782
|
-
},
|
|
4783
|
-
},
|
|
4784
|
-
},
|
|
4785
|
-
},
|
|
4786
|
-
render: (_args, value) => [{
|
|
4787
|
-
type: 'text',
|
|
4788
|
-
text: [
|
|
4789
|
-
...(value.inFlight.length === 0 ? [] : ['In flight:']),
|
|
4790
|
-
...value.inFlight.map((route) => ` → ${route.team} via ${route.peer === 'local' ? 'this host' : route.peer}`),
|
|
4791
|
-
`Peers (${String(value.peers.length)}):`,
|
|
4792
|
-
...value.peers.map((peer) => ` ${peer.url}${typeof peer.score === 'number' ? ` (score ${String(Math.round(peer.score))})` : ''}`),
|
|
4793
|
-
`Idempotency window: ${String(value.idempotency.window)}/${String(value.idempotency.cap)} (pending ${String(value.idempotency.pending)}, settled ${String(value.idempotency.settled)}) — claims ${String(value.idempotency.claimsFresh)}, replays ${String(value.idempotency.replays)}, conflicts ${String(value.idempotency.conflicts)}`,
|
|
4794
|
-
...(value.activity.length === 0 ? [] : ['Recent routes:']),
|
|
4795
|
-
...[...value.activity].reverse().slice(0, 10).map((entry) => ` ${entry.dir === 'in' ? '←' : '→'} ${entry.team} ${entry.ok ? 'ok' : 'failed'}`),
|
|
4796
|
-
].join('\n'),
|
|
4797
|
-
}],
|
|
4798
|
-
},
|
|
4799
|
-
presentCall: () => ({ card: 'generic', title: 'A2A network status', kind: 'other', rawInput: null }),
|
|
4800
|
-
execute: async (_args, exec) => {
|
|
4801
|
-
a2aJoinGateRefusal(exec);
|
|
4802
|
-
// S5 open plane: the unscoped snapshot, as before.
|
|
4803
|
-
if (!config.teamScopeInfo) {
|
|
4804
|
-
return {
|
|
4805
|
-
ok: true,
|
|
4806
|
-
peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
|
|
4807
|
-
inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })),
|
|
4808
|
-
activity: recentActivity.slice(),
|
|
4809
|
-
idempotency: idempotencyStore.stats(),
|
|
4810
|
-
};
|
|
4811
|
-
}
|
|
4812
|
-
// S5 scoped plane: the tracked fleet narrows to peers whose verified
|
|
4813
|
-
// card serves a joined team (bounded-concurrency memoized fetch — a
|
|
4814
|
-
// warm sweep costs no extra round trips); routing facts keep
|
|
4815
|
-
// host-internal traffic (peer 'local') and drop remote rows whose
|
|
4816
|
-
// team is outside the scope, so cross-fleet collaborations stay
|
|
4817
|
-
// unread here. The idempotency aggregate is host machinery, not
|
|
4818
|
-
// attribution — it stays unscoped like the ledger it serves.
|
|
4819
|
-
const joined = joinedTeamScope();
|
|
4820
|
-
const visibleTeams = new Set(joined);
|
|
4821
|
-
for (const agent of sessionNodes.values()) {
|
|
4822
|
-
if (teamMemberships.teamsOf(String(agent.id)).some(team => joined.has(team)))
|
|
4823
|
-
visibleTeams.add(sessionTeamOf(agent));
|
|
4824
|
-
}
|
|
4825
|
-
// The host's own process-team face is host-internal ALWAYS — a
|
|
4826
|
-
// zero-roster host must still see inbound deliveries and outbound
|
|
4827
|
-
// attempts addressed to itself (review M2: gating this on
|
|
4828
|
-
// joined.size > 0 self-blinded exactly that traffic).
|
|
4829
|
-
visibleTeams.add(config.team);
|
|
4830
|
-
// G2 (review artifact 688c0354d3 v2): the host-synthetic face rows —
|
|
4831
|
-
// the /a2a/log telemetry records under the synthetic team label
|
|
4832
|
-
// 'log' (recordActivity('in', 'log', caller) on both admission
|
|
4833
|
-
// outcomes) — are THIS host's own face facts, not fleet observation.
|
|
4834
|
-
// Scoping filtered them like foreign traffic and hid the host's own
|
|
4835
|
-
// inbound log-face telemetry from its own supervisor; they pass.
|
|
4836
|
-
visibleTeams.add('log');
|
|
4837
|
-
const fetch = memoizedCardFetch();
|
|
4838
|
-
const peerRows = await mapBounded(peerStore.list(), SWEEP_CONCURRENCY, async (url) => {
|
|
4839
|
-
const card = await fetch(url);
|
|
4840
|
-
return card !== undefined && cardServesJoined(card, joined) ? { url, score: peerStore.score(url) } : undefined;
|
|
4841
|
-
});
|
|
4842
|
-
const peers = peerRows.filter((row) => row !== undefined);
|
|
4843
|
-
const inScope = (entry) => entry.peer === 'local' || visibleTeams.has(entry.team);
|
|
4844
|
-
return {
|
|
4845
|
-
ok: true,
|
|
4846
|
-
peers,
|
|
4847
|
-
inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })).filter(inScope),
|
|
4848
|
-
activity: recentActivity.filter(inScope),
|
|
4849
|
-
idempotency: idempotencyStore.stats(),
|
|
4850
|
-
};
|
|
4851
|
-
},
|
|
4852
|
-
}));
|
|
4853
|
-
/** Human duration for a ledger row: how long a task has been owed or took. */
|
|
4854
|
-
const describeAge = (ms) => {
|
|
4855
|
-
const minutes = Math.floor(ms / 60_000);
|
|
4856
|
-
if (minutes < 1)
|
|
4857
|
-
return 'under a minute';
|
|
4858
|
-
if (minutes < 60)
|
|
4859
|
-
return `${String(minutes)}m`;
|
|
4860
|
-
return `${String(Math.floor(minutes / 60))}h${minutes % 60 === 0 ? '' : `${String(minutes % 60)}m`}`;
|
|
4861
|
-
};
|
|
4862
|
-
// S5 ruling: a2a_tasks stays unscoped — it is this host's own dispatch
|
|
4863
|
-
// ledger (pending / dead / archived), the receipt-reconciliation surface
|
|
4864
|
-
// for work this host itself routed, initiator-exempt dispatches included.
|
|
4865
|
-
// Its rows name teams this host already talked to; reading them back is
|
|
4866
|
-
// bookkeeping, not fleet disclosure.
|
|
4867
|
-
ctx.tools.register(defineTool({
|
|
4868
|
-
name: 'a2a_tasks',
|
|
4869
|
-
description: 'Read-only three-tier ledger of routed A2A tasks: pending rows still owed a receipt, rows '
|
|
4870
|
-
+ 'auto-dead-lettered past the stale TTL, and the bounded archive of correlated '
|
|
4871
|
-
+ '`[A2A receipt] task <task_id>` outcomes. Use it to reconcile dispatched async work instead of '
|
|
4872
|
-
+ 're-routing to ask for progress.',
|
|
4873
|
-
parameters: {},
|
|
4874
|
-
output: {
|
|
4875
|
-
schema: {
|
|
4876
|
-
type: 'object',
|
|
4877
|
-
additionalProperties: false,
|
|
4878
|
-
properties: {
|
|
4879
|
-
ok: { type: 'boolean', required: true },
|
|
4880
|
-
tasks: {
|
|
4881
|
-
type: 'array',
|
|
4882
|
-
required: true,
|
|
4883
|
-
items: {
|
|
4884
|
-
type: 'object',
|
|
4885
|
-
additionalProperties: false,
|
|
4886
|
-
properties: {
|
|
4887
|
-
taskId: { type: 'string', required: true },
|
|
4888
|
-
team: { type: 'string', required: true },
|
|
4889
|
-
peer: { type: 'string', required: true },
|
|
4890
|
-
startedAt: { type: 'number', required: true },
|
|
4891
|
-
contextId: { type: 'string' },
|
|
4892
|
-
brief: { type: 'string' },
|
|
4893
|
-
status: { type: 'string', required: true },
|
|
4894
|
-
deadAt: { type: 'number' },
|
|
4895
|
-
direction: { type: 'string', enum: ['inbound', 'outbound'] },
|
|
4896
|
-
receiptExpected: { type: 'boolean' },
|
|
4897
|
-
reason: { type: 'string', enum: ['fire-forget', 'unconsumed'] },
|
|
4898
|
-
},
|
|
4899
|
-
},
|
|
4900
|
-
},
|
|
4901
|
-
archive: {
|
|
4902
|
-
type: 'array',
|
|
4903
|
-
required: true,
|
|
4904
|
-
items: {
|
|
4905
|
-
type: 'object',
|
|
4906
|
-
additionalProperties: false,
|
|
4907
|
-
properties: {
|
|
4908
|
-
taskId: { type: 'string', required: true },
|
|
4909
|
-
team: { type: 'string', required: true },
|
|
4910
|
-
startedAt: { type: 'number', required: true },
|
|
4911
|
-
resolvedAt: { type: 'number', required: true },
|
|
4912
|
-
summary: { type: 'string' },
|
|
4913
|
-
brief: { type: 'string' },
|
|
4914
|
-
},
|
|
4915
|
-
},
|
|
4916
|
-
},
|
|
4917
|
-
archivedTotal: { type: 'number', required: true },
|
|
4918
|
-
},
|
|
4919
|
-
},
|
|
4920
|
-
render: (_args, value) => [{
|
|
4921
|
-
type: 'text',
|
|
4922
|
-
text: value.tasks.length === 0 && value.archivedTotal === 0
|
|
4923
|
-
? 'No routed tasks are owed a receipt.'
|
|
4924
|
-
: [
|
|
4925
|
-
...(() => {
|
|
4926
|
-
// C-ruling split (2026-09-01 decision seat): only young,
|
|
4927
|
-
// receipt-expected pending rows are settleable debt — the
|
|
4928
|
-
// stale tail is conversational noise waiting on the dead
|
|
4929
|
-
// TTL; count it, don't list it.
|
|
4930
|
-
const pending = value.tasks.filter((task) => task.status === 'pending');
|
|
4931
|
-
const settleable = pending.filter((task) => task.receiptExpected !== false && Date.now() - task.startedAt < 60 * 60_000);
|
|
4932
|
-
const staleCount = pending.length - settleable.length;
|
|
4933
|
-
return [
|
|
4934
|
-
...(settleable.length > 0 ? ['Owed receipts:'] : []),
|
|
4935
|
-
...settleable.map((task) => {
|
|
4936
|
-
const context = task.contextId === undefined || task.contextId === '' ? '' : `, follow-up context ${task.contextId}`;
|
|
4937
|
-
const brief = task.brief === undefined || task.brief === '' ? '' : ` — "${task.brief}"`;
|
|
4938
|
-
return ` - ${task.taskId}${brief} → ${task.team} (via ${task.peer === 'local' ? 'this host' : task.peer}), waiting ${describeAge(Date.now() - task.startedAt)}${context}`;
|
|
4939
|
-
}),
|
|
4940
|
-
...(staleCount > 0 ? [`Delivered conversationally without a formal receipt (${String(staleCount)}) — not debts; they auto-dead-letter at the stale TTL.`] : []),
|
|
4941
|
-
];
|
|
4942
|
-
})(),
|
|
4943
|
-
...(value.tasks.some((task) => task.status === 'dead') ? ['Dead-lettered (auto-flagged past the stale TTL; a revived target can still settle with a late receipt):'] : []),
|
|
4944
|
-
...value.tasks.filter((task) => task.status === 'dead').map((task) => {
|
|
4945
|
-
const context = task.contextId === undefined || task.contextId === '' ? '' : `, follow-up context ${task.contextId}`;
|
|
4946
|
-
const brief = task.brief === undefined || task.brief === '' ? '' : ` — "${task.brief}"`;
|
|
4947
|
-
return ` - ${task.taskId}${brief} → ${task.team} (via ${task.peer === 'local' ? 'this host' : task.peer}), dispatched ${describeAge(Date.now() - task.startedAt)} ago${context}`;
|
|
4948
|
-
}),
|
|
4949
|
-
...(value.archivedTotal > 0 ? [`Archived (${String(value.archivedTotal)}), most recent first:`] : []),
|
|
4950
|
-
...value.archive.map((entry) => {
|
|
4951
|
-
const brief = entry.brief === undefined || entry.brief === '' ? '' : ` — "${entry.brief}"`;
|
|
4952
|
-
return ` - ${entry.taskId}${brief} → ${entry.team}: after ${describeAge(entry.resolvedAt - entry.startedAt)}, ${entry.summary === undefined || entry.summary === '' ? 'resolved' : entry.summary}`;
|
|
4953
|
-
}),
|
|
4954
|
-
...(value.archivedTotal > value.archive.length ? [` (+${String(value.archivedTotal - value.archive.length)} older not shown)`] : []),
|
|
4955
|
-
].join('\n'),
|
|
4956
|
-
}],
|
|
4957
|
-
},
|
|
4958
|
-
presentCall: () => ({ card: 'generic', title: 'A2A task ledger', kind: 'other', rawInput: null }),
|
|
4959
|
-
execute: async (_args, exec) => {
|
|
4960
|
-
a2aJoinGateRefusal(exec);
|
|
4961
|
-
const archiveAll = taskLedger.archive();
|
|
4962
|
-
return {
|
|
4963
|
-
ok: true,
|
|
4964
|
-
tasks: taskLedger.list().map(task => ({ ...task })),
|
|
4965
|
-
archive: archiveAll.slice(0, 5).map(({ taskId, team, startedAt, resolvedAt, summary, brief }) => ({ taskId, team, startedAt, resolvedAt, ...(summary !== undefined && summary !== '' ? { summary } : {}), ...(brief !== undefined ? { brief } : {}) })),
|
|
4966
|
-
archivedTotal: archiveAll.length,
|
|
4967
|
-
};
|
|
4968
|
-
},
|
|
4969
|
-
}));
|
|
4970
|
-
ctx.tools.register(defineTool({
|
|
4971
|
-
name: 'a2a_probe',
|
|
4972
|
-
description: 'Probe the tracked peer fleet for reachability and round-trip latency: one verified-card fetch per peer '
|
|
4973
|
-
+ '(the same fetch discovery uses), reporting reachable peers with their team and latency and unreachable '
|
|
4974
|
-
+ 'ones with the failure reason. Pass url to probe one target instead of the whole fleet. '
|
|
4975
|
-
+ 'While teamScopeInfo is on (default), the sweep covers only peers serving a joined team, and a single URL is refused unless its verified card proves service. '
|
|
4976
|
-
+ 'Use it before dispatching verification work or when routes fail over, instead of guessing which node is down.',
|
|
4977
|
-
parameters: {
|
|
4978
|
-
url: { type: 'string', description: 'Optional single peer base URL to probe; empty probes every tracked peer.' },
|
|
4979
|
-
},
|
|
4980
|
-
output: {
|
|
4981
|
-
schema: {
|
|
4982
|
-
type: 'object',
|
|
4983
|
-
additionalProperties: false,
|
|
4984
|
-
properties: {
|
|
4985
|
-
ok: { type: 'boolean', required: true },
|
|
4986
|
-
results: {
|
|
4987
|
-
type: 'array',
|
|
4988
|
-
required: true,
|
|
4989
|
-
items: {
|
|
4990
|
-
type: 'object',
|
|
4991
|
-
additionalProperties: false,
|
|
4992
|
-
properties: {
|
|
4993
|
-
url: { type: 'string', required: true },
|
|
4994
|
-
reachable: { type: 'boolean', required: true },
|
|
4995
|
-
ms: { type: 'number', description: 'Round-trip of the card fetch, reachable or not.' },
|
|
4996
|
-
team: { type: 'string', description: 'The team the peer publishes, when reachable.' },
|
|
4997
|
-
error: { type: 'string', description: 'Why the peer is unreachable.' },
|
|
4998
|
-
},
|
|
4999
|
-
},
|
|
5000
|
-
},
|
|
5001
|
-
},
|
|
5002
|
-
},
|
|
5003
|
-
render: (_args, value) => [{
|
|
5004
|
-
type: 'text',
|
|
5005
|
-
text: value.results.length === 0
|
|
5006
|
-
? 'No peers are tracked; add seeds or let referrals arrive.'
|
|
5007
|
-
: [
|
|
5008
|
-
`Fleet probe (${String(value.results.length)}): ${String(value.results.filter((row) => row.reachable).length)} reachable, ${String(value.results.filter((row) => !row.reachable).length)} down`,
|
|
5009
|
-
...value.results.map((row) => ` ${row.reachable ? '✓' : '✗'} ${row.url}${row.reachable ? ` (team ${String(row.team)}, ${String(row.ms)}ms)` : ` (${String(row.error)})`}`),
|
|
5010
|
-
].join('\n'),
|
|
5011
|
-
}],
|
|
5012
|
-
},
|
|
5013
|
-
presentCall: args => ({ card: 'generic', title: args.url === undefined || args.url === '' ? 'Probe the A2A fleet' : `Probe A2A peer: ${args.url}`, kind: 'other', rawInput: args }),
|
|
5014
|
-
execute: async (args, exec) => {
|
|
5015
|
-
a2aJoinGateRefusal(exec);
|
|
5016
|
-
const probeOne = async (url) => {
|
|
5017
|
-
const startedAt = Date.now();
|
|
5018
|
-
const outcome = await client.fetchCardDetail(url);
|
|
5019
|
-
const ms = Date.now() - startedAt;
|
|
5020
|
-
// The fetch is the probe: settling it moves the peer's quality score
|
|
5021
|
-
// and learns its referrals, exactly like a discovery sweep would,
|
|
5022
|
-
// while the outcome's stage names the failure for the report.
|
|
5023
|
-
const card = settlePeerCard(url, outcome);
|
|
5024
|
-
if (card !== undefined) {
|
|
5025
|
-
// gh#71 fix ①: a successful full-face fetch persists into the warm
|
|
5026
|
-
// cache — the probe is often the FIRST contact (no directory sweep
|
|
5027
|
-
// has run), and the state face's peerOrg reads exactly this cache.
|
|
5028
|
-
cardCache.set(url, { at: Date.now(), card });
|
|
5029
|
-
return { url, reachable: true, ms, team: card.team };
|
|
5030
|
-
}
|
|
5031
|
-
if (outcome.ok)
|
|
5032
|
-
return { url, reachable: false, ms, error: 'dropped: the card lists this node back as its own referral' };
|
|
5033
|
-
return outcome.stage === 'unreachable'
|
|
5034
|
-
? { url, reachable: false, ms, error: `unreachable: ${outcome.detail}` }
|
|
5035
|
-
: { url, reachable: false, ms, error: `rejected: ${outcome.reason} card` };
|
|
5036
|
-
};
|
|
5037
|
-
// S5: a reachability report is fleet disclosure too. The sweep probes
|
|
5038
|
-
// only peers whose verified card serves a joined team (memoized —
|
|
5039
|
-
// warm cards cost nothing); a single-URL probe needs proof of service
|
|
5040
|
-
// — a FRESH card, or a stale cached one: a peer that once served a
|
|
5041
|
-
// joined team and is now down gets the normal down report (that
|
|
5042
|
-
// reachability fact belongs to the team), while a URL that never
|
|
5043
|
-
// proved service is refused without a probe (its reachability is
|
|
5044
|
-
// exactly the fact scoping withholds).
|
|
5045
|
-
const joined = joinedTeamScope();
|
|
5046
|
-
const fetch = memoizedCardFetch();
|
|
5047
|
-
const serves = async (url) => {
|
|
5048
|
-
const card = await fetch(url);
|
|
5049
|
-
return card !== undefined && cardServesJoined(card, joined);
|
|
5050
|
-
};
|
|
5051
|
-
if (args.url !== undefined && args.url !== '') {
|
|
5052
|
-
if (config.teamScopeInfo && !(await serves(args.url))) {
|
|
5053
|
-
const stale = cardCache.get(args.url)?.card;
|
|
5054
|
-
if (stale !== undefined && cardServesJoined(stale, joined)) {
|
|
5055
|
-
// Was serving, now unverifiable fresh — probe and report the
|
|
5056
|
-
// down state through the normal path.
|
|
5057
|
-
return { ok: true, results: await Promise.all([args.url].map(probeOne)) };
|
|
5058
|
-
}
|
|
5059
|
-
return { ok: true, results: [{ url: args.url, reachable: false, ms: 0, error: stale === undefined
|
|
5060
|
-
? 'refused (not probed): teamScopeInfo on — no verified card for this URL proves service to a joined team'
|
|
5061
|
-
: 'refused (not probed): teamScopeInfo on — this URL\'s verified card shows no team joined by this host\'s nodes' }] };
|
|
5062
|
-
}
|
|
5063
|
-
return { ok: true, results: await Promise.all([args.url].map(probeOne)) };
|
|
5064
|
-
}
|
|
5065
|
-
const targets = config.teamScopeInfo
|
|
5066
|
-
? await mapBounded(peerStore.list(), SWEEP_CONCURRENCY, async (url) => await serves(url) ? url : undefined).then(rows => rows.filter((url) => url !== undefined))
|
|
5067
|
-
: peerStore.list();
|
|
5068
|
-
// Promise.all keeps the report in the store's preference order.
|
|
5069
|
-
return { ok: true, results: await Promise.all(targets.map(probeOne)) };
|
|
5070
|
-
},
|
|
5071
|
-
}));
|
|
4070
|
+
infoTools.registerStatus();
|
|
4071
|
+
infoTools.registerTasks();
|
|
4072
|
+
infoTools.registerProbe();
|
|
5072
4073
|
const pendingFinals = new Map();
|
|
5073
4074
|
const armFlushTimeout = (agentId, waiter) => {
|
|
5074
4075
|
return ctx.timer.timeout(() => {
|
|
5075
4076
|
const entries = pendingFinals.get(agentId);
|
|
5076
|
-
|
|
4077
|
+
// An already-answered waiter is gone from the list: never answer twice.
|
|
4078
|
+
if (entries === undefined || !entries.includes(waiter))
|
|
5077
4079
|
return;
|
|
5078
4080
|
const kept = entries.filter(entry => entry !== waiter);
|
|
5079
4081
|
if (kept.length === 0)
|
|
@@ -5096,27 +4098,114 @@ ${message}`;
|
|
|
5096
4098
|
pendingFinals.delete(agentId);
|
|
5097
4099
|
return;
|
|
5098
4100
|
}
|
|
4101
|
+
// #114 S5: each waiter is answered from its OWN floor. The old shared
|
|
4102
|
+
// floor (the minimum sinceEvents) answered every waiter with one text, so
|
|
4103
|
+
// a busy target's other work line settled tasks it had not read yet.
|
|
5099
4104
|
const events = sessionEventsOf(agent);
|
|
5100
|
-
const
|
|
5101
|
-
|
|
5102
|
-
|
|
4105
|
+
const answered = [];
|
|
4106
|
+
// Logs that record turn boundaries bound each waiter to its consuming
|
|
4107
|
+
// turn; a log without any marker falls back to "this idle ends it".
|
|
4108
|
+
const turnMarked = events.some(event => event?.type === 'turn/start' || event?.type === 'turn/end');
|
|
4109
|
+
for (const entry of entries) {
|
|
4110
|
+
if (entry.taskId !== undefined) {
|
|
4111
|
+
entry.consumedAt ??= consumptionIndex(events, entry.taskId, entry.scannedTo ?? entry.sinceEvents);
|
|
4112
|
+
if (entry.consumedAt === undefined) {
|
|
4113
|
+
// Not consumed yet (the steer waits for the running turn's next
|
|
4114
|
+
// step, or the next turn): no final belongs to it — keep waiting.
|
|
4115
|
+
entry.scannedTo = events.length;
|
|
4116
|
+
continue;
|
|
4117
|
+
}
|
|
4118
|
+
// The consuming turn's window: up to its own `turn/end`, never into a
|
|
4119
|
+
// following turn (back-to-back turns can run with no idle between).
|
|
4120
|
+
// Without turn markers, this idle is the turn's end.
|
|
4121
|
+
let end = events.length;
|
|
4122
|
+
let completed = true;
|
|
4123
|
+
if (turnMarked) {
|
|
4124
|
+
const close = turnEndIndex(events, entry.consumedAt + 1);
|
|
4125
|
+
if (close === undefined) {
|
|
4126
|
+
// The consuming turn is still open: wait for its turn/end.
|
|
4127
|
+
continue;
|
|
4128
|
+
}
|
|
4129
|
+
end = close;
|
|
4130
|
+
completed = events[close]?.data?.reason?.kind === 'completed';
|
|
4131
|
+
}
|
|
4132
|
+
// The waiter closes HERE with that turn's last text. A turn that said
|
|
4133
|
+
// nothing, or ended aborted/failed/blocked, did no work this host can
|
|
4134
|
+
// vouch for: close as a placeholder — no receipt, the row stays owed.
|
|
4135
|
+
const reply = latestAssistantText(events, entry.consumedAt + 1, end);
|
|
4136
|
+
answered.push(reply !== '' && completed ? { entry, reply } : { entry, reply: '', placeholder: true });
|
|
4137
|
+
continue;
|
|
4138
|
+
}
|
|
4139
|
+
const reply = latestAssistantText(events, entry.sinceEvents);
|
|
4140
|
+
if (reply !== '')
|
|
4141
|
+
answered.push({ entry, reply });
|
|
4142
|
+
}
|
|
4143
|
+
if (answered.length === 0)
|
|
4144
|
+
return;
|
|
4145
|
+
const kept = entries.filter(entry => !answered.some(done => done.entry === entry));
|
|
4146
|
+
if (kept.length === 0)
|
|
4147
|
+
pendingFinals.delete(agentId);
|
|
4148
|
+
else
|
|
4149
|
+
pendingFinals.set(agentId, kept);
|
|
4150
|
+
for (const { entry, reply, placeholder } of answered) {
|
|
4151
|
+
entry.timeoutDisposer?.();
|
|
4152
|
+
if (placeholder === true)
|
|
4153
|
+
entry.answer('The consuming turn produced no completed reply.', true);
|
|
4154
|
+
else
|
|
4155
|
+
entry.answer(reply);
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
/** #114 S5: the index of the first `turn/end` at or after `from`, or undefined while the turn is open. */
|
|
4159
|
+
function turnEndIndex(events, from) {
|
|
4160
|
+
for (let index = from; index < events.length; index++) {
|
|
4161
|
+
if (events[index]?.type === 'turn/end')
|
|
4162
|
+
return index;
|
|
4163
|
+
}
|
|
4164
|
+
return undefined;
|
|
4165
|
+
}
|
|
4166
|
+
/** #114 S5: the steer header both delivery paths write; the id is captured whole, never prefix-matched. */
|
|
4167
|
+
const STEER_HEADER = /^\[A2A direct\] \(task ([^\s)]+)\) /;
|
|
4168
|
+
/**
|
|
4169
|
+
* #114 S5: the index of the user/message that consumed one task's steer —
|
|
4170
|
+
* the first at or after `from` whose first text block opens with the
|
|
4171
|
+
* `[A2A direct] (task <id>)` header for exactly this id.
|
|
4172
|
+
* @param from - first index not yet searched (starts at the pre-steer log length).
|
|
4173
|
+
* @returns the index, or undefined while the steer is still unconsumed.
|
|
4174
|
+
*/
|
|
4175
|
+
function consumptionIndex(events, taskId, from) {
|
|
4176
|
+
for (let index = from; index < events.length; index++) {
|
|
4177
|
+
const event = events[index];
|
|
4178
|
+
if (event?.type !== 'user/message')
|
|
4179
|
+
continue;
|
|
4180
|
+
const first = event.data?.content?.find(block => block.type === 'text' && typeof block.text === 'string');
|
|
4181
|
+
if (STEER_HEADER.exec(first?.text ?? '')?.[1] === taskId)
|
|
4182
|
+
return index;
|
|
4183
|
+
}
|
|
4184
|
+
return undefined;
|
|
4185
|
+
}
|
|
4186
|
+
/**
|
|
4187
|
+
* The newest assistant text at or after `floor`: at an idle flush that is
|
|
4188
|
+
* the final of the turn that just ended. Textless messages (tool-only, or
|
|
4189
|
+
* empty/whitespace text) are skipped, so a turn ending on a tool call still
|
|
4190
|
+
* answers with the last thing it said. '' when no message has text.
|
|
4191
|
+
* @param end - exclusive upper bound (default: the whole log).
|
|
4192
|
+
*/
|
|
4193
|
+
function latestAssistantText(events, floor, end = events.length) {
|
|
4194
|
+
for (let index = end - 1; index >= floor; index--) {
|
|
5103
4195
|
const event = events[index];
|
|
5104
4196
|
if (event === undefined || event.type !== 'assistant/message')
|
|
5105
4197
|
continue;
|
|
5106
4198
|
const content = event.data?.message?.content;
|
|
5107
4199
|
if (content === undefined)
|
|
5108
4200
|
continue;
|
|
5109
|
-
|
|
4201
|
+
const text = content
|
|
5110
4202
|
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
5111
4203
|
.map(block => block.text)
|
|
5112
4204
|
.join('\n');
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
if (reply !== '') {
|
|
5116
|
-
for (const entry of entries)
|
|
5117
|
-
entry.answer(reply);
|
|
5118
|
-
pendingFinals.delete(agentId);
|
|
4205
|
+
if (text.trim() !== '')
|
|
4206
|
+
return text;
|
|
5119
4207
|
}
|
|
4208
|
+
return '';
|
|
5120
4209
|
}
|
|
5121
4210
|
// Every inbound route is a direct route (final semantics), so the idle
|
|
5122
4211
|
// flush listens unconditionally — an empty pendingFinals map makes it a
|