@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.
Files changed (98) hide show
  1. package/README.md +11 -2
  2. package/README_zh.md +10 -1
  3. package/assets/nexusDist/assets/index-BACVZe0j.css +1 -0
  4. package/assets/nexusDist/assets/index-Bbxnpf2p.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +450 -59
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/canvas-store.js +6 -0
  11. package/lib/card.js +4 -2
  12. package/lib/cli-deposit.js +122 -7
  13. package/lib/client.js +64 -64
  14. package/lib/client.js.map +1 -1
  15. package/lib/faces/admin.js +176 -47
  16. package/lib/faces/canvas-notify.js +12 -3
  17. package/lib/faces/files.js +155 -0
  18. package/lib/faces/nodes.js +171 -0
  19. package/lib/faces/session-log.js +162 -0
  20. package/lib/faces/wire.js +212 -43
  21. package/lib/index.js +819 -1725
  22. package/lib/joined-store.js +23 -0
  23. package/lib/layout-store.js +3 -2
  24. package/lib/ledger-conservation.js +97 -0
  25. package/lib/legacy/teams-bridge-inbound.js +156 -0
  26. package/lib/node-bind.js +534 -0
  27. package/lib/node-github.js +72 -0
  28. package/lib/node-self.js +453 -0
  29. package/lib/node-store.js +484 -0
  30. package/lib/node-tasks.js +342 -0
  31. package/lib/origin-claim.js +246 -0
  32. package/lib/peer-directory.js +134 -0
  33. package/lib/receipt.js +26 -1
  34. package/lib/roster-op.js +104 -0
  35. package/lib/skill/SKILL.md +3 -1
  36. package/lib/skill/references/cli.md +83 -4
  37. package/lib/skill/references/collaboration.md +126 -74
  38. package/lib/skill/references/discovery.md +29 -0
  39. package/lib/skill/references/intake.md +107 -0
  40. package/lib/skill/references/router.md +96 -0
  41. package/lib/skill/references/setup.md +2 -1
  42. package/lib/task-auth.js +483 -0
  43. package/lib/task-ledger.js +324 -12
  44. package/lib/team-store.js +73 -2
  45. package/lib/tools/act.js +383 -0
  46. package/lib/tools/files.js +201 -0
  47. package/lib/tools/info.js +684 -0
  48. package/lib/types/a2a-client.d.ts +10 -4
  49. package/lib/types/auth-observer.d.ts +110 -0
  50. package/lib/types/blob-store.d.ts +83 -0
  51. package/lib/types/canvas-store.d.ts +4 -0
  52. package/lib/types/cli-deposit.d.ts +38 -0
  53. package/lib/types/faces/admin.d.ts +15 -2
  54. package/lib/types/faces/canvas-notify.d.ts +4 -1
  55. package/lib/types/faces/files.d.ts +53 -0
  56. package/lib/types/faces/nodes.d.ts +69 -0
  57. package/lib/types/faces/session-log.d.ts +44 -0
  58. package/lib/types/faces/wire.d.ts +51 -5
  59. package/lib/types/index.d.ts +27 -0
  60. package/lib/types/joined-store.d.ts +9 -0
  61. package/lib/types/ledger-conservation.d.ts +77 -0
  62. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  63. package/lib/types/node-bind.d.ts +220 -0
  64. package/lib/types/node-github.d.ts +35 -0
  65. package/lib/types/node-self.d.ts +178 -0
  66. package/lib/types/node-store.d.ts +180 -0
  67. package/lib/types/node-tasks.d.ts +121 -0
  68. package/lib/types/origin-claim.d.ts +119 -0
  69. package/lib/types/peer-directory.d.ts +39 -0
  70. package/lib/types/receipt.d.ts +12 -0
  71. package/lib/types/roster-op.d.ts +21 -0
  72. package/lib/types/task-auth.d.ts +271 -0
  73. package/lib/types/task-ledger.d.ts +143 -6
  74. package/lib/types/team-store.d.ts +16 -0
  75. package/lib/types/tools/act.d.ts +52 -0
  76. package/lib/types/tools/files.d.ts +38 -0
  77. package/lib/types/tools/info.d.ts +102 -0
  78. package/lib/types/types.d.ts +6 -0
  79. package/nexus-stage/src/canvas-ops.ts +3 -2
  80. package/nexus-stage/src/federation.ts +1 -0
  81. package/nexus-stage/src/layout-doc.ts +6 -4
  82. package/nexus-stage/src/main.ts +68 -20
  83. package/nexus-stage/src/planning-frame-hue.ts +13 -0
  84. package/nexus-stage/src/planning-legend.ts +1 -0
  85. package/nexus-stage/src/planning-member-handles.ts +34 -0
  86. package/nexus-stage/src/planning-menus.ts +28 -12
  87. package/nexus-stage/src/planning-mirror.ts +47 -3
  88. package/nexus-stage/src/planning-reachability.ts +28 -0
  89. package/nexus-stage/src/planning-roster.ts +15 -0
  90. package/nexus-stage/src/planning-types.ts +2 -2
  91. package/nexus-stage/src/planning-view.ts +57 -52
  92. package/nexus-stage/src/planning.css +6 -0
  93. package/nexus-stage/src/wb-directory.ts +13 -0
  94. package/nexus-stage/src/workbench.css +3 -0
  95. package/nexus-stage/src/workbench.ts +7 -1
  96. package/package.json +3 -2
  97. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
  98. package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
@@ -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
- id: String(agent.id),
61
- label: sessionLabelOf(agent),
62
- team: sessionTeamOf(agent),
63
- ...nodeMetadataOf(agent),
64
- joined: sessionNodes.has(String(agent.id)),
65
- live: true,
66
- teams: [...teamMemberships.teamsOf(String(agent.id))],
67
- // #19 D3: the session's exposure tier (linked floor /
68
- // fleet opt-up) — readable on the state face.
69
- exposure: exposureStore.tierOf(String(agent.id)),
70
- ...(canvasStore.teamsOf(String(agent.id)).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(String(agent.id))] } : {}),
71
- ...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
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
- ...(canvasStore.teamsOf(id).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(id)] } : {}),
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
- members: canvasStore.membersOf(name).map(id => {
113
- // Dual shape: local members report this zone's join/live
114
- // facts; remote refs are opaque team handles — the state
115
- // face passes them through with remote:true for the
116
- // workbench's member chips and pickers.
117
- if (isRemoteMemberRef(id)) {
118
- return { id, team: id, remote: true };
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
- id,
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 card = d.cardCache.get(url)?.card;
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
- const out = cliDeposits.deposit(card);
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
- payload = JSON.stringify({ ok: true, mail: cliDeposits.readMail(handle) });
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 globalThis.fetch(target, {
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 !== 'create' ? { id: idV !== '' && !isRemoteMemberRef(idV) && (sessionNodes.has(idV) || joinedSessions.has(idV)) ? `${config.team}/${id8(idV)}` : idV } : {}) }),
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 === '' || !memberValid) {
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
- payload = JSON.stringify({ ok: canvasStore.removeMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
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
- for (const url of recipients()) {
42
- void globalThis.fetch(url.replace(/\/$/, '') + '/a2a/canvas-changed', {
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
+ }