@nelsonlongxiang/dsh-open-a2a-net 0.6.31 → 0.6.33
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 +11 -2
- package/README_zh.md +10 -1
- package/assets/nexusDist/assets/index-BACVZe0j.css +1 -0
- package/assets/nexusDist/assets/index-Bbxnpf2p.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +450 -59
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/canvas-store.js +6 -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 +176 -47
- 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 +212 -43
- package/lib/index.js +819 -1725
- package/lib/joined-store.js +23 -0
- package/lib/layout-store.js +3 -2
- 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/roster-op.js +104 -0
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +83 -4
- 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 +73 -2
- 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/canvas-store.d.ts +4 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +15 -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 +51 -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/roster-op.d.ts +21 -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 +16 -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 +3 -2
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/layout-doc.ts +6 -4
- package/nexus-stage/src/main.ts +68 -20
- package/nexus-stage/src/planning-frame-hue.ts +13 -0
- package/nexus-stage/src/planning-legend.ts +1 -0
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-menus.ts +28 -12
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-roster.ts +15 -0
- package/nexus-stage/src/planning-types.ts +2 -2
- package/nexus-stage/src/planning-view.ts +57 -52
- package/nexus-stage/src/planning.css +6 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +7 -1
- package/package.json +3 -2
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
- package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
package/lib/faces/admin.js
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
-
import { isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
|
|
21
|
+
import { canvasNameReserved as reservedCanvasName, isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
|
|
22
22
|
import { refreshPeerCard, registerCanvasNotify } from "./canvas-notify.js";
|
|
23
23
|
import { applyOwnerSettings } from "../owner-settings.js";
|
|
24
|
+
import { rosterOp } from "../roster-op.js";
|
|
24
25
|
import { resolveStageMount } from "../stage-mount.js";
|
|
25
26
|
import { ARCHIVE_CAP, archivedEndedBy } from "../task-ledger.js";
|
|
26
27
|
import { MAX_LAYOUT_BODY_BYTES } from "../transport-caps.js";
|
|
@@ -30,7 +31,7 @@ import { MAX_LAYOUT_BODY_BYTES } from "../transport-caps.js";
|
|
|
30
31
|
* @param d - the captures listed on {@link AdminFaceDeps}.
|
|
31
32
|
*/
|
|
32
33
|
export function registerAdminFaces(webServer, d) {
|
|
33
|
-
const { ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, indexUrl, 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, } = d;
|
|
34
|
+
const { ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, indexUrl, controlRoute, controlTrustedWithoutKey, readJsonBody, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, authorizationView, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, } = d;
|
|
34
35
|
ctx.effect(() => webServer.register({
|
|
35
36
|
kind: 'exact',
|
|
36
37
|
path: '/__dsh_a2a/state',
|
|
@@ -56,20 +57,25 @@ export function registerAdminFaces(webServer, d) {
|
|
|
56
57
|
const tFacts0 = Date.now();
|
|
57
58
|
const sessions = [...liveRoots.values()]
|
|
58
59
|
.filter(agent => isArchived?.(String(agent.id)) !== true)
|
|
59
|
-
.map(agent =>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
.map(agent => {
|
|
61
|
+
// gh#152: canvas teams through the local lens — a handle-stored
|
|
62
|
+
// membership (gh#131) shows here exactly as routing reads it.
|
|
63
|
+
const canvasTeams = canvasTeamsOfSession(String(agent.id));
|
|
64
|
+
return {
|
|
65
|
+
id: String(agent.id),
|
|
66
|
+
label: sessionLabelOf(agent),
|
|
67
|
+
team: sessionTeamOf(agent),
|
|
68
|
+
...nodeMetadataOf(agent),
|
|
69
|
+
joined: sessionNodes.has(String(agent.id)),
|
|
70
|
+
live: true,
|
|
71
|
+
teams: [...teamMemberships.teamsOf(String(agent.id))],
|
|
72
|
+
// #19 D3: the session's exposure tier (linked floor /
|
|
73
|
+
// fleet opt-up) — readable on the state face.
|
|
74
|
+
exposure: exposureStore.tierOf(String(agent.id)),
|
|
75
|
+
...(canvasTeams.length > 0 ? { canvasTeams } : {}),
|
|
76
|
+
...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
73
79
|
// Cold joined sessions: a remembered intent whose Agent is not
|
|
74
80
|
// back yet (host restarted, session not opened). The row carries
|
|
75
81
|
// no facts — the Web client merges the session title from its
|
|
@@ -96,34 +102,52 @@ export function registerAdminFaces(webServer, d) {
|
|
|
96
102
|
joined: true,
|
|
97
103
|
live: false,
|
|
98
104
|
teams: [...teamMemberships.teamsOf(id)],
|
|
99
|
-
|
|
105
|
+
// gh#152: same local lens as the live rows (handle-stored counts).
|
|
106
|
+
...(canvasTeamsOfSession(id).length > 0 ? { canvasTeams: canvasTeamsOfSession(id) } : {}),
|
|
100
107
|
...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
|
|
101
108
|
});
|
|
102
109
|
}
|
|
103
110
|
const body = JSON.stringify({
|
|
104
111
|
nodes: true,
|
|
112
|
+
zone: config.team,
|
|
105
113
|
version: PLUGIN_VERSION,
|
|
114
|
+
// gh#193: a dark host says so — an empty remote list is not an outage.
|
|
115
|
+
...(config.isolated ? { isolated: true } : {}),
|
|
106
116
|
sessions,
|
|
107
117
|
groups: groupStore.list(),
|
|
108
118
|
canvas: {
|
|
109
119
|
teams: canvasStore.list().map(name => ({
|
|
110
120
|
name,
|
|
111
121
|
team: canvasTeamOf(name),
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
// gh#152: members as the local tiers read them (gh#131). One
|
|
123
|
+
// of this host's own handles renders as the local session it
|
|
124
|
+
// names — join/live facts instead of an opaque remote chip — and
|
|
125
|
+
// the two stored shapes of one session collapse to ONE row (the
|
|
126
|
+
// store keeps both). Genuinely remote refs pass through with
|
|
127
|
+
// remote:true for the workbench's member chips and pickers.
|
|
128
|
+
members: (() => {
|
|
129
|
+
const rows = [];
|
|
130
|
+
const seenLocal = new Set();
|
|
131
|
+
for (const id of canvasStore.membersOf(name)) {
|
|
132
|
+
if (isRemoteMemberRef(id)) {
|
|
133
|
+
const localId = localSessionOfHandle(id);
|
|
134
|
+
if (localId !== undefined) {
|
|
135
|
+
if (seenLocal.has(localId))
|
|
136
|
+
continue;
|
|
137
|
+
seenLocal.add(localId);
|
|
138
|
+
rows.push({ id: localId, team: `${config.team}/${id8(localId)}`, joined: sessionNodes.has(localId) || joinedSessions.has(localId), live: sessionNodes.has(localId) });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
rows.push({ id, team: id, remote: true });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (seenLocal.has(id))
|
|
145
|
+
continue;
|
|
146
|
+
seenLocal.add(id);
|
|
147
|
+
rows.push({ id, team: `${config.team}/${id8(id)}`, joined: sessionNodes.has(id) || joinedSessions.has(id), live: sessionNodes.has(id) });
|
|
119
148
|
}
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
team: `${config.team}/${id8(id)}`,
|
|
123
|
-
joined: sessionNodes.has(id) || joinedSessions.has(id),
|
|
124
|
-
live: sessionNodes.has(id),
|
|
125
|
-
};
|
|
126
|
-
}),
|
|
149
|
+
return rows;
|
|
150
|
+
})(),
|
|
127
151
|
})),
|
|
128
152
|
},
|
|
129
153
|
host: lanIp === '' ? {} : { lanIp },
|
|
@@ -168,6 +192,10 @@ export function registerAdminFaces(webServer, d) {
|
|
|
168
192
|
// say nothing about WHAT is owed — supervision cannot tell
|
|
169
193
|
// a nudge from an abandon without it.
|
|
170
194
|
...(task.brief !== undefined ? { brief: task.brief } : {}),
|
|
195
|
+
// #114 S3: the real sender, so supervision buckets by seat, not "home".
|
|
196
|
+
...(task.sender !== undefined ? { sender: task.sender } : {}),
|
|
197
|
+
// #114 S2: the authority an inbound row was born with; a pre-S2 inbound row says so (S4).
|
|
198
|
+
...(task.auth !== undefined ? { auth: task.auth } : task.direction === 'inbound' ? { authLegacy: true } : {}),
|
|
171
199
|
// C-ruling bucket split: a pending row is settleable while
|
|
172
200
|
// it is young, expected, and answerable; past the stale
|
|
173
201
|
// hour it is conversational noise waiting on the dead TTL.
|
|
@@ -178,13 +206,18 @@ export function registerAdminFaces(webServer, d) {
|
|
|
178
206
|
tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
|
|
179
207
|
tasksDead: taskLedger.list()
|
|
180
208
|
.filter(task => task.status === 'dead')
|
|
181
|
-
.map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}) })),
|
|
209
|
+
.map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}), ...(task.sender !== undefined ? { sender: task.sender } : {}), ...(task.auth !== undefined ? { auth: task.auth } : task.direction === 'inbound' ? { authLegacy: true } : {}) })),
|
|
182
210
|
// gh#31: per-task outcomes for rows that LEFT the ledger — the
|
|
183
211
|
// chat drawer used to guess (settled, abandoned or cancelled).
|
|
184
212
|
// Bounded to the archive (≤64), newest first, with the
|
|
185
213
|
// controlled-vocabulary verdict each row settled by.
|
|
186
214
|
// gh#31 follow-up: archive() is already newest first (settles
|
|
187
215
|
// prepend), so the projection keeps that order — the earlier
|
|
216
|
+
// gh#154: the conservation verdict rides the state face — lifetime
|
|
217
|
+
// counters (restart-safe), duplicates checked before the count
|
|
218
|
+
// identity, delta naming any gap. L8 can read this directly instead
|
|
219
|
+
// of cross-round bookkeeping.
|
|
220
|
+
conservation: taskLedger.conservation(),
|
|
188
221
|
// .reverse() served it oldest first. Each row also says HOW it
|
|
189
222
|
// ended (endedBy: abandon and cancel both record outcome
|
|
190
223
|
// 'abandoned', and so does a peer's own 'abandoned' receipt),
|
|
@@ -204,8 +237,33 @@ export function registerAdminFaces(webServer, d) {
|
|
|
204
237
|
...(row.lateReceiptAt !== undefined ? { late: true } : {}),
|
|
205
238
|
...(row.brief !== undefined ? { brief: row.brief } : {}),
|
|
206
239
|
...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
|
|
240
|
+
...(row.sender !== undefined ? { sender: row.sender } : {}),
|
|
241
|
+
...(row.auth !== undefined ? { auth: row.auth } : {}),
|
|
242
|
+
// #114 S4: who settled it and how they stood on the chain.
|
|
243
|
+
...(row.settledBy !== undefined ? { settledBy: row.settledBy } : {}),
|
|
207
244
|
})),
|
|
208
245
|
archivedCount: taskLedger.archive().length,
|
|
246
|
+
// #114 S4: settlement authorization. The team verifier holds
|
|
247
|
+
// `refusalsTotal` (monotonic) to zero; `offChain`, `legacy` and
|
|
248
|
+
// `compat` count the bounded archive window (ARCHIVE_CAP rows), so
|
|
249
|
+
// they are a window, not a lifetime total.
|
|
250
|
+
settlement: (() => {
|
|
251
|
+
const archive = taskLedger.archive();
|
|
252
|
+
const roles = archive.flatMap(row => (row.settledBy !== undefined ? [row.settledBy.chain_role] : []));
|
|
253
|
+
return {
|
|
254
|
+
mode: config.settlementAuth,
|
|
255
|
+
offChain: roles.filter(r => r === 'off-chain').length,
|
|
256
|
+
legacy: roles.filter(r => r === 'legacy').length,
|
|
257
|
+
compat: roles.filter(r => r === 'compat').length,
|
|
258
|
+
refusals: taskLedger.settlementRefusals(),
|
|
259
|
+
// Monotonic: every refusal noted, including repeats folded into one ring entry.
|
|
260
|
+
refusalsTotal: taskLedger.settlementRefusalsTotal(),
|
|
261
|
+
};
|
|
262
|
+
})(),
|
|
263
|
+
// #114 S6: allowlist version history and the affected-task entries
|
|
264
|
+
// (observe mode: a record of what an allowlist change put on another
|
|
265
|
+
// footing — nothing was cancelled).
|
|
266
|
+
authorization: authorizationView(),
|
|
209
267
|
// Ledger density face: the settled archive split by verdict, so
|
|
210
268
|
// the workbench can say WHICH way those rows settled instead of
|
|
211
269
|
// just how many. Additive and bounded (outcome vocabulary is a
|
|
@@ -230,18 +288,31 @@ export function registerAdminFaces(webServer, d) {
|
|
|
230
288
|
// board renders REMOTE team frames from this. Absent until a
|
|
231
289
|
// linked fetch warmed the card.
|
|
232
290
|
peerOrg: peerStore.list().map(url => {
|
|
233
|
-
const
|
|
291
|
+
const entry = d.cardCache.get(url);
|
|
292
|
+
const card = entry?.card;
|
|
234
293
|
const org = card?.canvasOrg;
|
|
294
|
+
// gh#106: a kept card's org is the peer's last known plan, not a live one.
|
|
295
|
+
const unreachable = card !== undefined && entry?.missAt !== undefined ? { unreachable: true } : {};
|
|
235
296
|
return Array.isArray(org)
|
|
236
|
-
? { url, org: org.filter((e) => e !== null && typeof e === 'object')
|
|
297
|
+
? { url, ...unreachable, org: org.filter((e) => e !== null && typeof e === 'object')
|
|
237
298
|
.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') : [] }))
|
|
238
299
|
.filter(e => e.team !== '') }
|
|
239
|
-
: { url, org: [] };
|
|
300
|
+
: { url, ...unreachable, org: [] };
|
|
240
301
|
}),
|
|
241
302
|
// gh#9+gh#10 M1: envelope-absent deliveries during the
|
|
242
303
|
// OriginClaim window — the operator watches this drain to
|
|
243
304
|
// zero before closing the window.
|
|
244
305
|
legacyInbound: d.legacyInboundCount,
|
|
306
|
+
// Accepted unbound (no-audience) OriginClaim envelopes — the
|
|
307
|
+
// drain gauge for closing originClaimUnboundWindow.
|
|
308
|
+
unboundInbound: d.unboundInboundCount,
|
|
309
|
+
// Of those, envelopes whose bound form missed here: the sender
|
|
310
|
+
// dials an origin missing from originClaimAudiences.
|
|
311
|
+
boundMissInbound: d.boundMissInboundCount,
|
|
312
|
+
// gh#113: CLI mailbox loss, all handles — mail the 64-row cap
|
|
313
|
+
// pushed out, and mail cut at 4000 chars. The team verifier
|
|
314
|
+
// holds `dropped` to zero.
|
|
315
|
+
cliMailbox: cliDeposits.mailStats(),
|
|
245
316
|
// F-observability: the boot prewarm's lifecycle — a dead or
|
|
246
317
|
// skipped prewarm is the root shape behind "cold rows pile up
|
|
247
318
|
// after restart" and must be readable, not a console rumor.
|
|
@@ -276,6 +347,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
276
347
|
host: String(row.origin ?? ''),
|
|
277
348
|
...(row.workspace !== undefined && row.workspace !== '' ? { workspace: row.workspace } : {}),
|
|
278
349
|
...(row.legacy === true ? { legacy: true } : {}),
|
|
350
|
+
...(row.unreachable === true ? { unreachable: true } : {}),
|
|
279
351
|
teams: [...(row.teams ?? [])],
|
|
280
352
|
remote: true,
|
|
281
353
|
})),
|
|
@@ -341,13 +413,18 @@ export function registerAdminFaces(webServer, d) {
|
|
|
341
413
|
handler: controlRoute((req, res) => {
|
|
342
414
|
readJsonBody(req, res, (body) => {
|
|
343
415
|
const id = typeof body.id === 'string' ? body.id : '';
|
|
416
|
+
// gh#131: read while the session is still joined — its own handle reads as it only then.
|
|
417
|
+
const ownHandle = id !== '' ? ownCanvasHandleOf(id) : undefined;
|
|
344
418
|
if (id !== '')
|
|
345
419
|
sessionNodes.delete(id);
|
|
346
420
|
joinedSessions.remove(id);
|
|
347
421
|
// Leaving the network leaves every canvas team too: membership
|
|
348
422
|
// without join consent would be a routing backdoor. Team
|
|
349
|
-
// roster declarations die the same way (S2 consistency)
|
|
423
|
+
// roster declarations die the same way (S2 consistency) — and so
|
|
424
|
+
// does a membership a fleet edit stored by the session's handle.
|
|
350
425
|
canvasStore.dropMember(id);
|
|
426
|
+
if (ownHandle !== undefined)
|
|
427
|
+
canvasStore.dropMember(ownHandle);
|
|
351
428
|
teamMemberships.dropSession(id);
|
|
352
429
|
const payload = JSON.stringify({ id });
|
|
353
430
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
@@ -367,7 +444,16 @@ export function registerAdminFaces(webServer, d) {
|
|
|
367
444
|
let payload;
|
|
368
445
|
if (action === 'publish') {
|
|
369
446
|
const card = typeof body.card === 'string' ? body.card : '';
|
|
370
|
-
|
|
447
|
+
// Review of PR #135: a CLI may not be named after this host's zone —
|
|
448
|
+
// its handle would read as one of the zone's own faces.
|
|
449
|
+
const name = (() => { try {
|
|
450
|
+
const team = JSON.parse(card).team;
|
|
451
|
+
return typeof team === 'string' ? team.split('/')[0] : '';
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return '';
|
|
455
|
+
} })();
|
|
456
|
+
const out = name === config.team ? { ok: false, error: `a CLI identity may not be named after this host's zone "${config.team}" — choose another agent name` } : cliDeposits.deposit(card);
|
|
371
457
|
payload = JSON.stringify(out.ok
|
|
372
458
|
? { ok: true, handle: out.handle, expiresAt: out.expiresAt, cardUrl: `${d.ownCardUrl}/a2a/cli/${encodeURIComponent(out.handle)}` }
|
|
373
459
|
: { ok: false, error: out.error });
|
|
@@ -377,7 +463,10 @@ export function registerAdminFaces(webServer, d) {
|
|
|
377
463
|
}
|
|
378
464
|
else if (action === 'inbox') {
|
|
379
465
|
const handle = typeof body.handle === 'string' ? body.handle : '';
|
|
380
|
-
|
|
466
|
+
// gh#113: seq bounds and the loss counter ride beside the rows; after_seq trims to newer rows.
|
|
467
|
+
const after = body.after_seq;
|
|
468
|
+
const box = cliDeposits.readMailbox(handle, typeof after === 'number' && Number.isInteger(after) && after >= 0 ? after : undefined);
|
|
469
|
+
payload = JSON.stringify({ ok: true, ...box });
|
|
381
470
|
}
|
|
382
471
|
else {
|
|
383
472
|
payload = JSON.stringify({ ok: true, deposits: cliDeposits.list().map(row => ({ handle: row.handle, name: row.name, depositedAt: row.depositedAt, expiresAt: row.expiresAt })) });
|
|
@@ -405,8 +494,8 @@ export function registerAdminFaces(webServer, d) {
|
|
|
405
494
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
406
495
|
res.end(payload);
|
|
407
496
|
};
|
|
408
|
-
if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member'].includes(opV) || (opV !== 'create' && nameV === '')) {
|
|
409
|
-
send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member), and name are required' }));
|
|
497
|
+
if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member', 'remove'].includes(opV) || (opV !== 'create' && nameV === '')) {
|
|
498
|
+
send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member | remove), and name are required' }));
|
|
410
499
|
return;
|
|
411
500
|
}
|
|
412
501
|
if (!peerStore.list().includes(peerUrl)) {
|
|
@@ -415,12 +504,12 @@ export function registerAdminFaces(webServer, d) {
|
|
|
415
504
|
}
|
|
416
505
|
try {
|
|
417
506
|
const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
|
|
418
|
-
const reply = await
|
|
507
|
+
const reply = await d.peerFetch(target, {
|
|
419
508
|
method: 'POST',
|
|
420
|
-
headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
|
|
509
|
+
headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST' }) },
|
|
421
510
|
// A local session id means nothing on the peer: send its zone/id8 handle (the workbench
|
|
422
511
|
// drops a local card into a remote frame with its raw session id, which the peer refused).
|
|
423
|
-
body: JSON.stringify({ op: opV, name: nameV, ...(opV
|
|
512
|
+
body: JSON.stringify({ op: opV, name: nameV, ...(opV === 'add-member' || opV === 'remove-member' ? { id: idV !== '' && !isRemoteMemberRef(idV) && (sessionNodes.has(idV) || joinedSessions.has(idV)) ? `${config.team}/${id8(idV)}` : idV } : {}) }),
|
|
424
513
|
signal: AbortSignal.timeout(15_000),
|
|
425
514
|
});
|
|
426
515
|
const raw = await reply.text();
|
|
@@ -446,6 +535,39 @@ export function registerAdminFaces(webServer, d) {
|
|
|
446
535
|
});
|
|
447
536
|
}),
|
|
448
537
|
}), 'a2a: fleet-canvas-op relay route');
|
|
538
|
+
// A roster owner gesture writes the same teams.json declarations as a2a_team_join; it cannot admit remote or unjoined ids.
|
|
539
|
+
ctx.effect(() => webServer.register({
|
|
540
|
+
kind: 'exact', path: '/__dsh_a2a/roster-op',
|
|
541
|
+
handler: controlRoute((req, res) => {
|
|
542
|
+
const send = (payload) => {
|
|
543
|
+
const text = JSON.stringify(payload);
|
|
544
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
|
|
545
|
+
res.end(text);
|
|
546
|
+
};
|
|
547
|
+
if (req.method !== 'POST') {
|
|
548
|
+
send({ ok: false, error: 'POST required' });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
readJsonBody(req, res, body => {
|
|
552
|
+
try {
|
|
553
|
+
send(rosterOp({
|
|
554
|
+
zone: config.team, home, store: teamMemberships, joined: id => joinedSessions.has(id),
|
|
555
|
+
canvasExists: name => canvasStore.hasTeam(name) || canvasStore.hasTeam('roster:' + name),
|
|
556
|
+
allowlist: () => d.ownerAllowlist, setAllowlist: list => { d.ownerAllowlist = list; },
|
|
557
|
+
audit: (action, team, id, ok, reason) => {
|
|
558
|
+
teamAuditRing.push({ at: Date.now(), action, team, id, ok, reason, actor: 'human:owner' });
|
|
559
|
+
if (teamAuditRing.length > 32)
|
|
560
|
+
teamAuditRing.splice(0, teamAuditRing.length - 32);
|
|
561
|
+
},
|
|
562
|
+
}, body));
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
d.logger.warn('roster-op failed: %s', String(error));
|
|
566
|
+
send({ ok: false, error: 'roster operation unavailable' });
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
}),
|
|
570
|
+
}), 'a2a: owner roster route');
|
|
449
571
|
// Owner ruling 2026-10-29 (single owner + mirror): tell linked peers to re-read this host's card on any canvas change.
|
|
450
572
|
registerCanvasNotify(webServer, d);
|
|
451
573
|
// gh#34 owner three-key panel: the remote build-out unlock knobs,
|
|
@@ -561,6 +683,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
561
683
|
// remembered intent): canvas routing only reaches sessions the user
|
|
562
684
|
// put on the network by gesture — no routing backdoor over unjoined
|
|
563
685
|
// sessions.
|
|
686
|
+
const canvasNameReserved = (name) => reservedCanvasName(name, config.team, d.ownerAllowlist, teamMemberships);
|
|
564
687
|
ctx.effect(() => webServer.register({
|
|
565
688
|
kind: 'exact',
|
|
566
689
|
path: '/__dsh_a2a/canvas',
|
|
@@ -570,8 +693,8 @@ export function registerAdminFaces(webServer, d) {
|
|
|
570
693
|
let payload;
|
|
571
694
|
if (action === 'create') {
|
|
572
695
|
const name = typeof body.name === 'string' ? body.name : '';
|
|
573
|
-
const stored = canvasStore.create(name);
|
|
574
|
-
payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, name: stored, teams: canvasStore.list() });
|
|
696
|
+
const stored = canvasNameReserved(name) ? undefined : canvasStore.create(name);
|
|
697
|
+
payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid, roster-reserved name or team cap reached' } : { ok: true, name: stored, teams: canvasStore.list() });
|
|
575
698
|
}
|
|
576
699
|
else if (action === 'remove') {
|
|
577
700
|
const name = typeof body.name === 'string' ? body.name : '';
|
|
@@ -587,7 +710,10 @@ export function registerAdminFaces(webServer, d) {
|
|
|
587
710
|
const memberValid = isRemoteMemberRef(id)
|
|
588
711
|
? validRemoteMemberRef(id)
|
|
589
712
|
: id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
|
|
590
|
-
if (name
|
|
713
|
+
if (!canvasStore.hasTeam(name) && canvasNameReserved(name)) {
|
|
714
|
+
payload = JSON.stringify({ ok: false, error: 'roster reserves this canvas name' });
|
|
715
|
+
}
|
|
716
|
+
else if (name === '' || !memberValid) {
|
|
591
717
|
payload = JSON.stringify({ ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required' });
|
|
592
718
|
}
|
|
593
719
|
else {
|
|
@@ -602,7 +728,10 @@ export function registerAdminFaces(webServer, d) {
|
|
|
602
728
|
payload = JSON.stringify({ ok: false, error: 'name and id are required' });
|
|
603
729
|
}
|
|
604
730
|
else {
|
|
605
|
-
|
|
731
|
+
// gh#131: removing a local session removes its handle-stored slot too.
|
|
732
|
+
const ownHandle = isRemoteMemberRef(id) ? undefined : ownCanvasHandleOf(id);
|
|
733
|
+
const removed = [id, ...(ownHandle !== undefined ? [ownHandle] : [])].map(each => canvasStore.removeMember(name, each)).some(Boolean);
|
|
734
|
+
payload = JSON.stringify({ ok: removed, teams: canvasStore.list(), members: canvasStore.membersOf(name) });
|
|
606
735
|
}
|
|
607
736
|
}
|
|
608
737
|
else {
|
|
@@ -36,12 +36,21 @@ export function registerCanvasNotify(webServer, d) {
|
|
|
36
36
|
// signs it): only a peer that is served the full card ever sees canvasOrg.
|
|
37
37
|
return card !== undefined && callerLinkedToThisHost(card.team, url, card);
|
|
38
38
|
});
|
|
39
|
+
// Who was linked at the last flush. A change can end a link (dissolving
|
|
40
|
+
// the only team that names a peer's handle), and that peer must still
|
|
41
|
+
// hear about it — its next card read is what drops the mirror.
|
|
42
|
+
let lastRecipients = [];
|
|
39
43
|
const flush = () => {
|
|
40
44
|
timer = undefined;
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
const now = recipients();
|
|
46
|
+
const tracked = new Set(peerStore.list()); // an untracked peer gets no more envelopes
|
|
47
|
+
const targets = new Set([...now, ...lastRecipients.filter(url => tracked.has(url))]);
|
|
48
|
+
lastRecipients = now;
|
|
49
|
+
for (const url of targets) {
|
|
50
|
+
const target = url.replace(/\/$/, '') + '/a2a/canvas-changed';
|
|
51
|
+
void d.peerFetch(target, {
|
|
43
52
|
method: 'POST',
|
|
44
|
-
headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
|
|
53
|
+
headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST' }) },
|
|
45
54
|
body: '{}',
|
|
46
55
|
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
47
56
|
}).then(async (reply) => {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { BLOB_FILE_MAX } from "../blob-store.js";
|
|
2
|
+
import { isRootOriginUrl } from "../origin-claim.js";
|
|
3
|
+
import { claimCovers } from "../task-auth.js";
|
|
4
|
+
/** Pulls per proven signer (host zone or CLI handle) in a sliding minute (design §8; the FloodGuard figure). */
|
|
5
|
+
export const BLOB_PULLS_PER_MINUTE = 16;
|
|
6
|
+
/** Distinct signers the pull limiter remembers; a full table refuses new signers rather than forgetting live ones. */
|
|
7
|
+
const PULL_CALLERS_MAX = 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Whether a grant's target lets this proven caller pull (design §3.2). For a HOST signer: the
|
|
10
|
+
* caller's own label; the signer host itself (a grant to the whole zone); one of the signer host's
|
|
11
|
+
* canvas teams (`<signer>/canvas/<name>` — that host answers for its own membership); or a canvas
|
|
12
|
+
* team of THIS host (`<zone>/canvas/<name>`; another host's same-named team is a different team) of
|
|
13
|
+
* which the caller ITSELF is a member. A grant to one session is not another session's, even on the
|
|
14
|
+
* same host (#91 F2: the honest host's tool names the pulling session, so a mistake cannot cross
|
|
15
|
+
* sessions). A CLI handle is an identity, never a zone (review of PR #208 R1): only its own grant.
|
|
16
|
+
* `caller` is already proven by `signer`.
|
|
17
|
+
*/
|
|
18
|
+
export function grantAdmits(to, caller, signer, zone, localCanvasMembers, cli = false) {
|
|
19
|
+
if (to === caller)
|
|
20
|
+
return true;
|
|
21
|
+
if (cli)
|
|
22
|
+
return false;
|
|
23
|
+
if (to === signer)
|
|
24
|
+
return true;
|
|
25
|
+
const own = `${signer}/canvas/`;
|
|
26
|
+
if (to.startsWith(own) && to.length > own.length)
|
|
27
|
+
return true;
|
|
28
|
+
const prefix = `${zone}/canvas/`;
|
|
29
|
+
return to.startsWith(prefix) && to.length > prefix.length && localCanvasMembers(to.slice(prefix.length)).includes(caller);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Register the pull face and the stage face on the shared web server.
|
|
33
|
+
* @param d - the captures listed on {@link FileFaceDeps}.
|
|
34
|
+
*/
|
|
35
|
+
export function registerFileFaces(webServer, d) {
|
|
36
|
+
const { ctx, config, blobStore, canvasStore, verifyOriginClaim, secretEqual, controlRoute, recordActivity } = d;
|
|
37
|
+
const now = d.now ?? (() => Date.now());
|
|
38
|
+
const pulls = new Map();
|
|
39
|
+
const json = (res, status, body) => {
|
|
40
|
+
const payload = JSON.stringify(body);
|
|
41
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
42
|
+
res.end(payload);
|
|
43
|
+
};
|
|
44
|
+
/** One more pull for a proven signer inside the minute, or false when it is over budget (or the table is full). */
|
|
45
|
+
const takePull = (signer) => {
|
|
46
|
+
const at = now();
|
|
47
|
+
for (const [key, times] of pulls)
|
|
48
|
+
if (times.every(t => at - t >= 60_000))
|
|
49
|
+
pulls.delete(key);
|
|
50
|
+
const recent = (pulls.get(signer) ?? []).filter(t => at - t < 60_000);
|
|
51
|
+
if (recent.length >= BLOB_PULLS_PER_MINUTE)
|
|
52
|
+
return false;
|
|
53
|
+
if (!pulls.has(signer) && pulls.size >= PULL_CALLERS_MAX)
|
|
54
|
+
return false;
|
|
55
|
+
pulls.set(signer, [...recent, at]);
|
|
56
|
+
return true;
|
|
57
|
+
};
|
|
58
|
+
ctx.effect(() => webServer.register({
|
|
59
|
+
kind: 'exact',
|
|
60
|
+
path: '/a2a/blob',
|
|
61
|
+
handler: async (req, res) => {
|
|
62
|
+
if (req.method !== 'GET') {
|
|
63
|
+
json(res, 405, { ok: false, error: 'GET is the only method on this face' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (config.apiKey !== '' && !secretEqual(String(req.headers['x-api-key'] ?? ''), config.apiKey)) {
|
|
67
|
+
json(res, 401, { ok: false, error: 'unauthorized: X-API-Key required (apiKey is configured)' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
71
|
+
const sha256 = url.searchParams.get('sha256') ?? '';
|
|
72
|
+
const caller = url.searchParams.get('caller_session') ?? '';
|
|
73
|
+
const refuse = (status, code, error) => {
|
|
74
|
+
recordActivity('in', 'blob', caller === '' ? 'anonymous' : caller, false);
|
|
75
|
+
json(res, status, { ok: false, code, error });
|
|
76
|
+
};
|
|
77
|
+
if (caller === '') {
|
|
78
|
+
refuse(403, 'untrusted-origin', 'caller_session is required');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const claim = await verifyOriginClaim(req, { cli: true });
|
|
82
|
+
if (!claim.ok) {
|
|
83
|
+
refuse(403, 'untrusted-origin', `OriginClaim verification failed (${claim.reason}) — a file is pulled only with a signed envelope`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// A deposited CLI handle proves exactly itself (R1: `H/child` is not H); a host proves its zone.
|
|
87
|
+
const proven = claim.cli === true
|
|
88
|
+
? caller === claim.caller
|
|
89
|
+
: claim.bound === true && isRootOriginUrl(claim.callerUrl) && claimCovers(caller, claim.caller);
|
|
90
|
+
if (!proven) {
|
|
91
|
+
refuse(403, 'untrusted-origin', 'the envelope does not prove this caller (it must be the audience-bound form, signed from a root card URL whose zone covers caller_session)');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const grant = blobStore.list().find(g => g.sha256 === sha256 && grantAdmits(g.to, caller, claim.caller, config.team, name => canvasStore.membersOf(name), claim.cli === true));
|
|
95
|
+
// One answer for "no such blob", "expired" and "not yours": a refusal is no existence oracle.
|
|
96
|
+
if (grant === undefined) {
|
|
97
|
+
refuse(403, 'not-granted', 'no live grant lets this caller pull that file');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Budgeted by the PROVEN signer (a host zone or a CLI handle), never the self-described label (R1).
|
|
101
|
+
if (!takePull(claim.caller)) {
|
|
102
|
+
refuse(429, 'rate-limited', `at most ${String(BLOB_PULLS_PER_MINUTE)} pulls per signer per minute`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const bytes = blobStore.read(grant);
|
|
106
|
+
if (bytes === undefined) {
|
|
107
|
+
refuse(410, 'gone', 'the file is no longer stored on this host');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
recordActivity('in', 'blob', caller, true);
|
|
111
|
+
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': bytes.byteLength, 'X-A2A-Blob-Sha256': grant.sha256, 'X-A2A-Blob-Name': encodeURIComponent(grant.name) });
|
|
112
|
+
res.end(bytes);
|
|
113
|
+
},
|
|
114
|
+
}), 'a2a: file-channel pull face');
|
|
115
|
+
ctx.effect(() => webServer.register({
|
|
116
|
+
kind: 'exact',
|
|
117
|
+
path: '/__dsh_a2a/blob',
|
|
118
|
+
handler: controlRoute((req, res) => {
|
|
119
|
+
if (req.method !== 'POST') {
|
|
120
|
+
json(res, 405, { ok: false, error: 'POST raw bytes with ?to=<team>&name=<file>' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
124
|
+
const to = url.searchParams.get('to') ?? '';
|
|
125
|
+
const name = url.searchParams.get('name') ?? 'file';
|
|
126
|
+
const by = url.searchParams.get('by') ?? 'control';
|
|
127
|
+
const declared = Number(req.headers['content-length'] ?? NaN);
|
|
128
|
+
if (Number.isFinite(declared) && declared > BLOB_FILE_MAX) {
|
|
129
|
+
json(res, 413, { ok: false, code: 'too-large', error: `the file channel takes at most ${String(BLOB_FILE_MAX)} bytes` });
|
|
130
|
+
req.resume();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
let over = false;
|
|
136
|
+
req.on('data', (chunk) => {
|
|
137
|
+
size += chunk.byteLength;
|
|
138
|
+
if (size > BLOB_FILE_MAX) {
|
|
139
|
+
over = true;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!over)
|
|
143
|
+
chunks.push(chunk);
|
|
144
|
+
});
|
|
145
|
+
req.on('end', () => {
|
|
146
|
+
if (over) {
|
|
147
|
+
json(res, 413, { ok: false, code: 'too-large', error: `the file channel takes at most ${String(BLOB_FILE_MAX)} bytes` });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const staged = blobStore.stage(Buffer.concat(chunks), { name, to, by });
|
|
151
|
+
json(res, staged.ok ? 200 : staged.code === 'too-large' ? 413 : staged.code === 'full' ? 507 : 400, { ...staged });
|
|
152
|
+
});
|
|
153
|
+
}),
|
|
154
|
+
}), 'a2a: file-channel stage face');
|
|
155
|
+
}
|