@nelsonlongxiang/dsh-open-a2a-net 0.6.37 → 0.6.39
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 +340 -335
- package/README_zh.md +241 -236
- package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
- package/cli/a2a.mjs +44 -6
- package/cli/serve.mjs +132 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-discovery.js +29 -0
- package/lib/canvas-id.js +27 -0
- package/lib/canvas-store.js +7 -1
- package/lib/client.js +63 -63
- package/lib/config.js +125 -0
- package/lib/direct-forward.js +105 -37
- package/lib/faces/admin.js +14 -1
- package/lib/faces/wire.js +41 -10
- package/lib/flood-guard.js +3 -1
- package/lib/index.js +116 -155
- package/lib/live-roots.js +39 -0
- package/lib/origin-claim.js +4 -3
- package/lib/peer-directory.js +1 -1
- package/lib/queue-guard.js +3 -1
- package/lib/skill/references/cli.md +214 -199
- package/lib/skill/references/pitfalls.md +1 -1
- package/lib/skill/references/routing.md +4 -0
- package/lib/skill/references/setup.md +5 -9
- package/lib/task-auth.js +15 -11
- package/lib/task-ledger-rows.js +32 -4
- package/lib/task-ledger.js +11 -5
- package/lib/tools/info.js +12 -5
- package/lib/types/canvas-discovery.d.ts +20 -0
- package/lib/types/canvas-id.d.ts +9 -0
- package/lib/types/config.d.ts +278 -0
- package/lib/types/direct-forward.d.ts +33 -2
- package/lib/types/faces/admin.d.ts +12 -0
- package/lib/types/faces/wire.d.ts +10 -1
- package/lib/types/index.d.ts +3 -277
- package/lib/types/live-roots.d.ts +35 -0
- package/lib/types/origin-claim.d.ts +5 -1
- package/lib/types/queue-guard.d.ts +2 -0
- package/lib/types/task-auth.d.ts +5 -0
- package/lib/types/task-ledger-rows.d.ts +16 -5
- package/lib/types/task-ledger.d.ts +3 -0
- package/lib/types/tools/info.d.ts +3 -0
- package/lib/types/types.d.ts +2 -0
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/package.json +218 -218
package/lib/config.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** The plugin row's configuration: the `Config` interface and its Schemastery schema (moved out of index.ts, #77). */
|
|
2
|
+
import s from '@deepseek-ai/schemastery';
|
|
3
|
+
import { TASK_STALE_TTL_MS } from "./task-ledger.js";
|
|
4
|
+
import { DEFAULT_TARGET_QUEUE_CAP } from "./queue-guard.js";
|
|
5
|
+
/** Schemastery configuration for the A2A client plugin row. */
|
|
6
|
+
export const Config = s.object({
|
|
7
|
+
apiKey: s.string().default(''),
|
|
8
|
+
session: s.string().default(''),
|
|
9
|
+
team: s.string().default('dsh'),
|
|
10
|
+
/** S4 fix ③: host-side (agent-less) routing calls — audit them (default) or refuse outright. */
|
|
11
|
+
gateAgentLessCalls: s.union(['audit', 'refuse']).default('audit'),
|
|
12
|
+
settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
|
|
13
|
+
announce: s.boolean().default(false),
|
|
14
|
+
isolated: s.boolean().default(false),
|
|
15
|
+
forwardDirect: s.boolean().default(true),
|
|
16
|
+
agentName: s.string().default('DeepSeek Harness A2A node'),
|
|
17
|
+
peers: s.array(s.string()).default([]),
|
|
18
|
+
delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
|
|
19
|
+
dshHome: s.string().default(''),
|
|
20
|
+
sessionNodes: s.boolean().default(true),
|
|
21
|
+
wakeJoinedOnBoot: s.boolean().default(false),
|
|
22
|
+
wakePrewarmDelayMs: s.number().default(10_000),
|
|
23
|
+
wakePrewarmQuietMs: s.number().default(5_000),
|
|
24
|
+
wakeBootStaggerMs: s.number().default(3_000),
|
|
25
|
+
wakeReconcile: s.boolean().default(true),
|
|
26
|
+
wakeReconcileIntervalMs: s.number().default(60_000),
|
|
27
|
+
wakeReconcileBackoffBaseMs: s.number().default(5_000),
|
|
28
|
+
wakeReconcileMaxBackoffMs: s.number().default(600_000),
|
|
29
|
+
/**
|
|
30
|
+
* S4 (gh#11 N7): the joinable-team curation list. A model calling
|
|
31
|
+
* a2a_team_join may only declare membership in a team with an EXACT
|
|
32
|
+
* entry here — trailing-`*` patterns are readable but authorize nothing
|
|
33
|
+
* (no mint rights: a pattern pre-authorizing every future name under it
|
|
34
|
+
* would let a model mint teams nobody created). Default empty: the
|
|
35
|
+
* owner's no-self-join ruling, institutionalized as default-deny; a
|
|
36
|
+
* refused join carries guidance instead of failing silently.
|
|
37
|
+
*/
|
|
38
|
+
teamJoinAllowlist: s.array(s.string()).default([]),
|
|
39
|
+
seatNames: s.dict(s.any()).default({}),
|
|
40
|
+
/**
|
|
41
|
+
* S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
|
|
42
|
+
* caller↔target shared team membership before addressing — a session
|
|
43
|
+
* with no declared team has no network (the unteamed-peer defect the
|
|
44
|
+
* owner reproduced on 4080 and locally). Fleet compatibility rides the
|
|
45
|
+
* roster capability flag (0.5.44 cards advertise roster:true; older
|
|
46
|
+
* peers read as legacy). Set false to restore the open outbound seam.
|
|
47
|
+
*/
|
|
48
|
+
teamScopeRouting: s.boolean().default(true),
|
|
49
|
+
/**
|
|
50
|
+
* S5 (default-on, owner directive 2026-10-20): the information-plane
|
|
51
|
+
* tools answer from the joined-team scope only — a2a_teams lists
|
|
52
|
+
* [this host] rows plus peer rows serving a joined team (note field
|
|
53
|
+
* explains the scope); a2a_status narrows peers / in-flight / activity
|
|
54
|
+
* to that scope (local traffic and idempotency aggregates stay);
|
|
55
|
+
* a2a_probe sweeps serving peers and refuses a single URL whose
|
|
56
|
+
* verified card cannot prove service. A host whose nodes declare no
|
|
57
|
+
* teams keeps host-internal facts only. Set false to restore
|
|
58
|
+
* fleet-wide disclosure.
|
|
59
|
+
*/
|
|
60
|
+
teamScopeInfo: s.boolean().default(true),
|
|
61
|
+
/**
|
|
62
|
+
* Peer boundary model — connection layer: an empty list keeps the
|
|
63
|
+
* historical open-referral posture; a non-empty list admits a referral
|
|
64
|
+
* only when its host matches an entry (exact host[:port], bare domain
|
|
65
|
+
* suffix, or full URL). Seeds from `peers` are owner-configured and
|
|
66
|
+
* unaffected.
|
|
67
|
+
*/
|
|
68
|
+
peerAllowlist: s.array(s.string()).default([]),
|
|
69
|
+
/**
|
|
70
|
+
* Peer boundary model — connection layer: when false, referral URLs are
|
|
71
|
+
* neither learned nor offered; the tracked set stays the configured seed
|
|
72
|
+
* set for the lifetime of the config that named them.
|
|
73
|
+
*/
|
|
74
|
+
referralLearning: s.boolean().default(true),
|
|
75
|
+
/**
|
|
76
|
+
* gh#8 (N9): team-name resolution rides the operator-established peer
|
|
77
|
+
* basis (config seeds + delegates) only; the referral-learned tail's
|
|
78
|
+
* self-published card claims do not bind team names. Set true to restore
|
|
79
|
+
* the legacy whole-store basis (fleet migration window).
|
|
80
|
+
*/
|
|
81
|
+
referralTeamResolution: s.boolean().default(false),
|
|
82
|
+
/**
|
|
83
|
+
* #19 D2 (ruling final, owner-level knob): how much of a session team's
|
|
84
|
+
* activity EXCERPT the card face publishes in sessionTeams descriptions.
|
|
85
|
+
* 'full' (default) keeps the verbatim tail excerpt; 'masked' truncates
|
|
86
|
+
* to a shape-only prefix; 'off' omits the description field entirely.
|
|
87
|
+
* Titles and team handles stay in every tier — the knob is the excerpt,
|
|
88
|
+
* not the listing.
|
|
89
|
+
*/
|
|
90
|
+
exposureExcerpt: s.union(['full', 'masked', 'off']).default('full'),
|
|
91
|
+
/**
|
|
92
|
+
* gh#9+gh#10 OriginClaim window: absent envelopes still walk N3 during
|
|
93
|
+
* the window (counted as legacyInbound); closing it rejects them like
|
|
94
|
+
* failed signatures. See docs/protocol/delivery-origin-auth.md §4.
|
|
95
|
+
*/
|
|
96
|
+
originClaimWindow: s.boolean().default(true),
|
|
97
|
+
/**
|
|
98
|
+
* OriginClaim unbound window: also sign/accept the legacy envelope that
|
|
99
|
+
* names no audience (counted as unboundInbound); closing it leaves the
|
|
100
|
+
* audience-bound form only. See docs/protocol/delivery-origin-auth.md §2.1.
|
|
101
|
+
*/
|
|
102
|
+
originClaimUnboundWindow: s.boolean().default(true),
|
|
103
|
+
/** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
|
|
104
|
+
originClaimAudiences: s.array(s.string()).default([]),
|
|
105
|
+
/** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
|
|
106
|
+
originClaimUrl: s.string().default(''),
|
|
107
|
+
/**
|
|
108
|
+
* v0.5.23 (async-stall): delay before a delivered-but-unconsumed async
|
|
109
|
+
* route re-wakes its target with a one-line nudge (defect
|
|
110
|
+
* t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
|
|
111
|
+
*/
|
|
112
|
+
asyncNudgeDelayMs: s.number().default(120_000),
|
|
113
|
+
/** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
|
|
114
|
+
targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
|
|
115
|
+
stateColdRowsTtlMs: s.number().default(5_000),
|
|
116
|
+
cardCacheTtlMs: s.number().default(60_000),
|
|
117
|
+
cardCacheNegativeTtlMs: s.number().default(30_000),
|
|
118
|
+
remoteRowsTtlMs: s.number().default(15_000),
|
|
119
|
+
cardTtlMs: s.number().default(172_800_000),
|
|
120
|
+
flushTimeoutMs: s.number().default(300_000),
|
|
121
|
+
routeTimeoutMs: s.number().default(1_800_000),
|
|
122
|
+
taskStaleTtlMs: s.number().default(TASK_STALE_TTL_MS),
|
|
123
|
+
nativeTeamsInbound: s.boolean().default(false),
|
|
124
|
+
nativeRoundWaitMs: s.number().default(180_000),
|
|
125
|
+
});
|
package/lib/direct-forward.js
CHANGED
|
@@ -2,60 +2,128 @@
|
|
|
2
2
|
import { DEFAULT_HOP_BUDGET, guardHeader, mintGuard } from "./recursion-guard.js";
|
|
3
3
|
import { briefOf } from "./task-ledger.js";
|
|
4
4
|
import { parseReceipt } from "./receipt.js";
|
|
5
|
+
import { splitCanvasOwner } from "./canvas-id.js";
|
|
6
|
+
import { claimCovers } from "./task-auth.js";
|
|
7
|
+
/** An early receipt waits at most this long for its forward row to be born (#298). */
|
|
8
|
+
export const BIRTH_WAIT_CAP_MS = 60_000;
|
|
9
|
+
/** Birth barrier exists only for eligible, actively dispatched tasks; receipts never reserve a slot. */
|
|
10
|
+
export class ForwardBirths {
|
|
11
|
+
waitCapMs;
|
|
12
|
+
pending = new Map();
|
|
13
|
+
constructor(waitCapMs = BIRTH_WAIT_CAP_MS) {
|
|
14
|
+
this.waitCapMs = waitCapMs;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolves when the birth is released or after the cap, whichever is first. After the cap the receipt
|
|
18
|
+
* is judged by the normal rules against whatever row exists then — never accepted in advance (#298).
|
|
19
|
+
*/
|
|
20
|
+
wait(taskId) {
|
|
21
|
+
const birth = this.pending.get(taskId);
|
|
22
|
+
if (birth === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
let timer;
|
|
25
|
+
const cap = new Promise(resolve => { timer = setTimeout(resolve, this.waitCapMs); timer.unref?.(); });
|
|
26
|
+
return Promise.race([birth, cap]).finally(() => clearTimeout(timer));
|
|
27
|
+
}
|
|
28
|
+
begin(taskId) {
|
|
29
|
+
if (this.pending.size >= 64 || this.pending.has(taskId))
|
|
30
|
+
return undefined;
|
|
31
|
+
let release;
|
|
32
|
+
this.pending.set(taskId, new Promise(resolve => { release = resolve; }));
|
|
33
|
+
return () => { this.pending.delete(taskId); release(); };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
5
36
|
export function createDirectForward(d) {
|
|
6
37
|
return async (request) => {
|
|
7
|
-
const refuse = (why) => ({ ok: false, code: -32000, task_id: request.taskId, error: 'not forwarded: ' + why });
|
|
38
|
+
const refuse = (why, next) => ({ ok: false, code: -32000, task_id: request.taskId, error: 'not forwarded: ' + why, ...(next !== undefined ? { next } : {}) });
|
|
39
|
+
const publish = 'run `a2a-net publish` to renew this CLI deposit on its home host, then send again with a new task id';
|
|
8
40
|
if (!d.enabled())
|
|
9
|
-
return refuse('forwardDirect is off');
|
|
41
|
+
return refuse('forwardDirect is off', 'ask this host\'s operator to set forwardDirect: true (the default since 0.6.38)');
|
|
10
42
|
if (d.isolated())
|
|
11
|
-
return refuse('host is isolated');
|
|
43
|
+
return refuse('host is isolated', 'this host forwards nothing while isolated; deposit the CLI on another home host');
|
|
12
44
|
if (request.guarded)
|
|
13
45
|
return refuse('an inbound X-A2A-Guard is present; already forwarded');
|
|
14
46
|
if (!request.localCliProof || !d.deposited(request.caller))
|
|
15
|
-
return refuse('caller is not a proven online local CLI deposit');
|
|
16
|
-
|
|
47
|
+
return refuse('caller is not a proven online local CLI deposit', publish);
|
|
48
|
+
// #282 S2: candidates honour an owner-qualified id; scope, the peer, the receivers and the ledger see the plain team.
|
|
49
|
+
const plainTeam = splitCanvasOwner(request.team).team;
|
|
50
|
+
const scope = await d.scope(request.caller, plainTeam);
|
|
17
51
|
if (!scope.ok)
|
|
18
|
-
return refuse('target is outside sender scope: ' + scope.error);
|
|
52
|
+
return refuse('target is outside sender scope: ' + scope.error, `ask this host's operator to link ${request.team} here: drag it (or one of its members) into a canvas team on this host`);
|
|
19
53
|
const failures = [];
|
|
20
54
|
const directory = await d.candidates(request.team, failures, request.caller);
|
|
21
55
|
const guard = guardHeader({ ...mintGuard(), hopBudget: DEFAULT_HOP_BUDGET - 1 });
|
|
22
56
|
for (const url of directory.urls) {
|
|
23
57
|
if (!d.deposited(request.caller))
|
|
24
|
-
return refuse('local CLI deposit expired before dispatch');
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (receivers
|
|
30
|
-
failures.push(url + ':
|
|
58
|
+
return refuse('local CLI deposit expired before dispatch', publish);
|
|
59
|
+
const peerCard = await directory.card(url);
|
|
60
|
+
const receivers = plainTeam.includes('/canvas/') ? peerCard?.canvasOrg?.find(row => row.team === plainTeam)?.members.filter(member => member !== request.caller) : undefined;
|
|
61
|
+
if (plainTeam.includes('/canvas/')) {
|
|
62
|
+
// #282 S3 (same team authorizes): the owner's signed canvas is the return path; receipts settle only from these frozen receivers on this peer key (#274).
|
|
63
|
+
if (receivers === undefined || receivers.length === 0) {
|
|
64
|
+
failures.push(url + ': the owner card lists no receiving member of this canvas');
|
|
31
65
|
continue;
|
|
32
66
|
}
|
|
33
67
|
}
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
d.
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
// Reserve only after all eligibility/receiver-link gates. A receipt owes no receipt.
|
|
69
|
+
const receipt = parseReceipt(request.message) !== null;
|
|
70
|
+
const release = receipt ? () => { } : d.births.begin(request.taskId);
|
|
71
|
+
if (release === undefined)
|
|
72
|
+
return refuse('forward receipt correlation capacity is full', 'wait for in-flight forwards to settle, then send again with a new task id');
|
|
73
|
+
try {
|
|
74
|
+
// The same selected-peer snapshot supplies both return-link gate and settlement birth.
|
|
75
|
+
const answer = await d.dispatch(url, plainTeam === request.team ? request : { ...request, team: plainTeam }, guard);
|
|
76
|
+
const result = !answer.ok && answer.ownBudgetExhausted === true ? { ...answer, code: -32005 } : answer;
|
|
77
|
+
d.activity(plainTeam, url, result.ok);
|
|
78
|
+
if (result.ok || result.code === -32005) {
|
|
79
|
+
// A receipt owes no receipt; native bridge rounds have no A2A receipt contract.
|
|
80
|
+
// Match routeTrunk/trackOwedTask rather than manufacturing an unpayable debt.
|
|
81
|
+
if (parseReceipt(request.message) !== null)
|
|
82
|
+
return { ...result, receipt: 'none' };
|
|
83
|
+
if (result.ok && result.bridge !== undefined)
|
|
84
|
+
return { ...result, receipt: 'none' };
|
|
85
|
+
const deliveredTo = result.ok ? result.deliveredTo : undefined;
|
|
86
|
+
const birth = { forwarded: true, ...(request.auth !== undefined ? { auth: request.auth } : {}), ...(peerCard?.publicKey ? { peerKey: peerCard.publicKey } : {}), ...(peerCard?.session ? { peerHost: peerCard.session } : {}), ...(deliveredTo !== undefined ? { deliveredTo } : {}), ...(receivers !== undefined ? { receiverMembers: receivers } : {}) };
|
|
87
|
+
// Exactly one outbound row. A completed sync response is receiptless bookkeeping.
|
|
88
|
+
if (result.ok && (result.task_status === 'TASK_STATE_COMPLETED' || result.receipt === 'none'))
|
|
89
|
+
d.ledger.trackReceiptless(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
|
|
90
|
+
else
|
|
91
|
+
d.ledger.track(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
// The shared dispatcher's sync recovery query may prove a definitive failed
|
|
95
|
+
// outcome; like routeTrunk, that is an eligible miss, not an uncertain -32005.
|
|
96
|
+
failures.push(url + ': ' + result.error);
|
|
97
|
+
if (result.code === -32002 || result.code === -32003)
|
|
98
|
+
return { ...result, task_id: request.taskId };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
release();
|
|
52
102
|
}
|
|
53
|
-
// The shared dispatcher's sync recovery query may prove a definitive failed
|
|
54
|
-
// outcome; like routeTrunk, that is an eligible miss, not an uncertain -32005.
|
|
55
|
-
failures.push(url + ': ' + result.error);
|
|
56
|
-
if (result.code === -32002 || result.code === -32003)
|
|
57
|
-
return { ...result, task_id: request.taskId };
|
|
58
103
|
}
|
|
59
|
-
|
|
104
|
+
const noReceiver = failures.some(f => f.includes('lists no receiving member'));
|
|
105
|
+
const next = noReceiver ? `the owner's canvas ${plainTeam} lists no member besides you; ask its owner to add one, or route to a member's own handle` : failures.length === 0 ? `no linked peer lists ${request.team}; check the name with \`a2a-net teams\`` : undefined;
|
|
106
|
+
return { ok: false, code: -32004, task_id: request.taskId, error: 'forwarding to "' + request.team + '" failed: ' + (failures.join('; ') || 'no eligible peer candidate'), ...(next !== undefined ? { next } : {}) };
|
|
60
107
|
};
|
|
61
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* #282 S3 (same team authorizes): the admission basis for a receipt delivered to a local CLI mailbox
|
|
111
|
+
* from a member the home never linked — only for that CLI's own pending forward, only from a receiver
|
|
112
|
+
* frozen at birth, only when the body-bound (Sig3) claim's card key is the frozen peer key. Anything else:
|
|
113
|
+
* undefined. Trust assumption (design D2): the selected owner host is honest about its own canvas — it may
|
|
114
|
+
* list any label as a receiver, including one of this host's session labels in the same zone.
|
|
115
|
+
*/
|
|
116
|
+
export function forwardReceiptBasis(row, caller, cliHandle, claim) {
|
|
117
|
+
// The signer's zone must cover the caller label: B never signs for another zone's member (review of #287 M4).
|
|
118
|
+
if (!claimCovers(caller, claim.caller))
|
|
119
|
+
return undefined;
|
|
120
|
+
if (row === undefined || row.status !== 'pending' || row.forwarded !== true || row.sender?.kind !== 'cli' || row.sender.id !== cliHandle)
|
|
121
|
+
return undefined;
|
|
122
|
+
// Body-bound (Sig3) only: a stripped Sig3 would let a captured envelope carry another body (review of #287 P3-1).
|
|
123
|
+
if (!claim.bound || claim.bodyBound !== true || row.peerKey === undefined || claim.card.publicKey !== row.peerKey)
|
|
124
|
+
return undefined;
|
|
125
|
+
// A legacy row's deliveredTo may be the canvas handle itself (#272/#275): never a receiver.
|
|
126
|
+
if (row.deliveredTo !== undefined && (row.deliveredTo === row.team || row.deliveredTo.includes('/canvas/')))
|
|
127
|
+
return undefined;
|
|
128
|
+
return (row.deliveredTo !== undefined ? [row.deliveredTo] : row.receiverMembers ?? []).includes(caller) ? `peer-org:${row.team}` : undefined;
|
|
129
|
+
}
|
package/lib/faces/admin.js
CHANGED
|
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { canvasNameReserved as reservedCanvasName, isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
|
|
22
|
+
import { qualifiedCanvasId } from "../canvas-id.js";
|
|
22
23
|
import { refreshPeerCard, registerCanvasNotify } from "./canvas-notify.js";
|
|
23
24
|
import { CLI_HANDLE_RE } from "../cli-deposit.js";
|
|
24
25
|
import { applyOwnerSettings, parseSeatNames } from "../owner-settings.js";
|
|
@@ -125,6 +126,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
125
126
|
const body = JSON.stringify({
|
|
126
127
|
nodes: true,
|
|
127
128
|
zone: config.team,
|
|
129
|
+
ownerHost: d.ownCardUrl,
|
|
128
130
|
version: PLUGIN_VERSION,
|
|
129
131
|
// gh#193: a dark host says so — an empty remote list is not an outage.
|
|
130
132
|
...(config.isolated ? { isolated: true } : {}),
|
|
@@ -134,6 +136,8 @@ export function registerAdminFaces(webServer, d) {
|
|
|
134
136
|
teams: canvasStore.list().map(name => ({
|
|
135
137
|
name,
|
|
136
138
|
team: canvasTeamOf(name),
|
|
139
|
+
// #282 S2: the owner-qualified routing id.
|
|
140
|
+
id: `${canvasTeamOf(name)}@${d.ownCanvasFingerprint}`,
|
|
137
141
|
// gh#152: members as the local tiers read them (gh#131). One
|
|
138
142
|
// of this host's own handles renders as the local session it
|
|
139
143
|
// names — join/live facts instead of an opaque remote chip — and
|
|
@@ -204,6 +208,9 @@ export function registerAdminFaces(webServer, d) {
|
|
|
204
208
|
taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
|
|
205
209
|
direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
|
|
206
210
|
...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}),
|
|
211
|
+
...(task.forwarded === true ? { forwarded: true } : {}),
|
|
212
|
+
...(task.deliveredTo !== undefined ? { deliveredTo: task.deliveredTo } : {}),
|
|
213
|
+
...(task.receiverMembers !== undefined ? { receiverMembers: task.receiverMembers } : {}),
|
|
207
214
|
// The dispatch brief rides every row: a task id and a team
|
|
208
215
|
// say nothing about WHAT is owed — supervision cannot tell
|
|
209
216
|
// a nudge from an abandon without it.
|
|
@@ -256,6 +263,8 @@ export function registerAdminFaces(webServer, d) {
|
|
|
256
263
|
...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
|
|
257
264
|
...(row.sender !== undefined ? { sender: row.sender } : {}),
|
|
258
265
|
...(row.forwarded === true ? { forwarded: true } : {}),
|
|
266
|
+
...(row.deliveredTo !== undefined ? { deliveredTo: row.deliveredTo } : {}),
|
|
267
|
+
...(row.receiverMembers !== undefined ? { receiverMembers: row.receiverMembers } : {}),
|
|
259
268
|
...(row.auth !== undefined ? { auth: row.auth } : {}),
|
|
260
269
|
// #114 S4: who settled it and how they stood on the chain.
|
|
261
270
|
...(row.settledBy !== undefined ? { settledBy: row.settledBy } : {}),
|
|
@@ -270,6 +279,8 @@ export function registerAdminFaces(webServer, d) {
|
|
|
270
279
|
const roles = archive.flatMap(row => (row.settledBy !== undefined ? [row.settledBy.chain_role] : []));
|
|
271
280
|
return {
|
|
272
281
|
mode: config.settlementAuth,
|
|
282
|
+
...(d.enforceRefused !== undefined ? { enforceRefused: d.enforceRefused } : {}),
|
|
283
|
+
enforceBlockers: taskLedger.settlementEnforceBlockers(),
|
|
273
284
|
offChain: roles.filter(r => r === 'off-chain').length,
|
|
274
285
|
legacy: roles.filter(r => r === 'legacy').length,
|
|
275
286
|
compat: roles.filter(r => r === 'compat').length,
|
|
@@ -313,7 +324,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
313
324
|
const unreachable = card !== undefined && entry?.missAt !== undefined ? { unreachable: true } : {};
|
|
314
325
|
return Array.isArray(org)
|
|
315
326
|
? { url, ...unreachable, org: org.filter((e) => e !== null && typeof e === 'object')
|
|
316
|
-
.map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
|
|
327
|
+
.map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', id: typeof e.team === 'string' ? qualifiedCanvasId(e.team, card?.publicKey) : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
|
|
317
328
|
.filter(e => e.team !== '') }
|
|
318
329
|
: { url, ...unreachable, org: [] };
|
|
319
330
|
}),
|
|
@@ -327,6 +338,8 @@ export function registerAdminFaces(webServer, d) {
|
|
|
327
338
|
// Of those, envelopes whose bound form missed here: the sender
|
|
328
339
|
// dials an origin missing from originClaimAudiences.
|
|
329
340
|
boundMissInbound: d.boundMissInboundCount,
|
|
341
|
+
// Who missed, by verified caller and the origin it dialled (newest first, ≤16).
|
|
342
|
+
boundMissRecent: d.boundMissRecent,
|
|
330
343
|
// gh#113: CLI mailbox loss, all handles — mail the 64-row cap
|
|
331
344
|
// pushed out, and mail cut at 4000 chars. The team verifier
|
|
332
345
|
// holds `dropped` to zero.
|
package/lib/faces/wire.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
|
|
2
|
+
import { splitCanvasOwner } from "../canvas-id.js";
|
|
2
3
|
import { CLI_HANDLE_RE } from "../cli-deposit.js";
|
|
3
4
|
import { TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_LIMIT } from "../session-transcript.js";
|
|
4
5
|
import { refuseIfBudgetExhausted } from "../recursion-guard.js";
|
|
5
6
|
import { peerPayloadFingerprint, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED } from "../idempotency-store.js";
|
|
6
7
|
import { briefOf } from "../task-ledger.js";
|
|
7
8
|
import { parseReceipt, taskIdRefusal } from "../receipt.js";
|
|
8
|
-
import { buildAuthorization, claimCovers, refusalOf } from "../task-auth.js";
|
|
9
|
+
import { buildAuthorization, claimCovers, nextOf, refusalOf } from "../task-auth.js";
|
|
9
10
|
import { isRootOriginUrl } from "../origin-claim.js";
|
|
10
11
|
import { withResponseFields } from "../response-fields.js";
|
|
11
12
|
import { WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "../transport-caps.js";
|
|
@@ -53,7 +54,7 @@ export function settlerClaimFields(claim, originOf) {
|
|
|
53
54
|
* @param d - the captures listed on {@link WireDeps}.
|
|
54
55
|
*/
|
|
55
56
|
export function registerWireFaces(webServer, d) {
|
|
56
|
-
const { ctx, logger, config, cliDeposits, canvasStore, canvasNameReserved, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, remoteCliProof, callerLinkedToThisHost, recordActivity, secretEqual, a2aCallerRefusal, a2aCallerAdmission, allowlistVersionNow, observeAuthorization, scanSessionLogList, readSessionLogTail, taskLedger, refuseIfFlooded, refuseIfQueueFull, settleAndAnnounce, session, resolveAgentForTeam, parseCanvasTeamName, canvasLiveAgent, wakeColdTeam, probeConsumption, sessionTeamOf, localSessionOfHandle, steerRelay, fromClause, sanitizeFromClause, armReceiptAutosend, eventCountOf, receiptExpectationOf, armAsyncNudge, liveAgent, callerIsAgent, selfRouteText, noWakeFaceText, archivedColdMatchText, noColdMatchText, nativeTeamsPrepare, nativeTeamsRound, routeIntoAgent, } = d;
|
|
57
|
+
const { ctx, logger, config, cliDeposits, canvasStore, canvasNameReserved, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, remoteCliProof, callerLinkedToThisHost, recordActivity, secretEqual, a2aCallerRefusal, a2aCallerAdmission, allowlistVersionNow, observeAuthorization, scanSessionLogList, readSessionLogTail, taskLedger, refuseIfFlooded, refuseIfQueueFull, settleAndAnnounce, waitForForwardBirth, session, resolveAgentForTeam, parseCanvasTeamName, canvasLiveAgent, wakeColdTeam, probeConsumption, sessionTeamOf, localSessionOfHandle, steerRelay, fromClause, sanitizeFromClause, armReceiptAutosend, eventCountOf, receiptExpectationOf, armAsyncNudge, liveAgent, callerIsAgent, selfRouteText, noWakeFaceText, archivedColdMatchText, noColdMatchText, nativeTeamsPrepare, nativeTeamsRound, routeIntoAgent, } = d;
|
|
57
58
|
// gh#45 CLI 户口卡面 (peer face, unsigned card serve): the deposited
|
|
58
59
|
// CLI's SIGNED card is served verbatim under its own base URL — an
|
|
59
60
|
// OriginClaim verification of a CLI envelope fetches exactly here
|
|
@@ -434,7 +435,9 @@ export function registerWireFaces(webServer, d) {
|
|
|
434
435
|
return;
|
|
435
436
|
}
|
|
436
437
|
}
|
|
437
|
-
|
|
438
|
+
// #282 S2: our own `<canvas>@<fp8>` is our canvas; a foreign suffix stays for the forward walk.
|
|
439
|
+
const ownerQualified = splitCanvasOwner(typeof body.team === 'string' ? body.team : '');
|
|
440
|
+
const team = ownerQualified.owner !== undefined && ownerQualified.owner === d.ownCanvasFingerprint ? ownerQualified.team : typeof body.team === 'string' ? body.team : '';
|
|
438
441
|
const message = typeof body.message === 'string' ? body.message : '';
|
|
439
442
|
const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
|
|
440
443
|
// P2 receipt-callback: where THIS node's receipt should route on
|
|
@@ -482,11 +485,25 @@ export function registerWireFaces(webServer, d) {
|
|
|
482
485
|
const foreignCli = config.teamScopeRouting && CLI_HANDLE_RE.test(caller) && cliDeposits.get(caller) === undefined;
|
|
483
486
|
const earlyClaim = foreignCli ? await verifyOriginClaim(req, { cli: true, rawBody }) : undefined;
|
|
484
487
|
const cliProof = foreignCli && earlyClaim?.ok ? await remoteCliProof(caller, earlyClaim).catch(() => undefined) : undefined;
|
|
485
|
-
|
|
488
|
+
let admission = a2aCallerAdmission(caller, team, cliProof);
|
|
489
|
+
// #282 S3 (same team authorizes): a receipt for this host's own forward, to the CLI that sent it, from a
|
|
490
|
+
// receiver frozen at birth on the selected peer's key — the owner's signed canvas is the return path.
|
|
491
|
+
const echoed = !admission.ok && cliDeposits.get(team) !== undefined ? parseReceipt(message) : null;
|
|
492
|
+
if (echoed !== null) {
|
|
493
|
+
// Verify first, so an unsigned or Sig2-only caller cannot hold the connection on an in-flight forward (#287 review P3);
|
|
494
|
+
// then a body-bound receipt may beat the dispatch's HTTP answer: wait for this forward's row to be born (#287 P2).
|
|
495
|
+
const receiptClaim = earlyClaim ?? await verifyOriginClaim(req, { cli: true, rawBody });
|
|
496
|
+
if (receiptClaim.ok && receiptClaim.bodyBound === true)
|
|
497
|
+
await waitForForwardBirth(message);
|
|
498
|
+
const basis = receiptClaim.ok ? d.forwardReceiptBasis(echoed.taskId, caller, team, receiptClaim) : undefined;
|
|
499
|
+
if (basis !== undefined)
|
|
500
|
+
admission = { ok: true, via: 'peer-org', rule: 'peer-org', basis: [basis] };
|
|
501
|
+
}
|
|
486
502
|
const callerRefusal = refusalOf(admission);
|
|
487
503
|
if (callerRefusal !== undefined || !admission.ok) {
|
|
488
504
|
recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
|
|
489
|
-
const
|
|
505
|
+
const next = nextOf(admission);
|
|
506
|
+
const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED', ...(next !== undefined ? { next } : {}) });
|
|
490
507
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
491
508
|
res.end(payload);
|
|
492
509
|
return;
|
|
@@ -559,6 +576,8 @@ export function registerWireFaces(webServer, d) {
|
|
|
559
576
|
// #114 S4: the settler is this delivery's caller, as proven here. A CLI identity's claim URL
|
|
560
577
|
// is not a host's origin (its origin is unconstrained), so it never vouches for one (review of PR #140 P1-2).
|
|
561
578
|
// #139: the claim's card key names the host whatever address spelling it signed with.
|
|
579
|
+
// The response can race a fast peer receipt. Wait for this exact forwarding birth before judging or acknowledging it.
|
|
580
|
+
await waitForForwardBirth(message);
|
|
562
581
|
const refusedSettlement = settleAndAnnounce(message, { caller, proof: callerProof, ...settlerClaimFields(claim, originOf), ingress: 'wire' });
|
|
563
582
|
// #139 (design §4.4): every answer this delivery gets carries the refusal.
|
|
564
583
|
if (refusedSettlement !== undefined)
|
|
@@ -587,13 +606,16 @@ export function registerWireFaces(webServer, d) {
|
|
|
587
606
|
// quiet window around it.
|
|
588
607
|
d.touchWakeDemand();
|
|
589
608
|
const forwardMiss = async (original) => {
|
|
590
|
-
|
|
609
|
+
// #282 S2 review P1: forward with the id the caller sent — our own `@fp8` must find no other owner.
|
|
610
|
+
const result = await d.forwardDirect({ team: typeof body.team === 'string' && ownerQualified.owner !== undefined ? body.team : team, message, caller, taskId, contextId, noWait,
|
|
611
|
+
auth: buildAuthorization(admission, { caller, proof: callerProof, ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}), ingress: 'wire', allowlistVersion: admittedAt.version, target: team, receiptTarget: caller, receivedAt: admittedAt.at }),
|
|
591
612
|
localCliProof: claim.ok && claim.cli === true && claim.bound && claim.caller === caller, guarded: req.headers['x-a2a-guard'] !== undefined }).catch((error) => ({ ok: false, error: `forwarding unavailable: ${String(error)}`, code: -32000, task_id: taskId }));
|
|
613
|
+
const failure = result.ok ? '' : await d.diagnoseCanvas(team, original + ' — ' + result.error, claim.ok && claim.cli === true && claim.bound && claim.caller === caller ? caller : undefined);
|
|
592
614
|
if (!result.ok && result.code !== -32005)
|
|
593
|
-
idempotencyStore.recordOutcome(taskId, { status: 'failed', error:
|
|
615
|
+
idempotencyStore.recordOutcome(taskId, { status: 'failed', error: failure });
|
|
594
616
|
else if (result.ok && result.task_status === 'TASK_STATE_COMPLETED')
|
|
595
617
|
idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: result.reply });
|
|
596
|
-
const payload = JSON.stringify(result.ok ? { routed: true, ...(result.task_status === 'TASK_STATE_DELIVERED' ? { delivered: true } : { result: { text: result.reply } }), team, task_id: taskId, context_id: contextId, task_status: result.task_status, consumed: result.consumed, bridge: result.bridge, receipt: result.receipt, forwarded: true } : { error:
|
|
618
|
+
const payload = JSON.stringify(result.ok ? { routed: true, ...(result.task_status === 'TASK_STATE_DELIVERED' ? { delivered: true } : { result: { text: result.reply } }), team, task_id: taskId, context_id: contextId, task_status: result.task_status, consumed: result.consumed, bridge: result.bridge, receipt: result.receipt, forwarded: true } : { error: failure, code: result.code ?? -32000, receipt: result.receipt, task_id: taskId, team, task_status: result.code === -32005 ? 'TASK_STATE_DELIVERED' : 'TASK_STATE_FAILED', ...(result.next !== undefined ? { next: result.next } : {}) });
|
|
597
619
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
598
620
|
res.end(payload);
|
|
599
621
|
};
|
|
@@ -809,7 +831,16 @@ export function registerWireFaces(webServer, d) {
|
|
|
809
831
|
})();
|
|
810
832
|
return;
|
|
811
833
|
}
|
|
812
|
-
|
|
834
|
+
let syncTarget;
|
|
835
|
+
// A forwarded synchronous delivery still owes its automatic receipt after the HTTP waiter expires.
|
|
836
|
+
const syncSteered = req.headers['x-a2a-guard'] === undefined || parseReceipt(message) !== null ? undefined : (target, floor) => {
|
|
837
|
+
syncTarget = sessionTeamOf(target);
|
|
838
|
+
const receiptTarget = callback !== '' ? callback : caller;
|
|
839
|
+
const auth = buildAuthorization(admission, { caller, proof: callerProof, ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}), ...(admission.rule === 'remote-ref-cli' && claim.ok ? { carrierUrl: originOf(claim.callerUrl) } : {}), ingress: 'wire', allowlistVersion: admittedAt.version, target: team, receiptTarget, receivedAt: admittedAt.at });
|
|
840
|
+
taskLedger.trackInbound(taskId, team, receiptTarget, contextId, receiptExpectationOf(receiptTarget) !== 'process-label', briefOf(message), auth, syncTarget === team ? undefined : syncTarget);
|
|
841
|
+
armReceiptAutosend(target, taskId, receiptTarget, message, floor);
|
|
842
|
+
};
|
|
843
|
+
void routeIntoAgent(team, message, caller, taskId, proofClause, directHeader, undefined, syncSteered).then(async (outcome) => {
|
|
813
844
|
if (!outcome.ok && outcome.localMiss) {
|
|
814
845
|
await forwardMiss(outcome.error);
|
|
815
846
|
return;
|
|
@@ -840,7 +871,7 @@ export function registerWireFaces(webServer, d) {
|
|
|
840
871
|
team,
|
|
841
872
|
session,
|
|
842
873
|
result: { text: outcome.reply },
|
|
843
|
-
...(
|
|
874
|
+
...(syncTarget !== undefined ? { delivered_to: syncTarget } : {}),
|
|
844
875
|
task_id: taskId,
|
|
845
876
|
context_id: contextId,
|
|
846
877
|
task_status: outcome.status ?? 'TASK_STATE_COMPLETED',
|
package/lib/flood-guard.js
CHANGED
|
@@ -104,7 +104,9 @@ export function createFloodGuard(outcomes, recordActivity) {
|
|
|
104
104
|
const error = 'flood-guard: ' + reason + ' (gh#91): file data must use an independent channel; no delivery occurred' + (advice === undefined ? '' : '; use-file-channel: ' + advice.message);
|
|
105
105
|
outcomes.recordOutcome(taskId, { status: 'failed', error });
|
|
106
106
|
recordActivity('in', team, caller, false);
|
|
107
|
-
|
|
107
|
+
// #282 S1: one next step — file bytes go through the file channel, never chat messages.
|
|
108
|
+
const next = 'send file data with a2a_file_send / `a2a-net file send <path> <team>` instead of message chunks; resend other text as one message with a new task id';
|
|
109
|
+
const payload = JSON.stringify({ error, reason, code: WIRE_ERROR_RATE_LIMITED, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED', next });
|
|
108
110
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
109
111
|
res.end(payload);
|
|
110
112
|
return true;
|