@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/README_zh.md +3 -0
- package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +445 -57
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/card.js +4 -2
- package/lib/cli-deposit.js +122 -7
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +133 -43
- package/lib/faces/canvas-notify.js +12 -3
- package/lib/faces/files.js +155 -0
- package/lib/faces/nodes.js +171 -0
- package/lib/faces/session-log.js +162 -0
- package/lib/faces/wire.js +206 -39
- package/lib/index.js +809 -1720
- package/lib/joined-store.js +23 -0
- package/lib/ledger-conservation.js +97 -0
- package/lib/legacy/teams-bridge-inbound.js +156 -0
- package/lib/node-bind.js +534 -0
- package/lib/node-github.js +72 -0
- package/lib/node-self.js +453 -0
- package/lib/node-store.js +484 -0
- package/lib/node-tasks.js +342 -0
- package/lib/origin-claim.js +246 -0
- package/lib/peer-directory.js +134 -0
- package/lib/receipt.js +26 -1
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +82 -3
- package/lib/skill/references/collaboration.md +146 -73
- package/lib/skill/references/discovery.md +29 -0
- package/lib/skill/references/intake.md +107 -0
- package/lib/skill/references/router.md +96 -0
- package/lib/skill/references/setup.md +2 -1
- package/lib/task-auth.js +483 -0
- package/lib/task-ledger.js +324 -12
- package/lib/team-store.js +24 -1
- package/lib/tools/act.js +383 -0
- package/lib/tools/files.js +201 -0
- package/lib/tools/info.js +684 -0
- package/lib/types/a2a-client.d.ts +10 -4
- package/lib/types/auth-observer.d.ts +110 -0
- package/lib/types/blob-store.d.ts +83 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +14 -2
- package/lib/types/faces/canvas-notify.d.ts +4 -1
- package/lib/types/faces/files.d.ts +53 -0
- package/lib/types/faces/nodes.d.ts +69 -0
- package/lib/types/faces/session-log.d.ts +44 -0
- package/lib/types/faces/wire.d.ts +50 -5
- package/lib/types/index.d.ts +27 -0
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/ledger-conservation.d.ts +77 -0
- package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
- package/lib/types/node-bind.d.ts +220 -0
- package/lib/types/node-github.d.ts +35 -0
- package/lib/types/node-self.d.ts +178 -0
- package/lib/types/node-store.d.ts +180 -0
- package/lib/types/node-tasks.d.ts +121 -0
- package/lib/types/origin-claim.d.ts +119 -0
- package/lib/types/peer-directory.d.ts +39 -0
- package/lib/types/receipt.d.ts +12 -0
- package/lib/types/task-auth.d.ts +271 -0
- package/lib/types/task-ledger.d.ts +143 -6
- package/lib/types/team-store.d.ts +9 -0
- package/lib/types/tools/act.d.ts +52 -0
- package/lib/types/tools/files.d.ts +38 -0
- package/lib/types/tools/info.d.ts +102 -0
- package/lib/types/types.d.ts +6 -0
- package/nexus-stage/src/canvas-ops.ts +17 -2
- package/nexus-stage/src/canvas-wire.ts +27 -19
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +41 -18
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +30 -27
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +5 -1
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
package/lib/node-self.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node self-update (docs/design/node-identity.md §6; slice N4): the
|
|
3
|
+
* `node_update_self` tool a session carrying a node may call to write ONE
|
|
4
|
+
* section of its node's mutable layer, and the human review queue.
|
|
5
|
+
*
|
|
6
|
+
* - Only bound sessions get the tool (it is registered with the node's
|
|
7
|
+
* sections at mount). Its parameters cannot name, role, team or touch the
|
|
8
|
+
* core layer: one of the four mutable sections, text, reason, CAS sha.
|
|
9
|
+
* - Session-level taint (§6, H2; owner ruling 2026-11-03 「会话级污染」, which
|
|
10
|
+
* reads §9's `via != host-face` as "this session has received input from
|
|
11
|
+
* outside this host"): once a session has, every self-update it makes is
|
|
12
|
+
* parked in the review queue, where a human approves or rejects it through
|
|
13
|
+
* the apiKey-gated nodes face. Either of two signals taints:
|
|
14
|
+
* 1. the host marks the session when it DELIVERS remote content into it
|
|
15
|
+
* (every relay steer and the queue-mode nudge — {@link SessionTaint});
|
|
16
|
+
* the mark is persisted and never cleared;
|
|
17
|
+
* 2. a scan of the session's whole event log (fail-closed backstop): an
|
|
18
|
+
* `a2a-relay` message that is not a host-built node reminder, a `user`
|
|
19
|
+
* message whose controller rpcId starts with `a2a-` (this plugin's
|
|
20
|
+
* nudge), or any source kind not known here. Context the agent loop
|
|
21
|
+
* injects (runtime/time/skill/tool-job notices…) is neutral.
|
|
22
|
+
* 3. this plugin's own A2A tools that hand the model peer-originated text
|
|
23
|
+
* (any route answer — a reply or an error —, inbound task briefs, peer
|
|
24
|
+
* card rows — {@link toolTaint}) mark the calling session when they return.
|
|
25
|
+
* Messages that carry ANOTHER session's text (a subagent's closing message,
|
|
26
|
+
* a peer agent's message, a referenced transcript) take that session's
|
|
27
|
+
* taint, so a subagent that talked to a peer cannot launder its reply.
|
|
28
|
+
* Only remote EVIDENCE is persisted (a relay, a nudge, a tainted other
|
|
29
|
+
* session, an A2A tool read); an unknown source, an unknown sender or a log
|
|
30
|
+
* with no person's input refuses this one call (fail-closed) without
|
|
31
|
+
* marking, so a host feature this module does not know yet never taints a
|
|
32
|
+
* session forever.
|
|
33
|
+
* The boundary, stated honestly: remote text the model reads through
|
|
34
|
+
* general tools (a fetched page, an issue body) is not a delivery and does
|
|
35
|
+
* not taint; nor can a `kind:'user'` prompt another plugin submits through
|
|
36
|
+
* the session controller be told apart from a person (only this plugin's
|
|
37
|
+
* nudge, rpcId `a2a-*`, is recognised).
|
|
38
|
+
* - Daily cap: at most {@link SELF_UPDATE_DAILY_MAX} node writes per node
|
|
39
|
+
* per UTC day; beyond that the call is refused (not queued).
|
|
40
|
+
* - Each write freezes a full mutable version through the store (quota,
|
|
41
|
+
* CAS, max+1), attributed `by: node:<session>`, `via: node-tool`; the
|
|
42
|
+
* session's frozen prompt does not change — the write is echoed in a live
|
|
43
|
+
* runtime context instead (§5).
|
|
44
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/node-self
|
|
45
|
+
*/
|
|
46
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
47
|
+
import { join } from 'node:path';
|
|
48
|
+
import { randomBytes } from 'node:crypto';
|
|
49
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
50
|
+
import { MUTABLE_SECTIONS, QUOTA } from "./node-store.js";
|
|
51
|
+
export const SELF_UPDATE_DAILY_MAX = 8;
|
|
52
|
+
/** Queued writes awaiting a human, across all nodes. */
|
|
53
|
+
export const REVIEW_QUEUE_MAX = 64;
|
|
54
|
+
/** Queued writes per node: a tainted session cannot crowd out every other node's items. */
|
|
55
|
+
export const REVIEW_PER_NODE_MAX = 8;
|
|
56
|
+
/** Input from a person at this host (UI or controller RPC); an `a2a-` rpcId is this plugin's nudge, not a person. */
|
|
57
|
+
const LOCAL_SOURCES = new Set(['user']);
|
|
58
|
+
/**
|
|
59
|
+
* Messages the harness or agent loop injects on its own: they neither prove a person nor carry a
|
|
60
|
+
* delivery, so they neither count as input nor taint (projections of state already on this host).
|
|
61
|
+
*/
|
|
62
|
+
const NEUTRAL_SOURCES = new Set([
|
|
63
|
+
'runtime-context', 'time-context', 'skill-catalog', 'tool-jobs', 'user-approval', 'model-selection', 'tool-registry', 'ptc-mode', 'system-prompt',
|
|
64
|
+
// Host features of a long-running session: compaction (its own history), a person's /skill, schedules, goals.
|
|
65
|
+
'compact-checkpoint', 'skill-invocation', 'schedule', 'goal',
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* Messages that carry ANOTHER session's text into this one, and where the field naming that session
|
|
69
|
+
* sits: a subagent's closing message, a peer agent's message, a referenced transcript. Their taint is
|
|
70
|
+
* the other session's: see {@link sessionSource}.
|
|
71
|
+
*/
|
|
72
|
+
const DERIVED_SOURCES = {
|
|
73
|
+
'subagent-settled': 'senderSessionId',
|
|
74
|
+
'agent-message': 'senderSessionId',
|
|
75
|
+
'session-reference': 'sessionId',
|
|
76
|
+
};
|
|
77
|
+
/** This plugin's A2A tools that hand the model peer-originated text ({@link toolTaint}). */
|
|
78
|
+
const A2A_READ_TOOLS = new Set(['a2a_route', 'a2a_tasks', 'a2a_teams', 'a2a_status', 'a2a_probe', 'a2a_file_send', 'a2a_file_fetch']);
|
|
79
|
+
/** Host-only marker routeTrunk puts on a route result it built from this host alone (a symbol: no peer JSON can carry it). */
|
|
80
|
+
export const LOCAL_ROUTE = Symbol('a2a.localRoute');
|
|
81
|
+
/** Mark a route result as built on this host alone (host refusals, host-built delivery notes, a clean local session's reply). */
|
|
82
|
+
export function localRoute(value) { return Object.assign(value, { [LOCAL_ROUTE]: true }); }
|
|
83
|
+
/**
|
|
84
|
+
* gh#198 (owner ruling 2026-11-03 「按建议判据做」): whether one of this plugin's A2A tools just handed the
|
|
85
|
+
* model content that came from outside this host — a route that reached (or tried) a peer, inbound task
|
|
86
|
+
* rows, a directory row that is not this host's, a status/probe that names peers. A result built from
|
|
87
|
+
* this host's own facts alone is not remote. `a2a_route` reads the host-written `peer_content` flag the
|
|
88
|
+
* tool sets last (so a peer reply cannot set it); anything else defaults to remote.
|
|
89
|
+
*/
|
|
90
|
+
export function peerContentOf(name, value) {
|
|
91
|
+
const v = value;
|
|
92
|
+
if (v === null || typeof v !== 'object')
|
|
93
|
+
return false;
|
|
94
|
+
const rows = (x) => (Array.isArray(x) ? x : []);
|
|
95
|
+
const remotePeer = (row) => row?.peer !== 'local';
|
|
96
|
+
// a2a_file_send routes through the same trunk and carries the same host-written flag (#91 F2).
|
|
97
|
+
if (name === 'a2a_route' || name === 'a2a_file_send')
|
|
98
|
+
return v.peer_content !== false;
|
|
99
|
+
// A fetched file is content from outside this host, always (#91 design §3.5).
|
|
100
|
+
if (name === 'a2a_file_fetch')
|
|
101
|
+
return true;
|
|
102
|
+
if (name === 'a2a_tasks')
|
|
103
|
+
return rows(v.tasks).length > 0 || rows(v.archive).length > 0;
|
|
104
|
+
if (name === 'a2a_teams')
|
|
105
|
+
return rows(v.teams).some(row => row?.local !== true);
|
|
106
|
+
if (name === 'a2a_status')
|
|
107
|
+
return rows(v.peers).length > 0 || rows(v.inFlight).some(remotePeer) || rows(v.activity).some(remotePeer);
|
|
108
|
+
if (name === 'a2a_probe')
|
|
109
|
+
return rows(v.results).length > 0;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The via to mark the calling session with after one of the A2A tools returned, or '' when its result
|
|
114
|
+
* carries nothing from outside this host ({@link peerContentOf}).
|
|
115
|
+
*/
|
|
116
|
+
export function toolTaint(name, value) {
|
|
117
|
+
return A2A_READ_TOOLS.has(name) && peerContentOf(name, value) ? name.replace('_', '-') : '';
|
|
118
|
+
}
|
|
119
|
+
/** The `tool/result` meta the A2A tools persist, so the log backstop reads the same verdict ({@link sessionSource}). */
|
|
120
|
+
export const peerContentMeta = (name) => (_args, value) => ({ a2aPeerContent: peerContentOf(name, value) });
|
|
121
|
+
/**
|
|
122
|
+
* The event-log backstop of the session taint: whether ANY input in the session's log came from
|
|
123
|
+
* outside this host. A node reminder is local only when the HOST built it (`nodeReminder: true` on an
|
|
124
|
+
* `a2a-relay` source — remote deliveries are relayed by this host with a fixed source shape, so a
|
|
125
|
+
* remote caller cannot set that field). A session with no person's input at all is not local either.
|
|
126
|
+
*/
|
|
127
|
+
export function sessionSource(events, others = { tainted: () => false, local: () => false }) {
|
|
128
|
+
let inputs = 0;
|
|
129
|
+
// gh#198: a top-level A2A call whose persisted result says (host-written meta, not an error) that it
|
|
130
|
+
// carried nothing from outside this host is not evidence. A run_code sub-dispatch has no meta, and an
|
|
131
|
+
// older log has none either: both stay evidence.
|
|
132
|
+
const localCalls = new Set();
|
|
133
|
+
for (const event of events) {
|
|
134
|
+
const r = event;
|
|
135
|
+
if (r?.type === 'tool/result' && r.data?.meta?.a2aPeerContent === false && r.data.message?.isError !== true)
|
|
136
|
+
localCalls.add(r.data.message?.toolCallId);
|
|
137
|
+
}
|
|
138
|
+
for (const event of events) {
|
|
139
|
+
const e = event;
|
|
140
|
+
// An A2A tool call in the log (native or a run_code sub-dispatch) is remote evidence: it survives into a
|
|
141
|
+
// fork's inherited prefix, and backs up a mark that was lost.
|
|
142
|
+
if ((e?.type === 'tool/call' || e?.type === 'tool/ptc-dispatch') && typeof e.data?.name === 'string' && A2A_READ_TOOLS.has(e.data.name)) {
|
|
143
|
+
if (e.type === 'tool/call' && e.data.callId !== undefined && localCalls.has(e.data.callId))
|
|
144
|
+
continue;
|
|
145
|
+
return { local: false, via: `tool:${e.data.name}`, evidence: true };
|
|
146
|
+
}
|
|
147
|
+
if (e?.type !== 'user/message')
|
|
148
|
+
continue;
|
|
149
|
+
const source = e.data?.source;
|
|
150
|
+
const kind = source?.kind;
|
|
151
|
+
if (typeof kind !== 'string')
|
|
152
|
+
return { local: false, via: 'unknown', evidence: false };
|
|
153
|
+
if (NEUTRAL_SOURCES.has(kind))
|
|
154
|
+
continue;
|
|
155
|
+
if (kind === 'a2a-relay' && source?.nodeReminder === true)
|
|
156
|
+
continue;
|
|
157
|
+
// gh#198: an in-process delivery the HOST stamped with its local sender takes that session's taint
|
|
158
|
+
// (a remote delivery never carries the field: the host builds every relay source itself).
|
|
159
|
+
if (kind === 'a2a-relay' && typeof source?.localSender === 'string' && source.localSender !== '') {
|
|
160
|
+
if (others.tainted(source.localSender))
|
|
161
|
+
return { local: false, via: 'a2a-relay:tainted', evidence: true };
|
|
162
|
+
if (!others.local(source.localSender))
|
|
163
|
+
return { local: false, via: 'a2a-relay:unknown-sender', evidence: false };
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (kind === 'a2a-relay')
|
|
167
|
+
return { local: false, via: kind, evidence: true };
|
|
168
|
+
if (kind === 'user' && typeof source?.rpcId === 'string' && source.rpcId.startsWith('a2a-'))
|
|
169
|
+
return { local: false, via: 'a2a-nudge', evidence: true };
|
|
170
|
+
const field = DERIVED_SOURCES[kind];
|
|
171
|
+
if (field !== undefined) {
|
|
172
|
+
// Another session's text: its taint becomes ours (persisted); a clean one is neutral. A subagent is
|
|
173
|
+
// always this host's; a peer agent or a reference must be a session this host knows, else this
|
|
174
|
+
// call is refused (a native-teams bridge could name a remote caller here).
|
|
175
|
+
// A session reference names its sessions in references[].sessionId (the installed shape); the others name one.
|
|
176
|
+
const named = kind === 'session-reference' && Array.isArray(source?.references)
|
|
177
|
+
? source.references.map(r => r?.sessionId)
|
|
178
|
+
: [source?.[field]];
|
|
179
|
+
if (named.length === 0 || named.some(id => typeof id !== 'string' || id === ''))
|
|
180
|
+
return { local: false, via: `${kind}:no-sender`, evidence: false };
|
|
181
|
+
if (named.some(id => others.tainted(id)))
|
|
182
|
+
return { local: false, via: `${kind}:tainted`, evidence: true };
|
|
183
|
+
if (kind !== 'subagent-settled' && !named.every(id => others.local(id)))
|
|
184
|
+
return { local: false, via: `${kind}:unknown-sender`, evidence: false };
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!LOCAL_SOURCES.has(kind))
|
|
188
|
+
return { local: false, via: kind, evidence: false };
|
|
189
|
+
inputs++;
|
|
190
|
+
}
|
|
191
|
+
return inputs > 0 ? { local: true } : { local: false, via: 'no-input', evidence: false };
|
|
192
|
+
}
|
|
193
|
+
/** Sessions remembered as tainted; the oldest beyond this are forgotten (a forgotten one re-taints from its log or next delivery). */
|
|
194
|
+
export const TAINT_CAP = 4096;
|
|
195
|
+
/**
|
|
196
|
+
* The persisted half of the session taint (`node-taint.json`): the host marks a session the moment it
|
|
197
|
+
* delivers remote content into it, and the mark is never cleared. A file that does not parse is kept
|
|
198
|
+
* in place and read-only, and then EVERY session reads as tainted (fail-closed).
|
|
199
|
+
*/
|
|
200
|
+
export class SessionTaint {
|
|
201
|
+
root;
|
|
202
|
+
now;
|
|
203
|
+
rows = [];
|
|
204
|
+
readOnlyReason = '';
|
|
205
|
+
constructor(root, now = () => new Date().toISOString()) {
|
|
206
|
+
this.root = root;
|
|
207
|
+
this.now = now;
|
|
208
|
+
this.restore();
|
|
209
|
+
}
|
|
210
|
+
get file() { return join(this.root, 'node-taint.json'); }
|
|
211
|
+
restore() {
|
|
212
|
+
if (this.root === '' || !existsSync(this.file))
|
|
213
|
+
return;
|
|
214
|
+
let raw;
|
|
215
|
+
try {
|
|
216
|
+
raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
raw = undefined;
|
|
220
|
+
}
|
|
221
|
+
const list = raw?.sessions;
|
|
222
|
+
if (!Array.isArray(list)) {
|
|
223
|
+
const aside = `${this.file}.corrupt-${this.now().replace(/[:.]/g, '-')}`;
|
|
224
|
+
try {
|
|
225
|
+
copyFileSync(this.file, aside);
|
|
226
|
+
}
|
|
227
|
+
catch { /* the original stays in place either way */ }
|
|
228
|
+
this.readOnlyReason = `node-taint.json is unreadable (copy kept at ${aside}); every session reads as tainted until it is repaired`;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const r of list) {
|
|
232
|
+
// A row that is not a mark means the file was edited or damaged: fail closed, as for an unreadable file.
|
|
233
|
+
if (r === null || typeof r !== 'object' || typeof r.session !== 'string' || r.session === '' || typeof r.via !== 'string') {
|
|
234
|
+
const aside = `${this.file}.corrupt-${this.now().replace(/[:.]/g, '-')}`;
|
|
235
|
+
try {
|
|
236
|
+
copyFileSync(this.file, aside);
|
|
237
|
+
}
|
|
238
|
+
catch { /* the original stays in place either way */ }
|
|
239
|
+
this.readOnlyReason = `node-taint.json has a malformed row (copy kept at ${aside}); every session reads as tainted until it is repaired`;
|
|
240
|
+
this.rows = [];
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (!this.rows.some(x => x.session === r.session))
|
|
244
|
+
this.rows.push({ session: r.session, via: r.via, at: typeof r.at === 'string' ? r.at : '' });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
get readOnly() { return this.readOnlyReason; }
|
|
248
|
+
/** Why a session is tainted ('' = it is not). Fail-closed while the file is unreadable. */
|
|
249
|
+
of(session) {
|
|
250
|
+
if (this.readOnlyReason !== '')
|
|
251
|
+
return 'taint-store-unreadable';
|
|
252
|
+
return this.rows.find(r => r.session === session)?.via ?? '';
|
|
253
|
+
}
|
|
254
|
+
/** Mark a session (idempotent). Never throws; a mark that cannot be written still holds in memory. */
|
|
255
|
+
mark(session, via) {
|
|
256
|
+
if (session === '' || this.of(session) !== '')
|
|
257
|
+
return;
|
|
258
|
+
this.rows = [...this.rows, { session, via: via === '' ? 'delivery' : via, at: this.now() }].slice(-TAINT_CAP);
|
|
259
|
+
if (this.root === '' || this.readOnlyReason !== '')
|
|
260
|
+
return;
|
|
261
|
+
try {
|
|
262
|
+
mkdirSync(this.root, { recursive: true });
|
|
263
|
+
const tmp = `${this.file}.${String(process.pid)}.tmp`;
|
|
264
|
+
writeFileSync(tmp, JSON.stringify({ sessions: this.rows }), { mode: 0o600 });
|
|
265
|
+
renameSync(tmp, this.file);
|
|
266
|
+
}
|
|
267
|
+
catch { /* memory still holds it for this process; the log backstop covers a restart */ }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const fail = (code, error) => ({ ok: false, code, error });
|
|
271
|
+
function itemOf(raw) {
|
|
272
|
+
const r = raw;
|
|
273
|
+
if (r === null || typeof r !== 'object')
|
|
274
|
+
return undefined;
|
|
275
|
+
const keys = ['id', 'node', 'section', 'text', 'reason', 'expected_sha256', 'session', 'via', 'at'];
|
|
276
|
+
if (!keys.every(k => typeof r[k] === 'string'))
|
|
277
|
+
return undefined;
|
|
278
|
+
if (!MUTABLE_SECTIONS.includes(r.section))
|
|
279
|
+
return undefined;
|
|
280
|
+
return r;
|
|
281
|
+
}
|
|
282
|
+
/** The human review queue for remote-turn self-updates (`node-review.json`; corrupt = kept in place, read-only). */
|
|
283
|
+
export class NodeReviewQueue {
|
|
284
|
+
root;
|
|
285
|
+
now;
|
|
286
|
+
items = [];
|
|
287
|
+
readOnlyReason = '';
|
|
288
|
+
constructor(root, now = () => new Date().toISOString()) {
|
|
289
|
+
this.root = root;
|
|
290
|
+
this.now = now;
|
|
291
|
+
this.restore();
|
|
292
|
+
}
|
|
293
|
+
get file() { return join(this.root, 'node-review.json'); }
|
|
294
|
+
restore() {
|
|
295
|
+
if (this.root === '' || !existsSync(this.file))
|
|
296
|
+
return;
|
|
297
|
+
let raw;
|
|
298
|
+
try {
|
|
299
|
+
raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
raw = undefined;
|
|
303
|
+
}
|
|
304
|
+
const list = raw?.items;
|
|
305
|
+
if (!Array.isArray(list)) {
|
|
306
|
+
const aside = `${this.file}.corrupt-${this.now().replace(/[:.]/g, '-')}`;
|
|
307
|
+
try {
|
|
308
|
+
copyFileSync(this.file, aside);
|
|
309
|
+
}
|
|
310
|
+
catch { /* the original stays in place either way */ }
|
|
311
|
+
this.readOnlyReason = `node-review.json is unreadable (copy kept at ${aside}); repair it, then restart`;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
for (const raw of list) {
|
|
315
|
+
const item = itemOf(raw);
|
|
316
|
+
if (item !== undefined && !this.items.some(i => i.id === item.id))
|
|
317
|
+
this.items.push(item);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
commit(items) {
|
|
321
|
+
if (this.readOnlyReason !== '')
|
|
322
|
+
return fail('unavailable', `the review queue is read-only: ${this.readOnlyReason}`);
|
|
323
|
+
if (this.root !== '') {
|
|
324
|
+
try {
|
|
325
|
+
mkdirSync(this.root, { recursive: true });
|
|
326
|
+
const tmp = `${this.file}.${String(process.pid)}.tmp`;
|
|
327
|
+
writeFileSync(tmp, JSON.stringify({ items }), { mode: 0o600 });
|
|
328
|
+
renameSync(tmp, this.file);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
return fail('unavailable', `could not persist node-review.json: ${String(error)}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
this.items = items;
|
|
335
|
+
return { ok: true, value: true };
|
|
336
|
+
}
|
|
337
|
+
get readOnly() { return this.readOnlyReason; }
|
|
338
|
+
list(node) { return node === undefined ? this.items : this.items.filter(i => i.node === node); }
|
|
339
|
+
get(id) { return this.items.find(i => i.id === id); }
|
|
340
|
+
/**
|
|
341
|
+
* Queue one write. A newer proposal for the same node, section and session REPLACES the older one
|
|
342
|
+
* (the human reviews the latest wish, not a backlog of drafts); the text must fit the section quota;
|
|
343
|
+
* each node holds at most {@link REVIEW_PER_NODE_MAX} items and the queue {@link REVIEW_QUEUE_MAX}.
|
|
344
|
+
*/
|
|
345
|
+
add(input) {
|
|
346
|
+
if ([...input.text].length > QUOTA.mutableSection)
|
|
347
|
+
return fail('quota', `section text exceeds ${String(QUOTA.mutableSection)} characters`);
|
|
348
|
+
const rest = this.items.filter(i => !(i.node === input.node && i.section === input.section && i.session === input.session));
|
|
349
|
+
if (rest.filter(i => i.node === input.node).length >= REVIEW_PER_NODE_MAX)
|
|
350
|
+
return fail('quota', `node ${input.node} already has ${String(REVIEW_PER_NODE_MAX)} writes waiting for review — a human must clear them first`);
|
|
351
|
+
if (rest.length >= REVIEW_QUEUE_MAX)
|
|
352
|
+
return fail('quota', `the review queue is full (${String(REVIEW_QUEUE_MAX)} items) — a human must clear it first`);
|
|
353
|
+
const item = { ...input, id: `rv-${randomBytes(6).toString('hex')}`, at: this.now() };
|
|
354
|
+
const done = this.commit([...rest, item]);
|
|
355
|
+
return done.ok ? { ok: true, value: item } : done;
|
|
356
|
+
}
|
|
357
|
+
remove(id) {
|
|
358
|
+
if (this.get(id) === undefined)
|
|
359
|
+
return fail('not-found', `no review item ${id}`);
|
|
360
|
+
return this.commit(this.items.filter(i => i.id !== id));
|
|
361
|
+
}
|
|
362
|
+
/** A node archived: its pending items go with it. */
|
|
363
|
+
dropNode(node) {
|
|
364
|
+
if (this.items.some(i => i.node === node))
|
|
365
|
+
this.commit(this.items.filter(i => i.node !== node));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Node writes (`by: node:…`) frozen for `node` on the UTC day of `now`. */
|
|
369
|
+
export function nodeWritesToday(store, node, now) {
|
|
370
|
+
const day = now.slice(0, 10);
|
|
371
|
+
let n = 0;
|
|
372
|
+
for (const version of store.versions(node, 'mutable')) {
|
|
373
|
+
const v = store.version(node, 'mutable', version);
|
|
374
|
+
if (v !== undefined && typeof v.by === 'string' && v.by.startsWith('node:') && typeof v.at === 'string' && v.at.slice(0, 10) === day)
|
|
375
|
+
n++;
|
|
376
|
+
}
|
|
377
|
+
return n;
|
|
378
|
+
}
|
|
379
|
+
/** One self-update call, decided. Pure over its deps (the tool below only adapts it). */
|
|
380
|
+
export function selfUpdate(d, node, session, agent, args) {
|
|
381
|
+
const section = typeof args.section === 'string' ? args.section : '';
|
|
382
|
+
if (!MUTABLE_SECTIONS.includes(section))
|
|
383
|
+
return { ok: false, code: 'bad-request', error: `section must be one of ${MUTABLE_SECTIONS.join(' / ')}` };
|
|
384
|
+
if (typeof args.text !== 'string')
|
|
385
|
+
return { ok: false, code: 'bad-request', error: 'text must be a string' };
|
|
386
|
+
const reason = typeof args.reason === 'string' ? args.reason.trim() : '';
|
|
387
|
+
if (reason === '')
|
|
388
|
+
return { ok: false, code: 'bad-request', error: 'reason is required' };
|
|
389
|
+
const expected = typeof args.expected_sha256 === 'string' ? args.expected_sha256 : '';
|
|
390
|
+
// The CAS sha the model needs: every refusal hands back the layer's current one.
|
|
391
|
+
const current = d.store.get(node)?.prompt.mutable_sha256 ?? '';
|
|
392
|
+
const marked = d.taint.of(session);
|
|
393
|
+
const logged = marked === '' ? sessionSource(d.events(agent), { tainted: id => d.taint.of(id) !== '', local: id => d.localSession?.(id) === true }) : undefined;
|
|
394
|
+
// Only remote evidence is persisted; an unknown source or no person's input refuses this call alone.
|
|
395
|
+
if (logged !== undefined && !logged.local && logged.evidence)
|
|
396
|
+
d.taint.mark(session, `log:${logged.via}`);
|
|
397
|
+
const via = marked !== '' ? marked : logged !== undefined && !logged.local ? `log:${logged.via}` : '';
|
|
398
|
+
if (via !== '') {
|
|
399
|
+
const parked = d.queue.add({ node, section, text: args.text, reason, expected_sha256: expected, session, via });
|
|
400
|
+
if (!parked.ok)
|
|
401
|
+
return { ok: false, code: parked.code, current_sha256: current, error: `this session has received input from outside this host (${via}); the write could not be queued for review: ${parked.error}` };
|
|
402
|
+
return { ok: false, code: 'queued', queued: parked.value.id, current_sha256: current, error: `this session has received input from outside this host (${via}), so the write was NOT applied: it waits for a human in the review queue (${parked.value.id})` };
|
|
403
|
+
}
|
|
404
|
+
const now = (d.now ?? (() => new Date().toISOString()))();
|
|
405
|
+
if (nodeWritesToday(d.store, node, now) >= SELF_UPDATE_DAILY_MAX)
|
|
406
|
+
return { ok: false, code: 'quota', current_sha256: current, error: `daily self-update cap reached (${String(SELF_UPDATE_DAILY_MAX)} per node per UTC day)` };
|
|
407
|
+
const written = d.store.writeLayer(node, 'mutable', { [section]: args.text }, { expectedSha: expected, reason, by: `node:${session}`, via: 'node-tool' });
|
|
408
|
+
if (!written.ok)
|
|
409
|
+
return { ok: false, code: written.code, current_sha256: current, error: written.error };
|
|
410
|
+
d.onWritten(session, written.value, section);
|
|
411
|
+
return { ok: true, version: written.value.version, sha256: written.value.sha256 };
|
|
412
|
+
}
|
|
413
|
+
/** The tool registered on a bound session's own scope. */
|
|
414
|
+
export function selfUpdateTool(d, node, session) {
|
|
415
|
+
return defineTool({
|
|
416
|
+
name: 'node_update_self',
|
|
417
|
+
description: `Update one section of YOUR node's self-notes (node "${node}", the 节点自记 layer): 当前状态 / 执行要点 / 已知陷阱 / 当前重点. `
|
|
418
|
+
+ `Give the FULL new text of that section (≤${String(QUOTA.mutableSection)} characters), a reason, and the sha256 of the mutable layer you read `
|
|
419
|
+
+ '(the CAS guard: the a2a:node:self context shows the current one, and every reply of this tool returns current_sha256). '
|
|
420
|
+
+ 'The new version takes effect from your next session; this session sees it as a runtime note. You cannot change your name, role, team or '
|
|
421
|
+
+ 'the 节点契约. Once this session has received input from outside this host (an A2A delivery), no write is applied — each is queued for a '
|
|
422
|
+
+ `human to approve. At most ${String(SELF_UPDATE_DAILY_MAX)} writes per node per day.`,
|
|
423
|
+
parameters: {
|
|
424
|
+
section: { type: 'string', required: true, enum: [...MUTABLE_SECTIONS], description: 'Which section to replace.' },
|
|
425
|
+
text: { type: 'string', required: true, description: 'The full new text of that section.' },
|
|
426
|
+
reason: { type: 'string', required: true, description: 'Why (recorded with the version).' },
|
|
427
|
+
expected_sha256: { type: 'string', required: true, description: 'The current mutable layer sha256 ("" if the layer has no version yet).' },
|
|
428
|
+
},
|
|
429
|
+
output: {
|
|
430
|
+
schema: {
|
|
431
|
+
type: 'object',
|
|
432
|
+
additionalProperties: false,
|
|
433
|
+
properties: {
|
|
434
|
+
ok: { type: 'boolean', required: true },
|
|
435
|
+
version: { type: 'number' },
|
|
436
|
+
sha256: { type: 'string' },
|
|
437
|
+
queued: { type: 'string', description: 'Review item id when the write was parked for a human.' },
|
|
438
|
+
current_sha256: { type: 'string', description: 'The mutable layer sha to pass as expected_sha256 next time.' },
|
|
439
|
+
code: { type: 'string' },
|
|
440
|
+
error: { type: 'string' },
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
render: (_args, value) => [{
|
|
444
|
+
type: 'text',
|
|
445
|
+
text: value.ok === true
|
|
446
|
+
? `节点自记已写入 v${String(value.version)}(sha256 ${String(value.sha256)})`
|
|
447
|
+
: `未写入:${String(value.error)}${typeof value.current_sha256 === 'string' ? `(当前 sha256 ${value.current_sha256})` : ''}`,
|
|
448
|
+
}],
|
|
449
|
+
},
|
|
450
|
+
presentCall: () => ({ card: 'generic', title: 'Node self-update', kind: 'other', rawInput: null }),
|
|
451
|
+
execute: async (args, exec) => selfUpdate(d, node, session, exec?.agent, args),
|
|
452
|
+
});
|
|
453
|
+
}
|