@nelsonlongxiang/dsh-open-a2a-net 0.6.42 → 0.7.0
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 +1 -0
- package/README_zh.md +1 -0
- package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/cli/a2a.mjs +43 -7
- package/lib/a2a-client.js +10 -2
- package/lib/admitted-bookkeeping.js +7 -0
- package/lib/canvas-discovery.js +11 -1
- package/lib/canvas-member.js +43 -0
- package/lib/canvas-store.js +315 -17
- package/lib/client.js +62 -72
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/direct-forward.js +9 -7
- package/lib/faces/admin.js +150 -67
- package/lib/faces/files.js +2 -1
- package/lib/faces/wire.js +569 -469
- package/lib/followup-delivery.js +89 -0
- package/lib/idempotency-store.js +8 -0
- package/lib/inbound-verification.js +23 -0
- package/lib/index.js +534 -166
- package/lib/nudge-observability.js +34 -0
- package/lib/origin-claim.js +36 -9
- package/lib/outbound-scope.js +4 -2
- package/lib/peer-directory.js +15 -5
- package/lib/peer-store.js +31 -17
- package/lib/queue-guard.js +4 -3
- package/lib/receipt-ladder.js +1 -1
- package/lib/receipt-retry.js +58 -0
- package/lib/reconciliation-reminders.js +148 -0
- package/lib/reconciliation-state.js +27 -0
- package/lib/session-transcript.js +46 -18
- package/lib/skill/SKILL.md +5 -0
- package/lib/skill/references/cli.md +4 -1
- package/lib/skill/references/routing.md +31 -1
- package/lib/task-auth.js +3 -3
- package/lib/task-delivery.js +23 -0
- package/lib/task-ledger-persistence.js +51 -0
- package/lib/task-ledger-rows.js +5 -0
- package/lib/task-ledger.js +219 -20
- package/lib/tools/info.js +37 -7
- package/lib/types/a2a-client.d.ts +1 -1
- package/lib/types/admitted-bookkeeping.d.ts +5 -0
- package/lib/types/canvas-member.d.ts +10 -0
- package/lib/types/canvas-store.d.ts +56 -1
- package/lib/types/config.d.ts +2 -0
- package/lib/types/faces/admin.d.ts +21 -0
- package/lib/types/faces/files.d.ts +1 -1
- package/lib/types/faces/wire.d.ts +21 -14
- package/lib/types/followup-delivery.d.ts +25 -0
- package/lib/types/idempotency-store.d.ts +2 -0
- package/lib/types/inbound-verification.d.ts +14 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/nudge-observability.d.ts +17 -0
- package/lib/types/origin-claim.d.ts +7 -1
- package/lib/types/outbound-scope.d.ts +1 -0
- package/lib/types/peer-store.d.ts +4 -0
- package/lib/types/queue-guard.d.ts +9 -2
- package/lib/types/receipt-ladder.d.ts +2 -0
- package/lib/types/receipt-retry.d.ts +22 -0
- package/lib/types/reconciliation-reminders.d.ts +41 -0
- package/lib/types/reconciliation-state.d.ts +32 -0
- package/lib/types/session-transcript.d.ts +6 -1
- package/lib/types/task-auth.d.ts +7 -0
- package/lib/types/task-delivery.d.ts +17 -0
- package/lib/types/task-ledger-persistence.d.ts +3 -0
- package/lib/types/task-ledger-rows.d.ts +19 -2
- package/lib/types/task-ledger.d.ts +48 -1
- package/lib/types/tools/info.d.ts +3 -0
- package/lib/types/types.d.ts +3 -0
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +17 -2
- package/nexus-stage/src/member-provenance-picker.ts +13 -0
- package/nexus-stage/src/planning-member-handles.ts +4 -1
- package/nexus-stage/src/planning-view.ts +3 -3
- package/nexus-stage/src/workbench.ts +51 -7
- package/nexus-stage/src/world.ts +1 -0
- package/package.json +3 -2
- package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Observation of accepted input whose task birth did not commit; not a task status. */
|
|
2
|
+
import { safeObservationCode } from "./nudge-observability.js";
|
|
3
|
+
export const ADMITTED_BOOKKEEPING_NOTE = '已送达、接收方记账失败,请对账,不要重派。Delivered, but receiver bookkeeping failed; reconcile the original task, do not redispatch.';
|
|
4
|
+
export class AdmittedBookkeepingError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(error) { super(ADMITTED_BOOKKEEPING_NOTE); this.code = safeObservationCode(error, 'LEDGER_WRITE_FAILED'); }
|
|
7
|
+
}
|
package/lib/canvas-discovery.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Read-only canvas ownership projection; never authorizes or selects a dispatch candidate. */
|
|
2
2
|
import { advertisedCanvasTeams } from "./peer-directory.js";
|
|
3
|
+
import { splitQualifiedMember } from "./canvas-member.js";
|
|
3
4
|
export function canvasOwners(local, ownUrl, peers) {
|
|
4
5
|
const rows = local.map(row => ({ ...row, owner: ownUrl, memberCount: row.members.length, local: true }));
|
|
5
6
|
const seen = new Set();
|
|
@@ -14,7 +15,16 @@ export function canvasOwners(local, ownUrl, peers) {
|
|
|
14
15
|
return rows;
|
|
15
16
|
}
|
|
16
17
|
export function canvasMemberships(owners, handle, localId, resolveLocal) {
|
|
17
|
-
return owners.
|
|
18
|
+
return owners.flatMap(row => {
|
|
19
|
+
const matched = (row.scopeMembers ?? row.members).filter(ref => (row.local && localId !== undefined && ref === localId) ||
|
|
20
|
+
(resolveLocal !== undefined && localId !== undefined && (ref === handle || splitQualifiedMember(ref)?.handle === handle) && resolveLocal(ref) === localId) ||
|
|
21
|
+
(resolveLocal === undefined && ref === handle));
|
|
22
|
+
if (!matched.length)
|
|
23
|
+
return [];
|
|
24
|
+
const inferred = matched.some(ref => ref === handle && splitQualifiedMember(ref) === undefined);
|
|
25
|
+
const { members: _members, scopeMembers: _scopeMembers, ...detail } = row;
|
|
26
|
+
return [{ ...detail, provenance: inferred ? 'unknown' : 'confirmed', ...(inferred ? { compatibilityInference: true } : {}) }];
|
|
27
|
+
});
|
|
18
28
|
}
|
|
19
29
|
export function canvasFailure(team, owners, reason) {
|
|
20
30
|
if (!new RegExp('^.+/canvas/[^/]+$').test(team))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Session-member provenance (#334). fp8 selects an owner; only a full key anchors it. */
|
|
2
|
+
import { createPublicKey } from 'node:crypto';
|
|
3
|
+
import { canvasOwnerFingerprint } from "./canvas-id.js";
|
|
4
|
+
import { CLI_HANDLE_RE } from "./cli-deposit.js";
|
|
5
|
+
/** CLI handles keep their existing home/deposit rules and are never parsed as session members. */
|
|
6
|
+
export function splitQualifiedMember(ref) {
|
|
7
|
+
if (CLI_HANDLE_RE.test(ref))
|
|
8
|
+
return undefined;
|
|
9
|
+
const match = /^([^/\s@]+\/[0-9a-f]{8})@([0-9a-f]{8})$/.exec(ref);
|
|
10
|
+
return match ? { handle: match[1], fingerprint: match[2] } : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function validMemberOwner(ref, key) {
|
|
13
|
+
const member = splitQualifiedMember(ref);
|
|
14
|
+
if (!member || !key || canvasOwnerFingerprint(key) !== member.fingerprint)
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const bytes = Buffer.from(key, 'base64'), publicKey = createPublicKey({ key: bytes, format: 'der', type: 'spki' });
|
|
18
|
+
return publicKey.asymmetricKeyType === 'ed25519' && publicKey.export({ format: 'der', type: 'spki' }).equals(bytes) && bytes.toString('base64') === key;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function qualifiedSessionMember(handle, key) {
|
|
25
|
+
const ref = handle + '@' + canvasOwnerFingerprint(key);
|
|
26
|
+
return validMemberOwner(ref, key) ? ref : undefined;
|
|
27
|
+
}
|
|
28
|
+
export function memberProvenance(ref, ownerKey, ownKey) {
|
|
29
|
+
if (CLI_HANDLE_RE.test(ref))
|
|
30
|
+
return 'cli';
|
|
31
|
+
if (!ref.includes('/'))
|
|
32
|
+
return 'local-id';
|
|
33
|
+
const qualified = splitQualifiedMember(ref);
|
|
34
|
+
if (!qualified)
|
|
35
|
+
return ref.includes('@') ? 'conflict' : 'unknown';
|
|
36
|
+
if (ownerKey === undefined)
|
|
37
|
+
return qualified.fingerprint === canvasOwnerFingerprint(ownKey) ? 'confirmed-local' : 'unknown';
|
|
38
|
+
if (!validMemberOwner(ref, ownerKey))
|
|
39
|
+
return 'conflict';
|
|
40
|
+
if (qualified.fingerprint === canvasOwnerFingerprint(ownKey) && ownerKey !== ownKey)
|
|
41
|
+
return 'conflict';
|
|
42
|
+
return ownerKey === ownKey ? 'confirmed-local' : 'confirmed-remote';
|
|
43
|
+
}
|
package/lib/canvas-store.js
CHANGED
|
@@ -16,9 +16,16 @@
|
|
|
16
16
|
* read fresh by the state route.
|
|
17
17
|
* @module @nelsonlongxiang/dsh-open-a2a-net/canvas-store
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, openSync, closeSync, fsyncSync } from 'node:fs';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { hostname } from 'node:os';
|
|
20
22
|
import { dirname } from 'node:path';
|
|
21
23
|
import { CLI_HANDLE_RE } from "./cli-deposit.js";
|
|
24
|
+
import { splitQualifiedMember, validMemberOwner, memberProvenance } from "./canvas-member.js";
|
|
25
|
+
/** A bare session handle is not a CLI identity or a qualified owner selector. */
|
|
26
|
+
export function legacyBareMember(id) {
|
|
27
|
+
return isRemoteMemberRef(id) && !CLI_HANDLE_RE.test(id) && !/@[0-9a-f]{8}$/.test(id);
|
|
28
|
+
}
|
|
22
29
|
/** Upper bounds: a canvas taxonomy, not a database. */
|
|
23
30
|
export const CANVAS_TEAM_CAP = 64;
|
|
24
31
|
export const CANVAS_MEMBER_CAP = 32;
|
|
@@ -67,18 +74,29 @@ function validNewName(raw) {
|
|
|
67
74
|
* registry knowledge: callers validate join state and resolve members.
|
|
68
75
|
*/
|
|
69
76
|
export class CanvasStore {
|
|
77
|
+
options;
|
|
70
78
|
/** Team entries in creation order; members in add order (routing priority). */
|
|
71
79
|
teams = [];
|
|
72
80
|
cliHome = {};
|
|
73
81
|
cliHomeCleared = new Set();
|
|
82
|
+
suspended = [];
|
|
83
|
+
revision = 0;
|
|
84
|
+
committed = '';
|
|
85
|
+
memberOwners = {};
|
|
86
|
+
disk;
|
|
87
|
+
readOnlyReason = '';
|
|
88
|
+
lastWriteError = '';
|
|
89
|
+
failedSuspension = false;
|
|
74
90
|
path;
|
|
75
91
|
listeners = new Set();
|
|
76
92
|
/**
|
|
77
93
|
* @param path - the snapshot file; an empty path keeps the store in memory.
|
|
78
94
|
*/
|
|
79
|
-
constructor(path) {
|
|
95
|
+
constructor(path, options = {}) {
|
|
96
|
+
this.options = options;
|
|
80
97
|
this.path = path;
|
|
81
98
|
this.restore();
|
|
99
|
+
this.committed = this.serialize();
|
|
82
100
|
}
|
|
83
101
|
/**
|
|
84
102
|
* Observe changes: the listener runs after every write that changed the
|
|
@@ -95,7 +113,60 @@ export class CanvasStore {
|
|
|
95
113
|
if (this.path === '' || !existsSync(this.path))
|
|
96
114
|
return;
|
|
97
115
|
try {
|
|
98
|
-
|
|
116
|
+
this.disk = readFileSync(this.path, 'utf8');
|
|
117
|
+
const snapshot = JSON.parse(this.disk);
|
|
118
|
+
if (snapshot?.formatVersion !== undefined && snapshot.formatVersion !== 2) {
|
|
119
|
+
this.readOnlyReason = 'unsupported canvas formatVersion';
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (snapshot?.memberOwners && typeof snapshot.memberOwners === 'object' && !Array.isArray(snapshot.memberOwners))
|
|
123
|
+
this.memberOwners = { ...snapshot.memberOwners };
|
|
124
|
+
if (snapshot?.formatVersion === 2) {
|
|
125
|
+
if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || !Array.isArray(snapshot.suspendedMembers) || !Array.isArray(snapshot.teams) || snapshot.teams.length > CANVAS_TEAM_CAP)
|
|
126
|
+
throw Error('incomplete or oversized canvas v2 snapshot');
|
|
127
|
+
const names = new Set();
|
|
128
|
+
for (const entry of snapshot.teams) {
|
|
129
|
+
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || validName(entry.name) !== entry.name || names.has(entry.name) || !Array.isArray(entry.members) || entry.members.length > CANVAS_MEMBER_CAP)
|
|
130
|
+
throw Error('invalid canvas v2 team');
|
|
131
|
+
names.add(entry.name);
|
|
132
|
+
const members = new Set();
|
|
133
|
+
for (const member of entry.members) {
|
|
134
|
+
if (typeof member !== 'string' || member === '' || /[\x00-\x1f\x7f]/.test(member) || members.has(member))
|
|
135
|
+
throw Error('invalid canvas v2 member');
|
|
136
|
+
members.add(member);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (snapshot.memberOwners !== undefined && (snapshot.memberOwners === null || typeof snapshot.memberOwners !== 'object' || Array.isArray(snapshot.memberOwners) || Object.entries(snapshot.memberOwners).some(([ref, key]) => typeof key !== 'string' || !validMemberOwner(ref, key))))
|
|
140
|
+
throw Error('invalid canvas v2 owners');
|
|
141
|
+
const fpKeys = new Map();
|
|
142
|
+
for (const [ref, key] of Object.entries(snapshot.memberOwners ?? {})) {
|
|
143
|
+
const fp = splitQualifiedMember(ref).fingerprint;
|
|
144
|
+
if (fpKeys.has(fp) && fpKeys.get(fp) !== key)
|
|
145
|
+
throw Error('conflicting canvas member fingerprints');
|
|
146
|
+
fpKeys.set(fp, key);
|
|
147
|
+
}
|
|
148
|
+
if (snapshot.cliHome !== undefined && (snapshot.cliHome === null || typeof snapshot.cliHome !== 'object' || Array.isArray(snapshot.cliHome) || Object.entries(snapshot.cliHome).some(([handle, home]) => !CLI_HANDLE_RE.test(handle) || typeof home !== 'string' || !validHomeOrigin(home) || !snapshot.teams.some(t => t.members.includes(handle)))))
|
|
149
|
+
throw Error('invalid canvas v2 CLI homes');
|
|
150
|
+
if (snapshot.cliHomeCleared !== undefined && (!Array.isArray(snapshot.cliHomeCleared) || new Set(snapshot.cliHomeCleared).size !== snapshot.cliHomeCleared.length || snapshot.cliHomeCleared.some(handle => typeof handle !== 'string' || !CLI_HANDLE_RE.test(handle) || !snapshot.teams.some(t => t.members.includes(handle)))))
|
|
151
|
+
throw Error('invalid canvas v2 cleared homes');
|
|
152
|
+
}
|
|
153
|
+
this.revision = Number.isSafeInteger(snapshot?.revision) && snapshot.revision >= 0 ? snapshot.revision : 0;
|
|
154
|
+
if (snapshot?.suspendedMembers !== undefined) {
|
|
155
|
+
if (!Array.isArray(snapshot.suspendedMembers))
|
|
156
|
+
throw Error('invalid suspension records');
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
for (const row of snapshot.suspendedMembers) {
|
|
159
|
+
if (!row || typeof row.team !== 'string' || typeof row.member !== 'string' || typeof row.localSessionId !== 'string' || !['leave', 'archive', 'remove'].includes(row.reason) || typeof row.at !== 'string' || !Number.isFinite(Date.parse(row.at)) || typeof row.token !== 'string' || !row.token)
|
|
160
|
+
throw Error('invalid suspension record');
|
|
161
|
+
if (snapshot.formatVersion === 2 && !snapshot.teams?.some(t => t.name === row.team && t.members.includes(row.member)))
|
|
162
|
+
throw Error('orphan suspension record');
|
|
163
|
+
const key = JSON.stringify([row.team, row.member, row.reason, row.localSessionId]);
|
|
164
|
+
if (seen.has(key))
|
|
165
|
+
throw Error('duplicate suspension record');
|
|
166
|
+
seen.add(key);
|
|
167
|
+
this.suspended.push({ ...row });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
99
170
|
if (!Array.isArray(snapshot?.teams))
|
|
100
171
|
return;
|
|
101
172
|
const kept = [];
|
|
@@ -125,13 +196,31 @@ export class CanvasStore {
|
|
|
125
196
|
}
|
|
126
197
|
}
|
|
127
198
|
catch {
|
|
128
|
-
|
|
129
|
-
|
|
199
|
+
this.teams = [];
|
|
200
|
+
this.suspended = [];
|
|
201
|
+
// A malformed v2 suspension must never silently reactivate members.
|
|
202
|
+
if (this.disk?.includes('suspendedMembers') || this.disk?.includes('formatVersion'))
|
|
203
|
+
this.readOnlyReason = 'invalid canvas v2 snapshot';
|
|
130
204
|
}
|
|
131
205
|
}
|
|
132
206
|
/** Persist the current snapshot (no-op without a path), then tell the listeners. */
|
|
133
207
|
persist() {
|
|
134
|
-
|
|
208
|
+
try {
|
|
209
|
+
this.write();
|
|
210
|
+
this.committed = this.serialize();
|
|
211
|
+
this.lastWriteError = '';
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
this.lastWriteError = String(error);
|
|
215
|
+
const previous = JSON.parse(this.committed);
|
|
216
|
+
this.teams = previous.teams.map(t => ({ name: t.name, members: [...t.members] }));
|
|
217
|
+
this.suspended = [...previous.suspendedMembers ?? []];
|
|
218
|
+
this.memberOwners = { ...previous.memberOwners };
|
|
219
|
+
this.cliHome = { ...previous.cliHome };
|
|
220
|
+
this.cliHomeCleared = new Set(previous.cliHomeCleared);
|
|
221
|
+
this.revision = previous.revision ?? 0;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
135
224
|
for (const listener of this.listeners) {
|
|
136
225
|
try {
|
|
137
226
|
listener();
|
|
@@ -139,17 +228,100 @@ export class CanvasStore {
|
|
|
139
228
|
catch { /* a listener's failure is its own */ }
|
|
140
229
|
}
|
|
141
230
|
}
|
|
231
|
+
serialize() {
|
|
232
|
+
return JSON.stringify({ teams: this.teams, memberOwners: this.memberOwners, suspendedMembers: this.suspended, cliHome: this.cliHome, cliHomeCleared: [...this.cliHomeCleared], revision: this.revision });
|
|
233
|
+
}
|
|
142
234
|
write() {
|
|
143
|
-
if (this.
|
|
144
|
-
|
|
145
|
-
|
|
235
|
+
if (this.readOnlyReason)
|
|
236
|
+
throw Error(this.readOnlyReason);
|
|
237
|
+
const revision = this.revision + 1;
|
|
238
|
+
const snapshot = { formatVersion: 2, revision,
|
|
239
|
+
teams: this.teams.map(t => ({ name: t.name, members: [...t.members] })),
|
|
240
|
+
suspendedMembers: this.suspended.map(r => ({ ...r })),
|
|
241
|
+
...(Object.keys(this.memberOwners).length ? { memberOwners: this.memberOwners } : {}),
|
|
242
|
+
...(Object.keys(this.cliHome).length ? { cliHome: { ...this.cliHome } } : {}),
|
|
243
|
+
...(this.cliHomeCleared.size ? { cliHomeCleared: [...this.cliHomeCleared] } : {}) };
|
|
244
|
+
const bytes = JSON.stringify(snapshot);
|
|
245
|
+
if (this.path !== '') {
|
|
146
246
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
147
|
-
const
|
|
148
|
-
|
|
247
|
+
const lock = this.path + '.lock';
|
|
248
|
+
// Never steal another writer's lock. Diagnostics do not authorize removal.
|
|
249
|
+
try {
|
|
250
|
+
mkdirSync(lock);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
throw Error('canvas lock held at ' + lock + ': ' + JSON.stringify(this.lockInfo()) + ' (' + String(error) + ')');
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
writeFileSync(lock + '/owner.json', JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() }), { mode: 0o600, flag: 'wx' });
|
|
257
|
+
const current = existsSync(this.path) ? readFileSync(this.path, 'utf8') : undefined;
|
|
258
|
+
if (current !== this.disk)
|
|
259
|
+
throw Error('canvas snapshot changed externally; reload before writing');
|
|
260
|
+
let oldVersion;
|
|
261
|
+
try {
|
|
262
|
+
oldVersion = current === undefined ? undefined : JSON.parse(current)?.formatVersion;
|
|
263
|
+
}
|
|
264
|
+
catch { /* back up malformed legacy data too */ }
|
|
265
|
+
if (current !== undefined && oldVersion !== 2) {
|
|
266
|
+
const backup = this.path + '.pre-v2';
|
|
267
|
+
if (existsSync(backup)) {
|
|
268
|
+
if (readFileSync(backup, 'utf8') !== current)
|
|
269
|
+
throw Error('canvas pre-v2 backup differs; inspect before migration: ' + backup);
|
|
270
|
+
}
|
|
271
|
+
else
|
|
272
|
+
this.replaceFile(backup, current);
|
|
273
|
+
}
|
|
274
|
+
this.replaceFile(this.path, bytes);
|
|
275
|
+
this.disk = bytes;
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
rmSync(lock, { recursive: true });
|
|
279
|
+
}
|
|
149
280
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
281
|
+
this.revision = revision;
|
|
282
|
+
}
|
|
283
|
+
replaceFile(target, bytes) {
|
|
284
|
+
const temporary = target + '.tmp-' + randomUUID();
|
|
285
|
+
try {
|
|
286
|
+
writeFileSync(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
|
287
|
+
const fd = openSync(temporary, 'r+');
|
|
288
|
+
try {
|
|
289
|
+
fsyncSync(fd);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
closeSync(fd);
|
|
293
|
+
}
|
|
294
|
+
renameSync(temporary, target);
|
|
295
|
+
if (process.platform !== 'win32') {
|
|
296
|
+
try {
|
|
297
|
+
const dir = openSync(dirname(target), 'r');
|
|
298
|
+
try {
|
|
299
|
+
fsyncSync(dir);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
closeSync(dir);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// Rename already committed: rolled-back memory must not be used as active state.
|
|
307
|
+
this.readOnlyReason = 'canvas directory fsync failed after rename: ' + String(error);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
rmSync(temporary, { force: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
lockInfo() {
|
|
317
|
+
const path = this.path + '.lock';
|
|
318
|
+
if (this.path === '' || !existsSync(path))
|
|
319
|
+
return undefined;
|
|
320
|
+
try {
|
|
321
|
+
return { path, owner: JSON.parse(readFileSync(path + '/owner.json', 'utf8')) };
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
return { path, error: 'owner metadata unavailable: ' + String(error) };
|
|
153
325
|
}
|
|
154
326
|
}
|
|
155
327
|
/** Every team name, in creation order. */
|
|
@@ -163,7 +335,121 @@ export class CanvasStore {
|
|
|
163
335
|
/** The named team's member ids in routing-priority order (a copy). */
|
|
164
336
|
membersOf(name) {
|
|
165
337
|
const entry = this.teams.find(t => t.name === name);
|
|
166
|
-
|
|
338
|
+
if (this.readOnlyReason || this.failedSuspension)
|
|
339
|
+
return [];
|
|
340
|
+
return entry === undefined ? [] : entry.members.filter(id => {
|
|
341
|
+
if (this.isSuspended(name, id) || (this.options.legacyBareMembers === false && legacyBareMember(id)))
|
|
342
|
+
return false;
|
|
343
|
+
if (this.options.ownPublicKey && id.includes('@') && !CLI_HANDLE_RE.test(id)) {
|
|
344
|
+
const provenance = memberProvenance(id, this.memberOwners[id], this.options.ownPublicKey);
|
|
345
|
+
return provenance === 'confirmed-local' || provenance === 'confirmed-remote';
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** Inspection only: never use raw rows for routing, authorization or counts. */
|
|
351
|
+
rawMembersOf(name) { return [...(this.teams.find(t => t.name === name)?.members ?? [])]; }
|
|
352
|
+
isSuspended(name, member) { return this.suspended.some(r => r.team === name && r.member === member); }
|
|
353
|
+
suspensionOf(name, member) { return this.suspended.filter(r => r.team === name && r.member === member).map(r => ({ ...r })); }
|
|
354
|
+
state() {
|
|
355
|
+
return { revision: this.revision, suspendedMembers: this.suspended.map(r => ({ ...r })),
|
|
356
|
+
directoryFsync: process.platform === 'win32' ? 'unavailable on Windows; file fsync and atomic rename only' : 'required after rename',
|
|
357
|
+
...(this.lockInfo() ? { lock: this.lockInfo() } : {}),
|
|
358
|
+
...(this.readOnlyReason || this.failedSuspension || this.lastWriteError ? { error: this.readOnlyReason || (this.failedSuspension ? 'canvas suspension persistence failed; restart after repair' : this.lastWriteError) } : {}) };
|
|
359
|
+
}
|
|
360
|
+
ownerKeyOf(member) { return this.memberOwners[member]; }
|
|
361
|
+
ownerConflict(member, key) {
|
|
362
|
+
const fp = splitQualifiedMember(member)?.fingerprint;
|
|
363
|
+
return Object.entries(this.memberOwners).some(([ref, pinned]) => (ref === member || splitQualifiedMember(ref)?.fingerprint === fp) && pinned !== key);
|
|
364
|
+
}
|
|
365
|
+
/** Owner control only: CAS the complete slot and preserve suspension plus routing priority. */
|
|
366
|
+
confirmMember(team, member, replacement, ownerKey, revision, expectedOwner) {
|
|
367
|
+
if (this.readOnlyReason || this.failedSuspension)
|
|
368
|
+
return { ok: false, error: this.state().error };
|
|
369
|
+
const entry = this.teams.find(t => t.name === team), index = entry?.members.indexOf(member) ?? -1;
|
|
370
|
+
if (!entry || index < 0 || revision !== this.revision || expectedOwner !== (this.memberOwners[member] ?? null))
|
|
371
|
+
return { ok: false, error: 'canvas version, member slot or owner changed; refresh first' };
|
|
372
|
+
const sameId8 = (value) => value.replace(/^session-/, '').slice(0, 8);
|
|
373
|
+
const originalHandle = splitQualifiedMember(member)?.handle ?? member;
|
|
374
|
+
const replacementHandle = splitQualifiedMember(replacement)?.handle ?? replacement;
|
|
375
|
+
if (member.includes('/') && (replacement.includes('/') ? originalHandle !== replacementHandle : !originalHandle.endsWith('/' + sameId8(replacement))))
|
|
376
|
+
return { ok: false, error: 'confirmation must identify the same original session handle' };
|
|
377
|
+
if (!member.includes('/') || !replacement || CLI_HANDLE_RE.test(member) || CLI_HANDLE_RE.test(replacement))
|
|
378
|
+
return { ok: false, error: 'session provenance confirmation only; CLI homes use their own controls' };
|
|
379
|
+
const qualified = splitQualifiedMember(replacement);
|
|
380
|
+
if ((replacement.includes('/') && !qualified) || (qualified && (!ownerKey || !validMemberOwner(replacement, ownerKey) || this.ownerConflict(replacement, ownerKey))))
|
|
381
|
+
return { ok: false, error: 'owner key/fingerprint conflict or unproved member' };
|
|
382
|
+
if (this.memberOwners[member] !== undefined && (replacement !== member || ownerKey !== this.memberOwners[member]))
|
|
383
|
+
return { ok: false, error: 'confirmed owner cannot be silently replaced' };
|
|
384
|
+
const equivalent = entry.members.indexOf(replacement);
|
|
385
|
+
if (equivalent >= 0 && equivalent !== index && this.suspensionOf(team, member).some(row => this.suspensionOf(team, replacement).some(other => other.reason === row.reason && other.localSessionId === row.localSessionId)))
|
|
386
|
+
return { ok: false, error: 'both slots have independent matching suspension reasons; handle them explicitly before merging' };
|
|
387
|
+
if (equivalent >= 0 && equivalent !== index)
|
|
388
|
+
entry.members.splice(index, 1);
|
|
389
|
+
else
|
|
390
|
+
entry.members[index] = replacement;
|
|
391
|
+
this.suspended = this.suspended.map(row => row.team === team && row.member === member ? { ...row, member: replacement } : row)
|
|
392
|
+
.filter((row, i, all) => all.findIndex(other => other.team === row.team && other.member === row.member && other.reason === row.reason && other.localSessionId === row.localSessionId) === i);
|
|
393
|
+
if (qualified)
|
|
394
|
+
this.memberOwners[replacement] = ownerKey;
|
|
395
|
+
try {
|
|
396
|
+
this.persist();
|
|
397
|
+
return { ok: true };
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
return { ok: false, error: String(error) };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** Durably revoke canvas consent BEFORE join/agent/roster state is removed. */
|
|
404
|
+
revokeLocal(id, ownQualified, inferredBare, reason, team) {
|
|
405
|
+
if (this.readOnlyReason || this.failedSuspension)
|
|
406
|
+
throw Error(this.state().error);
|
|
407
|
+
let changed = false;
|
|
408
|
+
for (const entry of this.teams) {
|
|
409
|
+
if (team !== undefined && entry.name !== team)
|
|
410
|
+
continue;
|
|
411
|
+
if (inferredBare !== undefined && entry.members.includes(inferredBare) && !this.suspended.some(r => r.team === entry.name && r.member === inferredBare && r.localSessionId === id && r.reason === reason)) {
|
|
412
|
+
this.suspended.push({ team: entry.name, member: inferredBare, localSessionId: id, reason, at: new Date().toISOString(), token: randomUUID() });
|
|
413
|
+
changed = true;
|
|
414
|
+
}
|
|
415
|
+
const kept = entry.members.filter(m => (m !== id && m !== ownQualified) || this.isSuspended(entry.name, m));
|
|
416
|
+
if (kept.length !== entry.members.length) {
|
|
417
|
+
entry.members = kept;
|
|
418
|
+
changed = true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (changed) {
|
|
422
|
+
try {
|
|
423
|
+
this.persist();
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
this.failedSuspension = true;
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return changed;
|
|
431
|
+
}
|
|
432
|
+
/** Owner-only CAS. The caller supplies the exact slot and the complete current suspension records. */
|
|
433
|
+
regulateSuspended(action, team, member, revision, expected) {
|
|
434
|
+
if (this.readOnlyReason || this.failedSuspension)
|
|
435
|
+
return { ok: false, error: this.state().error };
|
|
436
|
+
const records = this.suspensionOf(team, member);
|
|
437
|
+
if (revision !== this.revision || !records.length || !this.rawMembersOf(team).includes(member) || JSON.stringify(expected) !== JSON.stringify(records))
|
|
438
|
+
return { ok: false, error: 'canvas version, member slot or suspension records changed; refresh first' };
|
|
439
|
+
const old = this.suspended;
|
|
440
|
+
const entry = this.teams.find(t => t.name === team), members = entry.members;
|
|
441
|
+
this.suspended = this.suspended.filter(r => r.team !== team || r.member !== member);
|
|
442
|
+
if (action === 'delete')
|
|
443
|
+
entry.members = entry.members.filter(m => m !== member);
|
|
444
|
+
try {
|
|
445
|
+
this.persist();
|
|
446
|
+
return { ok: true };
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
this.suspended = old;
|
|
450
|
+
entry.members = members;
|
|
451
|
+
return { ok: false, error: String(error) };
|
|
452
|
+
}
|
|
167
453
|
}
|
|
168
454
|
/** Frozen, operator-linked CLI home; undefined means unknown or ambiguous. */
|
|
169
455
|
cliHomeOf(handle) { return this.cliHome[handle]; }
|
|
@@ -188,7 +474,7 @@ export class CanvasStore {
|
|
|
188
474
|
}
|
|
189
475
|
/** Every team name the id is a member of, in team creation order. */
|
|
190
476
|
teamsOf(id) {
|
|
191
|
-
return this.teams.filter(t => t.
|
|
477
|
+
return this.teams.filter(t => this.membersOf(t.name).includes(id)).map(t => t.name);
|
|
192
478
|
}
|
|
193
479
|
/**
|
|
194
480
|
* Create a named team; whitespace trims, duplicates are idempotent, and
|
|
@@ -216,6 +502,8 @@ export class CanvasStore {
|
|
|
216
502
|
const index = this.teams.findIndex(t => t.name === name);
|
|
217
503
|
if (index < 0)
|
|
218
504
|
return false;
|
|
505
|
+
if (this.suspended.some(r => r.team === name))
|
|
506
|
+
return false;
|
|
219
507
|
const [removed] = this.teams.splice(index, 1);
|
|
220
508
|
for (const id of removed.members)
|
|
221
509
|
this.forgetUnusedHome(id);
|
|
@@ -233,10 +521,15 @@ export class CanvasStore {
|
|
|
233
521
|
return false;
|
|
234
522
|
if (home !== undefined && this.cliHome[id] !== undefined && this.cliHome[id] !== home && options.replaceHome !== true)
|
|
235
523
|
return false;
|
|
524
|
+
const qualified = splitQualifiedMember(id);
|
|
525
|
+
if (qualified && (!options.ownerKey || !validMemberOwner(id, options.ownerKey) || this.ownerConflict(id, options.ownerKey)))
|
|
526
|
+
return false;
|
|
236
527
|
const entry = this.teams.find(t => t.name === name);
|
|
237
528
|
if (entry === undefined)
|
|
238
529
|
return false;
|
|
239
530
|
if (entry.members.includes(id)) {
|
|
531
|
+
if (qualified && this.memberOwners[id] === undefined)
|
|
532
|
+
return false; // Existing unknown slots require explicit confirmation, not re-add.
|
|
240
533
|
if (home !== undefined && this.cliHome[id] !== home) {
|
|
241
534
|
this.cliHome[id] = home;
|
|
242
535
|
this.cliHomeCleared.delete(id);
|
|
@@ -247,6 +540,8 @@ export class CanvasStore {
|
|
|
247
540
|
if (entry.members.length >= CANVAS_MEMBER_CAP)
|
|
248
541
|
return false;
|
|
249
542
|
entry.members.push(id);
|
|
543
|
+
if (qualified)
|
|
544
|
+
this.memberOwners[id] = options.ownerKey;
|
|
250
545
|
if (home !== undefined) {
|
|
251
546
|
this.cliHome[id] = home;
|
|
252
547
|
this.cliHomeCleared.delete(id);
|
|
@@ -261,6 +556,9 @@ export class CanvasStore {
|
|
|
261
556
|
const entry = this.teams.find(t => t.name === name);
|
|
262
557
|
if (entry === undefined)
|
|
263
558
|
return false;
|
|
559
|
+
// Suspended slots require owner CAS, never a peer remove/readd round trip.
|
|
560
|
+
if (this.isSuspended(name, id))
|
|
561
|
+
return false;
|
|
264
562
|
const index = entry.members.indexOf(id);
|
|
265
563
|
if (index < 0)
|
|
266
564
|
return false;
|
|
@@ -284,7 +582,7 @@ export class CanvasStore {
|
|
|
284
582
|
let dirty = false;
|
|
285
583
|
for (const entry of this.teams) {
|
|
286
584
|
const index = entry.members.indexOf(id);
|
|
287
|
-
if (index >= 0) {
|
|
585
|
+
if (index >= 0 && !this.isSuspended(entry.name, id)) {
|
|
288
586
|
entry.members.splice(index, 1);
|
|
289
587
|
dirty = true;
|
|
290
588
|
}
|