@nelsonlongxiang/dsh-open-a2a-net 0.6.35 → 0.6.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/README_zh.md +5 -2
- package/assets/nexusDist/assets/index-C7HagaGy.css +1 -0
- package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +1 -1
- package/lib/a2a-client.js +3 -3
- package/lib/client.js +63 -63
- package/lib/faces/admin.js +23 -5
- package/lib/faces/canvas-notify.js +8 -6
- package/lib/faces/wire.js +11 -5
- package/lib/index.js +35 -10
- package/lib/origin-claim.js +30 -7
- package/lib/owner-settings.js +38 -1
- package/lib/peer-link-display.js +15 -0
- package/lib/queue-guard.js +3 -3
- package/lib/skill/references/cli.md +4 -0
- package/lib/skill/references/routing.md +4 -0
- package/lib/skill/references/setup.md +4 -0
- package/lib/task-ledger-rows.js +192 -0
- package/lib/task-ledger.js +11 -189
- package/lib/tools/info.js +7 -3
- package/lib/types/a2a-client.d.ts +1 -0
- package/lib/types/faces/admin.d.ts +1 -0
- package/lib/types/faces/canvas-notify.d.ts +3 -1
- package/lib/types/faces/wire.d.ts +1 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/origin-claim.d.ts +12 -3
- package/lib/types/owner-settings.d.ts +5 -0
- package/lib/types/peer-link-display.d.ts +13 -0
- package/lib/types/queue-guard.d.ts +3 -2
- package/lib/types/task-ledger-rows.d.ts +341 -0
- package/lib/types/task-ledger.d.ts +6 -284
- package/lib/types/tools/info.d.ts +2 -0
- package/lib/types/types.d.ts +2 -1
- package/nexus-stage/scripts/mockhost.py +14 -6
- package/nexus-stage/src/planning-card.ts +5 -3
- package/nexus-stage/src/planning-member-handles.ts +28 -1
- package/nexus-stage/src/planning-menus.ts +12 -5
- package/nexus-stage/src/planning-view.ts +26 -16
- package/nexus-stage/src/seat-name-editor.ts +31 -0
- package/nexus-stage/src/session-drag.ts +64 -0
- package/nexus-stage/src/wb-directory.ts +2 -0
- package/nexus-stage/src/workbench.css +2 -0
- package/nexus-stage/src/workbench.ts +16 -5
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-Du2E6DJL.js +0 -9
- package/assets/nexusDist/assets/index-z33lHtLw.css +0 -1
package/lib/client.js
CHANGED
|
@@ -22,8 +22,8 @@ window.__ModuleLoader__.load({
|
|
|
22
22
|
return n;
|
|
23
23
|
}
|
|
24
24
|
//#endregion
|
|
25
|
-
//#region \0dsh-css:D:\workspace\dsh-open-a2a-net\tmp\release-0.6.
|
|
26
|
-
const css = ".
|
|
25
|
+
//#region \0dsh-css:D:\workspace\dsh-open-a2a-net\tmp\release-0.6.37-d5df179\src\src\client\A2aControl.module.css.mjs
|
|
26
|
+
const css = ".efpdCG_wrap{max-width:100%;display:inline-flex;position:relative}.efpdCG_trigger{align-items:center;gap:4px;display:inline-flex;position:relative}.efpdCG_unreadBadge{text-align:center;color:#fff;background:var(--dsh-accent,#4c8bf5);border-radius:7px;min-width:14px;height:14px;padding:0 3px;font-size:9px;line-height:14px;animation:2s ease-in-out infinite efpdCG_badgePulse;position:absolute;top:-6px;right:-8px}@keyframes efpdCG_badgePulse{0%,to{box-shadow:0 0 #4c8bf573}50%{box-shadow:0 0 0 4px #4c8bf500}}@media (prefers-reduced-motion:reduce){.efpdCG_unreadBadge{animation:none}}.efpdCG_popover{z-index:1100;border:1px solid var(--dsh-border,#2a2a30);background:var(--dsh-surface-raised,#1c1c22);border-radius:10px;flex-direction:column;gap:8px;width:320px;max-width:calc(100vw - 16px);max-height:60vh;padding:12px;display:flex;position:fixed;overflow-y:auto;box-shadow:0 8px 24px #00000059}.efpdCG_title{font-size:13px;font-weight:600}.efpdCG_sectionTitle{border-top:1px solid var(--dsh-border,#2a2a30);text-transform:uppercase;letter-spacing:.04em;opacity:.65;margin-top:4px;padding-top:8px;font-size:11px;font-weight:600}.efpdCG_stageRow{border-top:1px solid var(--dsh-border,#2a2a30);align-items:center;gap:8px;margin-top:4px;padding-top:8px;display:flex}.efpdCG_stageTitle{text-transform:uppercase;letter-spacing:.04em;opacity:.65;font-size:11px;font-weight:600}.efpdCG_stageLinks{gap:6px;display:flex}.efpdCG_stageLink{border:1px solid var(--dsh-border,#2a2a30);color:inherit;border-radius:999px;align-items:center;padding:3px 8px;font-size:11px;text-decoration:none;display:inline-flex}.efpdCG_stageLink:hover,.efpdCG_stageLink:focus-visible{border-color:var(--dsh-accent,#4c8bf5)}.efpdCG_empty{opacity:.7;font-size:12px}.efpdCG_row{border:1px solid #0000;border-radius:8px;align-items:center;gap:10px;padding:8px;display:flex}.efpdCG_joined{border-color:var(--dsh-border,#2a2a30)}.efpdCG_facts{text-align:left;min-width:0;color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;border-radius:6px;flex-direction:column;flex:1;gap:2px;padding:2px 4px;display:flex}.efpdCG_facts:hover{background:var(--dsh-surface-hover,#ffffff0f)}.efpdCG_nameRow{align-items:center;gap:6px;display:flex}.efpdCG_stateDot{border-radius:50%;flex:none;width:7px;height:7px}.efpdCG_stateDot.efpdCG_live{background:var(--dsh-accent,#4c8bf5)}.efpdCG_stateDot.efpdCG_live.efpdCG_joined{background:var(--dsh-success,#3fb96f)}.efpdCG_stateDot.efpdCG_cold{background:var(--dsh-warning,#d9a13c)}.efpdCG_name{text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;overflow:hidden}.efpdCG_description{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.efpdCG_team{opacity:.55;font-size:11px;font-family:var(--dsh-mono,monospace)}.efpdCG_note{opacity:.6;font-size:11px}.efpdCG_searchInput{box-sizing:border-box;border:1px solid var(--dsh-border,#2a2a30);background:var(--dsh-surface,#141419);width:100%;color:inherit;border-radius:6px;padding:5px 8px;font-size:12px}.efpdCG_searchInput:focus{outline:1px solid var(--dsh-accent,#4c8bf5)}.efpdCG_sessionGroup{flex-direction:column;gap:2px;display:flex}.efpdCG_groupHead{color:inherit;font:inherit;opacity:.75;cursor:pointer;text-align:left;background:0 0;border:none;align-items:center;gap:6px;padding:4px 2px;font-size:11px;font-weight:600;display:flex}.efpdCG_groupChevron{transition:transform .12s;display:inline-block}.efpdCG_groupChevronClosed{transform:rotate(-90deg)}.efpdCG_groupTag{text-overflow:ellipsis;white-space:nowrap;border:1px solid var(--dsh-border,#2a2a30);opacity:.8;border-radius:999px;flex:none;max-width:90px;padding:0 6px;font-size:10px;line-height:16px;overflow:hidden}.efpdCG_groupButton{width:24px;height:24px;color:inherit;opacity:.55;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:6px;flex:none;font-size:12px;line-height:1}.efpdCG_groupButton:hover{border-color:var(--dsh-border,#2a2a30);opacity:1}.efpdCG_groupPicker{border:1px dashed var(--dsh-border,#2a2a30);border-radius:8px;flex-wrap:wrap;gap:6px;width:100%;padding:8px;display:flex;position:relative}.efpdCG_groupOption{border:1px solid var(--dsh-border,#2a2a30);color:inherit;cursor:pointer;background:0 0;border-radius:999px;padding:2px 8px;font-size:11px}.efpdCG_groupOptionActive{border-color:var(--dsh-accent,#4c8bf5);color:var(--dsh-accent,#4c8bf5)}.efpdCG_groupNew{align-items:center;gap:6px;width:100%;margin-top:2px;display:flex}.efpdCG_groupInput{border:1px solid var(--dsh-border,#2a2a30);background:var(--dsh-surface,#141419);min-width:0;color:inherit;border-radius:6px;flex:1;padding:4px 8px;font-size:12px}.efpdCG_peerGrid{flex-wrap:wrap;gap:6px;display:flex}.efpdCG_peerChip{border:1px solid var(--dsh-border,#2a2a30);border-radius:999px;align-items:center;gap:6px;padding:3px 8px;font-size:11px;display:inline-flex}.efpdCG_peerHost{text-overflow:ellipsis;white-space:nowrap;max-width:160px;overflow:hidden}.efpdCG_peerScore{font-family:var(--dsh-mono,monospace);opacity:.6}.efpdCG_activityList{flex-direction:column;gap:2px;display:flex}.efpdCG_activityRow{border-radius:4px;align-items:center;gap:8px;padding:2px 4px;font-size:11px;display:flex}.efpdCG_activityRow[data-ok=false]{opacity:.55}.efpdCG_activityDir{flex:none;font-weight:600}.efpdCG_activityDir.efpdCG_in{color:var(--dsh-success,#3fb96f)}.efpdCG_activityDir.efpdCG_out{color:var(--dsh-accent,#4c8bf5)}.efpdCG_activityTeam{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-family:var(--dsh-mono,monospace);opacity:.8;flex:1;overflow:hidden}.efpdCG_activityPeer{text-overflow:ellipsis;white-space:nowrap;opacity:.55;max-width:90px;overflow:hidden}.efpdCG_activityTime{opacity:.5;font-family:var(--dsh-mono,monospace);flex:none}.efpdCG_activityRow[data-jump=true]{cursor:pointer}.efpdCG_activityRow[data-jump=true]:hover{background:var(--dsh-surface-hover,#ffffff0f)}.efpdCG_fresh{animation:1.2s ease-out 3 efpdCG_freshFlash}@keyframes efpdCG_freshFlash{0%{background:#4c8bf538}to{background:0 0}}@media (prefers-reduced-motion:reduce){.efpdCG_fresh{background:#4c8bf51f;animation:none}}.efpdCG_inFlightList{flex-direction:column;gap:2px;margin-bottom:4px;display:flex}.efpdCG_inFlightRow{border:1px dashed var(--dsh-border,#2a2a30);border-radius:4px;align-items:center;gap:8px;padding:3px 4px;font-size:11px;display:flex}.efpdCG_inFlightPulse{background:var(--dsh-accent,#4c8bf5);border-radius:50%;flex:none;width:7px;height:7px;animation:1.1s ease-in-out infinite efpdCG_flightPulse}@keyframes efpdCG_flightPulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.55)}}@media (prefers-reduced-motion:reduce){.efpdCG_inFlightPulse{animation:none}}.efpdCG_versionTag{color:var(--dsh-color-text-tertiary,#888);background:var(--dsh-color-bg-secondary,#8080801f);border-radius:999px;margin-left:auto;padding:1px 6px;font-size:10px;font-weight:500}.efpdCG_inFlightStale{opacity:.55}.efpdCG_canvasCreate{align-items:center;gap:6px;margin-bottom:4px;display:flex}.efpdCG_canvasCreate .efpdCG_groupInput{flex:1;min-width:0}.efpdCG_canvasChip{max-width:100%;color:var(--dsh-color-text-secondary,#aaa);background:var(--dsh-color-bg-secondary,#8080801f);border-radius:999px;align-items:center;gap:4px;margin:2px 4px 2px 0;padding:1px 6px;font-size:11px;display:inline-flex}.efpdCG_canvasChip>span:not([class]){white-space:nowrap;text-overflow:ellipsis;min-width:0;overflow:hidden}.efpdCG_canvasChipRemove{cursor:pointer;min-width:24px;min-height:24px;color:var(--dsh-color-text-tertiary,#888);background:0 0;border:none;padding:2px 5px;font-size:11px;line-height:1}.efpdCG_canvasChipRemove:hover{color:var(--dsh-color-text-primary,#eee)}.efpdCG_canvasChipRemove{transition:color .14s ease-out}@media (prefers-reduced-motion:reduce){.efpdCG_canvasChipRemove{transition:none}}.efpdCG_canvasChipRemove:focus-visible{outline:2px solid var(--dsh-color-primary,#4f8cff);outline-offset:1px;border-radius:4px}.efpdCG_canvasActions{gap:6px;margin-top:4px;display:flex}.efpdCG_inFlightStale .efpdCG_inFlightPulse{opacity:.4;animation:none}.efpdCG_failMark{color:#f87171;flex-shrink:0;font-size:11px}.efpdCG_staleNote{color:var(--dsh-text-muted,#94a3b8b3);padding:4px 8px;font-size:11px}";
|
|
27
27
|
const tagId = "@nelsonlongxiang/dsh-open-a2a-net/A2aControl.module.css";
|
|
28
28
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
29
29
|
const tag = document.createElement("style");
|
|
@@ -33,67 +33,67 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
document.head.appendChild(tag);
|
|
34
34
|
}
|
|
35
35
|
var A2aControl_module_css_default = {
|
|
36
|
-
"activityDir": "
|
|
37
|
-
"activityList": "
|
|
38
|
-
"activityPeer": "
|
|
39
|
-
"activityRow": "
|
|
40
|
-
"activityTeam": "
|
|
41
|
-
"activityTime": "
|
|
42
|
-
"badgePulse": "
|
|
43
|
-
"canvasActions": "
|
|
44
|
-
"canvasChip": "
|
|
45
|
-
"canvasChipRemove": "
|
|
46
|
-
"canvasCreate": "
|
|
47
|
-
"cold": "
|
|
48
|
-
"description": "
|
|
49
|
-
"empty": "
|
|
50
|
-
"facts": "
|
|
51
|
-
"failMark": "
|
|
52
|
-
"flightPulse": "
|
|
53
|
-
"fresh": "
|
|
54
|
-
"freshFlash": "
|
|
55
|
-
"groupButton": "
|
|
56
|
-
"groupChevron": "
|
|
57
|
-
"groupChevronClosed": "
|
|
58
|
-
"groupHead": "
|
|
59
|
-
"groupInput": "
|
|
60
|
-
"groupNew": "
|
|
61
|
-
"groupOption": "
|
|
62
|
-
"groupOptionActive": "
|
|
63
|
-
"groupPicker": "
|
|
64
|
-
"groupTag": "
|
|
65
|
-
"in": "
|
|
66
|
-
"inFlightList": "
|
|
67
|
-
"inFlightPulse": "
|
|
68
|
-
"inFlightRow": "
|
|
69
|
-
"inFlightStale": "
|
|
70
|
-
"joined": "
|
|
71
|
-
"live": "
|
|
72
|
-
"name": "
|
|
73
|
-
"nameRow": "
|
|
74
|
-
"note": "
|
|
75
|
-
"out": "
|
|
76
|
-
"peerChip": "
|
|
77
|
-
"peerGrid": "
|
|
78
|
-
"peerHost": "
|
|
79
|
-
"peerScore": "
|
|
80
|
-
"popover": "
|
|
81
|
-
"row": "
|
|
82
|
-
"searchInput": "
|
|
83
|
-
"sectionTitle": "
|
|
84
|
-
"sessionGroup": "
|
|
85
|
-
"stageLink": "
|
|
86
|
-
"stageLinks": "
|
|
87
|
-
"stageRow": "
|
|
88
|
-
"stageTitle": "
|
|
89
|
-
"staleNote": "
|
|
90
|
-
"stateDot": "
|
|
91
|
-
"team": "
|
|
92
|
-
"title": "
|
|
93
|
-
"trigger": "
|
|
94
|
-
"unreadBadge": "
|
|
95
|
-
"versionTag": "
|
|
96
|
-
"wrap": "
|
|
36
|
+
"activityDir": "efpdCG_activityDir",
|
|
37
|
+
"activityList": "efpdCG_activityList",
|
|
38
|
+
"activityPeer": "efpdCG_activityPeer",
|
|
39
|
+
"activityRow": "efpdCG_activityRow",
|
|
40
|
+
"activityTeam": "efpdCG_activityTeam",
|
|
41
|
+
"activityTime": "efpdCG_activityTime",
|
|
42
|
+
"badgePulse": "efpdCG_badgePulse",
|
|
43
|
+
"canvasActions": "efpdCG_canvasActions",
|
|
44
|
+
"canvasChip": "efpdCG_canvasChip",
|
|
45
|
+
"canvasChipRemove": "efpdCG_canvasChipRemove",
|
|
46
|
+
"canvasCreate": "efpdCG_canvasCreate",
|
|
47
|
+
"cold": "efpdCG_cold",
|
|
48
|
+
"description": "efpdCG_description",
|
|
49
|
+
"empty": "efpdCG_empty",
|
|
50
|
+
"facts": "efpdCG_facts",
|
|
51
|
+
"failMark": "efpdCG_failMark",
|
|
52
|
+
"flightPulse": "efpdCG_flightPulse",
|
|
53
|
+
"fresh": "efpdCG_fresh",
|
|
54
|
+
"freshFlash": "efpdCG_freshFlash",
|
|
55
|
+
"groupButton": "efpdCG_groupButton",
|
|
56
|
+
"groupChevron": "efpdCG_groupChevron",
|
|
57
|
+
"groupChevronClosed": "efpdCG_groupChevronClosed",
|
|
58
|
+
"groupHead": "efpdCG_groupHead",
|
|
59
|
+
"groupInput": "efpdCG_groupInput",
|
|
60
|
+
"groupNew": "efpdCG_groupNew",
|
|
61
|
+
"groupOption": "efpdCG_groupOption",
|
|
62
|
+
"groupOptionActive": "efpdCG_groupOptionActive",
|
|
63
|
+
"groupPicker": "efpdCG_groupPicker",
|
|
64
|
+
"groupTag": "efpdCG_groupTag",
|
|
65
|
+
"in": "efpdCG_in",
|
|
66
|
+
"inFlightList": "efpdCG_inFlightList",
|
|
67
|
+
"inFlightPulse": "efpdCG_inFlightPulse",
|
|
68
|
+
"inFlightRow": "efpdCG_inFlightRow",
|
|
69
|
+
"inFlightStale": "efpdCG_inFlightStale",
|
|
70
|
+
"joined": "efpdCG_joined",
|
|
71
|
+
"live": "efpdCG_live",
|
|
72
|
+
"name": "efpdCG_name",
|
|
73
|
+
"nameRow": "efpdCG_nameRow",
|
|
74
|
+
"note": "efpdCG_note",
|
|
75
|
+
"out": "efpdCG_out",
|
|
76
|
+
"peerChip": "efpdCG_peerChip",
|
|
77
|
+
"peerGrid": "efpdCG_peerGrid",
|
|
78
|
+
"peerHost": "efpdCG_peerHost",
|
|
79
|
+
"peerScore": "efpdCG_peerScore",
|
|
80
|
+
"popover": "efpdCG_popover",
|
|
81
|
+
"row": "efpdCG_row",
|
|
82
|
+
"searchInput": "efpdCG_searchInput",
|
|
83
|
+
"sectionTitle": "efpdCG_sectionTitle",
|
|
84
|
+
"sessionGroup": "efpdCG_sessionGroup",
|
|
85
|
+
"stageLink": "efpdCG_stageLink",
|
|
86
|
+
"stageLinks": "efpdCG_stageLinks",
|
|
87
|
+
"stageRow": "efpdCG_stageRow",
|
|
88
|
+
"stageTitle": "efpdCG_stageTitle",
|
|
89
|
+
"staleNote": "efpdCG_staleNote",
|
|
90
|
+
"stateDot": "efpdCG_stateDot",
|
|
91
|
+
"team": "efpdCG_team",
|
|
92
|
+
"title": "efpdCG_title",
|
|
93
|
+
"trigger": "efpdCG_trigger",
|
|
94
|
+
"unreadBadge": "efpdCG_unreadBadge",
|
|
95
|
+
"versionTag": "efpdCG_versionTag",
|
|
96
|
+
"wrap": "efpdCG_wrap"
|
|
97
97
|
};
|
|
98
98
|
//#endregion
|
|
99
99
|
//#region lib/client/A2aControl.js
|
package/lib/faces/admin.js
CHANGED
|
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
21
|
import { canvasNameReserved as reservedCanvasName, isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
|
|
22
22
|
import { refreshPeerCard, registerCanvasNotify } from "./canvas-notify.js";
|
|
23
23
|
import { CLI_HANDLE_RE } from "../cli-deposit.js";
|
|
24
|
-
import { applyOwnerSettings } from "../owner-settings.js";
|
|
24
|
+
import { applyOwnerSettings, parseSeatNames } from "../owner-settings.js";
|
|
25
25
|
import { rosterOp } from "../roster-op.js";
|
|
26
26
|
import { resolveStageMount } from "../stage-mount.js";
|
|
27
27
|
import { ARCHIVE_CAP, archivedEndedBy } from "../task-ledger.js";
|
|
@@ -112,6 +112,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
112
112
|
sessions.push({
|
|
113
113
|
id,
|
|
114
114
|
label: `${session}-${id8(id)}`,
|
|
115
|
+
...(d.ownerSeatNames[id] !== undefined ? { name: d.ownerSeatNames[id], seatName: d.ownerSeatNames[id] } : {}),
|
|
115
116
|
team: `${config.team}/${id8(id)}`,
|
|
116
117
|
joined: true,
|
|
117
118
|
live: false,
|
|
@@ -202,6 +203,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
202
203
|
.map(task => ({
|
|
203
204
|
taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
|
|
204
205
|
direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
|
|
206
|
+
...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}),
|
|
205
207
|
// The dispatch brief rides every row: a task id and a team
|
|
206
208
|
// say nothing about WHAT is owed — supervision cannot tell
|
|
207
209
|
// a nudge from an abandon without it.
|
|
@@ -220,7 +222,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
220
222
|
tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
|
|
221
223
|
tasksDead: taskLedger.list()
|
|
222
224
|
.filter(task => task.status === 'dead')
|
|
223
|
-
.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 } : {}) })),
|
|
225
|
+
.map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}), ...(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 } : {}) })),
|
|
224
226
|
// gh#31: per-task outcomes for rows that LEFT the ledger — the
|
|
225
227
|
// chat drawer used to guess (settled, abandoned or cancelled).
|
|
226
228
|
// Bounded to the archive (≤64), newest first, with the
|
|
@@ -243,6 +245,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
243
245
|
peer: row.peer,
|
|
244
246
|
startedAt: row.startedAt,
|
|
245
247
|
resolvedAt: row.resolvedAt,
|
|
248
|
+
...(row.answeredAt !== undefined ? { answeredAt: row.answeredAt } : {}),
|
|
246
249
|
outcome: row.outcome ?? 'unclassified',
|
|
247
250
|
endedBy: archivedEndedBy(row),
|
|
248
251
|
...(row.direction !== undefined ? { direction: row.direction } : {}),
|
|
@@ -520,12 +523,13 @@ export function registerAdminFaces(webServer, d) {
|
|
|
520
523
|
}
|
|
521
524
|
try {
|
|
522
525
|
const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
|
|
526
|
+
const 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 } : {}) });
|
|
523
527
|
const reply = await d.peerFetch(target, {
|
|
524
528
|
method: 'POST',
|
|
525
|
-
headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST' }) },
|
|
529
|
+
headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST', body }) },
|
|
526
530
|
// A local session id means nothing on the peer: send its zone/id8 handle (the workbench
|
|
527
531
|
// drops a local card into a remote frame with its raw session id, which the peer refused).
|
|
528
|
-
body
|
|
532
|
+
body,
|
|
529
533
|
signal: AbortSignal.timeout(15_000),
|
|
530
534
|
});
|
|
531
535
|
const raw = await reply.text();
|
|
@@ -600,6 +604,7 @@ export function registerAdminFaces(webServer, d) {
|
|
|
600
604
|
if (req.method === 'GET') {
|
|
601
605
|
const payload = JSON.stringify({
|
|
602
606
|
ok: true,
|
|
607
|
+
seatNames: { ...d.ownerSeatNames },
|
|
603
608
|
teamJoinAllowlist: [...d.ownerAllowlist],
|
|
604
609
|
teamScopeInfo: d.ownerTeamScopeInfo,
|
|
605
610
|
announce: config.announce === true,
|
|
@@ -612,6 +617,17 @@ export function registerAdminFaces(webServer, d) {
|
|
|
612
617
|
readJsonBody(req, res, (rawBody) => {
|
|
613
618
|
const body = rawBody;
|
|
614
619
|
const changes = {};
|
|
620
|
+
if (Object.hasOwn(body, 'seatName') || Object.hasOwn(body, 'seatNames')) {
|
|
621
|
+
const names = body.seatNames;
|
|
622
|
+
const parsedNames = parseSeatNames(names);
|
|
623
|
+
if (Object.hasOwn(body, 'seatName') || parsedNames === undefined) {
|
|
624
|
+
const payload = JSON.stringify({ ok: false, error: 'seatNames must map full local session ids to nonempty display names (max 64 characters, no controls); clear by omitting the id' });
|
|
625
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
626
|
+
res.end(payload);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
changes.seatNames = parsedNames;
|
|
630
|
+
}
|
|
615
631
|
if (Array.isArray(body.teamJoinAllowlist)) {
|
|
616
632
|
const list = body.teamJoinAllowlist.filter((name) => typeof name === 'string' && name !== '' && name.length <= 128);
|
|
617
633
|
changes.teamJoinAllowlist = [...new Set(list)];
|
|
@@ -632,7 +648,9 @@ export function registerAdminFaces(webServer, d) {
|
|
|
632
648
|
const { yaml, restartNeeded } = applyOwnerSettings(current, changes);
|
|
633
649
|
mkdirSync(home, { recursive: true });
|
|
634
650
|
writeFileSync(patchPath, yaml, 'utf8');
|
|
635
|
-
// Hot-apply the
|
|
651
|
+
// Hot-apply the call-time owner keys.
|
|
652
|
+
if (changes.seatNames !== undefined)
|
|
653
|
+
d.ownerSeatNames = changes.seatNames;
|
|
636
654
|
if (changes.teamJoinAllowlist !== undefined)
|
|
637
655
|
d.ownerAllowlist = [...changes.teamJoinAllowlist];
|
|
638
656
|
if (changes.teamScopeInfo !== undefined)
|
|
@@ -50,7 +50,7 @@ export function registerCanvasNotify(webServer, d) {
|
|
|
50
50
|
const target = url.replace(/\/$/, '') + '/a2a/canvas-changed';
|
|
51
51
|
void d.peerFetch(target, {
|
|
52
52
|
method: 'POST',
|
|
53
|
-
headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST' }) },
|
|
53
|
+
headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST', body: '{}' }) },
|
|
54
54
|
body: '{}',
|
|
55
55
|
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
56
56
|
}).then(async (reply) => {
|
|
@@ -95,11 +95,13 @@ export function registerCanvasNotify(webServer, d) {
|
|
|
95
95
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
|
|
96
96
|
res.end(text);
|
|
97
97
|
};
|
|
98
|
-
//
|
|
99
|
-
// and cut off anything larger.
|
|
98
|
+
// Retain only bounded raw ping bytes for Sig3 verification; cut off oversized uploads.
|
|
100
99
|
let size = 0;
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
const chunks = [];
|
|
101
|
+
req.on('data', (chunk) => { size += chunk.length; if (size > 4 * 1024) {
|
|
102
|
+
req.destroy();
|
|
103
|
+
return;
|
|
104
|
+
} chunks.push(chunk); });
|
|
103
105
|
req.on('end', () => {
|
|
104
106
|
void (async () => {
|
|
105
107
|
if (req.method !== 'POST') {
|
|
@@ -115,7 +117,7 @@ export function registerCanvasNotify(webServer, d) {
|
|
|
115
117
|
send({ ok: false, error: 'caller is not a tracked peer of this host' });
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
118
|
-
const claim = await verifyOriginClaim(req);
|
|
120
|
+
const claim = await verifyOriginClaim(req, { rawBody: Buffer.concat(chunks) });
|
|
119
121
|
if (!claim.ok) {
|
|
120
122
|
send({ ok: false, error: `untrusted-origin: OriginClaim verification failed (${claim.reason})` });
|
|
121
123
|
return;
|
package/lib/faces/wire.js
CHANGED
|
@@ -111,8 +111,13 @@ export function registerWireFaces(webServer, d) {
|
|
|
111
111
|
req.on('end', () => {
|
|
112
112
|
void (async () => {
|
|
113
113
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
if (size > 16 * 1024) {
|
|
115
|
+
send(413, JSON.stringify({ ok: false, error: 'payload too large' }));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const rawBody = Buffer.concat(chunks);
|
|
119
|
+
const body = JSON.parse(rawBody.toString('utf8'));
|
|
120
|
+
const claim = await verifyOriginClaim(req, { rawBody });
|
|
116
121
|
if (!claim.ok) {
|
|
117
122
|
logger.warn(`a2a: canvas-op claim failed (${claim.reason}) — caller=${String(req.headers['x-a2a-caller'] ?? '')} url=${String(req.headers['x-a2a-caller-url'] ?? '')} ts=${String(req.headers['x-a2a-caller-ts'] ?? '')}`);
|
|
118
123
|
send(200, JSON.stringify({ ok: false, error: `untrusted-origin: OriginClaim verification failed (${claim.reason}) — canvas regulation requires a signed envelope`, code: -32000 }));
|
|
@@ -402,9 +407,10 @@ export function registerWireFaces(webServer, d) {
|
|
|
402
407
|
}
|
|
403
408
|
return;
|
|
404
409
|
}
|
|
410
|
+
const rawBody = Buffer.concat(chunks);
|
|
405
411
|
let body;
|
|
406
412
|
try {
|
|
407
|
-
body = JSON.parse(
|
|
413
|
+
body = JSON.parse(rawBody.toString('utf8'));
|
|
408
414
|
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
|
409
415
|
throw new TypeError('direct body must be a JSON object'); // #168: `null` read body.team in this async 'end' callback → unhandled rejection → host exit
|
|
410
416
|
}
|
|
@@ -474,7 +480,7 @@ export function registerWireFaces(webServer, d) {
|
|
|
474
480
|
// F1: only a FOREIGN CLI handle needs a bound host claim and live home card
|
|
475
481
|
// before admission. Every other caller keeps the existing admission-first order.
|
|
476
482
|
const foreignCli = config.teamScopeRouting && CLI_HANDLE_RE.test(caller) && cliDeposits.get(caller) === undefined;
|
|
477
|
-
const earlyClaim = foreignCli ? await verifyOriginClaim(req, { cli: true }) : undefined;
|
|
483
|
+
const earlyClaim = foreignCli ? await verifyOriginClaim(req, { cli: true, rawBody }) : undefined;
|
|
478
484
|
const cliProof = foreignCli && earlyClaim?.ok ? await remoteCliProof(caller, earlyClaim).catch(() => undefined) : undefined;
|
|
479
485
|
const admission = a2aCallerAdmission(caller, team, cliProof);
|
|
480
486
|
const callerRefusal = refusalOf(admission);
|
|
@@ -496,7 +502,7 @@ export function registerWireFaces(webServer, d) {
|
|
|
496
502
|
// after the window closes it rejects like a failed signature —
|
|
497
503
|
// N3 proves a NAME's linkability, never key possession.
|
|
498
504
|
// gh#133: the delivery face is where a deposited CLI identity signs its routes.
|
|
499
|
-
const claim = earlyClaim ?? await verifyOriginClaim(req, { cli: true });
|
|
505
|
+
const claim = earlyClaim ?? await verifyOriginClaim(req, { cli: true, rawBody });
|
|
500
506
|
if (!claim.ok) {
|
|
501
507
|
if (claim.reason !== 'absent' || config.originClaimWindow === false) {
|
|
502
508
|
recordActivity('in', team, caller, false);
|
package/lib/index.js
CHANGED
|
@@ -31,8 +31,10 @@ import { GroupStore } from "./group-store.js";
|
|
|
31
31
|
import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
|
|
32
32
|
import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
|
|
33
33
|
import { LayoutStore } from "./layout-store.js";
|
|
34
|
+
import { peerLinkDisplay } from "./peer-link-display.js";
|
|
34
35
|
import { JoinedSessions } from "./joined-store.js";
|
|
35
36
|
import { ExposureStore } from "./exposure-store.js";
|
|
37
|
+
import { loadSeatNames } from "./owner-settings.js";
|
|
36
38
|
import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
|
|
37
39
|
import { cliHomeChoices, decideCliHome } from "./cli-home.js";
|
|
38
40
|
import { outboundScope } from "./outbound-scope.js";
|
|
@@ -111,6 +113,7 @@ export const Config = s.object({
|
|
|
111
113
|
* refused join carries guidance instead of failing silently.
|
|
112
114
|
*/
|
|
113
115
|
teamJoinAllowlist: s.array(s.string()).default([]),
|
|
116
|
+
seatNames: s.dict(s.any()).default({}),
|
|
114
117
|
/**
|
|
115
118
|
* S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
|
|
116
119
|
* caller↔target shared team membership before addressing — a session
|
|
@@ -570,7 +573,9 @@ export function apply(ctx, config) {
|
|
|
570
573
|
* request) and the relay/notify faces.
|
|
571
574
|
*/
|
|
572
575
|
function originClaimHeaders(target) {
|
|
573
|
-
|
|
576
|
+
// No local web/card anchor yet: keep the preexisting absent-envelope migration posture, not a malformed Sig3.
|
|
577
|
+
const signedTarget = ownCardUrl === '' ? { url: target.url, method: target.method } : target;
|
|
578
|
+
return signOriginClaim(signedTarget, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
|
|
574
579
|
}
|
|
575
580
|
const client = new A2aClient({
|
|
576
581
|
apiKey: config.apiKey,
|
|
@@ -598,6 +603,8 @@ export function apply(ctx, config) {
|
|
|
598
603
|
// are read at call time, so the panel's write applies WITHOUT a restart;
|
|
599
604
|
// announce is boot-time publishing (restart pending flag for the UI).
|
|
600
605
|
let ownerAllowlist = [...(config.teamJoinAllowlist ?? [])];
|
|
606
|
+
let ownerSeatNames = loadSeatNames(config.seatNames ?? {}, reason => logger.warn(reason));
|
|
607
|
+
ctx.tools.guard(exec => exec.name.startsWith('a2a_') && exec.arguments !== null && typeof exec.arguments === 'object' && (Object.hasOwn(exec.arguments, 'seatName') || Object.hasOwn(exec.arguments, 'seatNames')) ? 'seatName is human-owned; use the workbench owner settings, not model tools' : undefined);
|
|
601
608
|
// Truthiness matches the ORIGINAL read site (`if (config.teamScopeInfo)`):
|
|
602
609
|
// literal compositions without the schema default carry undefined, which
|
|
603
610
|
// reads as the OPEN plane — `!== false` would silently scope them.
|
|
@@ -1253,14 +1260,15 @@ export function apply(ctx, config) {
|
|
|
1253
1260
|
const LEGACY_PREWARM_PROBE_TITLE = '[prewarm] 请回复 ok。';
|
|
1254
1261
|
function nodeMetadataOf(agent) {
|
|
1255
1262
|
const title = sessionTitleOf(agent);
|
|
1256
|
-
const cwd = agent.session
|
|
1263
|
+
const cwd = agent.session?.header?.cwd;
|
|
1257
1264
|
// Display-layer fallback (pure presentation, zero data mutation): a title
|
|
1258
1265
|
// that IS the legacy probe reads as machine junk in every directory and
|
|
1259
1266
|
// card — surface the stable seat label instead. The session-title
|
|
1260
1267
|
// service's stored value is untouched.
|
|
1261
1268
|
const displayTitle = title !== undefined && title !== '' && title !== LEGACY_PREWARM_PROBE_TITLE ? title : undefined;
|
|
1262
1269
|
return {
|
|
1263
|
-
name: displayTitle !== undefined ? displayTitle : sessionLabelOf(agent),
|
|
1270
|
+
name: ownerSeatNames[String(agent.id)] ?? (displayTitle !== undefined ? displayTitle : sessionLabelOf(agent)),
|
|
1271
|
+
...(ownerSeatNames[String(agent.id)] !== undefined ? { seatName: ownerSeatNames[String(agent.id)] } : {}),
|
|
1264
1272
|
description: recentActivityOf(agent),
|
|
1265
1273
|
...(cwd !== undefined && cwd !== '' ? { workspace: cwd } : {}),
|
|
1266
1274
|
};
|
|
@@ -1659,6 +1667,8 @@ export function apply(ctx, config) {
|
|
|
1659
1667
|
get unboundInboundCount() { return unboundInboundCount; },
|
|
1660
1668
|
get boundMissInboundCount() { return boundMissInboundCount; },
|
|
1661
1669
|
get ownCardUrl() { return ownCardUrl; },
|
|
1670
|
+
get ownerSeatNames() { return ownerSeatNames; },
|
|
1671
|
+
set ownerSeatNames(v) { ownerSeatNames = v; },
|
|
1662
1672
|
get ownerAllowlist() { return ownerAllowlist; },
|
|
1663
1673
|
set ownerAllowlist(v) { ownerAllowlist = v; },
|
|
1664
1674
|
get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
|
|
@@ -1776,7 +1786,7 @@ export function apply(ctx, config) {
|
|
|
1776
1786
|
...[...sessionNodes.values()].filter(isJoinedNode).map(agent => exposureRow(sessionTeamOf(agent), nodeMetadataOf(agent))),
|
|
1777
1787
|
...joinedSessions.list()
|
|
1778
1788
|
.filter(id => !liveRoots.has(id) && isArchived?.(id) !== true)
|
|
1779
|
-
.map(id => exposureRow(`${config.team}/${id8(id)}`, { name: `${session}-${id8(id)}`, description: 'cold — not loaded; routing here wakes the session' })),
|
|
1789
|
+
.map(id => exposureRow(`${config.team}/${id8(id)}`, { name: ownerSeatNames[id] ?? `${session}-${id8(id)}`, ...(ownerSeatNames[id] !== undefined ? { seatName: ownerSeatNames[id] } : {}), description: 'cold — not loaded; routing here wakes the session' })),
|
|
1780
1790
|
// gh#45/47: deposited CLI identities publish as directory rows —
|
|
1781
1791
|
// the fleet sees them as member cards (draggable = the operator
|
|
1782
1792
|
// link). Their "zone" is the agent-name. gh#47: an EXPIRED
|
|
@@ -2025,6 +2035,7 @@ export function apply(ctx, config) {
|
|
|
2025
2035
|
// placeholder as one on the same-node loop-back.
|
|
2026
2036
|
if (placeholder === true)
|
|
2027
2037
|
return;
|
|
2038
|
+
taskLedger.markAnswered(taskId, sessionTeamOf(target));
|
|
2028
2039
|
// Idempotency: a row THIS ledger tracks must still be pending
|
|
2029
2040
|
// (settled/dead rows never re-send). A foreign task id (an inbound
|
|
2030
2041
|
// route the caller tracks on ITS node) has no local row — the final
|
|
@@ -2223,6 +2234,7 @@ export function apply(ctx, config) {
|
|
|
2223
2234
|
loopbackArrival: (config.originClaimUrl ?? '') === '',
|
|
2224
2235
|
now: Date.now(),
|
|
2225
2236
|
...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
|
|
2237
|
+
...(options?.rawBody !== undefined ? { rawBody: options.rawBody } : {}),
|
|
2226
2238
|
});
|
|
2227
2239
|
}
|
|
2228
2240
|
/**
|
|
@@ -2816,10 +2828,10 @@ export function apply(ctx, config) {
|
|
|
2816
2828
|
}
|
|
2817
2829
|
/**
|
|
2818
2830
|
* gh#20 W-A: THIS host's view of one tracked peer's link state (the
|
|
2819
|
-
*
|
|
2820
|
-
* · linked — bilateral seeding
|
|
2821
|
-
*
|
|
2822
|
-
*
|
|
2831
|
+
* display projection, not an admission decision:
|
|
2832
|
+
* · linked — bilateral URL seeding, or a canvas reference to an exact
|
|
2833
|
+
* nonlocal session handle uniquely advertised by that peer card key;
|
|
2834
|
+
* a shared zone or a minimal card alone never proves host identity;
|
|
2823
2835
|
* · stranger — no operator-established basis (a verified key is not a
|
|
2824
2836
|
* relationship);
|
|
2825
2837
|
* · verified — we hold a signature-valid cached card (the badge's
|
|
@@ -2834,6 +2846,15 @@ export function apply(ctx, config) {
|
|
|
2834
2846
|
* Pure over warm caches and config; never fetches.
|
|
2835
2847
|
*/
|
|
2836
2848
|
function peerLinkState(peerUrl) {
|
|
2849
|
+
return peerLinkDisplay(peerUrl, {
|
|
2850
|
+
card: cardCache.get(peerUrl)?.card, ownUrl: ownCardUrl, seeds: config.peers,
|
|
2851
|
+
refs: canvasStore.list().flatMap(name => [...canvasStore.remoteMembersOf(name)]),
|
|
2852
|
+
ownHandles: new Set([...joinedSessions.list(), ...liveRoots.keys()].map(id => `${config.team}/${id8(id)}`)),
|
|
2853
|
+
cards: [...cardCache.values()].flatMap(entry => entry.card === undefined ? [] : [entry.card]),
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
// Compatibility for existing CLI home/admission semantics: badge projection must not change authorization.
|
|
2857
|
+
function peerAdmissionLinkState(peerUrl) {
|
|
2837
2858
|
const cached = cardCache.get(peerUrl)?.card;
|
|
2838
2859
|
const verified = cached !== undefined;
|
|
2839
2860
|
const zone = cached?.team ?? '';
|
|
@@ -2900,7 +2921,7 @@ export function apply(ctx, config) {
|
|
|
2900
2921
|
const card = await fetchPeerCard(url).catch(() => undefined);
|
|
2901
2922
|
if (card !== undefined)
|
|
2902
2923
|
cardCache.set(url, { at: Date.now(), card });
|
|
2903
|
-
return { url, card, linked: card === undefined ||
|
|
2924
|
+
return { url, card, linked: card === undefined || peerAdmissionLinkState(url).link === 'linked' };
|
|
2904
2925
|
});
|
|
2905
2926
|
}
|
|
2906
2927
|
async function cliHomeForAdd(handle, requested) {
|
|
@@ -2934,7 +2955,7 @@ export function apply(ctx, config) {
|
|
|
2934
2955
|
if (card === undefined || card.publicKey !== claim.card.publicKey)
|
|
2935
2956
|
continue;
|
|
2936
2957
|
cardCache.set(url, { at: Date.now(), card });
|
|
2937
|
-
if (
|
|
2958
|
+
if (peerAdmissionLinkState(url).link !== 'linked')
|
|
2938
2959
|
continue;
|
|
2939
2960
|
const row = card.sessionTeams?.find(item => item.team === handle);
|
|
2940
2961
|
if (row !== undefined)
|
|
@@ -3317,6 +3338,10 @@ export function apply(ctx, config) {
|
|
|
3317
3338
|
// node team when it has joined, else the node label), so the receiver
|
|
3318
3339
|
// can answer with one a2a_route instead of an unroutable display label.
|
|
3319
3340
|
a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
|
|
3341
|
+
// A target-authored receipt attempt answers its own work even if scope/transport later refuses delivery.
|
|
3342
|
+
const answer = parseReceipt(args.message);
|
|
3343
|
+
if (agent !== undefined && answer !== null)
|
|
3344
|
+
taskLedger.markAnswered(answer.taskId, sessionTeamOf(agent));
|
|
3320
3345
|
// S3 (team-scoped routing, phase-2 default ON per the 2026-09-01 owner
|
|
3321
3346
|
// ruling; set false to restore the open outbound seam): a session
|
|
3322
3347
|
// caller must have declared membership in the target team before it
|
package/lib/origin-claim.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* face (card, /a2a/direct, /a2a/log, /a2a/canvas-op, /a2a/canvas-changed)
|
|
5
5
|
* runs.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Three canonical forms share the base caller headers
|
|
8
8
|
* (X-A2A-Caller / -Url / -Ts):
|
|
9
9
|
* · bound (v2, §2.1): JSON.stringify([tag, caller, callerUrl, ts,
|
|
10
10
|
* audience, METHOD, path]) in X-A2A-Caller-Sig2, the audience origin in
|
|
@@ -14,18 +14,22 @@
|
|
|
14
14
|
* · unbound (v1, legacy): JSON.stringify([caller, callerUrl, ts]) in
|
|
15
15
|
* X-A2A-Caller-Sig. Emitted and accepted only while the unbound window
|
|
16
16
|
* is open (originClaimUnboundWindow); accepted ones are counted.
|
|
17
|
-
*
|
|
17
|
+
* A present Sig3 requires the exact raw body and v3 signature, with no downgrade.
|
|
18
|
+
* Without Sig3, once the window closes a present Sig2 is the only thing verified. While
|
|
18
19
|
* it is open, a Sig2 that misses (audience or signature) falls back to the
|
|
19
20
|
* Sig the envelope also carries — no more than stripping Sig2 would give —
|
|
20
21
|
* and the miss is counted, so a peer dialled by an unlisted name keeps
|
|
21
22
|
* working through the rollout and shows on the gauge.
|
|
22
23
|
*/
|
|
23
|
-
import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
24
|
+
import { createHash, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
24
25
|
import { verifyCard } from "./card.js";
|
|
25
26
|
/** ts skew tolerance (protocol constant, §7). */
|
|
26
27
|
export const ORIGIN_CLAIM_SKEW_MS = 5 * 60_000;
|
|
27
28
|
/** Domain tag of the bound form: a v1 signature can never verify as v2. */
|
|
28
29
|
export const ORIGIN_CLAIM_BOUND_TAG = 'a2a-origin-claim/2';
|
|
30
|
+
export const ORIGIN_CLAIM_BODY_TAG = 'a2a-origin-claim/3';
|
|
31
|
+
/** Digest the exact UTF-8 string/bytes passed to transport, never reserialized JSON. */
|
|
32
|
+
export const originBodySha256 = (body) => createHash('sha256').update(body).digest('base64url');
|
|
29
33
|
/** Only the canonical origin root can claim that origin's identity. */
|
|
30
34
|
export function isRootOriginUrl(value) {
|
|
31
35
|
try {
|
|
@@ -62,6 +66,10 @@ export function unboundCanonical(caller, callerUrl, ts) {
|
|
|
62
66
|
export function boundCanonical(caller, callerUrl, ts, audience, method, path) {
|
|
63
67
|
return JSON.stringify([ORIGIN_CLAIM_BOUND_TAG, caller, callerUrl, ts, audience, method.toUpperCase(), path]);
|
|
64
68
|
}
|
|
69
|
+
/** The body-bound v3 form is domain-separated from v1/v2, with the digest appended. */
|
|
70
|
+
export function bodyBoundCanonical(caller, callerUrl, ts, audience, method, path, bodySha256) {
|
|
71
|
+
return JSON.stringify([ORIGIN_CLAIM_BODY_TAG, caller, callerUrl, ts, audience, method.toUpperCase(), path, bodySha256]);
|
|
72
|
+
}
|
|
65
73
|
/** An origin in URL's normal form, or '' when the text is not an http(s) URL. */
|
|
66
74
|
export function originOf(url) {
|
|
67
75
|
try {
|
|
@@ -98,6 +106,11 @@ export function signOriginClaim(target, signer) {
|
|
|
98
106
|
if (audience !== '') {
|
|
99
107
|
headers['X-A2A-Caller-Aud'] = audience;
|
|
100
108
|
headers['X-A2A-Caller-Sig2'] = sign(boundCanonical(caller, callerUrl, ts, audience, target.method, pathOf(target.url)));
|
|
109
|
+
if (target.body !== undefined) {
|
|
110
|
+
const digest = originBodySha256(target.body);
|
|
111
|
+
headers['X-A2A-Body-Sha256'] = digest;
|
|
112
|
+
headers['X-A2A-Caller-Sig3'] = sign(bodyBoundCanonical(caller, callerUrl, ts, audience, target.method, pathOf(target.url), digest));
|
|
113
|
+
}
|
|
101
114
|
}
|
|
102
115
|
if (signer.unbound)
|
|
103
116
|
headers['X-A2A-Caller-Sig'] = sign(unboundCanonical(caller, callerUrl, ts));
|
|
@@ -153,15 +166,21 @@ export async function verifyOriginClaim(req, d) {
|
|
|
153
166
|
const unboundSig = header('x-a2a-caller-sig');
|
|
154
167
|
const boundSig = header('x-a2a-caller-sig2');
|
|
155
168
|
const audience = header('x-a2a-caller-aud');
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
const hasBodySig = req.headers['x-a2a-caller-sig3'] !== undefined;
|
|
170
|
+
const bodySig = header('x-a2a-caller-sig3');
|
|
171
|
+
const digest = header('x-a2a-body-sha256');
|
|
172
|
+
// Presence, even malformed/empty, opts into v3. It must never become absent/legacy or fall back to Sig2/v1.
|
|
173
|
+
if (hasBodySig && (!bodySig || !/^[A-Za-z0-9_-]{43}$/.test(digest) || d.rawBody === undefined || originBodySha256(d.rawBody) !== digest))
|
|
174
|
+
return { ok: false, reason: 'body-mismatch' };
|
|
175
|
+
if (caller === '' || callerUrl === '' || !Number.isFinite(ts) || ts <= 0 || (unboundSig === '' && boundSig === '' && !hasBodySig))
|
|
176
|
+
return { ok: false, reason: hasBodySig ? 'bad-sig' : 'absent' };
|
|
158
177
|
if (!originClaimCallerShape.test(caller))
|
|
159
178
|
return { ok: false, reason: 'bad-caller-shape' };
|
|
160
179
|
if (Math.abs(d.now - ts) > ORIGIN_CLAIM_SKEW_MS)
|
|
161
180
|
return { ok: false, reason: 'bad-ts' };
|
|
162
|
-
const hasBound = boundSig !== '';
|
|
181
|
+
const hasBound = boundSig !== '' || hasBodySig;
|
|
163
182
|
const audienceOk = hasBound && requestAudiences(req, d.audiences, d.loopbackArrival ?? true).has(originOf(audience));
|
|
164
|
-
const fallback = d.unboundOpen && unboundSig !== '';
|
|
183
|
+
const fallback = !hasBodySig && d.unboundOpen && unboundSig !== '';
|
|
165
184
|
if (hasBound && !audienceOk && !fallback)
|
|
166
185
|
return { ok: false, reason: 'wrong-audience' };
|
|
167
186
|
if (!hasBound && !d.unboundOpen)
|
|
@@ -179,6 +198,10 @@ export async function verifyOriginClaim(req, d) {
|
|
|
179
198
|
// signature is accepted for migration compatibility but remains unbound.
|
|
180
199
|
let bound = false;
|
|
181
200
|
const signedBy = (card) => {
|
|
201
|
+
if (hasBodySig) {
|
|
202
|
+
bound = audienceOk && verifies(card, bodyBoundCanonical(caller, callerUrl, ts, audience, req.method ?? '', pathOf(req.url ?? ''), digest), bodySig);
|
|
203
|
+
return bound;
|
|
204
|
+
}
|
|
182
205
|
bound = audienceOk && verifies(card, boundCanonical(caller, callerUrl, ts, audience, req.method ?? '', pathOf(req.url ?? '')), boundSig);
|
|
183
206
|
return bound || (fallback && verifies(card, unboundCanonical(caller, callerUrl, ts), unboundSig));
|
|
184
207
|
};
|