@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/README_zh.md +3 -0
- package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +445 -57
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/card.js +4 -2
- package/lib/cli-deposit.js +122 -7
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +133 -43
- package/lib/faces/canvas-notify.js +12 -3
- package/lib/faces/files.js +155 -0
- package/lib/faces/nodes.js +171 -0
- package/lib/faces/session-log.js +162 -0
- package/lib/faces/wire.js +206 -39
- package/lib/index.js +809 -1720
- package/lib/joined-store.js +23 -0
- package/lib/ledger-conservation.js +97 -0
- package/lib/legacy/teams-bridge-inbound.js +156 -0
- package/lib/node-bind.js +534 -0
- package/lib/node-github.js +72 -0
- package/lib/node-self.js +453 -0
- package/lib/node-store.js +484 -0
- package/lib/node-tasks.js +342 -0
- package/lib/origin-claim.js +246 -0
- package/lib/peer-directory.js +134 -0
- package/lib/receipt.js +26 -1
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +82 -3
- package/lib/skill/references/collaboration.md +146 -73
- package/lib/skill/references/discovery.md +29 -0
- package/lib/skill/references/intake.md +107 -0
- package/lib/skill/references/router.md +96 -0
- package/lib/skill/references/setup.md +2 -1
- package/lib/task-auth.js +483 -0
- package/lib/task-ledger.js +324 -12
- package/lib/team-store.js +24 -1
- package/lib/tools/act.js +383 -0
- package/lib/tools/files.js +201 -0
- package/lib/tools/info.js +684 -0
- package/lib/types/a2a-client.d.ts +10 -4
- package/lib/types/auth-observer.d.ts +110 -0
- package/lib/types/blob-store.d.ts +83 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +14 -2
- package/lib/types/faces/canvas-notify.d.ts +4 -1
- package/lib/types/faces/files.d.ts +53 -0
- package/lib/types/faces/nodes.d.ts +69 -0
- package/lib/types/faces/session-log.d.ts +44 -0
- package/lib/types/faces/wire.d.ts +50 -5
- package/lib/types/index.d.ts +27 -0
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/ledger-conservation.d.ts +77 -0
- package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
- package/lib/types/node-bind.d.ts +220 -0
- package/lib/types/node-github.d.ts +35 -0
- package/lib/types/node-self.d.ts +178 -0
- package/lib/types/node-store.d.ts +180 -0
- package/lib/types/node-tasks.d.ts +121 -0
- package/lib/types/origin-claim.d.ts +119 -0
- package/lib/types/peer-directory.d.ts +39 -0
- package/lib/types/receipt.d.ts +12 -0
- package/lib/types/task-auth.d.ts +271 -0
- package/lib/types/task-ledger.d.ts +143 -6
- package/lib/types/team-store.d.ts +9 -0
- package/lib/types/tools/act.d.ts +52 -0
- package/lib/types/tools/files.d.ts +38 -0
- package/lib/types/tools/info.d.ts +102 -0
- package/lib/types/types.d.ts +6 -0
- package/nexus-stage/src/canvas-ops.ts +17 -2
- package/nexus-stage/src/canvas-wire.ts +27 -19
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +41 -18
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +30 -27
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +5 -1
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
package/lib/faces/admin.js
CHANGED
|
@@ -30,7 +30,7 @@ import { MAX_LAYOUT_BODY_BYTES } from "../transport-caps.js";
|
|
|
30
30
|
* @param d - the captures listed on {@link AdminFaceDeps}.
|
|
31
31
|
*/
|
|
32
32
|
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;
|
|
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, 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
34
|
ctx.effect(() => webServer.register({
|
|
35
35
|
kind: 'exact',
|
|
36
36
|
path: '/__dsh_a2a/state',
|
|
@@ -56,20 +56,25 @@ export function registerAdminFaces(webServer, d) {
|
|
|
56
56
|
const tFacts0 = Date.now();
|
|
57
57
|
const sessions = [...liveRoots.values()]
|
|
58
58
|
.filter(agent => isArchived?.(String(agent.id)) !== true)
|
|
59
|
-
.map(agent =>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
59
|
+
.map(agent => {
|
|
60
|
+
// gh#152: canvas teams through the local lens — a handle-stored
|
|
61
|
+
// membership (gh#131) shows here exactly as routing reads it.
|
|
62
|
+
const canvasTeams = canvasTeamsOfSession(String(agent.id));
|
|
63
|
+
return {
|
|
64
|
+
id: String(agent.id),
|
|
65
|
+
label: sessionLabelOf(agent),
|
|
66
|
+
team: sessionTeamOf(agent),
|
|
67
|
+
...nodeMetadataOf(agent),
|
|
68
|
+
joined: sessionNodes.has(String(agent.id)),
|
|
69
|
+
live: true,
|
|
70
|
+
teams: [...teamMemberships.teamsOf(String(agent.id))],
|
|
71
|
+
// #19 D3: the session's exposure tier (linked floor /
|
|
72
|
+
// fleet opt-up) — readable on the state face.
|
|
73
|
+
exposure: exposureStore.tierOf(String(agent.id)),
|
|
74
|
+
...(canvasTeams.length > 0 ? { canvasTeams } : {}),
|
|
75
|
+
...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
73
78
|
// Cold joined sessions: a remembered intent whose Agent is not
|
|
74
79
|
// back yet (host restarted, session not opened). The row carries
|
|
75
80
|
// no facts — the Web client merges the session title from its
|
|
@@ -96,34 +101,51 @@ export function registerAdminFaces(webServer, d) {
|
|
|
96
101
|
joined: true,
|
|
97
102
|
live: false,
|
|
98
103
|
teams: [...teamMemberships.teamsOf(id)],
|
|
99
|
-
|
|
104
|
+
// gh#152: same local lens as the live rows (handle-stored counts).
|
|
105
|
+
...(canvasTeamsOfSession(id).length > 0 ? { canvasTeams: canvasTeamsOfSession(id) } : {}),
|
|
100
106
|
...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
|
|
101
107
|
});
|
|
102
108
|
}
|
|
103
109
|
const body = JSON.stringify({
|
|
104
110
|
nodes: true,
|
|
105
111
|
version: PLUGIN_VERSION,
|
|
112
|
+
// gh#193: a dark host says so — an empty remote list is not an outage.
|
|
113
|
+
...(config.isolated ? { isolated: true } : {}),
|
|
106
114
|
sessions,
|
|
107
115
|
groups: groupStore.list(),
|
|
108
116
|
canvas: {
|
|
109
117
|
teams: canvasStore.list().map(name => ({
|
|
110
118
|
name,
|
|
111
119
|
team: canvasTeamOf(name),
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
// gh#152: members as the local tiers read them (gh#131). One
|
|
121
|
+
// of this host's own handles renders as the local session it
|
|
122
|
+
// names — join/live facts instead of an opaque remote chip — and
|
|
123
|
+
// the two stored shapes of one session collapse to ONE row (the
|
|
124
|
+
// store keeps both). Genuinely remote refs pass through with
|
|
125
|
+
// remote:true for the workbench's member chips and pickers.
|
|
126
|
+
members: (() => {
|
|
127
|
+
const rows = [];
|
|
128
|
+
const seenLocal = new Set();
|
|
129
|
+
for (const id of canvasStore.membersOf(name)) {
|
|
130
|
+
if (isRemoteMemberRef(id)) {
|
|
131
|
+
const localId = localSessionOfHandle(id);
|
|
132
|
+
if (localId !== undefined) {
|
|
133
|
+
if (seenLocal.has(localId))
|
|
134
|
+
continue;
|
|
135
|
+
seenLocal.add(localId);
|
|
136
|
+
rows.push({ id: localId, team: `${config.team}/${id8(localId)}`, joined: sessionNodes.has(localId) || joinedSessions.has(localId), live: sessionNodes.has(localId) });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
rows.push({ id, team: id, remote: true });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (seenLocal.has(id))
|
|
143
|
+
continue;
|
|
144
|
+
seenLocal.add(id);
|
|
145
|
+
rows.push({ id, team: `${config.team}/${id8(id)}`, joined: sessionNodes.has(id) || joinedSessions.has(id), live: sessionNodes.has(id) });
|
|
119
146
|
}
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
team: `${config.team}/${id8(id)}`,
|
|
123
|
-
joined: sessionNodes.has(id) || joinedSessions.has(id),
|
|
124
|
-
live: sessionNodes.has(id),
|
|
125
|
-
};
|
|
126
|
-
}),
|
|
147
|
+
return rows;
|
|
148
|
+
})(),
|
|
127
149
|
})),
|
|
128
150
|
},
|
|
129
151
|
host: lanIp === '' ? {} : { lanIp },
|
|
@@ -168,6 +190,10 @@ export function registerAdminFaces(webServer, d) {
|
|
|
168
190
|
// say nothing about WHAT is owed — supervision cannot tell
|
|
169
191
|
// a nudge from an abandon without it.
|
|
170
192
|
...(task.brief !== undefined ? { brief: task.brief } : {}),
|
|
193
|
+
// #114 S3: the real sender, so supervision buckets by seat, not "home".
|
|
194
|
+
...(task.sender !== undefined ? { sender: task.sender } : {}),
|
|
195
|
+
// #114 S2: the authority an inbound row was born with; a pre-S2 inbound row says so (S4).
|
|
196
|
+
...(task.auth !== undefined ? { auth: task.auth } : task.direction === 'inbound' ? { authLegacy: true } : {}),
|
|
171
197
|
// C-ruling bucket split: a pending row is settleable while
|
|
172
198
|
// it is young, expected, and answerable; past the stale
|
|
173
199
|
// hour it is conversational noise waiting on the dead TTL.
|
|
@@ -178,13 +204,18 @@ export function registerAdminFaces(webServer, d) {
|
|
|
178
204
|
tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
|
|
179
205
|
tasksDead: taskLedger.list()
|
|
180
206
|
.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 } : {}) })),
|
|
207
|
+
.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
208
|
// gh#31: per-task outcomes for rows that LEFT the ledger — the
|
|
183
209
|
// chat drawer used to guess (settled, abandoned or cancelled).
|
|
184
210
|
// Bounded to the archive (≤64), newest first, with the
|
|
185
211
|
// controlled-vocabulary verdict each row settled by.
|
|
186
212
|
// gh#31 follow-up: archive() is already newest first (settles
|
|
187
213
|
// prepend), so the projection keeps that order — the earlier
|
|
214
|
+
// gh#154: the conservation verdict rides the state face — lifetime
|
|
215
|
+
// counters (restart-safe), duplicates checked before the count
|
|
216
|
+
// identity, delta naming any gap. L8 can read this directly instead
|
|
217
|
+
// of cross-round bookkeeping.
|
|
218
|
+
conservation: taskLedger.conservation(),
|
|
188
219
|
// .reverse() served it oldest first. Each row also says HOW it
|
|
189
220
|
// ended (endedBy: abandon and cancel both record outcome
|
|
190
221
|
// 'abandoned', and so does a peer's own 'abandoned' receipt),
|
|
@@ -204,8 +235,33 @@ export function registerAdminFaces(webServer, d) {
|
|
|
204
235
|
...(row.lateReceiptAt !== undefined ? { late: true } : {}),
|
|
205
236
|
...(row.brief !== undefined ? { brief: row.brief } : {}),
|
|
206
237
|
...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
|
|
238
|
+
...(row.sender !== undefined ? { sender: row.sender } : {}),
|
|
239
|
+
...(row.auth !== undefined ? { auth: row.auth } : {}),
|
|
240
|
+
// #114 S4: who settled it and how they stood on the chain.
|
|
241
|
+
...(row.settledBy !== undefined ? { settledBy: row.settledBy } : {}),
|
|
207
242
|
})),
|
|
208
243
|
archivedCount: taskLedger.archive().length,
|
|
244
|
+
// #114 S4: settlement authorization. The team verifier holds
|
|
245
|
+
// `refusalsTotal` (monotonic) to zero; `offChain`, `legacy` and
|
|
246
|
+
// `compat` count the bounded archive window (ARCHIVE_CAP rows), so
|
|
247
|
+
// they are a window, not a lifetime total.
|
|
248
|
+
settlement: (() => {
|
|
249
|
+
const archive = taskLedger.archive();
|
|
250
|
+
const roles = archive.flatMap(row => (row.settledBy !== undefined ? [row.settledBy.chain_role] : []));
|
|
251
|
+
return {
|
|
252
|
+
mode: config.settlementAuth,
|
|
253
|
+
offChain: roles.filter(r => r === 'off-chain').length,
|
|
254
|
+
legacy: roles.filter(r => r === 'legacy').length,
|
|
255
|
+
compat: roles.filter(r => r === 'compat').length,
|
|
256
|
+
refusals: taskLedger.settlementRefusals(),
|
|
257
|
+
// Monotonic: every refusal noted, including repeats folded into one ring entry.
|
|
258
|
+
refusalsTotal: taskLedger.settlementRefusalsTotal(),
|
|
259
|
+
};
|
|
260
|
+
})(),
|
|
261
|
+
// #114 S6: allowlist version history and the affected-task entries
|
|
262
|
+
// (observe mode: a record of what an allowlist change put on another
|
|
263
|
+
// footing — nothing was cancelled).
|
|
264
|
+
authorization: authorizationView(),
|
|
209
265
|
// Ledger density face: the settled archive split by verdict, so
|
|
210
266
|
// the workbench can say WHICH way those rows settled instead of
|
|
211
267
|
// just how many. Additive and bounded (outcome vocabulary is a
|
|
@@ -230,18 +286,31 @@ export function registerAdminFaces(webServer, d) {
|
|
|
230
286
|
// board renders REMOTE team frames from this. Absent until a
|
|
231
287
|
// linked fetch warmed the card.
|
|
232
288
|
peerOrg: peerStore.list().map(url => {
|
|
233
|
-
const
|
|
289
|
+
const entry = d.cardCache.get(url);
|
|
290
|
+
const card = entry?.card;
|
|
234
291
|
const org = card?.canvasOrg;
|
|
292
|
+
// gh#106: a kept card's org is the peer's last known plan, not a live one.
|
|
293
|
+
const unreachable = card !== undefined && entry?.missAt !== undefined ? { unreachable: true } : {};
|
|
235
294
|
return Array.isArray(org)
|
|
236
|
-
? { url, org: org.filter((e) => e !== null && typeof e === 'object')
|
|
295
|
+
? { url, ...unreachable, org: org.filter((e) => e !== null && typeof e === 'object')
|
|
237
296
|
.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
297
|
.filter(e => e.team !== '') }
|
|
239
|
-
: { url, org: [] };
|
|
298
|
+
: { url, ...unreachable, org: [] };
|
|
240
299
|
}),
|
|
241
300
|
// gh#9+gh#10 M1: envelope-absent deliveries during the
|
|
242
301
|
// OriginClaim window — the operator watches this drain to
|
|
243
302
|
// zero before closing the window.
|
|
244
303
|
legacyInbound: d.legacyInboundCount,
|
|
304
|
+
// Accepted unbound (no-audience) OriginClaim envelopes — the
|
|
305
|
+
// drain gauge for closing originClaimUnboundWindow.
|
|
306
|
+
unboundInbound: d.unboundInboundCount,
|
|
307
|
+
// Of those, envelopes whose bound form missed here: the sender
|
|
308
|
+
// dials an origin missing from originClaimAudiences.
|
|
309
|
+
boundMissInbound: d.boundMissInboundCount,
|
|
310
|
+
// gh#113: CLI mailbox loss, all handles — mail the 64-row cap
|
|
311
|
+
// pushed out, and mail cut at 4000 chars. The team verifier
|
|
312
|
+
// holds `dropped` to zero.
|
|
313
|
+
cliMailbox: cliDeposits.mailStats(),
|
|
245
314
|
// F-observability: the boot prewarm's lifecycle — a dead or
|
|
246
315
|
// skipped prewarm is the root shape behind "cold rows pile up
|
|
247
316
|
// after restart" and must be readable, not a console rumor.
|
|
@@ -276,6 +345,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
276
345
|
host: String(row.origin ?? ''),
|
|
277
346
|
...(row.workspace !== undefined && row.workspace !== '' ? { workspace: row.workspace } : {}),
|
|
278
347
|
...(row.legacy === true ? { legacy: true } : {}),
|
|
348
|
+
...(row.unreachable === true ? { unreachable: true } : {}),
|
|
279
349
|
teams: [...(row.teams ?? [])],
|
|
280
350
|
remote: true,
|
|
281
351
|
})),
|
|
@@ -341,13 +411,18 @@ export function registerAdminFaces(webServer, d) {
|
|
|
341
411
|
handler: controlRoute((req, res) => {
|
|
342
412
|
readJsonBody(req, res, (body) => {
|
|
343
413
|
const id = typeof body.id === 'string' ? body.id : '';
|
|
414
|
+
// gh#131: read while the session is still joined — its own handle reads as it only then.
|
|
415
|
+
const ownHandle = id !== '' ? ownCanvasHandleOf(id) : undefined;
|
|
344
416
|
if (id !== '')
|
|
345
417
|
sessionNodes.delete(id);
|
|
346
418
|
joinedSessions.remove(id);
|
|
347
419
|
// Leaving the network leaves every canvas team too: membership
|
|
348
420
|
// without join consent would be a routing backdoor. Team
|
|
349
|
-
// roster declarations die the same way (S2 consistency)
|
|
421
|
+
// roster declarations die the same way (S2 consistency) — and so
|
|
422
|
+
// does a membership a fleet edit stored by the session's handle.
|
|
350
423
|
canvasStore.dropMember(id);
|
|
424
|
+
if (ownHandle !== undefined)
|
|
425
|
+
canvasStore.dropMember(ownHandle);
|
|
351
426
|
teamMemberships.dropSession(id);
|
|
352
427
|
const payload = JSON.stringify({ id });
|
|
353
428
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
@@ -367,7 +442,16 @@ export function registerAdminFaces(webServer, d) {
|
|
|
367
442
|
let payload;
|
|
368
443
|
if (action === 'publish') {
|
|
369
444
|
const card = typeof body.card === 'string' ? body.card : '';
|
|
370
|
-
|
|
445
|
+
// Review of PR #135: a CLI may not be named after this host's zone —
|
|
446
|
+
// its handle would read as one of the zone's own faces.
|
|
447
|
+
const name = (() => { try {
|
|
448
|
+
const team = JSON.parse(card).team;
|
|
449
|
+
return typeof team === 'string' ? team.split('/')[0] : '';
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return '';
|
|
453
|
+
} })();
|
|
454
|
+
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
455
|
payload = JSON.stringify(out.ok
|
|
372
456
|
? { ok: true, handle: out.handle, expiresAt: out.expiresAt, cardUrl: `${d.ownCardUrl}/a2a/cli/${encodeURIComponent(out.handle)}` }
|
|
373
457
|
: { ok: false, error: out.error });
|
|
@@ -377,7 +461,10 @@ export function registerAdminFaces(webServer, d) {
|
|
|
377
461
|
}
|
|
378
462
|
else if (action === 'inbox') {
|
|
379
463
|
const handle = typeof body.handle === 'string' ? body.handle : '';
|
|
380
|
-
|
|
464
|
+
// gh#113: seq bounds and the loss counter ride beside the rows; after_seq trims to newer rows.
|
|
465
|
+
const after = body.after_seq;
|
|
466
|
+
const box = cliDeposits.readMailbox(handle, typeof after === 'number' && Number.isInteger(after) && after >= 0 ? after : undefined);
|
|
467
|
+
payload = JSON.stringify({ ok: true, ...box });
|
|
381
468
|
}
|
|
382
469
|
else {
|
|
383
470
|
payload = JSON.stringify({ ok: true, deposits: cliDeposits.list().map(row => ({ handle: row.handle, name: row.name, depositedAt: row.depositedAt, expiresAt: row.expiresAt })) });
|
|
@@ -405,8 +492,8 @@ export function registerAdminFaces(webServer, d) {
|
|
|
405
492
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
406
493
|
res.end(payload);
|
|
407
494
|
};
|
|
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' }));
|
|
495
|
+
if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member', 'remove'].includes(opV) || (opV !== 'create' && nameV === '')) {
|
|
496
|
+
send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member | remove), and name are required' }));
|
|
410
497
|
return;
|
|
411
498
|
}
|
|
412
499
|
if (!peerStore.list().includes(peerUrl)) {
|
|
@@ -415,12 +502,12 @@ export function registerAdminFaces(webServer, d) {
|
|
|
415
502
|
}
|
|
416
503
|
try {
|
|
417
504
|
const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
|
|
418
|
-
const reply = await
|
|
505
|
+
const reply = await d.peerFetch(target, {
|
|
419
506
|
method: 'POST',
|
|
420
|
-
headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
|
|
507
|
+
headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST' }) },
|
|
421
508
|
// A local session id means nothing on the peer: send its zone/id8 handle (the workbench
|
|
422
509
|
// 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
|
|
510
|
+
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
511
|
signal: AbortSignal.timeout(15_000),
|
|
425
512
|
});
|
|
426
513
|
const raw = await reply.text();
|
|
@@ -602,7 +689,10 @@ export function registerAdminFaces(webServer, d) {
|
|
|
602
689
|
payload = JSON.stringify({ ok: false, error: 'name and id are required' });
|
|
603
690
|
}
|
|
604
691
|
else {
|
|
605
|
-
|
|
692
|
+
// gh#131: removing a local session removes its handle-stored slot too.
|
|
693
|
+
const ownHandle = isRemoteMemberRef(id) ? undefined : ownCanvasHandleOf(id);
|
|
694
|
+
const removed = [id, ...(ownHandle !== undefined ? [ownHandle] : [])].map(each => canvasStore.removeMember(name, each)).some(Boolean);
|
|
695
|
+
payload = JSON.stringify({ ok: removed, teams: canvasStore.list(), members: canvasStore.membersOf(name) });
|
|
606
696
|
}
|
|
607
697
|
}
|
|
608
698
|
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
|
+
}
|