brainclaw 1.23.0 → 1.25.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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-cloud.js +121 -13
- package/dist/cli/register-code-map.js +9 -2
- package/dist/commands/cloud.js +534 -39
- package/dist/commands/code-map.js +119 -2
- package/dist/commands/mcp-catalog.js +46 -0
- package/dist/commands/mcp.js +54 -2
- package/dist/core/code-map/backend.js +158 -1
- package/dist/core/code-map/export.js +212 -0
- package/dist/core/code-map/freshness.js +3 -2
- package/dist/core/code-map/impact.js +377 -0
- package/dist/core/code-map/indexes.js +27 -3
- package/dist/core/code-map/lang/typescript/config.js +271 -0
- package/dist/core/code-map/lang/typescript/index.js +20 -4
- package/dist/core/code-map/query.js +76 -13
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +1 -0
- package/dist/core/code-map/types.js +15 -0
- package/dist/core/federation-emit.js +283 -0
- package/dist/core/federation-grant-transport.js +196 -0
- package/dist/core/federation-grant.js +223 -0
- package/dist/core/federation-keyring.js +39 -0
- package/dist/core/federation-opaque-ids.js +111 -0
- package/dist/core/federation-outbox-v2.js +36 -2
- package/dist/core/federation-pairing.js +87 -12
- package/dist/core/federation-pull.js +523 -0
- package/dist/core/federation-push.js +287 -0
- package/dist/core/federation-rotation.js +124 -0
- package/dist/core/federation-state.js +81 -6
- package/dist/core/protocol-tool-policy.js +3 -0
- package/dist/core/worktree.js +89 -2
- package/dist/facts.js +14 -11
- package/dist/facts.json +13 -10
- package/docs/cli.md +8 -0
- package/docs/code-map.md +24 -1
- package/docs/design/federation-onboarding-usecases.md +254 -0
- package/docs/design/pairing-v3-brief.md +80 -0
- package/docs/integrations/mcp.md +5 -2
- package/docs/mcp-schema-changelog.md +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fédération v2 — TRANSPORT : drainer l'outbox vers le cloud.
|
|
3
|
+
*
|
|
4
|
+
* ── POURQUOI CE MODULE EST SÉPARÉ DE L'ÉMISSION ───────────────────────────────
|
|
5
|
+
* `federation-emit.ts` scelle et met en file ; celui-ci envoie. La séparation n'est pas
|
|
6
|
+
* cosmétique : une file qui saurait aussi parler au réseau serait un SECOND chemin par
|
|
7
|
+
* lequel un objet pourrait sortir sans passer par les trois filets du projecteur. Ici, tout
|
|
8
|
+
* ce qui part a déjà été scellé — ce module ne voit que des `sealed` opaques et ne peut donc
|
|
9
|
+
* pas divulguer de clair, même par erreur de programmation.
|
|
10
|
+
*
|
|
11
|
+
* ── CE QU'IL FAIT DE L'ÉCHEC ──────────────────────────────────────────────────
|
|
12
|
+
* Une entrée qui échoue RESTE en attente, avec son erreur enregistrée. Elle n'est ni
|
|
13
|
+
* supprimée ni déplacée en `conflict` : un échec réseau n'est pas un conflit de révision, et
|
|
14
|
+
* les confondre ferait disparaître de la file une opération jamais émise.
|
|
15
|
+
*
|
|
16
|
+
* Seul un 409 fait passer en `conflict` — c'est le cas où le cloud dit « ta base_rev est
|
|
17
|
+
* périmée », donc un désaccord d'état qu'un renvoi ne résoudra pas.
|
|
18
|
+
*/
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { list, transition } from './federation-outbox-v2.js';
|
|
21
|
+
import { loadConnectionState } from './federation-state.js';
|
|
22
|
+
import { loadAgentSigningKey } from './agent-registry.js';
|
|
23
|
+
import { logger } from './logger.js';
|
|
24
|
+
/**
|
|
25
|
+
* Met une entrée d'outbox à la forme que le cloud attend RÉELLEMENT.
|
|
26
|
+
*
|
|
27
|
+
* ── POURQUOI CETTE FONCTION EXISTE, ET CE QU'ELLE A COÛTÉ ────────────────────
|
|
28
|
+
* La première version envoyait `{ envelope, key_epoch, base_rev }` — la forme que je
|
|
29
|
+
* SUPPOSAIS. Le cloud attend des champs À PLAT : id, entity_kind, entity_id, rev,
|
|
30
|
+
* sealed_b64, content_hash, meta, et quatre champs d'origine. Mon test de bout en bout
|
|
31
|
+
* n'a rien vu : il utilisait un `fetch` simulé, donc il validait mon hypothèse de l'API
|
|
32
|
+
* contre elle-même.
|
|
33
|
+
*
|
|
34
|
+
* C'est la même leçon que d'habitude, à un endroit nouveau : un contrat inter-services ne
|
|
35
|
+
* se vérifie que contre le service, jamais contre l'idée qu'on s'en fait.
|
|
36
|
+
*
|
|
37
|
+
* `sealed` est encodé en base64 d'un JSON canonique : le cloud le stocke sans jamais
|
|
38
|
+
* l'ouvrir — il n'en a pas la clé.
|
|
39
|
+
*/
|
|
40
|
+
/** En-têtes communs : type, idempotence, et porteur si le déploiement l'exige. */
|
|
41
|
+
function authHeaders(idempotencyKey, apiKey) {
|
|
42
|
+
const headers = {
|
|
43
|
+
'content-type': 'application/json',
|
|
44
|
+
// La clé d'idempotence voyage AUSSI en en-tête : le cloud doit pouvoir dédoublonner
|
|
45
|
+
// sans ouvrir le corps, qu'il ne peut de toute façon pas lire.
|
|
46
|
+
'idempotency-key': idempotencyKey,
|
|
47
|
+
};
|
|
48
|
+
if (apiKey)
|
|
49
|
+
headers['authorization'] = `Bearer ${apiKey}`;
|
|
50
|
+
return headers;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Charge SIGNÉE DU TRANSPORT — reconstruite exactement comme le cloud la reconstruit.
|
|
54
|
+
*
|
|
55
|
+
* L'ordre des clés compte : le cloud fait `JSON.stringify` sur cet objet littéral, sans
|
|
56
|
+
* tri. Une clé déplacée produit une chaîne différente, donc un condensé différent, donc un
|
|
57
|
+
* refus SIG_PAYLOAD_HASH_MISMATCH que rien dans le message n'expliquerait.
|
|
58
|
+
*/
|
|
59
|
+
function transportPayload(wire, baseRev) {
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
v: 1,
|
|
62
|
+
kind: 'brainclaw.federation.v2.envelope',
|
|
63
|
+
envelope_id: wire['id'],
|
|
64
|
+
project_id: wire['__project_id'],
|
|
65
|
+
entity_kind: wire['entity_kind'],
|
|
66
|
+
entity_id: wire['entity_id'],
|
|
67
|
+
rev: wire['rev'],
|
|
68
|
+
base_rev: baseRev,
|
|
69
|
+
content_hash: wire['content_hash'],
|
|
70
|
+
key_epoch: wire['key_epoch'],
|
|
71
|
+
is_tombstone: false,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Signe la charge de transport et renseigne les trois champs qui en dépendent.
|
|
76
|
+
*
|
|
77
|
+
* ── POURQUOI LE TRANSPORT SIGNE, ALORS QUE L'ENVELOPPE EST DÉJÀ SIGNÉE ───────
|
|
78
|
+
* Ce sont DEUX garanties distinctes. La signature d'enveloppe (RFC) lie meta ‖ sealed ‖
|
|
79
|
+
* key_epoch : elle dit « ce contenu vient de cet auteur ». La signature de transport lie
|
|
80
|
+
* envelope_id, rev, base_rev et content_hash : elle dit « cette opération-ci s'applique à
|
|
81
|
+
* CETTE révision », ce que la première ne peut pas dire — ces champs n'existent pas encore
|
|
82
|
+
* au moment de sceller.
|
|
83
|
+
*
|
|
84
|
+
* ── ET C'EST CE QUI DÉBLOQUE LE RECALAGE SUR 409 ────────────────────────────
|
|
85
|
+
* J'avais d'abord jugé les deux contrats incompatibles (dec#160 §6), parce que `base_rev`
|
|
86
|
+
* peut changer au réessai et invaliderait une signature calculée à l'émission. Faire signer
|
|
87
|
+
* le TRANSPORT lève l'objection : il resigne simplement avec la nouvelle valeur.
|
|
88
|
+
*
|
|
89
|
+
* Cette clé Ed25519 SIGNE, elle ne déchiffre rien : la propriété « ce module ne peut pas
|
|
90
|
+
* divulguer de clair » tient toujours.
|
|
91
|
+
*/
|
|
92
|
+
function signTransport(wire, baseRev, identityPem) {
|
|
93
|
+
const payload = Buffer.from(transportPayload(wire, baseRev), 'utf-8');
|
|
94
|
+
const signature = crypto.sign(null, payload, crypto.createPrivateKey(identityPem));
|
|
95
|
+
const body = { ...wire, base_rev: baseRev };
|
|
96
|
+
delete body['__project_id'];
|
|
97
|
+
return {
|
|
98
|
+
...body,
|
|
99
|
+
origin_sig: signature.toString('base64'),
|
|
100
|
+
origin_sig_payload_hash: crypto.createHash('sha256').update(payload).digest('hex'),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function toWireBody(entry) {
|
|
104
|
+
const env = entry.sealed;
|
|
105
|
+
const meta = env.meta;
|
|
106
|
+
const transport = (meta['transport'] ?? {});
|
|
107
|
+
// ── L'EMPREINTE PORTE SUR CE QUI EST RÉELLEMENT ENVOYÉ ──────────────────────
|
|
108
|
+
//
|
|
109
|
+
// Le cloud recalcule SHA-256 sur les octets DÉCODÉS de `sealed_b64`, en hexadécimal, et
|
|
110
|
+
// refuse tout écart (CONTENT_HASH_MISMATCH). Le `content_hash` du core est calculé
|
|
111
|
+
// autrement — base64url sur une autre sérialisation — donc le réutiliser tel quel
|
|
112
|
+
// échouait systématiquement.
|
|
113
|
+
//
|
|
114
|
+
// Le calculer ICI, sur les octets exacts qu'on encode, rend l'accord vrai PAR
|
|
115
|
+
// CONSTRUCTION plutôt que par coïncidence de conventions. Deux sérialisations qui
|
|
116
|
+
// doivent produire le même condensé sont un pari ; hacher ce qu'on envoie n'en est pas un.
|
|
117
|
+
const sealedBytes = Buffer.from(JSON.stringify(env.sealed), 'utf-8');
|
|
118
|
+
const contentHashHex = crypto.createHash('sha256').update(sealedBytes).digest('hex');
|
|
119
|
+
return {
|
|
120
|
+
id: String(transport['idempotency_key'] ?? entry.idempotency_key),
|
|
121
|
+
entity_kind: meta['kind'],
|
|
122
|
+
entity_id: meta['id_opaque'],
|
|
123
|
+
rev: String(meta['base_rev'] ?? entry.base_rev ?? 0),
|
|
124
|
+
// Aucune tête connue au premier envoi : le cloud l'annonce dans son 409 et l'appelant
|
|
125
|
+
// se recale une fois. Suivre cette tête localement dupliquerait un état dont le cloud
|
|
126
|
+
// est déjà l'autorité.
|
|
127
|
+
base_rev: null,
|
|
128
|
+
sealed_b64: sealedBytes.toString('base64'),
|
|
129
|
+
key_epoch: env.key_epoch,
|
|
130
|
+
content_hash: contentHashHex,
|
|
131
|
+
idempotency_key: String(transport['idempotency_key'] ?? entry.idempotency_key),
|
|
132
|
+
// `key_id` de l'enveloppe EST l'empreinte du signataire (cf. federation-emit).
|
|
133
|
+
// L'agent d'origine voyage à part : le cloud vérifie l'un ET l'autre.
|
|
134
|
+
origin_agent_id: entry.origin_agent_id ?? env.origin_sig.key_id,
|
|
135
|
+
// Renseignés par `signTransport` : ils dépendent de `base_rev`, connu seulement ici.
|
|
136
|
+
origin_sig: '',
|
|
137
|
+
origin_sig_payload_hash: '',
|
|
138
|
+
origin_signer_fingerprint: env.origin_sig.key_id,
|
|
139
|
+
meta,
|
|
140
|
+
// ── L'ENVELOPPE SIGNÉE, VERBATIM (dec#162) ──────────────────────────────────
|
|
141
|
+
//
|
|
142
|
+
// La signature de TRANSPORT ci-dessus prouve « cette opération s'applique à cette
|
|
143
|
+
// révision » ; elle ne prouve RIEN sur l'auteur du contenu. La signature d'AUTEUR
|
|
144
|
+
// (origin_sig sur meta‖sealed‖key_epoch, cf. buildEnvelope) est la seule qui le fasse —
|
|
145
|
+
// et `toWireBody` l'écrasait jusqu'ici en réutilisant le champ `origin_sig` pour le
|
|
146
|
+
// transport. Un lecteur (pull) ne pouvait donc pas vérifier qui a écrit l'enveloppe.
|
|
147
|
+
//
|
|
148
|
+
// On transporte l'enveloppe complète SANS LA TOUCHER. Le cloud la stocke telle quelle et
|
|
149
|
+
// la rend au pull ; le vérificateur y retrouve `origin_sig.value` d'auteur et
|
|
150
|
+
// recanonicalise meta/sealed lui-même (federation-inbound). Aucune donnée NOUVELLE ne
|
|
151
|
+
// fuit : `meta` partait déjà en clair au relais (ligne ci-dessus) — on ne fait que
|
|
152
|
+
// PERSISTER ce qui transitait déjà. Le champ n'entre pas dans la signature de transport :
|
|
153
|
+
// son intégrité est portée par la signature d'auteur qu'il contient.
|
|
154
|
+
envelope_json: JSON.stringify(entry.sealed),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Envoie les enveloppes en attente.
|
|
159
|
+
*
|
|
160
|
+
* REFUSE plutôt que de deviner : sans appairage actif, sans URL, la fonction lève. Pousser
|
|
161
|
+
* vers une adresse devinée enverrait des enveloppes chiffrées à un tiers — inintelligibles
|
|
162
|
+
* pour lui, mais c'est une fuite de métadonnées et de trafic, pas un non-événement.
|
|
163
|
+
*/
|
|
164
|
+
export async function pushPending(options = {}) {
|
|
165
|
+
const cwd = options.cwd ?? process.cwd();
|
|
166
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
167
|
+
const result = { attempted: 0, sent: 0, conflicts: 0, failed: 0, errors: [] };
|
|
168
|
+
const state = loadConnectionState(cwd);
|
|
169
|
+
if (!state || state.enrollment.stage !== 'active') {
|
|
170
|
+
throw new Error('Aucun appairage actif : rien n\'est envoyé tant que l\'appairage n\'est pas confirmé localement.');
|
|
171
|
+
}
|
|
172
|
+
// MANQUE MESURÉ (2026-08-09) : `FederationConnectionState` ne persiste AUCUNE adresse de
|
|
173
|
+
// cloud — ni à la racine, ni dans `sync`. L'appairage prend `--url` puis l'oublie. Chaque
|
|
174
|
+
// commande doit donc la repasser. C'est un défaut d'ergonomie à corriger côté appairage,
|
|
175
|
+
// pas ici : deviner une adresse enverrait le trafic d'un projet à un tiers — illisible
|
|
176
|
+
// pour lui, mais une fuite de métadonnées et de trafic n'est pas un non-événement.
|
|
177
|
+
const base = (options.url ?? '').replace(/\/+$/, '');
|
|
178
|
+
if (!base) {
|
|
179
|
+
throw new Error('Adresse du cloud inconnue : passez --url. Elle n\'est pas conservée par l\'appairage ' +
|
|
180
|
+
'(état de connexion sans champ d\'URL), et aucune adresse n\'est devinée.');
|
|
181
|
+
}
|
|
182
|
+
const pending = list('pending', cwd);
|
|
183
|
+
// File vide = rien à signer : sortir AVANT de résoudre l'identité. Le signataire est
|
|
184
|
+
// déduit de la première entrée pending ; le chercher sur une file vide transformait
|
|
185
|
+
// « tout est déjà parti » en erreur d'identité — vécu le 2026-08-10, juste après un
|
|
186
|
+
// envoi complet dont il ne restait que des conflits.
|
|
187
|
+
if (pending.length === 0)
|
|
188
|
+
return result;
|
|
189
|
+
// L'identité SIGNATAIRE du transport : celle de l'agent qui a produit les enveloppes.
|
|
190
|
+
// Elle est portée par l'entrée d'outbox, donc le transport n'a pas à deviner qui signe.
|
|
191
|
+
const agentId = options.agentId ?? pending[0]?.origin_agent_id;
|
|
192
|
+
const identity = agentId ? loadAgentSigningKey(agentId) : undefined;
|
|
193
|
+
if (!identity) {
|
|
194
|
+
throw new Error('Identité de signature introuvable : le cloud vérifie une signature de TRANSPORT ' +
|
|
195
|
+
'liant envelope_id, rev et base_rev. Sans elle, chaque envoi est refusé en 422.');
|
|
196
|
+
}
|
|
197
|
+
const identityPem = identity.privateKeyPem;
|
|
198
|
+
const batch = options.limit ? pending.slice(0, options.limit) : pending;
|
|
199
|
+
result.attempted = batch.length;
|
|
200
|
+
if (options.dryRun)
|
|
201
|
+
return result;
|
|
202
|
+
const endpoint = `${base}/api/v1/projects/${state.cloud_project_id}/projection/envelopes`;
|
|
203
|
+
for (const entry of batch) {
|
|
204
|
+
try {
|
|
205
|
+
const wire = { ...toWireBody(entry), __project_id: state.cloud_project_id };
|
|
206
|
+
let signed = signTransport(wire, null, identityPem);
|
|
207
|
+
let res = await doFetch(endpoint, {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: authHeaders(entry.idempotency_key, options.apiKey),
|
|
210
|
+
body: JSON.stringify(signed),
|
|
211
|
+
});
|
|
212
|
+
// ── RECALAGE SUR 409, UNE SEULE FOIS ──────────────────────────────────
|
|
213
|
+
//
|
|
214
|
+
// Le cloud applique une concurrence optimiste : `base_rev` doit désigner la tête
|
|
215
|
+
// courante. Un émetteur qui n'a jamais poussé ne CONNAÎT pas cette tête — et la
|
|
216
|
+
// suivre localement dupliquerait un état dont le cloud est déjà l'autorité.
|
|
217
|
+
//
|
|
218
|
+
// Le refus la contient (`expected_base_rev`) : on renvoie donc une fois avec la
|
|
219
|
+
// valeur annoncée. UNE SEULE, délibérément : boucler transformerait un désaccord
|
|
220
|
+
// réel en écrasement silencieux du travail d'un autre appareil.
|
|
221
|
+
if (res.status === 409) {
|
|
222
|
+
const detail = (await res.clone().json().catch(() => ({})));
|
|
223
|
+
// `current_head_rev` est le nom que le serveur DÉPLOYÉ répond (projection.ts,
|
|
224
|
+
// REV_CONFLICT). Les deux autres sont des noms historiques gardés en repli.
|
|
225
|
+
// Dérive constatée le 2026-08-10 : le client lisait `expected_base_rev` sur une
|
|
226
|
+
// réponse qui ne l'a jamais porté — le recalage ne se déclenchait donc JAMAIS et
|
|
227
|
+
// chaque mise à jour finissait en conflit. Le test unitaire rejoue depuis la
|
|
228
|
+
// forme de réponse RÉELLE du serveur ; leçon dec#160/162, un contrat inter-
|
|
229
|
+
// services ne se vérifie que contre le service.
|
|
230
|
+
const expected = detail['current_head_rev'] ?? detail['expected_base_rev'] ?? detail['expected'];
|
|
231
|
+
if (typeof expected === 'string' || typeof expected === 'number') {
|
|
232
|
+
// On RESIGNE avec la nouvelle base_rev : c'est précisément ce que la signature
|
|
233
|
+
// au niveau du transport rend possible, et qu'une signature figée à l'émission
|
|
234
|
+
// interdisait.
|
|
235
|
+
signed = signTransport(wire, String(expected), identityPem);
|
|
236
|
+
res = await doFetch(endpoint, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: authHeaders(entry.idempotency_key, options.apiKey),
|
|
239
|
+
body: JSON.stringify(signed),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (res.ok || res.status === 202) {
|
|
244
|
+
transition(entry.idempotency_key, 'pending', 'synced', cwd);
|
|
245
|
+
result.sent += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (res.status === 409) {
|
|
249
|
+
// Désaccord de révision : un renvoi à l'identique échouera pareil. L'entrée passe
|
|
250
|
+
// en `conflict` pour être VUE, pas retentée en boucle.
|
|
251
|
+
transition(entry.idempotency_key, 'pending', 'conflict', cwd, (e) => ({
|
|
252
|
+
...e,
|
|
253
|
+
last_error: `409 conflit de révision (base_rev=${entry.base_rev})`,
|
|
254
|
+
}));
|
|
255
|
+
result.conflicts += 1;
|
|
256
|
+
result.errors.push({ idempotency_key: entry.idempotency_key, status: 409, reason: 'conflit de révision' });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const body = await res.text();
|
|
260
|
+
result.failed += 1;
|
|
261
|
+
result.errors.push({
|
|
262
|
+
idempotency_key: entry.idempotency_key,
|
|
263
|
+
status: res.status,
|
|
264
|
+
reason: body.slice(0, 200),
|
|
265
|
+
});
|
|
266
|
+
// RESTE en attente : un refus temporaire ne doit pas faire disparaître l'opération.
|
|
267
|
+
transition(entry.idempotency_key, 'pending', 'pending', cwd, (e) => ({
|
|
268
|
+
...e,
|
|
269
|
+
attempts: e.attempts + 1,
|
|
270
|
+
last_error: `HTTP ${res.status}`,
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
275
|
+
result.failed += 1;
|
|
276
|
+
result.errors.push({ idempotency_key: entry.idempotency_key, reason });
|
|
277
|
+
transition(entry.idempotency_key, 'pending', 'pending', cwd, (e) => ({
|
|
278
|
+
...e,
|
|
279
|
+
attempts: e.attempts + 1,
|
|
280
|
+
last_error: reason,
|
|
281
|
+
}));
|
|
282
|
+
logger.warn(`Envoi échoué (${entry.idempotency_key}) : ${reason}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=federation-push.js.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotation d'epoch — retirer la LECTURE FUTURE à un appareil (pln#658, dec#163).
|
|
3
|
+
*
|
|
4
|
+
* ── CE QUE LA ROTATION FAIT, ET CE QU'ELLE NE PEUT PAS FAIRE ──────────────────
|
|
5
|
+
* Elle crée un epoch N+1 dont le révoqué n'a pas la clé, et fait basculer les écritures
|
|
6
|
+
* dessus. À partir du cutover, il ne lit plus RIEN de nouveau.
|
|
7
|
+
*
|
|
8
|
+
* Elle ne lui retire PAS ce qu'il détient déjà. C'est cryptographiquement impossible : une
|
|
9
|
+
* clé copiée sur une machine y reste. Le prétendre serait mentir, et dec#163 §3 l'interdit
|
|
10
|
+
* explicitement. La révocation est FORWARD-ONLY, et c'est dit à l'opérateur en toutes
|
|
11
|
+
* lettres au moment où il tourne la clé — pas dans une note de bas de page.
|
|
12
|
+
*
|
|
13
|
+
* ── LE QUORUM EST APPLIQUÉ ICI, ET SEULEMENT ICI (dec#163 §4) ─────────────────
|
|
14
|
+
* « Émettre au-delà du premier epoch » = créer un epoch N+1. C'est le moment exact où la
|
|
15
|
+
* perte d'une machine cesse d'être théorique : après rotation, l'ancien epoch ne sert plus
|
|
16
|
+
* qu'à relire le passé, et si personne d'autre ne détient le nouveau, une panne de disque
|
|
17
|
+
* emporte tout le futur. `recoveryReadiness` était RAPPORTÉ et jamais bloquant (mesuré) —
|
|
18
|
+
* il devient une condition.
|
|
19
|
+
*
|
|
20
|
+
* En solo, l'opérateur peut passer outre, mais son consentement est PERSISTÉ : on ne
|
|
21
|
+
* demande pas deux fois, et surtout on garde la trace qu'il a été demandé.
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import { loadConnectionState, saveConnectionState, recoveryReadiness, } from './federation-state.js';
|
|
25
|
+
import { storeEpochPrivateKey, epochPublicKey, heldEpochs } from './federation-keyring.js';
|
|
26
|
+
export const FORWARD_ONLY_NOTICE = "La rotation ne retire à personne ce qu'il détient DÉJÀ : un appareil révoqué peut " +
|
|
27
|
+
"toujours relire ce qui a été scellé avant le cutover. Elle ferme la lecture du FUTUR, " +
|
|
28
|
+
'pas celle du passé — aucune cryptographie ne peut faire autrement.';
|
|
29
|
+
const SOLO_CONSENT_STATEMENT = "Je comprends que la perte de cette machine signifie la perte définitive de l'historique " +
|
|
30
|
+
'scellé : aucune restauration côté cloud ne le ramènera.';
|
|
31
|
+
export function soloConsentStatement() {
|
|
32
|
+
return SOLO_CONSENT_STATEMENT;
|
|
33
|
+
}
|
|
34
|
+
function readConsent(state) {
|
|
35
|
+
return state.solo_recovery_consent;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Enregistre le consentement solo. Idempotent : réaccepter ne réécrit pas la date d'origine,
|
|
39
|
+
* parce que la question « quand cette personne a-t-elle compris le risque ? » a UNE réponse.
|
|
40
|
+
*/
|
|
41
|
+
export function acceptSoloRecoveryRisk(cwd) {
|
|
42
|
+
const state = loadConnectionState(cwd ?? process.cwd());
|
|
43
|
+
if (!state)
|
|
44
|
+
throw new Error('Aucun appairage local : rien à consentir.');
|
|
45
|
+
const existing = readConsent(state);
|
|
46
|
+
if (existing)
|
|
47
|
+
return existing;
|
|
48
|
+
const consent = {
|
|
49
|
+
accepted_at: new Date().toISOString(),
|
|
50
|
+
statement: SOLO_CONSENT_STATEMENT,
|
|
51
|
+
};
|
|
52
|
+
saveConnectionState({ ...state, ...{ solo_recovery_consent: consent } }, cwd ?? process.cwd());
|
|
53
|
+
return consent;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Fait tourner l'epoch : crée N+1 localement et bascule les écritures dessus.
|
|
57
|
+
*
|
|
58
|
+
* NE REMET RIEN AUX AUTRES — c'est délibérément une seconde étape (`cloud grant`). Coupler
|
|
59
|
+
* les deux ferait qu'un échec de remise laisserait un cutover à moitié fait : les écritures
|
|
60
|
+
* seraient déjà passées sous N+1 pendant que les lecteurs légitimes n'auraient pas la clé.
|
|
61
|
+
* Le résultat NOMME donc les destinataires à re-servir.
|
|
62
|
+
*/
|
|
63
|
+
export function rotateEpoch(options = {}) {
|
|
64
|
+
const cwd = options.cwd ?? process.cwd();
|
|
65
|
+
const state = loadConnectionState(cwd);
|
|
66
|
+
if (!state) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false, reason: 'no_pairing',
|
|
69
|
+
detail: 'Aucun appairage local.',
|
|
70
|
+
remedy: 'Appairer cet appareil avant toute rotation (`brainclaw cloud connect`).',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const held = heldEpochs(state.cloud_project_id, options.home);
|
|
74
|
+
const current = state.keys?.current_epoch ?? 0;
|
|
75
|
+
if (current <= 0 || !held.includes(current)) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false, reason: 'not_holder',
|
|
78
|
+
detail: `Cet appareil ne détient pas l'epoch courant (${current}) — il ne peut pas en produire le suivant.`,
|
|
79
|
+
remedy: "Se faire remettre l'epoch courant par un détenteur (`brainclaw cloud grant`) avant de tourner.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ── LE QUORUM, APPLIQUÉ (dec#163 §4) ────────────────────────────────────────
|
|
83
|
+
const readiness = recoveryReadiness(state);
|
|
84
|
+
const consent = readConsent(state);
|
|
85
|
+
if (!readiness.ready && !consent && !options.force) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false, reason: 'recovery_quorum',
|
|
88
|
+
detail: `${readiness.reason ?? 'Quorum de récupération non atteint.'} ` +
|
|
89
|
+
"Tourner l'epoch maintenant ferait dépendre TOUT le futur du projet de cette seule machine.",
|
|
90
|
+
remedy: "Appairer un second appareil de récupération, OU accepter explicitement le risque " +
|
|
91
|
+
'(`brainclaw cloud accept-solo-risk`), ce qui sera consigné avec sa date.',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const next = current + 1;
|
|
95
|
+
// Ne jamais écraser : si N+1 existe déjà, c'est qu'une rotation a déjà eu lieu (reprise
|
|
96
|
+
// après interruption). On la constate au lieu d'en fabriquer une seconde.
|
|
97
|
+
const already = epochPublicKey(state.cloud_project_id, next, options.home);
|
|
98
|
+
if (!already) {
|
|
99
|
+
const generated = crypto.generateKeyPairSync('x25519');
|
|
100
|
+
storeEpochPrivateKey(state.cloud_project_id, next, generated.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), options.home);
|
|
101
|
+
}
|
|
102
|
+
const materialized = epochPublicKey(state.cloud_project_id, next, options.home);
|
|
103
|
+
if (!materialized) {
|
|
104
|
+
// Vérifier APRÈS écriture : basculer les écritures sur une clé qu'on ne relit pas
|
|
105
|
+
// produirait des enveloppes que PERSONNE ne peut ouvrir, y compris nous.
|
|
106
|
+
throw new Error(`Epoch ${next} écrit mais non relisible — le cutover est ANNULÉ pour ne pas sceller ` +
|
|
107
|
+
'sous une clé fantôme.');
|
|
108
|
+
}
|
|
109
|
+
// CUTOVER : les écritures suivantes scellent sous N+1 (emitProjections lit current_epoch).
|
|
110
|
+
const readable = heldEpochs(state.cloud_project_id, options.home);
|
|
111
|
+
saveConnectionState({
|
|
112
|
+
...state,
|
|
113
|
+
keys: { ...(state.keys ?? { current_epoch: 0, known_epochs: [] }), current_epoch: next, known_epochs: readable },
|
|
114
|
+
}, cwd);
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
previous_epoch: current,
|
|
118
|
+
new_epoch: next,
|
|
119
|
+
new_epoch_fingerprint: materialized.fingerprint,
|
|
120
|
+
readable_epochs: readable,
|
|
121
|
+
forward_only_notice: FORWARD_ONLY_NOTICE,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=federation-rotation.js.map
|
|
@@ -34,7 +34,9 @@ import { heldEpochs } from './federation-keyring.js';
|
|
|
34
34
|
import { counters as outboxCounters } from './federation-outbox-v2.js';
|
|
35
35
|
import { logger } from './logger.js';
|
|
36
36
|
const CONNECTION_FILE = 'connection.json';
|
|
37
|
-
export const FEDERATION_STATE_SCHEMA = 'brainclaw.federation-connection/
|
|
37
|
+
export const FEDERATION_STATE_SCHEMA = 'brainclaw.federation-connection/v3';
|
|
38
|
+
/** Schéma v2 — singleton mono-agent. Lu en tolérance, jamais écrit (trp#1625). */
|
|
39
|
+
const FEDERATION_STATE_SCHEMA_V2 = 'brainclaw.federation-connection/v2';
|
|
38
40
|
/** Nombre d'appareils de récupération exigés avant la première enveloppe (RFC §5.3). */
|
|
39
41
|
export const REQUIRED_RECOVERY_DEVICES = 2;
|
|
40
42
|
// ── Emplacement ───────────────────────────────────────────────────────────────
|
|
@@ -63,12 +65,73 @@ export function loadConnectionState(cwd = process.cwd()) {
|
|
|
63
65
|
return undefined;
|
|
64
66
|
}
|
|
65
67
|
const state = raw;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
const knownSchema = state.schema === FEDERATION_STATE_SCHEMA || state.schema === FEDERATION_STATE_SCHEMA_V2;
|
|
69
|
+
if (!knownSchema || !state.cloud_project_id || !state.device?.device_id) {
|
|
70
|
+
// Un schéma inconnu (v1) n'est PAS migré (dec#156) : abandonné, pas déprécié. Mais la
|
|
71
|
+
// v2 EST lue — c'est le format vivant sur les machines déjà appairées ; le refuser
|
|
72
|
+
// détruirait leur appairage au premier chargement après mise à jour.
|
|
73
|
+
logger.warn(`État de connexion fédération ignoré : schéma '${String(state.schema)}' non reconnu.`);
|
|
69
74
|
return undefined;
|
|
70
75
|
}
|
|
71
|
-
return normalizeState(state, cwd);
|
|
76
|
+
return normalizeState(projectToV3(state), cwd);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Projette un état v2 (singleton) vers la forme v3 (liste de pairings) À LA LECTURE.
|
|
80
|
+
*
|
|
81
|
+
* Un v2 n'a pas de `pairings` ni de `cloud_url` ni d'agent mémorisé. On dérive un pairing
|
|
82
|
+
* unique de son `enrollment` — l'agent est marqué inconnu, faute de l'information : le
|
|
83
|
+
* singleton v2 ne l'a jamais stockée, et l'inventer serait mentir. Un état déjà v3 passe
|
|
84
|
+
* inchangé. Rien n'est réécrit sur disque ici : la conversion n'a lieu qu'en mémoire, et
|
|
85
|
+
* la prochaine ÉCRITURE (un appairage, une révocation) persistera la forme v3.
|
|
86
|
+
*/
|
|
87
|
+
function projectToV3(state) {
|
|
88
|
+
if (Array.isArray(state.pairings)) {
|
|
89
|
+
return { ...state, schema: FEDERATION_STATE_SCHEMA };
|
|
90
|
+
}
|
|
91
|
+
const enrollment = state.enrollment ?? { stage: 'unpaired', updated_at: nowISO() };
|
|
92
|
+
const derived = {
|
|
93
|
+
agent_id: '(inconnu — appairé en v2)',
|
|
94
|
+
stage: enrollment.stage,
|
|
95
|
+
enrollment_id: enrollment.enrollment_id,
|
|
96
|
+
role: enrollment.role,
|
|
97
|
+
updated_at: enrollment.updated_at,
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
schema: FEDERATION_STATE_SCHEMA,
|
|
102
|
+
pairings: enrollment.enrollment_id || enrollment.stage !== 'unpaired' ? [derived] : [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// ── Manipulation des pairings (v3) ─────────────────────────────────────────────
|
|
106
|
+
/** Le pairing d'un agent donné, ou `undefined`. */
|
|
107
|
+
export function pairingForAgent(state, agentId) {
|
|
108
|
+
return state.pairings.find((p) => p.agent_id === agentId);
|
|
109
|
+
}
|
|
110
|
+
/** Y a-t-il au moins un pairing actif ? Décide notamment la genèse d'epoch (premier appareil). */
|
|
111
|
+
export function hasActivePairing(state) {
|
|
112
|
+
return state.pairings.some((p) => p.stage === 'active');
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Ajoute ou met à jour le pairing d'un agent, SANS toucher aux autres, et maintient le
|
|
116
|
+
* miroir `enrollment` sur le pairing touché. Retourne un nouvel état (immuable).
|
|
117
|
+
*
|
|
118
|
+
* C'est l'unique porte par laquelle un pairing entre ou change : centraliser le maintien
|
|
119
|
+
* du miroir ici évite qu'un écrivain oublie de le synchroniser et fasse diverger la vue
|
|
120
|
+
* mono-agent de la source de vérité.
|
|
121
|
+
*/
|
|
122
|
+
export function upsertPairing(state, pairing) {
|
|
123
|
+
const others = state.pairings.filter((p) => p.agent_id !== pairing.agent_id);
|
|
124
|
+
return {
|
|
125
|
+
...state,
|
|
126
|
+
pairings: [...others, pairing],
|
|
127
|
+
enrollment: {
|
|
128
|
+
stage: pairing.stage,
|
|
129
|
+
enrollment_id: pairing.enrollment_id,
|
|
130
|
+
role: pairing.role,
|
|
131
|
+
updated_at: pairing.updated_at,
|
|
132
|
+
},
|
|
133
|
+
updated_at: nowISO(),
|
|
134
|
+
};
|
|
72
135
|
}
|
|
73
136
|
/**
|
|
74
137
|
* Réconcilie l'état déclaré avec le DISQUE.
|
|
@@ -154,11 +217,16 @@ function assertNoSecret(state) {
|
|
|
154
217
|
*/
|
|
155
218
|
export function createConnectionState(params) {
|
|
156
219
|
const now = nowISO();
|
|
220
|
+
const firstPairing = params.agentId
|
|
221
|
+
? { agent_id: params.agentId, stage: 'pending', enrollment_id: params.enrollmentId, updated_at: now }
|
|
222
|
+
: undefined;
|
|
157
223
|
return {
|
|
158
224
|
schema: FEDERATION_STATE_SCHEMA,
|
|
159
225
|
cloud_project_id: params.cloudProjectId,
|
|
226
|
+
cloud_url: params.cloudUrl,
|
|
160
227
|
workspace_path: path.resolve(params.workspacePath ?? process.cwd()),
|
|
161
228
|
enrollment: { stage: 'pending', enrollment_id: params.enrollmentId, updated_at: now },
|
|
229
|
+
pairings: firstPairing ? [firstPairing] : [],
|
|
162
230
|
device: params.device,
|
|
163
231
|
peer_devices: [],
|
|
164
232
|
// 0 = « aucun epoch » et non « premier epoch ». Le premier epoch remis est le 1 ;
|
|
@@ -250,18 +318,25 @@ export function summarizeConnection(cwd = process.cwd()) {
|
|
|
250
318
|
stage: 'unpaired',
|
|
251
319
|
current_epoch: 0,
|
|
252
320
|
readable_epochs: [],
|
|
321
|
+
pairings: [],
|
|
253
322
|
sync,
|
|
254
323
|
recovery: { ready: false, enrolled: 0, required: REQUIRED_RECOVERY_DEVICES },
|
|
255
324
|
};
|
|
256
325
|
}
|
|
257
326
|
return {
|
|
258
|
-
|
|
327
|
+
// Connecté dès qu'AU MOINS UN agent est actif, OU que le miroir l'indique. Le OR est
|
|
328
|
+
// une ceinture de sécurité pour l'affichage : un état projeté depuis v2, ou construit
|
|
329
|
+
// à la main, peut porter un miroir actif sans pairing correspondant. Mieux vaut
|
|
330
|
+
// afficher « connecté » et laisser l'opérateur voir que masquer un appairage réel.
|
|
331
|
+
connected: hasActivePairing(state) || state.enrollment.stage === 'active',
|
|
259
332
|
cloud_project_id: state.cloud_project_id,
|
|
260
333
|
stage: state.enrollment.stage,
|
|
261
334
|
role: state.enrollment.role,
|
|
262
335
|
current_epoch: state.keys.current_epoch,
|
|
263
336
|
readable_epochs: state.keys.known_epochs,
|
|
264
337
|
device_fingerprint: state.device.x25519_fingerprint,
|
|
338
|
+
cloud_url: state.cloud_url,
|
|
339
|
+
pairings: state.pairings.map((p) => ({ agent_id: p.agent_id, stage: p.stage, role: p.role })),
|
|
265
340
|
sync,
|
|
266
341
|
last_pull_at: state.sync.last_pull_at,
|
|
267
342
|
recovery: recoveryReadiness(state),
|
|
@@ -48,6 +48,9 @@ export const MCP_HEADLESS_AUTO_TOOL_NAMES = [
|
|
|
48
48
|
'bclaw_code_status',
|
|
49
49
|
'bclaw_code_find',
|
|
50
50
|
'bclaw_code_brief',
|
|
51
|
+
'bclaw_code_impact',
|
|
52
|
+
'bclaw_code_export',
|
|
53
|
+
'bclaw_code_outline',
|
|
51
54
|
'bclaw_send_message',
|
|
52
55
|
'bclaw_ack_message',
|
|
53
56
|
'bclaw_write_note',
|