@nelsonlongxiang/dsh-open-a2a-net 0.6.33 → 0.6.34
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 +5 -2
- package/README_zh.md +5 -2
- package/assets/nexusDist/assets/index-D2BGWOeU.js +9 -0
- package/assets/nexusDist/assets/index-z33lHtLw.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/lib/a2a-client.js +9 -2
- package/lib/auth-observer.js +1 -1
- package/lib/canvas-store.js +44 -6
- package/lib/card.js +2 -1
- package/lib/cli-home.js +44 -0
- package/lib/client/A2aControl.js +1 -1
- package/lib/client.js +65 -65
- package/lib/client.js.map +1 -1
- package/lib/direct-forward.js +61 -0
- package/lib/faces/admin.js +41 -5
- package/lib/faces/wire.js +55 -16
- package/lib/index.js +110 -64
- package/lib/outbound-scope.js +25 -0
- package/lib/queue-guard.js +37 -0
- package/lib/skill/references/cli.md +5 -4
- package/lib/skill/references/pitfalls.md +1 -4
- package/lib/task-auth.js +52 -3
- package/lib/task-ledger.js +4 -1
- package/lib/tools/info.js +19 -3
- package/lib/types/a2a-client.d.ts +1 -1
- package/lib/types/auth-observer.d.ts +2 -2
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/cli-home.d.ts +24 -0
- package/lib/types/client/A2aControl.d.ts +1 -1
- package/lib/types/direct-forward.d.ts +32 -0
- package/lib/types/faces/admin.d.ts +8 -1
- package/lib/types/faces/wire.d.ts +12 -4
- package/lib/types/index.d.ts +4 -0
- package/lib/types/outbound-scope.d.ts +21 -0
- package/lib/types/queue-guard.d.ts +32 -0
- package/lib/types/runtime.d.ts +1 -1
- package/lib/types/task-auth.d.ts +17 -2
- package/lib/types/task-ledger.d.ts +6 -1
- package/lib/types/types.d.ts +8 -0
- package/nexus-stage/src/cli-home-picker.ts +42 -0
- package/nexus-stage/src/main.ts +9 -2
- package/nexus-stage/src/planning-card.ts +61 -0
- package/nexus-stage/src/planning-menus.ts +42 -7
- package/nexus-stage/src/planning-view.ts +14 -32
- package/nexus-stage/src/planning.css +17 -0
- package/nexus-stage/src/workbench.css +4 -0
- package/nexus-stage/src/workbench.ts +22 -5
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +2 -1
- package/assets/nexusDist/assets/index-BACVZe0j.css +0 -1
- package/assets/nexusDist/assets/index-Bbxnpf2p.js +0 -9
package/lib/index.js
CHANGED
|
@@ -34,6 +34,9 @@ import { LayoutStore } from "./layout-store.js";
|
|
|
34
34
|
import { JoinedSessions } from "./joined-store.js";
|
|
35
35
|
import { ExposureStore } from "./exposure-store.js";
|
|
36
36
|
import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
|
|
37
|
+
import { cliHomeChoices, decideCliHome } from "./cli-home.js";
|
|
38
|
+
import { outboundScope } from "./outbound-scope.js";
|
|
39
|
+
import { createDirectForward } from "./direct-forward.js";
|
|
37
40
|
import { TeamMembershipStore } from "./team-store.js";
|
|
38
41
|
import { PeerStore } from "./peer-store.js";
|
|
39
42
|
import { SelfReferralFilter } from "./self-suppress.js";
|
|
@@ -42,11 +45,12 @@ import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
|
|
|
42
45
|
import { runReceiptLadder } from "./receipt-ladder.js";
|
|
43
46
|
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
|
|
44
47
|
import { createFloodGuard } from "./flood-guard.js";
|
|
48
|
+
import { createQueueGuard, DEFAULT_TARGET_QUEUE_CAP, queueFullRefusal, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
|
|
45
49
|
import { resolveZone } from "./zone.js";
|
|
46
50
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
47
51
|
import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
|
|
48
52
|
import { AuthObserver } from "./auth-observer.js";
|
|
49
|
-
import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission } from "./task-auth.js";
|
|
53
|
+
import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
|
|
50
54
|
import s from '@deepseek-ai/schemastery';
|
|
51
55
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
52
56
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
@@ -83,6 +87,7 @@ export const Config = s.object({
|
|
|
83
87
|
settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
|
|
84
88
|
announce: s.boolean().default(false),
|
|
85
89
|
isolated: s.boolean().default(false),
|
|
90
|
+
forwardDirect: s.boolean().default(false),
|
|
86
91
|
agentName: s.string().default('DeepSeek Harness A2A node'),
|
|
87
92
|
peers: s.array(s.string()).default([]),
|
|
88
93
|
delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
|
|
@@ -179,6 +184,8 @@ export const Config = s.object({
|
|
|
179
184
|
* t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
|
|
180
185
|
*/
|
|
181
186
|
asyncNudgeDelayMs: s.number().default(120_000),
|
|
187
|
+
/** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
|
|
188
|
+
targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
|
|
182
189
|
stateColdRowsTtlMs: s.number().default(5_000),
|
|
183
190
|
cardCacheTtlMs: s.number().default(60_000),
|
|
184
191
|
cardCacheNegativeTtlMs: s.number().default(30_000),
|
|
@@ -799,6 +806,8 @@ export function apply(ctx, config) {
|
|
|
799
806
|
// refused-but-idempotent, same-key different-payload answers conflict.
|
|
800
807
|
const idempotencyStore = new IdempotencyStore(join(home, 'a2a', 'idempotency.json'));
|
|
801
808
|
const refuseIfFlooded = createFloodGuard(idempotencyStore, recordActivity);
|
|
809
|
+
const ownTask = (taskId) => taskLedger.list().some(r => r.taskId === taskId) || taskLedger.archive().some(r => r.taskId === taskId);
|
|
810
|
+
const refuseIfQueueFull = createQueueGuard(() => taskLedger.list(), () => config.targetQueueCap, ownTask, idempotencyStore, recordActivity);
|
|
802
811
|
// Unmasked-identity suppression: gossip mirrors peer lists back and forth,
|
|
803
812
|
// so a URL whose fetch just proved it serves this node — or an already
|
|
804
813
|
// tracked node (an alias spelling) — keeps coming home as an inbound
|
|
@@ -1639,7 +1648,7 @@ export function apply(ctx, config) {
|
|
|
1639
1648
|
whenWebServerSettled((webServer) => {
|
|
1640
1649
|
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1641
1650
|
registerAdminFaces(webServer, {
|
|
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,
|
|
1651
|
+
ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
|
|
1643
1652
|
indexUrl: import.meta.url,
|
|
1644
1653
|
get cardCache() { return cardCache; },
|
|
1645
1654
|
get remoteRowsCache() { return remoteRowsCache; },
|
|
@@ -1771,7 +1780,7 @@ export function apply(ctx, config) {
|
|
|
1771
1780
|
// deposit stays as an OFFLINE row (tombstone) so the board keeps
|
|
1772
1781
|
// the member card grayed-but-present and canvas refs never
|
|
1773
1782
|
// dangle silently; re-publish (same handle) un-tombstones.
|
|
1774
|
-
...cliDeposits.directoryRows().map(row => exposureRow(row.team, { name: row.name, description: row.description })),
|
|
1783
|
+
...cliDeposits.directoryRows().map(row => exposureRow(row.team, { name: row.name, description: row.description, offline: row.offline })),
|
|
1775
1784
|
];
|
|
1776
1785
|
const memberships = teamMemberships.list().flatMap(entry => entry.teams.map(team => ({ node: `${config.team}/${id8(entry.session)}`, team })));
|
|
1777
1786
|
const body = JSON.stringify({
|
|
@@ -2139,7 +2148,7 @@ export function apply(ctx, config) {
|
|
|
2139
2148
|
}
|
|
2140
2149
|
return { ok: false, error: 'No live DSH agent is available to accept this message.' };
|
|
2141
2150
|
}
|
|
2142
|
-
return { ok: false, error: wake.kind === 'no-face' ? noWakeFaceText(team) : wake.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team) };
|
|
2151
|
+
return { ok: false, ...(wake.kind === 'no-match' ? { localMiss: true } : {}), error: wake.kind === 'no-face' ? noWakeFaceText(team) : wake.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team) };
|
|
2143
2152
|
}
|
|
2144
2153
|
/**
|
|
2145
2154
|
* Steer one direct route request into one named agent and resolve with its
|
|
@@ -2382,6 +2391,7 @@ export function apply(ctx, config) {
|
|
|
2382
2391
|
},
|
|
2383
2392
|
liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
|
|
2384
2393
|
cliDeposited: handle => cliDeposits.get(handle) !== undefined,
|
|
2394
|
+
cliHomeOf: handle => canvasStore.cliHomeOf(handle),
|
|
2385
2395
|
};
|
|
2386
2396
|
/** W1/W2 shared-team admission (see sameZoneAdmission in src/task-auth.ts). */
|
|
2387
2397
|
function sameZoneSharedTeamRefusal(caller, targetTeam) {
|
|
@@ -2393,8 +2403,8 @@ export function apply(ctx, config) {
|
|
|
2393
2403
|
return verdict === undefined ? undefined : refusalOf(verdict);
|
|
2394
2404
|
}
|
|
2395
2405
|
/** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
|
|
2396
|
-
function a2aCallerAdmission(caller, targetTeam) {
|
|
2397
|
-
return callerAdmission(admissionRecords, caller, targetTeam);
|
|
2406
|
+
function a2aCallerAdmission(caller, targetTeam, cliProof) {
|
|
2407
|
+
return callerAdmission(admissionRecords, caller, targetTeam, cliProof);
|
|
2398
2408
|
}
|
|
2399
2409
|
function a2aCallerRefusal(caller, targetTeam) {
|
|
2400
2410
|
return refusalOf(a2aCallerAdmission(caller, targetTeam));
|
|
@@ -2418,6 +2428,7 @@ export function apply(ctx, config) {
|
|
|
2418
2428
|
// Local members by their handle (design §2.3), remote refs as they are stored.
|
|
2419
2429
|
canvas: canvasStore.list().map(name => [canvasTeamOf(name), canvasStore.membersOf(name).map(m => (m.includes('/') ? m : handle(m)))]),
|
|
2420
2430
|
cli: cliDeposits.list().map(row => row.handle),
|
|
2431
|
+
cliHome: canvasStore.pinnedCliHomes(),
|
|
2421
2432
|
links: config.peers.map(peer => { try {
|
|
2422
2433
|
return new URL(peer).origin;
|
|
2423
2434
|
}
|
|
@@ -2443,7 +2454,7 @@ export function apply(ctx, config) {
|
|
|
2443
2454
|
cause,
|
|
2444
2455
|
rows: () => taskLedger.list(),
|
|
2445
2456
|
// A local-candidate record (#139) is judged as the local candidate admits; every other one by the wire face.
|
|
2446
|
-
judge: (caller, target, ingress) => (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
|
|
2457
|
+
judge: (caller, target, ingress, auth) => auth.admission_rule === 'remote-ref-cli' ? recheckRemoteCliAuthorization(admissionRecords, auth) : (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
|
|
2447
2458
|
});
|
|
2448
2459
|
if (entry === undefined)
|
|
2449
2460
|
return;
|
|
@@ -2539,6 +2550,8 @@ export function apply(ctx, config) {
|
|
|
2539
2550
|
joinedSessions,
|
|
2540
2551
|
idempotencyStore,
|
|
2541
2552
|
verifyOriginClaim,
|
|
2553
|
+
remoteCliProof,
|
|
2554
|
+
forwardDirect: request => forwardDirect(request),
|
|
2542
2555
|
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
2543
2556
|
recordActivity,
|
|
2544
2557
|
bumpLegacyInbound: () => { legacyInboundCount += 1; },
|
|
@@ -2551,6 +2564,7 @@ export function apply(ctx, config) {
|
|
|
2551
2564
|
readSessionLogTail,
|
|
2552
2565
|
taskLedger,
|
|
2553
2566
|
refuseIfFlooded,
|
|
2567
|
+
refuseIfQueueFull,
|
|
2554
2568
|
settleAndAnnounce,
|
|
2555
2569
|
touchWakeDemand: () => { lastWakeDemandAt = Date.now(); },
|
|
2556
2570
|
session,
|
|
@@ -2876,6 +2890,55 @@ export function apply(ctx, config) {
|
|
|
2876
2890
|
}
|
|
2877
2891
|
return { link: linked ? 'linked' : 'stranger', verified };
|
|
2878
2892
|
}
|
|
2893
|
+
async function freshCliHomeCards() {
|
|
2894
|
+
return await mapBounded(peerStore.list(), 6, async (url) => {
|
|
2895
|
+
// A failed read cannot prove a tracked host is unlinked (especially after a cold boot).
|
|
2896
|
+
// Conservatively leave automatic ownership unknown; explicit fresh choices still work.
|
|
2897
|
+
const card = await fetchPeerCard(url).catch(() => undefined);
|
|
2898
|
+
if (card !== undefined)
|
|
2899
|
+
cardCache.set(url, { at: Date.now(), card });
|
|
2900
|
+
return { url, card, linked: card === undefined || peerLinkState(url).link === 'linked' };
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
async function cliHomeForAdd(handle, requested) {
|
|
2904
|
+
const localHousehold = () => cliDeposits.directoryRows().some(row => row.team === handle);
|
|
2905
|
+
if (localHousehold())
|
|
2906
|
+
return decideCliHome(handle, requested, [], true);
|
|
2907
|
+
return decideCliHome(handle, requested, CLI_HANDLE_RE.test(handle) ? await freshCliHomeCards() : [], localHousehold());
|
|
2908
|
+
}
|
|
2909
|
+
async function cliHomeOptions(handle) {
|
|
2910
|
+
const locallyHeld = () => cliDeposits.directoryRows().some(row => row.team === handle);
|
|
2911
|
+
if (locallyHeld())
|
|
2912
|
+
return cliHomeChoices(handle, [], true);
|
|
2913
|
+
return cliHomeChoices(handle, await freshCliHomeCards(), locallyHeld());
|
|
2914
|
+
}
|
|
2915
|
+
/** Remote CLI admission never trusts a retained stale cache entry after a failed home read. */
|
|
2916
|
+
async function remoteCliProof(handle, claim) {
|
|
2917
|
+
const home = canvasStore.cliHomeOf(handle);
|
|
2918
|
+
if (home === undefined || !claim.bound || claim.cli === true || !isRootOriginUrl(claim.callerUrl))
|
|
2919
|
+
return undefined;
|
|
2920
|
+
if (new URL(claim.callerUrl).origin !== home)
|
|
2921
|
+
return undefined;
|
|
2922
|
+
for (const url of peerStore.list()) {
|
|
2923
|
+
try {
|
|
2924
|
+
if (new URL(url).origin !== home)
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
catch {
|
|
2928
|
+
continue;
|
|
2929
|
+
}
|
|
2930
|
+
const card = await fetchPeerCard(url);
|
|
2931
|
+
if (card === undefined || card.publicKey !== claim.card.publicKey)
|
|
2932
|
+
continue;
|
|
2933
|
+
cardCache.set(url, { at: Date.now(), card });
|
|
2934
|
+
if (peerLinkState(url).link !== 'linked')
|
|
2935
|
+
continue;
|
|
2936
|
+
const row = card.sessionTeams?.find(item => item.team === handle);
|
|
2937
|
+
if (row !== undefined)
|
|
2938
|
+
return { origin: home, viaUrl: claim.callerUrl, bound: true, online: row.offline === false };
|
|
2939
|
+
}
|
|
2940
|
+
return { origin: home, viaUrl: claim.callerUrl, bound: true, online: false };
|
|
2941
|
+
}
|
|
2879
2942
|
function cachedCardFetch(url) {
|
|
2880
2943
|
const hit = cardCache.get(url);
|
|
2881
2944
|
const now = Date.now();
|
|
@@ -3217,6 +3280,22 @@ export function apply(ctx, config) {
|
|
|
3217
3280
|
* caller label — the "能发即能收" invariant: whatever it addresses can
|
|
3218
3281
|
* answer it with one route.
|
|
3219
3282
|
*/
|
|
3283
|
+
const forwardDirect = createDirectForward({
|
|
3284
|
+
enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
|
|
3285
|
+
memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
|
|
3286
|
+
scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
|
|
3287
|
+
candidates: async (team, failures, caller) => { const fetch = memoizedCardFetch(); return { urls: await directoryPeerCandidates(fetch, team, failures, { id: caller, handle: caller }), card: fetch }; },
|
|
3288
|
+
dispatch: (url, request, guard) => dispatchPeerCandidate(url, request.team, request.message, request.contextId, new AbortController().signal, request.caller, request.noWait, request.taskId, request.caller, guard),
|
|
3289
|
+
ledger: taskLedger, activity: (team, peer, ok) => recordActivity('fwd', team, peer, ok),
|
|
3290
|
+
});
|
|
3291
|
+
const senderScopeRecords = {
|
|
3292
|
+
zone: config.team, id8, teamsOf: id => teamMemberships.teamsOf(id), membersOf: team => teamMemberships.membersOf(team),
|
|
3293
|
+
canvasNames: () => canvasStore.list(), canvasMembers: name => canvasStore.membersOf(name), canvasTeam: canvasTeamOf,
|
|
3294
|
+
peerOrgs: function* () { for (const hit of cardCache.values())
|
|
3295
|
+
if (hit.card?.canvasOrg)
|
|
3296
|
+
yield hit.card.canvasOrg; },
|
|
3297
|
+
warmPeers: () => mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch),
|
|
3298
|
+
};
|
|
3220
3299
|
async function routeTrunk(args, agent, signal, hostCaller) {
|
|
3221
3300
|
// Fix ③ (S4 audit): host-side calls carry no agent identity — every one
|
|
3222
3301
|
// lands in the team audit ring, and the owner can flip the gate from
|
|
@@ -3252,62 +3331,11 @@ export function apply(ctx, config) {
|
|
|
3252
3331
|
}
|
|
3253
3332
|
catch { /* no initiator in this fiber */ }
|
|
3254
3333
|
if (!initiator) {
|
|
3255
|
-
const callerTeams = teamMemberships.teamsOf(scopeCaller);
|
|
3256
|
-
const team = String(args.team ?? '').trim();
|
|
3257
|
-
const sharedRoster = callerTeams.some(roster => teamMemberships.membersOf(roster).some(id => `${config.team}/${id8(id)}` === team));
|
|
3258
|
-
// gh#71: the SHARED-CANVAS basis and the warm cache's peer-org
|
|
3259
|
-
// basis (an org entry naming BOTH the caller and the target
|
|
3260
|
-
// handle) count as declared here too — the same link the
|
|
3261
|
-
// admission core honors on the receiving side; without this the
|
|
3262
|
-
// tool face refused routes the canvas itself had authorized.
|
|
3263
|
-
// Canvas members mix two shapes: LOCAL sessions sit by session
|
|
3264
|
-
// id, cross-host members by zone/id8 handle — match either.
|
|
3265
3334
|
const callerHandle = `${config.team}/${id8(scopeCaller)}`;
|
|
3266
3335
|
scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
// group's own team handle (canvasTeamOf(name)) is the match
|
|
3271
|
-
// (the caller sits in the very group being addressed).
|
|
3272
|
-
const sharedCanvas = canvasStore.list().some(name => {
|
|
3273
|
-
const members = canvasStore.membersOf(name);
|
|
3274
|
-
const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
|
|
3275
|
-
return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
|
|
3276
|
-
});
|
|
3277
|
-
// A direct group route must not require a prior directory call. Fetch
|
|
3278
|
-
// only the same eligible peers as resolution; then apply the
|
|
3279
|
-
// unchanged sender/receiver admission rules to their verified cards.
|
|
3280
|
-
if (team.includes('/canvas/') && !sharedCanvas && !callerTeams.includes(team))
|
|
3281
|
-
await mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch);
|
|
3282
|
-
let orgShared = false;
|
|
3283
|
-
for (const cached of cardCache.values()) {
|
|
3284
|
-
const org = cached.card?.canvasOrg;
|
|
3285
|
-
if (!Array.isArray(org))
|
|
3286
|
-
continue;
|
|
3287
|
-
for (const e of org) {
|
|
3288
|
-
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
3289
|
-
continue;
|
|
3290
|
-
const inOrg = e.members.includes(scopeCaller) || e.members.includes(callerHandle);
|
|
3291
|
-
if (inOrg && (e.members.includes(team) || e.team === team)) {
|
|
3292
|
-
orgShared = true;
|
|
3293
|
-
break;
|
|
3294
|
-
}
|
|
3295
|
-
}
|
|
3296
|
-
if (orgShared)
|
|
3297
|
-
break;
|
|
3298
|
-
}
|
|
3299
|
-
if (callerTeams.length === 0 && !sharedRoster && !sharedCanvas && !orgShared) {
|
|
3300
|
-
return localRoute({
|
|
3301
|
-
ok: false,
|
|
3302
|
-
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)`,
|
|
3303
|
-
});
|
|
3304
|
-
}
|
|
3305
|
-
if (!callerTeams.includes(team) && !sharedRoster && !sharedCanvas && !orgShared) {
|
|
3306
|
-
return localRoute({
|
|
3307
|
-
ok: false,
|
|
3308
|
-
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)`,
|
|
3309
|
-
});
|
|
3310
|
-
}
|
|
3336
|
+
const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
|
|
3337
|
+
if (!scope.ok)
|
|
3338
|
+
return localRoute(scope);
|
|
3311
3339
|
}
|
|
3312
3340
|
}
|
|
3313
3341
|
let callerSession = resolveCallerLabel({
|
|
@@ -3470,6 +3498,12 @@ export function apply(ctx, config) {
|
|
|
3470
3498
|
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3471
3499
|
return { ok: false, error: idRefusal, code: -32000 };
|
|
3472
3500
|
}
|
|
3501
|
+
// gh#224: a full target queue refuses a same-host delivery as the wire face does (receipts, CLI mailboxes pass).
|
|
3502
|
+
const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, cliDeposits.get(team) !== undefined, ownTask);
|
|
3503
|
+
if (queueRefusal !== undefined) {
|
|
3504
|
+
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3505
|
+
return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL };
|
|
3506
|
+
}
|
|
3473
3507
|
const canvasName = parseCanvasTeamName(team);
|
|
3474
3508
|
// Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
|
|
3475
3509
|
// or a session label) and quick-dispatch BORROWS a live session's team
|
|
@@ -3739,9 +3773,9 @@ export function apply(ctx, config) {
|
|
|
3739
3773
|
* exactly like same-host ones; older peers that ignore the flag simply
|
|
3740
3774
|
* keep the blocking wait (graceful degradation).
|
|
3741
3775
|
*/
|
|
3742
|
-
async function dispatchPeerCandidate(url, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller, callbackAddress) {
|
|
3776
|
+
async function dispatchPeerCandidate(url, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller, callbackAddress, guard) {
|
|
3743
3777
|
const flight = beginRoute(team, url);
|
|
3744
|
-
const result = await client.routeDirect(url, team, message, contextId, signal, callerSession, asyncMode, taskIdFromCaller, callbackAddress);
|
|
3778
|
+
const result = await client.routeDirect(url, team, message, contextId, signal, callerSession, asyncMode, taskIdFromCaller, callbackAddress, guard);
|
|
3745
3779
|
endRoute(flight);
|
|
3746
3780
|
recordActivity('out', team, url, result.ok);
|
|
3747
3781
|
// F1 half-transaction compensation (t-mth5gmxy): the client's 15s budget
|
|
@@ -3778,6 +3812,17 @@ export function apply(ctx, config) {
|
|
|
3778
3812
|
* owner escalation actually fires for cross-node callback addresses.
|
|
3779
3813
|
*/
|
|
3780
3814
|
async function dispatchAnywhere(team, message, callerSession, contextId, signal, asyncMode, taskId, peerCaller) {
|
|
3815
|
+
// A local online household is an owner-established fact and receives locally.
|
|
3816
|
+
// Otherwise only the pinned home may receive; never fall back to directory claims.
|
|
3817
|
+
if (CLI_HANDLE_RE.test(team) && parseReceipt(message) !== null && cliDeposits.get(team) === undefined) {
|
|
3818
|
+
const home = canvasStore.cliHomeOf(team);
|
|
3819
|
+
if (home === undefined)
|
|
3820
|
+
throw new Error(`CLI receipt home is not pinned for ${team}`);
|
|
3821
|
+
const delivered = await dispatchPeerCandidate(home, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
|
|
3822
|
+
if (!delivered.ok)
|
|
3823
|
+
throw new Error(`CLI receipt to pinned home failed: ${delivered.error}`);
|
|
3824
|
+
return delivered;
|
|
3825
|
+
}
|
|
3781
3826
|
const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
|
|
3782
3827
|
// A background send books no outbound row: its birth facts have no row to go on.
|
|
3783
3828
|
if (taskId !== undefined)
|
|
@@ -3844,7 +3889,8 @@ export function apply(ctx, config) {
|
|
|
3844
3889
|
const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
|
|
3845
3890
|
// Check after fetching all cards: a seeded peer publishing the same id8
|
|
3846
3891
|
// makes the caller handle ambiguous, so do not send a canvas payload.
|
|
3847
|
-
const
|
|
3892
|
+
const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
|
|
3893
|
+
const resolveLocal = sessions === undefined ? undefined : (ref) => cliDeposits.get(ref) !== undefined ? ref : sessions(ref);
|
|
3848
3894
|
const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
|
|
3849
3895
|
if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
|
|
3850
3896
|
failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function outboundScope(r, id, handle, team) {
|
|
2
|
+
const teams = r.teamsOf(id);
|
|
3
|
+
const sharedRoster = teams.some(roster => r.membersOf(roster).some(member => r.zone + '/' + r.id8(member) === team));
|
|
4
|
+
const sharedCanvas = r.canvasNames().some(name => {
|
|
5
|
+
const members = r.canvasMembers(name);
|
|
6
|
+
return (members.includes(id) || members.includes(handle)) && (members.includes(team) || r.canvasTeam(name) === team);
|
|
7
|
+
});
|
|
8
|
+
if (team.includes('/canvas/') && !sharedCanvas && !teams.includes(team))
|
|
9
|
+
await r.warmPeers();
|
|
10
|
+
let orgShared = false;
|
|
11
|
+
for (const org of r.peerOrgs())
|
|
12
|
+
for (const row of org) {
|
|
13
|
+
if (typeof row.team !== 'string' || row.team === '' || !Array.isArray(row.members))
|
|
14
|
+
continue;
|
|
15
|
+
if ((row.members.includes(id) || row.members.includes(handle)) && (row.members.includes(team) || row.team === team)) {
|
|
16
|
+
orgShared = true;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (teams.length === 0 && !sharedRoster && !sharedCanvas && !orgShared)
|
|
21
|
+
return { ok: false, error: `team-scoped routing is on and this session (${r.id8(id)}) has declared no team membership — a teamless node has no network. Declare one with a2a_team_join (subject to the owner's teamJoinAllowlist)` };
|
|
22
|
+
if (!teams.includes(team) && !sharedRoster && !sharedCanvas && !orgShared)
|
|
23
|
+
return { ok: false, error: `team-scoped routing is on: routing to "${team}" requires declared membership. This session's teams: ${teams.join(', ')}. a2a_team_join can declare it (subject to the owner's teamJoinAllowlist)` };
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { parseReceipt } from "./receipt.js";
|
|
2
|
+
export const WIRE_ERROR_QUEUE_FULL = -32009;
|
|
3
|
+
export const DEFAULT_TARGET_QUEUE_CAP = 3;
|
|
4
|
+
/** How many receipt-owing tasks this host holds pending for `team`. */
|
|
5
|
+
export function queuedFor(rows, team) {
|
|
6
|
+
return rows.filter(row => row.status === 'pending' && row.receiptExpected !== false &&
|
|
7
|
+
(row.direction === 'inbound' || row.peer === 'local') &&
|
|
8
|
+
(row.team === team || row.deliveredTo === team)).length;
|
|
9
|
+
}
|
|
10
|
+
/** The refusal text when `team`'s queue is full, else undefined. `cap <= 0` disables the guard. */
|
|
11
|
+
export function queueFullRefusal(rows, team, message, cap, mailbox, ownTask) {
|
|
12
|
+
// A config without the key (old patch layer, test harness) gets the shipped default, not "no cap".
|
|
13
|
+
const limit = typeof cap === 'number' && Number.isFinite(cap) ? cap : DEFAULT_TARGET_QUEUE_CAP;
|
|
14
|
+
if (limit <= 0 || mailbox)
|
|
15
|
+
return undefined;
|
|
16
|
+
const receipt = parseReceipt(message);
|
|
17
|
+
if (receipt !== null && receipt.taskId !== '' && ownTask(receipt.taskId))
|
|
18
|
+
return undefined;
|
|
19
|
+
const queued = queuedFor(rows, team);
|
|
20
|
+
if (queued < limit)
|
|
21
|
+
return undefined;
|
|
22
|
+
return `target-queue-full (gh#224): "${team}" already has ${String(queued)} task(s) waiting for a receipt (cap ${String(limit)}) — nothing was delivered. Wait for a receipt from it (a2a inbox / a2a_tasks), or send again later with a NEW task id (this one is recorded as failed); do not resend in a loop. A task that never gets a receipt turns dead after taskStaleTtlMs and stops counting.`;
|
|
23
|
+
}
|
|
24
|
+
/** Wire-face adapter in the flood guard's shape: a refused fresh key is FAILED, not stranded pending. */
|
|
25
|
+
export function createQueueGuard(rows, cap, ownTask, outcomes, recordActivity) {
|
|
26
|
+
return (caller, message, team, taskId, mailbox, res) => {
|
|
27
|
+
const error = queueFullRefusal(rows(), team, message, cap(), mailbox, ownTask);
|
|
28
|
+
if (error === undefined)
|
|
29
|
+
return false;
|
|
30
|
+
outcomes.recordOutcome(taskId, { status: 'failed', error });
|
|
31
|
+
recordActivity('in', team, caller, false);
|
|
32
|
+
const payload = JSON.stringify({ error, reason: 'target-queue-full', code: WIRE_ERROR_QUEUE_FULL, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED' });
|
|
33
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
34
|
+
res.end(payload);
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -111,10 +111,11 @@ model tools.
|
|
|
111
111
|
- Reusing an id for a different message, or switching `--sync` on or off,
|
|
112
112
|
is refused (exit 1): drop `--task` to send it as a new task.
|
|
113
113
|
- `report` takes `--task` the same way.
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
-
|
|
117
|
-
|
|
114
|
+
- Home-host forwarding is opt-in: `forwardDirect: true` forwards one hop only after a local target miss. H must not be isolated, the CLI must be online and sign a bound envelope, the inbound request must have no Guard, and the target must pass H’s sender scope.
|
|
115
|
+
- Both operators establish links: at A, put the CLI and B receiving member handles in an operator canvas; at B, link the CLI handle and pin its home to A. A remote canvas target requires A to directly link every advertised receiving member; a peer-org claim alone is insufficient for the return receipt.
|
|
116
|
+
- B signs its auto-receipt as the member that did the work. Its destination prefers an online CLI household on that host (an owner-controlled fact); otherwise it returns only to the pinned home, never another directory advertiser. In the normal single-home setup it settles A’s single forwarded outbound row on-chain under `enforce` and enters the CLI mailbox (`a2a-net inbox`). `--sync` returns B’s final reply. A 15s transport-budget expiry is `-32005`; reconcile by task id rather than resend blindly.
|
|
117
|
+
- With forwarding off or any prerequisite missing, the original "No live DSH session node accepts team" error includes the reason it was not forwarded. Receiving hosts never forward the forwarded request again.
|
|
118
|
+
- Admission: a deposited handle is a node fact of its home host (N3); that fact alone grants no remote scope or admission. Operator canvas membership and B’s home pin are still required. Global self-description is never a link.
|
|
118
119
|
- `a2a-net report <message…>` is a one-way report to the host's process team.
|
|
119
120
|
- A message that starts with `-` goes after `--`. Flag values never become
|
|
120
121
|
part of the message.
|
|
@@ -20,10 +20,7 @@
|
|
|
20
20
|
The `a2a-net` CLI (0.6.27+) is the supported exception: a DEPOSITED handle
|
|
21
21
|
is routable on its home host and receives into its mailbox
|
|
22
22
|
([cli.md](cli.md)).
|
|
23
|
-
- **Direct delivery
|
|
24
|
-
the host that receives it; a team on another host answers "No live DSH
|
|
25
|
-
session node accepts team". `a2a_route` resolves peers and forwards;
|
|
26
|
-
direct callers (the CLI included) must target their home host's teams.
|
|
23
|
+
- **"No live DSH session node accepts team" may name a forwarding prerequisite.** Direct delivery remains local by default. `forwardDirect: true` permits one hop only for an online, bound-signed local CLI, on a non-isolated home, with no inbound Guard and a target inside sender scope. A must link B’s receiving members; B must link the CLI and pin A as its home. Remote canvas org alone cannot authorize the receipt return path. Read the appended "not forwarded" reason; do not treat a missing link, expired deposit or already-forwarded request as a transport outage. The target host never forwards again.
|
|
27
24
|
- A sync route to a remote peer rides a 15s transport budget (a target on
|
|
28
25
|
this host gets 180s) — both are shorter than many model turns. For
|
|
29
26
|
anything that runs long, prefer `async: true` and the receipt contract.
|
package/lib/task-auth.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* @module @nelsonlongxiang/dsh-open-a2a-net/task-auth
|
|
14
14
|
*/
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
|
+
import { CLI_HANDLE_RE } from "./cli-deposit.js";
|
|
17
|
+
import { cliHomeOrigin } from "./cli-home.js";
|
|
16
18
|
/** Every rule's basis — the mapping the authorization record stores beside the rule. */
|
|
17
19
|
export const VIA_OF = {
|
|
18
20
|
'scope-off': 'open',
|
|
@@ -22,6 +24,7 @@ export const VIA_OF = {
|
|
|
22
24
|
'canvas-shared': 'canvas-member',
|
|
23
25
|
'remote-ref-samezone': 'canvas-member',
|
|
24
26
|
'remote-ref-foreign': 'canvas-member',
|
|
27
|
+
'remote-ref-cli': 'canvas-member',
|
|
25
28
|
'peer-org': 'peer-org',
|
|
26
29
|
'node-fact': 'joined-session',
|
|
27
30
|
'cli-deposit': 'cli-deposit',
|
|
@@ -209,7 +212,7 @@ export function localCandidateAdmission(r, callerSession, targetTeam) {
|
|
|
209
212
|
return joinedFaceAdmission(r, callerSession, targetTeam) ?? admitted('local-exempt');
|
|
210
213
|
}
|
|
211
214
|
/** Inbound admission for the peer faces (/a2a/direct, /a2a/log): caller_session is the contract. */
|
|
212
|
-
export function callerAdmission(r, caller, targetTeam) {
|
|
215
|
+
export function callerAdmission(r, caller, targetTeam, cliProof) {
|
|
213
216
|
if (!r.teamScopeRouting)
|
|
214
217
|
return admitted('scope-off');
|
|
215
218
|
if (caller === '') {
|
|
@@ -227,6 +230,21 @@ export function callerAdmission(r, caller, targetTeam) {
|
|
|
227
230
|
return admitted('remote-ref-foreign', basis);
|
|
228
231
|
return refused(`caller "${caller}" has no operator-established link on this node — cross-host admission requires a canvas remote membership this host's operator added (N3/N4: the caller's own card claims are self-description, not links). Ask this host's operator to add the member in the A2A workbench, or route through a linked member.`);
|
|
229
232
|
}
|
|
233
|
+
// A foreign CLI handle is never a free-form node fact: its operator link,
|
|
234
|
+
// pinned home, bound signer, and fresh online row must ALL hold.
|
|
235
|
+
if (CLI_HANDLE_RE.test(caller) && !r.cliDeposited(caller)) {
|
|
236
|
+
const basis = r.canvasNames().filter(name => r.canvasRemoteMembersOf(name).includes(caller)).map(name => r.canvasTeamOf(name));
|
|
237
|
+
if (basis.length === 0)
|
|
238
|
+
return refused(`remote CLI ${caller} has no operator-established canvas link`);
|
|
239
|
+
const home = r.cliHomeOf?.(caller);
|
|
240
|
+
if (home === undefined)
|
|
241
|
+
return refused(`remote CLI ${caller} home is not pinned`);
|
|
242
|
+
if (cliProof?.bound !== true || cliHomeOrigin(cliProof.origin) !== home || cliHomeOrigin(cliProof.viaUrl) !== home)
|
|
243
|
+
return refused(`remote CLI ${caller} signer does not match pinned home`);
|
|
244
|
+
if (cliProof.online !== true)
|
|
245
|
+
return refused(`remote CLI ${caller} is offline or home card could not be read`);
|
|
246
|
+
return admitted('remote-ref-cli', basis);
|
|
247
|
+
}
|
|
230
248
|
// Free-form label: only this host's own node facts (V3).
|
|
231
249
|
if (r.joined().includes(caller))
|
|
232
250
|
return admitted('node-fact');
|
|
@@ -241,6 +259,18 @@ export function callerAdmission(r, caller, targetTeam) {
|
|
|
241
259
|
return admitted('cli-deposit');
|
|
242
260
|
return refused(`caller_session "${caller}" matches no node fact on this host (joined session id, live node label, or deposited CLI handle) — free-form caller labels are self-description and are not admissible (N3). Pass caller_session as <zone>/<id8> with an operator-established link.`);
|
|
243
261
|
}
|
|
262
|
+
/** Revalidate only mutable operator authority, not already-verified birth-time identity or liveness. */
|
|
263
|
+
export function recheckRemoteCliAuthorization(r, auth) {
|
|
264
|
+
if (!r.teamScopeRouting)
|
|
265
|
+
return admitted('scope-off');
|
|
266
|
+
const basis = r.canvasNames().filter(name => r.canvasRemoteMembersOf(name).includes(auth.caller)).map(name => r.canvasTeamOf(name));
|
|
267
|
+
if (basis.length === 0)
|
|
268
|
+
return refused(`remote CLI ${auth.caller} has no operator-established canvas link`);
|
|
269
|
+
const home = r.cliHomeOf?.(auth.caller);
|
|
270
|
+
if (home === undefined || home !== cliHomeOrigin(auth.carrier_url))
|
|
271
|
+
return refused(`remote CLI ${auth.caller} pinned home changed`);
|
|
272
|
+
return admitted('remote-ref-cli', basis);
|
|
273
|
+
}
|
|
244
274
|
const sortedUnique = (values) => [...new Set(values)].sort();
|
|
245
275
|
const pairKey = (pair) => JSON.stringify(pair);
|
|
246
276
|
/**
|
|
@@ -265,6 +295,7 @@ export function canonicalAllowlist(inputs) {
|
|
|
265
295
|
roster,
|
|
266
296
|
canvas,
|
|
267
297
|
cli: sortedUnique(inputs.cli),
|
|
298
|
+
...(inputs.cliHome !== undefined && inputs.cliHome.length > 0 ? { cliHome: [...new Map(inputs.cliHome.map(p => [JSON.stringify(p), p])).values()].sort((a, b) => pairKey(a) < pairKey(b) ? -1 : pairKey(a) > pairKey(b) ? 1 : 0) } : {}),
|
|
268
299
|
links: sortedUnique(inputs.links),
|
|
269
300
|
});
|
|
270
301
|
}
|
|
@@ -278,7 +309,7 @@ export const AUTH_BASIS_CAP = 64;
|
|
|
278
309
|
const PROOFS = ['origin-claim', 'claim-mismatch', 'legacy-window', 'unbound', 'cli-identity', 'in-process', 'none'];
|
|
279
310
|
const RULES = Object.keys(VIA_OF);
|
|
280
311
|
/** Rules that admit on shared teams: their basis lists at least one, each once. Every other rule has an empty basis. */
|
|
281
|
-
const SHARED_RULES = ['roster-shared', 'canvas-shared', 'remote-ref-samezone', 'remote-ref-foreign', 'peer-org'];
|
|
312
|
+
const SHARED_RULES = ['roster-shared', 'canvas-shared', 'remote-ref-samezone', 'remote-ref-foreign', 'remote-ref-cli', 'peer-org'];
|
|
282
313
|
/** A record born more than this far in the future is a forgery or a broken clock, not a birth. */
|
|
283
314
|
export const AUTH_CLOCK_SKEW_MS = 24 * 60 * 60 * 1000;
|
|
284
315
|
/** Does a verified claim's signed zone cover the judged caller (the same name, or `<zone>/…`)? */
|
|
@@ -292,6 +323,7 @@ export function buildAuthorization(admission, facts) {
|
|
|
292
323
|
...(facts.claim !== undefined ? { claim: Object.freeze({ caller: facts.claim.caller, origin: facts.claim.origin }) } : {}),
|
|
293
324
|
ingress: facts.ingress,
|
|
294
325
|
via: admission.via,
|
|
326
|
+
...(facts.carrierUrl !== undefined ? { carrier_url: facts.carrierUrl } : {}),
|
|
295
327
|
admission_rule: admission.rule,
|
|
296
328
|
basis: Object.freeze([...admission.basis]),
|
|
297
329
|
allowlist_version: facts.allowlistVersion,
|
|
@@ -317,6 +349,7 @@ export function canonicalAuthorization(auth) {
|
|
|
317
349
|
...(auth.claim !== undefined ? { claim: { caller: auth.claim.caller, origin: auth.claim.origin } } : {}),
|
|
318
350
|
ingress: auth.ingress,
|
|
319
351
|
via: auth.via,
|
|
352
|
+
...(auth.carrier_url !== undefined ? { carrier_url: auth.carrier_url } : {}),
|
|
320
353
|
admission_rule: auth.admission_rule,
|
|
321
354
|
basis: sortedUnique(auth.basis),
|
|
322
355
|
allowlist_version: auth.allowlist_version,
|
|
@@ -335,6 +368,17 @@ export function authorizationRef(auth) {
|
|
|
335
368
|
return createHash('sha256').update(canonicalAuthorization(auth)).digest('hex').slice(0, 16);
|
|
336
369
|
}
|
|
337
370
|
const boundedString = (v, allowEmpty = false) => typeof v === 'string' && (allowEmpty || v !== '') && v.length <= AUTH_STRING_CAP;
|
|
371
|
+
const validCarrierUrl = (v) => {
|
|
372
|
+
if (!boundedString(v))
|
|
373
|
+
return false;
|
|
374
|
+
try {
|
|
375
|
+
const url = new URL(v);
|
|
376
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && url.origin === v;
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
338
382
|
/**
|
|
339
383
|
* Restore-side allowlist for one persisted record (design §2.5): all of it or
|
|
340
384
|
* none of it — a record with any field outside its closed set or bounds, or
|
|
@@ -362,6 +406,8 @@ export function parseAuthorization(raw, now = Date.now()) {
|
|
|
362
406
|
const rule = r.admission_rule;
|
|
363
407
|
if (r.via !== VIA_OF[rule])
|
|
364
408
|
return undefined;
|
|
409
|
+
if (rule === 'remote-ref-cli' ? !validCarrierUrl(r.carrier_url) : r.carrier_url !== undefined)
|
|
410
|
+
return undefined;
|
|
365
411
|
if (!Array.isArray(r.basis) || r.basis.length > AUTH_BASIS_CAP || !r.basis.every(b => boundedString(b)))
|
|
366
412
|
return undefined;
|
|
367
413
|
if (new Set(r.basis).size !== r.basis.length)
|
|
@@ -390,10 +436,13 @@ export function parseAuthorization(raw, now = Date.now()) {
|
|
|
390
436
|
const claimProof = ['origin-claim', 'claim-mismatch', 'unbound', 'cli-identity'].includes(r.proof);
|
|
391
437
|
if ((claim !== undefined) !== claimProof)
|
|
392
438
|
return undefined;
|
|
393
|
-
if (claim !== undefined && r.proof === 'origin-claim' && !claimCovers(r.caller, claim.caller))
|
|
439
|
+
if (claim !== undefined && r.proof === 'origin-claim' && (rule === 'remote-ref-cli' ? claim.origin !== r.carrier_url : !claimCovers(r.caller, claim.caller)))
|
|
440
|
+
return undefined;
|
|
441
|
+
if (rule === 'remote-ref-cli' && (r.proof !== 'origin-claim' || claim === undefined))
|
|
394
442
|
return undefined;
|
|
395
443
|
return buildAuthorization({ ok: true, via: VIA_OF[rule], rule, basis: r.basis }, {
|
|
396
444
|
caller: r.caller, proof: r.proof, ...(claim !== undefined ? { claim } : {}),
|
|
445
|
+
...(rule === 'remote-ref-cli' ? { carrierUrl: r.carrier_url } : {}),
|
|
397
446
|
ingress: r.ingress, allowlistVersion: r.allowlist_version, target: r.target,
|
|
398
447
|
receiptTarget: r.receipt_target, receivedAt: r.received_at, targetBusy: r.target_busy === true,
|
|
399
448
|
});
|
package/lib/task-ledger.js
CHANGED
|
@@ -65,7 +65,7 @@ export const TASK_STALE_TTL_MS = 24 * 60 * 60_000;
|
|
|
65
65
|
const ARCHIVE_ENDS = ['receipt', 'echo', 'abandoned', 'cancelled', 'evicted'];
|
|
66
66
|
/** Summary marker {@link TaskLedger.resolveEcho} writes on echo-settled rows. */
|
|
67
67
|
const ECHO_SUMMARY = '(settled by content echo)';
|
|
68
|
-
const SENDER_KINDS = ['session', 'host', 'bridge'];
|
|
68
|
+
const SENDER_KINDS = ['session', 'host', 'bridge', 'cli'];
|
|
69
69
|
/** Longest sender id kept; anything longer is malformed, not truncated. */
|
|
70
70
|
export const SENDER_ID_CAP = 256;
|
|
71
71
|
/**
|
|
@@ -120,6 +120,7 @@ function withAuth(raw, entry, now) {
|
|
|
120
120
|
/** #139: the routing facts settlement reads, copied between a row and its archive entry. */
|
|
121
121
|
function routingOf(row) {
|
|
122
122
|
return {
|
|
123
|
+
...(row.forwarded === true ? { forwarded: true } : {}),
|
|
123
124
|
...(row.peerKey !== undefined ? { peerKey: row.peerKey } : {}),
|
|
124
125
|
...(row.peerHost !== undefined ? { peerHost: row.peerHost } : {}),
|
|
125
126
|
...(row.deliveredTo !== undefined ? { deliveredTo: row.deliveredTo } : {}),
|
|
@@ -133,6 +134,7 @@ function birthFacts(peer, birth) {
|
|
|
133
134
|
const remote = peer !== 'local' && !peer.startsWith('local:');
|
|
134
135
|
return {
|
|
135
136
|
...(!remote && birth.auth !== undefined && birth.auth.ingress === 'local' ? { auth: birth.auth } : {}),
|
|
137
|
+
...(remote && birth.forwarded === true ? { forwarded: true } : {}),
|
|
136
138
|
...(remote && fact(birth.peerKey) ? { peerKey: birth.peerKey } : {}),
|
|
137
139
|
...(remote && fact(birth.peerHost) ? { peerHost: birth.peerHost } : {}),
|
|
138
140
|
...(fact(birth.deliveredTo) ? { deliveredTo: birth.deliveredTo } : {}),
|
|
@@ -150,6 +152,7 @@ function withRouting(entry) {
|
|
|
150
152
|
const fact = (v) => typeof v === 'string' && v !== '' && v.length <= ROUTING_FACT_CAP;
|
|
151
153
|
const remoteOutbound = entry.direction === 'outbound' && typeof entry.peer === 'string' && entry.peer !== 'local' && !entry.peer.startsWith('local:');
|
|
152
154
|
return {
|
|
155
|
+
...(remoteOutbound && entry.forwarded === true ? { forwarded: true } : {}),
|
|
153
156
|
...(remoteOutbound && fact(entry.peerKey) ? { peerKey: entry.peerKey } : {}),
|
|
154
157
|
...(remoteOutbound && fact(entry.peerHost) ? { peerHost: entry.peerHost } : {}),
|
|
155
158
|
...(fact(entry.deliveredTo) ? { deliveredTo: entry.deliveredTo } : {}),
|