@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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node task pointers (docs/design/node-identity.md §2 "任务指针", §5 task context, §6; slice N6).
|
|
3
|
+
*
|
|
4
|
+
* - A pointer is `owner/repo#n` plus a CACHED state — GitHub is the truth
|
|
5
|
+
* (`open` / `merged` / `closed` / `declined`; `unknown` until a check
|
|
6
|
+
* answers). Each records the node's `core_version` and `mutable_version`
|
|
7
|
+
* when it was tracked (§6 归因).
|
|
8
|
+
* - Kept in `node-tasks.json` under the a2a home, beside the roster rather
|
|
9
|
+
* than inside it: a cache that changes on every check must not rewrite
|
|
10
|
+
* the identity roster. The newest 64 per node stay — finished pointers are
|
|
11
|
+
* evicted before open ones — and evicted ones are appended to
|
|
12
|
+
* `node-tasks-archive.jsonl`.
|
|
13
|
+
* - Checks: once when a pointer is tracked, a full pass right after the
|
|
14
|
+
* plugin loads, and a full pass every 12 hours. Finished pointers are not
|
|
15
|
+
* re-checked (a re-opened issue keeps reading finished — the cost of not
|
|
16
|
+
* polling finished work). One check per ref at a time; a finished state
|
|
17
|
+
* is never walked back to open by a late answer. A probe that cannot
|
|
18
|
+
* answer marks the check `unknown` and keeps the last known state.
|
|
19
|
+
* - Reminder (§6 主触发): a pointer that finished AFTER it was tracked —
|
|
20
|
+
* it was `open` here, or GitHub's merged_at / closed_at is later than
|
|
21
|
+
* `since` — calls `onSettled` once. A pointer that finished before it
|
|
22
|
+
* was tracked, or whose finish time cannot be told, does not remind; an
|
|
23
|
+
* unknown state never does. A reminder that reached no session (a cold
|
|
24
|
+
* host boot: the load-time pass runs before any session is live) is kept
|
|
25
|
+
* as `remind_pending` and handed out the next time the node mounts on a
|
|
26
|
+
* session (`takePending`), so a merge during downtime is not swallowed.
|
|
27
|
+
* The finish-time comparison trusts the host clock; skew of a few minutes
|
|
28
|
+
* can add or drop a reminder for a pointer whose first check failed.
|
|
29
|
+
* - After the plugin unloads (`dispose`), a check still in flight neither
|
|
30
|
+
* writes nor reminds: a newer instance owns the file by then.
|
|
31
|
+
* - A file that does not parse is left in place and the table is read-only;
|
|
32
|
+
* malformed rows are backed up before the next write drops them (as the
|
|
33
|
+
* roster and the bindings table do).
|
|
34
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/node-tasks
|
|
35
|
+
*/
|
|
36
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { TASK_REF } from "./node-github.js";
|
|
39
|
+
export const TASK_KEEP = 64;
|
|
40
|
+
/** §5: the task context is generated, so it is truncated (not refused) at this many characters. */
|
|
41
|
+
export const TASKS_CONTEXT_MAX = 1500;
|
|
42
|
+
/** §6 / §8-8: the full check runs twice a day. */
|
|
43
|
+
export const TASKS_CHECK_MS = 12 * 60 * 60 * 1000;
|
|
44
|
+
/** The §6 reminder for a pointer that finished. */
|
|
45
|
+
export function settledReminder(p) {
|
|
46
|
+
const verb = p.state === 'merged' ? '合并' : p.state === 'declined' ? '被拒' : '关闭';
|
|
47
|
+
return `[节点复盘] ${p.ref} 已${verb}。这件事告一段落:按需更新节点自记(当前状态、执行要点、已知陷阱、当前重点)。`;
|
|
48
|
+
}
|
|
49
|
+
const FINISHED = new Set(['merged', 'closed', 'declined']);
|
|
50
|
+
const STATES = new Set(['open', 'merged', 'closed', 'declined', 'unknown']);
|
|
51
|
+
/** ISO-8601 instants only: a hand-edited `since` must not carry text into the task context. */
|
|
52
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
53
|
+
const chars = (text) => [...text].length;
|
|
54
|
+
const fail = (code, error) => ({ ok: false, code, error });
|
|
55
|
+
function pointerOf(raw) {
|
|
56
|
+
const r = raw;
|
|
57
|
+
if (r === null || typeof r !== 'object' || typeof r.ref !== 'string' || !TASK_REF.test(r.ref) || typeof r.state !== 'string' || !STATES.has(r.state))
|
|
58
|
+
return undefined;
|
|
59
|
+
if (typeof r.since !== 'string' || !ISO.test(r.since))
|
|
60
|
+
return undefined;
|
|
61
|
+
const n = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : 0;
|
|
62
|
+
return {
|
|
63
|
+
ref: r.ref, state: r.state, check: r.check === 'ok' || r.check === 'unknown' ? r.check : 'pending',
|
|
64
|
+
since: r.since, checked_at: typeof r.checked_at === 'string' && ISO.test(r.checked_at) ? r.checked_at : '',
|
|
65
|
+
core_version: n(r.core_version), mutable_version: n(r.mutable_version), by: typeof r.by === 'string' ? r.by.slice(0, 80) : '',
|
|
66
|
+
...(r.remind_pending === true && FINISHED.has(r.state) ? { remind_pending: true } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Whether a fresh answer means the pointer finished after it was tracked (see the module note). */
|
|
70
|
+
function finishedSinceTracked(before, fact) {
|
|
71
|
+
if (!FINISHED.has(fact.state) || FINISHED.has(before.state))
|
|
72
|
+
return false;
|
|
73
|
+
if (before.state === 'open')
|
|
74
|
+
return true;
|
|
75
|
+
const at = fact.finishedAt === undefined ? Number.NaN : Date.parse(fact.finishedAt);
|
|
76
|
+
const since = Date.parse(before.since);
|
|
77
|
+
return Number.isFinite(at) && Number.isFinite(since) && at >= since;
|
|
78
|
+
}
|
|
79
|
+
export class NodeTasks {
|
|
80
|
+
root;
|
|
81
|
+
d;
|
|
82
|
+
table = {};
|
|
83
|
+
readOnlyReason = '';
|
|
84
|
+
running;
|
|
85
|
+
inflight = new Map();
|
|
86
|
+
/** Owed reminders already handed out by this process: a clear that could not be written must not repeat at every mount. */
|
|
87
|
+
handedOut = new Set();
|
|
88
|
+
disposed = false;
|
|
89
|
+
now;
|
|
90
|
+
/** @param root - the `<home>/a2a` directory; '' keeps the table in memory (tests). */
|
|
91
|
+
constructor(root, d) {
|
|
92
|
+
this.root = root;
|
|
93
|
+
this.d = d;
|
|
94
|
+
this.now = d.now ?? (() => new Date().toISOString());
|
|
95
|
+
this.restore();
|
|
96
|
+
}
|
|
97
|
+
get file() { return join(this.root, 'node-tasks.json'); }
|
|
98
|
+
stamp() { return this.now().replace(/[:.]/g, '-'); }
|
|
99
|
+
restore() {
|
|
100
|
+
if (this.root === '' || !existsSync(this.file))
|
|
101
|
+
return;
|
|
102
|
+
let raw;
|
|
103
|
+
try {
|
|
104
|
+
raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
raw = undefined;
|
|
108
|
+
}
|
|
109
|
+
const tasks = raw?.tasks;
|
|
110
|
+
if (raw === null || typeof raw !== 'object' || tasks === null || typeof tasks !== 'object' || Array.isArray(tasks)) {
|
|
111
|
+
const aside = `${this.file}.corrupt-${this.stamp()}`;
|
|
112
|
+
try {
|
|
113
|
+
copyFileSync(this.file, aside);
|
|
114
|
+
}
|
|
115
|
+
catch { /* the original stays in place either way */ }
|
|
116
|
+
this.readOnlyReason = `node-tasks.json is unreadable (copy kept at ${aside}); repair it, then restart`;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let dropped = 0;
|
|
120
|
+
for (const [node, list] of Object.entries(tasks)) {
|
|
121
|
+
if (!Array.isArray(list)) {
|
|
122
|
+
dropped++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const kept = [];
|
|
126
|
+
for (const raw of list) {
|
|
127
|
+
const p = pointerOf(raw);
|
|
128
|
+
if (p === undefined || kept.some(k => k.ref === p.ref))
|
|
129
|
+
dropped++;
|
|
130
|
+
else
|
|
131
|
+
kept.push(p);
|
|
132
|
+
}
|
|
133
|
+
this.table[node] = kept;
|
|
134
|
+
}
|
|
135
|
+
// The next commit rewrites the table without the dropped rows: keep the original first.
|
|
136
|
+
if (dropped > 0) {
|
|
137
|
+
try {
|
|
138
|
+
copyFileSync(this.file, `${this.file}.dropped-${this.stamp()}`);
|
|
139
|
+
}
|
|
140
|
+
catch { /* best effort; the rows were unusable either way */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
commit(next, archived = []) {
|
|
144
|
+
if (this.disposed)
|
|
145
|
+
return fail('unavailable', 'node tasks were unloaded');
|
|
146
|
+
if (this.readOnlyReason !== '')
|
|
147
|
+
return fail('unavailable', `node tasks are read-only: ${this.readOnlyReason}`);
|
|
148
|
+
if (this.root !== '') {
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(this.root, { recursive: true });
|
|
151
|
+
const tmp = `${this.file}.${String(process.pid)}.tmp`;
|
|
152
|
+
writeFileSync(tmp, JSON.stringify({ tasks: next }), { mode: 0o600 });
|
|
153
|
+
// The archive is appended after the new table is staged and before it replaces the old one: a failed
|
|
154
|
+
// staging write leaves no archive rows; a crash (or a failed rename) after the append leaves the pointer
|
|
155
|
+
// in BOTH files — a duplicate, never a loss.
|
|
156
|
+
for (const a of archived)
|
|
157
|
+
appendFileSync(join(this.root, 'node-tasks-archive.jsonl'), `${JSON.stringify({ node: a.node, ...a.pointer, archived_at: this.now() })}\n`, { mode: 0o600 });
|
|
158
|
+
renameSync(tmp, this.file);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return fail('unavailable', `could not persist node-tasks.json: ${String(error)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
this.table = next;
|
|
165
|
+
return { ok: true, value: true };
|
|
166
|
+
}
|
|
167
|
+
get readOnly() { return this.readOnlyReason; }
|
|
168
|
+
list(node) { return this.table[node] ?? []; }
|
|
169
|
+
/**
|
|
170
|
+
* Run the checks on the plugin's context: one full pass right after load (a restart within 12 hours
|
|
171
|
+
* would otherwise never check), one every 12 hours, and stop writing once the plugin unloads.
|
|
172
|
+
*/
|
|
173
|
+
schedule(ctx) {
|
|
174
|
+
ctx.effect(() => () => { this.disposed = true; }, 'a2a: node tasks');
|
|
175
|
+
ctx.timer.timeout(() => { void this.checkAll(); }, 0);
|
|
176
|
+
ctx.timer.interval(() => { void this.checkAll(); }, TASKS_CHECK_MS);
|
|
177
|
+
}
|
|
178
|
+
/** Track a pointer at the node's current versions; checks it once in the background. Re-tracking is a no-op. */
|
|
179
|
+
track(node, ref, versions, by) {
|
|
180
|
+
if (typeof ref !== 'string' || !TASK_REF.test(ref))
|
|
181
|
+
return fail('bad-request', 'ref must be owner/repo#number');
|
|
182
|
+
const list = this.list(node);
|
|
183
|
+
const existing = list.find(p => p.ref === ref);
|
|
184
|
+
if (existing !== undefined)
|
|
185
|
+
return { ok: true, value: existing };
|
|
186
|
+
const pointer = { ref, state: 'unknown', check: 'pending', since: this.now(), checked_at: '', core_version: versions.core, mutable_version: versions.mutable, by };
|
|
187
|
+
// Over the cap: finished pointers go first (oldest first), then the oldest open ones, and a pointer that
|
|
188
|
+
// still owes a reminder goes last — evicting it would drop the reminder with it.
|
|
189
|
+
const all = [pointer, ...list];
|
|
190
|
+
const evict = new Set();
|
|
191
|
+
const pass = (pick) => {
|
|
192
|
+
for (const p of [...all].reverse()) {
|
|
193
|
+
if (all.length - evict.size <= TASK_KEEP)
|
|
194
|
+
return;
|
|
195
|
+
if (p !== pointer && !evict.has(p.ref) && pick(p))
|
|
196
|
+
evict.add(p.ref);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
pass(p => FINISHED.has(p.state) && p.remind_pending !== true);
|
|
200
|
+
pass(p => p.remind_pending !== true);
|
|
201
|
+
pass(() => true);
|
|
202
|
+
const done = this.commit({ ...this.table, [node]: all.filter(p => !evict.has(p.ref)) }, all.filter(p => evict.has(p.ref)).map(p => ({ node, pointer: p })));
|
|
203
|
+
if (!done.ok)
|
|
204
|
+
return done;
|
|
205
|
+
void this.check(node, ref);
|
|
206
|
+
return { ok: true, value: pointer };
|
|
207
|
+
}
|
|
208
|
+
untrack(node, ref) {
|
|
209
|
+
const list = this.list(node);
|
|
210
|
+
if (typeof ref !== 'string' || !list.some(p => p.ref === ref))
|
|
211
|
+
return fail('not-found', `${node} does not track ${String(ref)}`);
|
|
212
|
+
return this.commit({ ...this.table, [node]: list.filter(p => p.ref !== ref) });
|
|
213
|
+
}
|
|
214
|
+
/** An archived node's pointers move to the archive file. */
|
|
215
|
+
dropNode(node) {
|
|
216
|
+
const list = this.list(node);
|
|
217
|
+
if (list.length === 0)
|
|
218
|
+
return;
|
|
219
|
+
const next = { ...this.table };
|
|
220
|
+
delete next[node];
|
|
221
|
+
const done = this.commit(next, list.map(pointer => ({ node, pointer })));
|
|
222
|
+
if (!done.ok)
|
|
223
|
+
this.d.logger.warn('a2a nodes: could not archive the task pointers of %s: %s', node, done.error);
|
|
224
|
+
}
|
|
225
|
+
/** Check one pointer now; joins a check of the same pointer already in flight. Never throws. */
|
|
226
|
+
check(node, ref) {
|
|
227
|
+
const key = `${node}\u0000${ref}`;
|
|
228
|
+
const running = this.inflight.get(key);
|
|
229
|
+
if (running !== undefined)
|
|
230
|
+
return running;
|
|
231
|
+
const run = this.checkOnce(node, ref).finally(() => { this.inflight.delete(key); });
|
|
232
|
+
this.inflight.set(key, run);
|
|
233
|
+
return run;
|
|
234
|
+
}
|
|
235
|
+
async checkOnce(node, ref) {
|
|
236
|
+
let fact;
|
|
237
|
+
try {
|
|
238
|
+
fact = await this.d.probe(ref);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
fact = undefined;
|
|
242
|
+
}
|
|
243
|
+
if (this.disposed)
|
|
244
|
+
return;
|
|
245
|
+
// Re-read after the await: the pointer may have been untracked, or the node archived, meanwhile.
|
|
246
|
+
const list = this.list(node);
|
|
247
|
+
const before = list.find(p => p.ref === ref);
|
|
248
|
+
if (before === undefined)
|
|
249
|
+
return;
|
|
250
|
+
const after = fact === undefined
|
|
251
|
+
? { ...before, check: 'unknown', checked_at: this.now() }
|
|
252
|
+
// A finished state is final here: a late answer never walks it back.
|
|
253
|
+
: { ...before, state: FINISHED.has(before.state) ? before.state : fact.state, check: 'ok', checked_at: this.now() };
|
|
254
|
+
const done = this.commit({ ...this.table, [node]: list.map(p => p.ref === ref ? after : p) });
|
|
255
|
+
if (!done.ok) {
|
|
256
|
+
this.d.logger.warn('a2a nodes: checked %s for %s but could not record it: %s', ref, node, done.error);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (fact !== undefined && finishedSinceTracked(before, fact)) {
|
|
260
|
+
let reached = 0;
|
|
261
|
+
try {
|
|
262
|
+
reached = this.d.onSettled(node, after);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
this.d.logger.warn('a2a nodes: reminder for %s failed: %s', ref, String(error));
|
|
266
|
+
}
|
|
267
|
+
// Nobody carried the node right now (e.g. a cold boot): owe it to the next session that mounts it.
|
|
268
|
+
if (reached === 0) {
|
|
269
|
+
this.handedOut.delete(`${node}\u0000${ref}`);
|
|
270
|
+
this.setPending(node, ref, true);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
setPending(node, ref, pending) {
|
|
275
|
+
const list = this.list(node);
|
|
276
|
+
if (!list.some(p => p.ref === ref))
|
|
277
|
+
return;
|
|
278
|
+
const done = this.commit({ ...this.table, [node]: list.map(p => {
|
|
279
|
+
if (p.ref !== ref)
|
|
280
|
+
return p;
|
|
281
|
+
const { remind_pending: _drop, ...rest } = p;
|
|
282
|
+
return pending ? { ...rest, remind_pending: true } : rest;
|
|
283
|
+
}) });
|
|
284
|
+
if (!done.ok)
|
|
285
|
+
this.d.logger.warn('a2a nodes: could not record the owed reminder for %s: %s', ref, done.error);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The reminders owed to `node`, cleared as they are handed out (one session gets each). A clear that
|
|
289
|
+
* cannot be written still hands them out, once per process — a reminder may then repeat after a restart,
|
|
290
|
+
* never vanish.
|
|
291
|
+
*/
|
|
292
|
+
takePending(node) {
|
|
293
|
+
const owed = this.list(node).filter(p => p.remind_pending === true && !this.handedOut.has(`${node}\u0000${p.ref}`));
|
|
294
|
+
for (const p of owed) {
|
|
295
|
+
this.handedOut.add(`${node}\u0000${p.ref}`);
|
|
296
|
+
this.setPending(node, p.ref, false);
|
|
297
|
+
}
|
|
298
|
+
return owed;
|
|
299
|
+
}
|
|
300
|
+
/** The full pass: every pointer not yet finished, one at a time. A pass already running is joined. */
|
|
301
|
+
checkAll() {
|
|
302
|
+
this.running ??= (async () => {
|
|
303
|
+
try {
|
|
304
|
+
for (const [node, list] of Object.entries(this.table)) {
|
|
305
|
+
for (const p of list) {
|
|
306
|
+
if (this.disposed)
|
|
307
|
+
return;
|
|
308
|
+
if (!FINISHED.has(p.state))
|
|
309
|
+
await this.check(node, p.ref);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
this.running = undefined;
|
|
315
|
+
}
|
|
316
|
+
})();
|
|
317
|
+
return this.running;
|
|
318
|
+
}
|
|
319
|
+
/** §5 task context: open first, then newest; truncated (never refused) at {@link TASKS_CONTEXT_MAX}. '' when none. */
|
|
320
|
+
render(node) {
|
|
321
|
+
const list = [...this.list(node)].sort((a, b) => Number(b.state === 'open') - Number(a.state === 'open') || (a.since < b.since ? 1 : a.since > b.since ? -1 : 0));
|
|
322
|
+
if (list.length === 0)
|
|
323
|
+
return '';
|
|
324
|
+
const head = '## 节点任务(a2a:node:tasks)\n\n以 GitHub 为准,这里是缓存:「未知」表示这次没能核对(没有凭证或离线)。';
|
|
325
|
+
const label = (p) => p.state === 'unknown' ? '未知' : p.check === 'unknown' ? `${p.state}(未能核对)` : p.state;
|
|
326
|
+
const lines = list.map(p => `- ${label(p)} ${p.ref}(自 ${p.since.slice(0, 10)},契约 v${String(p.core_version)} / 自记 v${String(p.mutable_version)})`);
|
|
327
|
+
const out = [head, ''];
|
|
328
|
+
let used = chars(head) + 2;
|
|
329
|
+
for (let i = 0; i < lines.length; i++) {
|
|
330
|
+
const line = lines[i];
|
|
331
|
+
const rest = lines.length - i - 1;
|
|
332
|
+
const tail = rest > 0 ? chars(`\n另有 ${String(rest)} 条,见 GitHub。`) : 0;
|
|
333
|
+
if (used + chars(line) + 1 + tail > TASKS_CONTEXT_MAX) {
|
|
334
|
+
out.push(`另有 ${String(lines.length - i)} 条,见 GitHub。`);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
out.push(line);
|
|
338
|
+
used += chars(line) + 1;
|
|
339
|
+
}
|
|
340
|
+
return out.join('\n');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OriginClaim envelope (gh#9+gh#10, docs/protocol/delivery-origin-auth.md):
|
|
3
|
+
* the signer every outbound peer request stamps and the verifier every peer
|
|
4
|
+
* face (card, /a2a/direct, /a2a/log, /a2a/canvas-op, /a2a/canvas-changed)
|
|
5
|
+
* runs.
|
|
6
|
+
*
|
|
7
|
+
* Two canonical forms ride the same four base headers
|
|
8
|
+
* (X-A2A-Caller / -Url / -Ts):
|
|
9
|
+
* · bound (v2, §2.1): JSON.stringify([tag, caller, callerUrl, ts,
|
|
10
|
+
* audience, METHOD, path]) in X-A2A-Caller-Sig2, the audience origin in
|
|
11
|
+
* X-A2A-Caller-Aud. The verifier requires the audience to be one of its
|
|
12
|
+
* own origins and signs-over its own method and path, so an envelope is
|
|
13
|
+
* good at exactly the host and route it was made for.
|
|
14
|
+
* · unbound (v1, legacy): JSON.stringify([caller, callerUrl, ts]) in
|
|
15
|
+
* X-A2A-Caller-Sig. Emitted and accepted only while the unbound window
|
|
16
|
+
* is open (originClaimUnboundWindow); accepted ones are counted.
|
|
17
|
+
* Once the window closes a present Sig2 is the only thing verified. While
|
|
18
|
+
* it is open, a Sig2 that misses (audience or signature) falls back to the
|
|
19
|
+
* Sig the envelope also carries — no more than stripping Sig2 would give —
|
|
20
|
+
* and the miss is counted, so a peer dialled by an unlisted name keeps
|
|
21
|
+
* working through the rollout and shows on the gauge.
|
|
22
|
+
*/
|
|
23
|
+
import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
24
|
+
import { verifyCard } from "./card.js";
|
|
25
|
+
/** ts skew tolerance (protocol constant, §7). */
|
|
26
|
+
export const ORIGIN_CLAIM_SKEW_MS = 5 * 60_000;
|
|
27
|
+
/** Domain tag of the bound form: a v1 signature can never verify as v2. */
|
|
28
|
+
export const ORIGIN_CLAIM_BOUND_TAG = 'a2a-origin-claim/2';
|
|
29
|
+
/** Only the canonical origin root can claim that origin's identity. */
|
|
30
|
+
export function isRootOriginUrl(value) {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(value);
|
|
33
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && url.username === '' && url.password === '' && url.pathname === '/' && url.search === '' && url.hash === '';
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* #189 follow-up: whether a path-prefixed claim URL is EXACTLY a peer URL the operator seeded (a
|
|
41
|
+
* mounted card endpoint, delivery-origin-auth §"挂载卡片端点"): same origin and same path (trailing
|
|
42
|
+
* slashes ignored), neither carrying userinfo, query or fragment. A different path under the same
|
|
43
|
+
* origin is a different publisher and never matches.
|
|
44
|
+
*/
|
|
45
|
+
export function sameMountedUrl(seeded, claimUrl) {
|
|
46
|
+
try {
|
|
47
|
+
const a = new URL(seeded);
|
|
48
|
+
const b = new URL(claimUrl);
|
|
49
|
+
const clean = (u) => (u.protocol === 'http:' || u.protocol === 'https:') && u.username === '' && u.password === '' && u.search === '' && u.hash === '';
|
|
50
|
+
const path = (u) => u.pathname.replace(/\/+$/, '');
|
|
51
|
+
return clean(a) && clean(b) && a.origin === b.origin && path(a) !== '' && path(a) === path(b);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The unbound (v1) canonical form. */
|
|
58
|
+
export function unboundCanonical(caller, callerUrl, ts) {
|
|
59
|
+
return JSON.stringify([caller, callerUrl, ts]);
|
|
60
|
+
}
|
|
61
|
+
/** The bound (v2) canonical form; method upper-cased, path without query. */
|
|
62
|
+
export function boundCanonical(caller, callerUrl, ts, audience, method, path) {
|
|
63
|
+
return JSON.stringify([ORIGIN_CLAIM_BOUND_TAG, caller, callerUrl, ts, audience, method.toUpperCase(), path]);
|
|
64
|
+
}
|
|
65
|
+
/** An origin in URL's normal form, or '' when the text is not an http(s) URL. */
|
|
66
|
+
export function originOf(url) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = new URL(url);
|
|
69
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : '';
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The path a request names, without its query (the bound form signs this). */
|
|
76
|
+
export function pathOf(url) {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(url, 'http://x').pathname;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Sign one envelope for `target`. The bound form is always present; the
|
|
86
|
+
* unbound one only while `unbound` (the window) is open, so an upgraded
|
|
87
|
+
* host stops signing the unbound form the moment it closes.
|
|
88
|
+
*/
|
|
89
|
+
export function signOriginClaim(target, signer) {
|
|
90
|
+
const { caller, callerUrl, privateKey, now: ts } = signer;
|
|
91
|
+
const sign = (text) => edSign(undefined, Buffer.from(text, 'utf8'), privateKey).toString('base64');
|
|
92
|
+
const headers = {
|
|
93
|
+
'X-A2A-Caller': caller,
|
|
94
|
+
'X-A2A-Caller-Url': callerUrl,
|
|
95
|
+
'X-A2A-Caller-Ts': String(ts),
|
|
96
|
+
};
|
|
97
|
+
const audience = originOf(target.url);
|
|
98
|
+
if (audience !== '') {
|
|
99
|
+
headers['X-A2A-Caller-Aud'] = audience;
|
|
100
|
+
headers['X-A2A-Caller-Sig2'] = sign(boundCanonical(caller, callerUrl, ts, audience, target.method, pathOf(target.url)));
|
|
101
|
+
}
|
|
102
|
+
if (signer.unbound)
|
|
103
|
+
headers['X-A2A-Caller-Sig'] = sign(unboundCanonical(caller, callerUrl, ts));
|
|
104
|
+
return headers;
|
|
105
|
+
}
|
|
106
|
+
/** IPv4-mapped and bare IPv6 socket addresses in URL host form. */
|
|
107
|
+
function hostForm(address) {
|
|
108
|
+
const plain = address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
109
|
+
return plain.includes(':') ? `[${plain}]` : plain;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The origins this host answers to for one request: the configured ones
|
|
113
|
+
* (own card URL, operator aliases) plus the address the request actually
|
|
114
|
+
* arrived on — the socket's LOCAL end, which a sender cannot choose (unlike
|
|
115
|
+
* the Host header). A loopback arrival answers to all its loopback
|
|
116
|
+
* spellings — unless `loopbackArrival` is off (originClaimUrl declares a
|
|
117
|
+
* tunnel or proxy, whose requests all arrive on loopback), and then only
|
|
118
|
+
* when a configured origin is loopback at that port.
|
|
119
|
+
*/
|
|
120
|
+
export function requestAudiences(req, configured, loopbackArrival = true) {
|
|
121
|
+
const out = new Set();
|
|
122
|
+
for (const url of configured) {
|
|
123
|
+
const origin = originOf(url);
|
|
124
|
+
if (origin !== '')
|
|
125
|
+
out.add(origin);
|
|
126
|
+
}
|
|
127
|
+
const socket = req.socket;
|
|
128
|
+
const local = socket?.localAddress;
|
|
129
|
+
const port = socket?.localPort;
|
|
130
|
+
if (local !== undefined && port !== undefined) {
|
|
131
|
+
const scheme = socket?.encrypted === true ? 'https' : 'http';
|
|
132
|
+
const host = hostForm(local);
|
|
133
|
+
const loopback = host === '[::1]' || /^127\./.test(host);
|
|
134
|
+
const spellings = [host, '127.0.0.1', 'localhost', '[::1]'].map(name => originOf(`${scheme}://${name}:${String(port)}`));
|
|
135
|
+
const names = loopback ? (loopbackArrival || spellings.some(origin => out.has(origin)) ? spellings : []) : [spellings[0]];
|
|
136
|
+
for (const origin of names)
|
|
137
|
+
if (origin !== '')
|
|
138
|
+
out.add(origin);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
const originClaimCallerShape = /^[a-z0-9-]+$/i;
|
|
143
|
+
/**
|
|
144
|
+
* Verify one request's envelope. Cheap refusals (shape, ts, audience,
|
|
145
|
+
* closed window) answer before any card fetch; the card is then read from
|
|
146
|
+
* the shared cache or fetched PLAIN from the caller's well-known URL.
|
|
147
|
+
*/
|
|
148
|
+
export async function verifyOriginClaim(req, d) {
|
|
149
|
+
const header = (name) => String(req.headers[name] ?? '');
|
|
150
|
+
const caller = header('x-a2a-caller');
|
|
151
|
+
const callerUrl = header('x-a2a-caller-url');
|
|
152
|
+
const ts = Number(header('x-a2a-caller-ts'));
|
|
153
|
+
const unboundSig = header('x-a2a-caller-sig');
|
|
154
|
+
const boundSig = header('x-a2a-caller-sig2');
|
|
155
|
+
const audience = header('x-a2a-caller-aud');
|
|
156
|
+
if (caller === '' || callerUrl === '' || !Number.isFinite(ts) || ts <= 0 || (unboundSig === '' && boundSig === ''))
|
|
157
|
+
return { ok: false, reason: 'absent' };
|
|
158
|
+
if (!originClaimCallerShape.test(caller))
|
|
159
|
+
return { ok: false, reason: 'bad-caller-shape' };
|
|
160
|
+
if (Math.abs(d.now - ts) > ORIGIN_CLAIM_SKEW_MS)
|
|
161
|
+
return { ok: false, reason: 'bad-ts' };
|
|
162
|
+
const hasBound = boundSig !== '';
|
|
163
|
+
const audienceOk = hasBound && requestAudiences(req, d.audiences, d.loopbackArrival ?? true).has(originOf(audience));
|
|
164
|
+
const fallback = d.unboundOpen && unboundSig !== '';
|
|
165
|
+
if (hasBound && !audienceOk && !fallback)
|
|
166
|
+
return { ok: false, reason: 'wrong-audience' };
|
|
167
|
+
if (!hasBound && !d.unboundOpen)
|
|
168
|
+
return { ok: false, reason: 'unbound-closed' };
|
|
169
|
+
const verifies = (card, canonical, signature) => {
|
|
170
|
+
try {
|
|
171
|
+
const publicKey = createPublicKey({ key: Buffer.from(card.publicKey, 'base64'), format: 'der', type: 'spki' });
|
|
172
|
+
return edVerify(undefined, Buffer.from(canonical, 'utf8'), publicKey, Buffer.from(signature, 'base64'));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
// Only a successful bound signature is origin proof. A v1/fallback
|
|
179
|
+
// signature is accepted for migration compatibility but remains unbound.
|
|
180
|
+
let bound = false;
|
|
181
|
+
const signedBy = (card) => {
|
|
182
|
+
bound = audienceOk && verifies(card, boundCanonical(caller, callerUrl, ts, audience, req.method ?? '', pathOf(req.url ?? '')), boundSig);
|
|
183
|
+
return bound || (fallback && verifies(card, unboundCanonical(caller, callerUrl, ts), unboundSig));
|
|
184
|
+
};
|
|
185
|
+
const accepted = () => {
|
|
186
|
+
if (bound)
|
|
187
|
+
return;
|
|
188
|
+
d.onUnbound();
|
|
189
|
+
if (hasBound)
|
|
190
|
+
d.onBoundMiss?.();
|
|
191
|
+
};
|
|
192
|
+
// gh#133: a CLI identity deposited HERE, decided from the deposit table
|
|
193
|
+
// before any fetch (the CLI may reach its home through a proxy the host
|
|
194
|
+
// cannot dial back). Same forms, audience and window as a zone claim.
|
|
195
|
+
const cli = d.cliDeposit?.(callerUrl);
|
|
196
|
+
if (cli !== undefined) {
|
|
197
|
+
if (cli.card === undefined)
|
|
198
|
+
return { ok: false, reason: 'no-card' };
|
|
199
|
+
if (!signedBy(cli.card))
|
|
200
|
+
return { ok: false, reason: 'bad-sig' };
|
|
201
|
+
if (cli.name !== caller)
|
|
202
|
+
return { ok: false, reason: 'not-derivable' };
|
|
203
|
+
accepted();
|
|
204
|
+
return { ok: true, caller: cli.handle, callerUrl, card: cli.card, bound, cli: true };
|
|
205
|
+
}
|
|
206
|
+
// Fetch the caller's card PLAIN (REVIEW M2 third-round ruling: the
|
|
207
|
+
// envelope names a BARE ZONE, and derivation is the exact match
|
|
208
|
+
// card.team === caller below — both ride the minimal card's signed core,
|
|
209
|
+
// so a single envelope-less fetch suffices and never recurses). A warm
|
|
210
|
+
// cache entry short-circuits the fetch.
|
|
211
|
+
let card = d.cardCache.get(callerUrl)?.card;
|
|
212
|
+
if (card === undefined) {
|
|
213
|
+
try {
|
|
214
|
+
// callerUrl is the caller's BASE URL — the card lives under it.
|
|
215
|
+
const cardBase = new URL(callerUrl);
|
|
216
|
+
cardBase.username = '';
|
|
217
|
+
cardBase.password = '';
|
|
218
|
+
cardBase.pathname = `${cardBase.pathname.replace(/\/+$/, '')}/.well-known/agent-card.json`;
|
|
219
|
+
cardBase.search = '';
|
|
220
|
+
cardBase.hash = '';
|
|
221
|
+
const cardUrl = cardBase.toString();
|
|
222
|
+
const response = await d.fetch(cardUrl, { method: 'GET', headers: {}, redirect: 'manual' });
|
|
223
|
+
if (response.status >= 300 && response.status < 400)
|
|
224
|
+
return { ok: false, reason: 'not-derivable' };
|
|
225
|
+
const verdict = verifyCard(JSON.parse(await response.text()), Date.now());
|
|
226
|
+
if (verdict.ok)
|
|
227
|
+
card = verdict.card;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
card = undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (card === undefined)
|
|
234
|
+
return { ok: false, reason: 'no-card' };
|
|
235
|
+
if (!signedBy(card))
|
|
236
|
+
return { ok: false, reason: 'bad-sig' };
|
|
237
|
+
// Derivation (REVIEW M2, third-round ruling): card.team === caller EXACT.
|
|
238
|
+
// A zone/id8 envelope is bad-caller-shape at the gate — session identity
|
|
239
|
+
// rides the body's caller_session, judged by N3/S3 against the PUBLISHED
|
|
240
|
+
// roster (the prefix variant let a same-zone foreign host sign a target's
|
|
241
|
+
// session handle with its own key).
|
|
242
|
+
if (card.team !== caller)
|
|
243
|
+
return { ok: false, reason: 'not-derivable' };
|
|
244
|
+
accepted();
|
|
245
|
+
return { ok: true, caller, callerUrl, card, bound };
|
|
246
|
+
}
|