brainclaw 1.23.0 → 1.24.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/commands/cloud.js +534 -39
- 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 +375 -0
- package/dist/core/federation-push.js +274 -0
- package/dist/core/federation-rotation.js +124 -0
- package/dist/core/federation-state.js +81 -6
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/design/federation-onboarding-usecases.md +254 -0
- package/docs/design/pairing-v3-brief.md +80 -0
- package/package.json +1 -1
|
@@ -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),
|
package/dist/facts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
|
|
2
|
-
// Source: brainclaw v1.
|
|
2
|
+
// Source: brainclaw v1.24.0 on 2026-08-10T18:03:29.240Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-08-
|
|
4
|
+
"version": "1.24.0",
|
|
5
|
+
"generated_at": "2026-08-10T18:03:29.240Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 67,
|
|
8
8
|
"published_count": 65,
|
|
@@ -474,7 +474,7 @@ export const FACTS = {
|
|
|
474
474
|
},
|
|
475
475
|
"bench": {
|
|
476
476
|
"schema": "brainclaw.bench.v1",
|
|
477
|
-
"generated_at": "2026-08-
|
|
477
|
+
"generated_at": "2026-08-10T18:03:27.087Z",
|
|
478
478
|
"node_version": "v24.18.0",
|
|
479
479
|
"platform": "linux-x64",
|
|
480
480
|
"repeats": 3,
|
|
@@ -483,7 +483,7 @@ export const FACTS = {
|
|
|
483
483
|
"name": "cold_onboard",
|
|
484
484
|
"volume": "empty",
|
|
485
485
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
486
|
-
"duration_ms_median":
|
|
486
|
+
"duration_ms_median": 71,
|
|
487
487
|
"payload_chars_median": 1640,
|
|
488
488
|
"payload_tokens_est_median": 410
|
|
489
489
|
},
|
|
@@ -491,7 +491,7 @@ export const FACTS = {
|
|
|
491
491
|
"name": "warm_work",
|
|
492
492
|
"volume": "medium",
|
|
493
493
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
494
|
-
"duration_ms_median":
|
|
494
|
+
"duration_ms_median": 107,
|
|
495
495
|
"payload_chars_median": 2626,
|
|
496
496
|
"payload_tokens_est_median": 657
|
|
497
497
|
},
|
|
@@ -499,7 +499,7 @@ export const FACTS = {
|
|
|
499
499
|
"name": "first_edit",
|
|
500
500
|
"volume": "medium",
|
|
501
501
|
"description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
|
|
502
|
-
"duration_ms_median":
|
|
502
|
+
"duration_ms_median": 10,
|
|
503
503
|
"payload_chars_median": 499,
|
|
504
504
|
"payload_tokens_est_median": 125
|
|
505
505
|
}
|
package/dist/facts.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"generated_at": "2026-08-
|
|
2
|
+
"version": "1.24.0",
|
|
3
|
+
"generated_at": "2026-08-10T18:03:29.240Z",
|
|
4
4
|
"tools": {
|
|
5
5
|
"count": 67,
|
|
6
6
|
"published_count": 65,
|
|
@@ -472,7 +472,7 @@
|
|
|
472
472
|
},
|
|
473
473
|
"bench": {
|
|
474
474
|
"schema": "brainclaw.bench.v1",
|
|
475
|
-
"generated_at": "2026-08-
|
|
475
|
+
"generated_at": "2026-08-10T18:03:27.087Z",
|
|
476
476
|
"node_version": "v24.18.0",
|
|
477
477
|
"platform": "linux-x64",
|
|
478
478
|
"repeats": 3,
|
|
@@ -481,7 +481,7 @@
|
|
|
481
481
|
"name": "cold_onboard",
|
|
482
482
|
"volume": "empty",
|
|
483
483
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
484
|
-
"duration_ms_median":
|
|
484
|
+
"duration_ms_median": 71,
|
|
485
485
|
"payload_chars_median": 1640,
|
|
486
486
|
"payload_tokens_est_median": 410
|
|
487
487
|
},
|
|
@@ -489,7 +489,7 @@
|
|
|
489
489
|
"name": "warm_work",
|
|
490
490
|
"volume": "medium",
|
|
491
491
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
492
|
-
"duration_ms_median":
|
|
492
|
+
"duration_ms_median": 107,
|
|
493
493
|
"payload_chars_median": 2626,
|
|
494
494
|
"payload_tokens_est_median": 657
|
|
495
495
|
},
|
|
@@ -497,7 +497,7 @@
|
|
|
497
497
|
"name": "first_edit",
|
|
498
498
|
"volume": "medium",
|
|
499
499
|
"description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
|
|
500
|
-
"duration_ms_median":
|
|
500
|
+
"duration_ms_median": 10,
|
|
501
501
|
"payload_chars_median": 499,
|
|
502
502
|
"payload_tokens_est_median": 125
|
|
503
503
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Fédération cloud — cartographie des cas d'usage et des parcours
|
|
2
|
+
|
|
3
|
+
> Objectif fixé par l'opérateur (2026-08-09) : identifier **tous** les cas pour qu'un ou
|
|
4
|
+
> plusieurs humains, sur un ou plusieurs projets, avec un ou plusieurs agents, utilisent la
|
|
5
|
+
> fédération **simplement**. Point de départ : l'onboarding côté cloud, avec l'idée d'un
|
|
6
|
+
> appairage par agent via une URL d'activation.
|
|
7
|
+
>
|
|
8
|
+
> Chaque affirmation « état actuel » de ce document a été **mesurée sur le code ou en
|
|
9
|
+
> production cette session** — les références (dec#, trp#) pointent la mesure.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. Le vocabulaire d'abord — quatre identités, pas deux
|
|
14
|
+
|
|
15
|
+
Tout le reste du document repose sur cette distinction. La confusion entre ces quatre
|
|
16
|
+
notions est la cause directe des deux pièges déjà rencontrés (trp#1610, trp#1625).
|
|
17
|
+
|
|
18
|
+
| Identité | Ce qu'elle est | Sa preuve | Où elle vit |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| **Compte humain** | La personne — s'inscrit, approuve, administre | session web (email + mot de passe) | cloud (`users`) |
|
|
21
|
+
| **Appareil** | La machine — détient les clés de **déchiffrement** | clé X25519, empreinte comparée à l'appairage | local (`~/.brainclaw/keys/`) + cloud (`enrollments`) |
|
|
22
|
+
| **Agent** | Le logiciel (claude-code, codex…) — **signe** ce qu'il émet | clé Ed25519, attestée à l'appairage | local (registre d'agents) + cloud (`agents`) |
|
|
23
|
+
| **Projet** | Le magasin `.brainclaw/` et sa projection aveugle | — | local (source de vérité) + cloud (projection, dec#154) |
|
|
24
|
+
|
|
25
|
+
Relations cibles (dec#158/159) : un humain **possède** des appareils ; un appareil
|
|
26
|
+
**héberge** des agents ; un enrôlement lie *(agent, appareil, humain propriétaire, projet)*.
|
|
27
|
+
|
|
28
|
+
**Écart mesuré aujourd'hui** : l'enrôlement lie agent + appareil mais **pas l'humain**
|
|
29
|
+
(aucun `owner_user_id`), et l'état local ne connaît qu'**un** appairage par workspace, sans
|
|
30
|
+
mémoriser quel agent (trp#1625).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 2. La matrice des situations
|
|
35
|
+
|
|
36
|
+
Quatre axes : humains (1/N) × machines (1/N) × agents (1/N) × projets (1/N).
|
|
37
|
+
Les combinaisons se ramènent à six situations réellement distinctes :
|
|
38
|
+
|
|
39
|
+
| # | Situation | État aujourd'hui | Ce qui bloque |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| S1 | 1 humain, 1 machine, 1 agent, 1 projet | ✅ **fonctionne, vérifié en prod** | — |
|
|
42
|
+
| S2 | + agents supplémentaires sur la même machine | ❌ | `connection.json` singleton : le 2ᵉ `connect` **écrase** le 1ᵉʳ (trp#1625) |
|
|
43
|
+
| S3 | + machines supplémentaires (même humain) | ❌ | pas de **remise de clé d'epoch** : la 2ᵉ machine ne peut ni lire ni sceller (dec#159) |
|
|
44
|
+
| S4 | + humains supplémentaires (équipe) | ❌ | pas d'invitation par email (`404` si compte inexistant, zéro envoi d'email), pas de propriétaire d'appareil |
|
|
45
|
+
| S5 | plusieurs projets | 🟡 | 1 workspace = 1 projet : correct par construction ; clés API scopées par projet (vérifié : `403` croisé) ; mais l'URL du cloud n'est pas persistée, à repasser à chaque commande |
|
|
46
|
+
| S6 | agents sans humain au terminal (CI, headless) | ❌ non conçu | la cérémonie exige un humain qui compare des empreintes — cas à traiter explicitement, pas par contournement |
|
|
47
|
+
|
|
48
|
+
La règle de conception qui découle de dec#158 : **le solo est le cas dégénéré du modèle
|
|
49
|
+
d'équipe**, jamais une branche parallèle. S1 doit rester exactement « S4 où le même humain
|
|
50
|
+
joue tous les rôles et où les approbations s'effondrent en un geste ».
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 3. Les parcours, un par un
|
|
55
|
+
|
|
56
|
+
Convention : 🖥 = côté cloud (navigateur), ⌨ = côté machine (terminal), 👤 = geste humain
|
|
57
|
+
explicite. ✅/🟡/❌ = état mesuré de chaque étape.
|
|
58
|
+
|
|
59
|
+
### W1 — Solo : premier appairage (S1) — *fonctionne aujourd'hui*
|
|
60
|
+
|
|
61
|
+
Le cas « j'utilisais déjà brainclaw en local » : le magasin existe, le cloud est vide.
|
|
62
|
+
|
|
63
|
+
| # | Étape | Où | État |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| 1 | Créer un compte, créer/choisir le projet cloud | 🖥 | ✅ |
|
|
66
|
+
| 2 | « Connect an agent » → créer une invitation (rôle + libellé) → **code affiché une fois** (TTL 15 min, usage unique, seul le SHA-256 est stocké) | 🖥👤 | ✅ |
|
|
67
|
+
| 3 | `brainclaw cloud connect <code> --url <url> --agent <id>` **depuis le bon workspace** — le workspace appairé est affiché avec les empreintes | ⌨ | ✅ (garde trp#1610 livrée) |
|
|
68
|
+
| 4 | Comparer les **deux empreintes** terminal ↔ écran, approuver | 🖥👤 | ✅ |
|
|
69
|
+
| 5 | `brainclaw cloud await --url <url>` → appairage local actif, **genèse de la clé d'epoch 1** (premier appareil seulement) | ⌨ | ✅ (livré ce jour) |
|
|
70
|
+
| 6 | `brainclaw cloud push --url <url>` → plans + mémoire projet scellés, envoyés, stockés | ⌨ | ✅ (vérifié en prod : enveloppe en base, zéro fuite) |
|
|
71
|
+
|
|
72
|
+
**Frictions restantes de W1** (aucune bloquante) : l'URL doit être répétée à chaque commande
|
|
73
|
+
(l'état ne la persiste pas — mesuré) ; `connect` puis `await` sont deux commandes là où une
|
|
74
|
+
seule suffirait (`connect` pourrait attendre l'approbation en sondant) ; l'agent doit être
|
|
75
|
+
un identifiant opaque `[a-zA-Z0-9_-]{4,64}` et l'erreur ne le dit qu'après coup.
|
|
76
|
+
|
|
77
|
+
### W2 — Deuxième agent, même machine (S2) — *à construire*
|
|
78
|
+
|
|
79
|
+
Cible : les agents d'une même machine **partagent la clé X25519 de l'appareil** et signent
|
|
80
|
+
chacun avec leur Ed25519. C'est cohérent avec l'attestation existante (elle lie déjà un
|
|
81
|
+
Ed25519 à un X25519) : chaque agent atteste **la même** clé d'appareil.
|
|
82
|
+
|
|
83
|
+
| # | Étape | Où | État |
|
|
84
|
+
|---|---|---|---|
|
|
85
|
+
| 1 | Créer une invitation **par agent** (ou une invitation multi-usages ? → non : usage unique conservé, un code par agent) | 🖥👤 | ✅ (mécanique identique à W1) |
|
|
86
|
+
| 2 | `cloud connect <code> --agent <id2>` → détecte l'appairage existant, **réutilise la clé d'appareil**, ajoute un enrôlement à la liste | ⌨ | ❌ `connection.json` doit devenir une **liste d'appairages** `{agent, enrollment_id, role}` autour d'un `device` unique |
|
|
87
|
+
| 3 | Approbation par empreintes — l'empreinte de chiffrement **répète** celle de l'appareil, l'empreinte d'identité change par agent | 🖥👤 | 🟡 l'écran d'approbation existe ; afficher « appareil déjà connu » serait le bon signal |
|
|
88
|
+
| 4 | Chaque agent pousse sous sa propre signature ; l'origine (`origin_agent_id`) les distingue | ⌨ | ✅ le transport le fait déjà |
|
|
89
|
+
|
|
90
|
+
**Prérequis structurel** : migration de `connection.json` (forme v2 → v3) avec lecture
|
|
91
|
+
tolérante des deux formats — même discipline que la purge cloud (rien d'irréversible sans
|
|
92
|
+
chemin de retour).
|
|
93
|
+
|
|
94
|
+
### W3 — Deuxième machine, même humain (S3) — *bloqué sur la remise d'epoch*
|
|
95
|
+
|
|
96
|
+
| # | Étape | Où | État |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| 1 | Invitation + cérémonie sur la machine B (identique à W1 étapes 2–4) | 🖥⌨👤 | ✅ |
|
|
99
|
+
| 2 | La machine B est `active` **mais ne détient aucune clé d'epoch** : elle ne peut ni lire ni sceller | — | ⚠️ c'est l'état actuel : actif et inopérant, sans message |
|
|
100
|
+
| 3 | Une machine détentrice (A) **remet** les epochs autorisés : paquet HPKE scellé vers la X25519 attestée de B, manifeste signé (`epoch_grant`, dec#159) | ⌨ A | ❌ à construire — **le cœur du chantier** |
|
|
101
|
+
| 4 | B vérifie le manifeste, range les clés (`storeEpochPrivateKey` refuse déjà d'écraser une clé différente), relit le passé autorisé | ⌨ B | 🟡 primitives présentes, protocole absent |
|
|
102
|
+
|
|
103
|
+
**Point de vigilance déjà mesuré** : le premier appairage marque `recovery: true` et le
|
|
104
|
+
quorum de récupération (2 appareils) est **rapporté mais jamais appliqué** — le solo n'est
|
|
105
|
+
pas bloqué, mais la perte de l'unique machine = perte du passé, et rien ne l'affiche.
|
|
106
|
+
|
|
107
|
+
### W4 — Embarquer un deuxième développeur (S4) — *le parcours demandé, à construire*
|
|
108
|
+
|
|
109
|
+
Le principe directeur (convergence des deux critiques de l'idéation) : **deux approbations
|
|
110
|
+
de nature différente**. L'admin admet **l'humain** ; l'humain approuve **ses appareils**.
|
|
111
|
+
Un admin ne peut pas comparer les empreintes du terminal d'un tiers — le faire approuver à
|
|
112
|
+
sa place réduirait la cérémonie à un clic de confiance (dec#8).
|
|
113
|
+
|
|
114
|
+
| # | Étape | Où | État |
|
|
115
|
+
|---|---|---|---|
|
|
116
|
+
| 1 | Admin : « Inviter un membre » → email + rôle | 🖥👤 | ❌ aujourd'hui `404` si le compte n'existe pas |
|
|
117
|
+
| 2 | Le cloud envoie un email avec lien d'acceptation ; si le compte n'existe pas, le lien passe par l'inscription | 🖥 | ❌ **zéro envoi d'email dans le backend** (une skill `cloudflare-email-service` est disponible pour le construire) |
|
|
118
|
+
| 3 | Le membre accepte → `project_members` actif avec son rôle | 🖥👤 | 🟡 la table et les rôles existent, le flux non |
|
|
119
|
+
| 4 | Le membre crée **ses** invitations d'agent (portée : ses propres appareils) et fait W1/W2 sur ses machines | 🖥⌨👤 | ❌ nécessite `owner_user_id` sur `enrollments` + revendication de propriétaire **dans le payload d'attestation signé** (sinon la liaison humain↔appareil ne vaut que la parole du cloud) |
|
|
120
|
+
| 5 | L'**admin voit** les appareils du membre (métadonnées, pas les clés) ; le **membre** les approuve | 🖥 | ❌ l'écran d'approbation ne filtre pas par propriétaire |
|
|
121
|
+
| 6 | Un custodian remet les epochs selon l'**horizon** choisi (tout / à partir de maintenant / borné) | ⌨ | ❌ même chantier que W3-3 + **arbitrage produit** (voir §7) |
|
|
122
|
+
|
|
123
|
+
### W5 — Révoquer (agent, appareil, ou humain)
|
|
124
|
+
|
|
125
|
+
| # | Étape | Où | État |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| 1 | Révoquer l'enrôlement (bouton « Revoke ») | 🖥👤 | ✅ existe |
|
|
128
|
+
| 2 | Supprimer / renommer l'agent dans le registre cloud | 🖥 | ❌ **aucun `DELETE /agents/:id`**, le `PATCH` ne change que le statut — c'est le manque constaté par l'opérateur |
|
|
129
|
+
| 3 | Rotation d'epoch N+1, remise aux lecteurs restants | ⌨ | ❌ dépend de W3-3 |
|
|
130
|
+
| 4 | Affichage honnête : « ne lit plus le **futur** ; conserve ce qu'il avait déjà déchiffré » | 🖥 | ✅ le texte existe sur la page connect |
|
|
131
|
+
|
|
132
|
+
### W6 — Perte de machine / récupération
|
|
133
|
+
|
|
134
|
+
RFC §5.3 : un porteur restant approuve la nouvelle clé, enveloppe les epochs historiques
|
|
135
|
+
autorisés, révoque l'ancienne. Même mécanique que W3-3 avec un autorisateur différent —
|
|
136
|
+
**toute solution qui inventerait un second canal de transfert de clés créerait un second
|
|
137
|
+
endroit où une clé peut fuir** (conséquence 3 de dec#158).
|
|
138
|
+
État : ❌ (bloqué sur la remise d'epoch) + ⚠️ quorum non appliqué (mesuré).
|
|
139
|
+
|
|
140
|
+
### W7 — Quotidien : synchroniser
|
|
141
|
+
|
|
142
|
+
| # | Étape | État |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| 1 | `cloud push` — scelle, met en file, envoie ; refus des trois filets listés un par un | ✅ vérifié en prod |
|
|
145
|
+
| 2 | **Pull** — tirer les enveloppes des autres, vérifier (`verifyInbound` : roster, signature, AAD, anti-rejeu), matérialiser localement | ❌ **`verifyInbound` n'a aucun appelant de production** — symétrique exact de l'émission avant ce matin ; le « Materialized N signals » des sessions vient du chemin **v1** |
|
|
146
|
+
| 3 | Relais dashboard : changer statut/priorité depuis le web, appliqué localement (`applyCloudCommand`) | 🟡 primitives présentes des deux côtés, câblage du poll absent |
|
|
147
|
+
| 4 | Automatisation : push en fin de session, pull en début (hooks existants) | ❌ manuel aujourd'hui |
|
|
148
|
+
| 5 | Conflits : `409` → recalage signé une fois, sinon visible en `conflict` | ✅ |
|
|
149
|
+
|
|
150
|
+
### W8 — Multi-projets (S5)
|
|
151
|
+
|
|
152
|
+
Fonctionne par construction (1 workspace = 1 projet, table d'ids opaques cloisonnée par
|
|
153
|
+
projet cloud — testé). Reste : persister l'URL du cloud par appairage, et le sélecteur de
|
|
154
|
+
projet web existe déjà.
|
|
155
|
+
|
|
156
|
+
### W9 — Agent headless / CI (S6) — *à concevoir, pas à contourner*
|
|
157
|
+
|
|
158
|
+
La cérémonie exige un humain au terminal. Pour un runner CI éphémère, trois options à
|
|
159
|
+
trancher **plus tard** (aucune n'est urgente) : appairage du runner par son propriétaire au
|
|
160
|
+
provisioning ; « appareil de service » à rôle réduit (écriture seule, jamais custodian) ;
|
|
161
|
+
ou exclusion assumée (le CI passe par un membre humain). À documenter comme limite tant que
|
|
162
|
+
non conçu.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 4. L'URL d'activation — ce qu'elle change, ce qu'elle ne doit jamais porter
|
|
167
|
+
|
|
168
|
+
L'idée de l'opérateur est bonne et **peu coûteuse** : l'URL est un véhicule pour le code
|
|
169
|
+
d'invitation existant, pas un nouveau mécanisme.
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
https://app.brainclaw.dev/a/<code> ← partageable à l'humain OU collée à l'agent
|
|
173
|
+
brainclaw cloud connect <url|code> --agent x ← la CLI accepte les deux formes
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Ce que ça améliore : une seule chose à copier (aujourd'hui : code + URL + savoir où les
|
|
177
|
+
mettre) ; un agent à qui on colle l'URL peut en extraire le code ET l'adresse du
|
|
178
|
+
déploiement — ce qui règle au passage la non-persistance de l'URL.
|
|
179
|
+
|
|
180
|
+
**Les invariants qui rendent l'URL sûre** (tous déjà en place pour le code) :
|
|
181
|
+
usage unique, TTL 15 min, seul le SHA-256 stocké, et surtout — **l'URL ne donne aucun droit
|
|
182
|
+
de lecture**. Elle n'ouvre que le droit de *candidater* ; la sécurité reste dans la
|
|
183
|
+
comparaison d'empreintes à l'approbation, et les clés d'epoch ne voyagent **jamais** dans
|
|
184
|
+
une URL.
|
|
185
|
+
|
|
186
|
+
**Le piège à refuser explicitement** : mettre dans l'URL de quoi éviter l'approbation
|
|
187
|
+
(« lien magique » qui active). Ce serait le modèle moltbook, voir ci-dessous.
|
|
188
|
+
|
|
189
|
+
## 5. Pourquoi pas l'auto-enregistrement (le modèle moltbook), et ce qu'on en garde
|
|
190
|
+
|
|
191
|
+
Le modèle « l'agent s'enregistre tout seul dans l'application » échoue sur trois points,
|
|
192
|
+
tous structurels :
|
|
193
|
+
|
|
194
|
+
1. **Aucune liaison de propriété** — n'importe quel processus connaissant l'URL devient un
|
|
195
|
+
agent du projet ; l'identité se squatte.
|
|
196
|
+
2. **Le cloud peut fabriquer des agents** — sans approbation humaine par empreintes, un
|
|
197
|
+
cloud hostile insère son propre « membre fantôme » dans le projet, et le chiffrement de
|
|
198
|
+
bout en bout devient décoratif (c'est exactement l'attaque que l'attestation
|
|
199
|
+
X25519-par-Ed25519 ferme, migrations 0022/0025).
|
|
200
|
+
3. **Rien ne lie l'agent à une clé** — un agent enregistré sans attestation ne peut pas
|
|
201
|
+
recevoir d'epoch de façon vérifiable.
|
|
202
|
+
|
|
203
|
+
**Ce qu'on en garde** : la simplicité du geste — *un seul artefact à transmettre*. C'est
|
|
204
|
+
précisément l'URL d'activation (§4) : la simplicité de moltbook, la cérémonie en dessous.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 6. Besoins transverses (indépendants des parcours)
|
|
209
|
+
|
|
210
|
+
| Besoin | Pourquoi | État |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| Envoi d'email (invitations, notifications d'approbation) | W4 | ❌ — skill `cloudflare-email-service` disponible |
|
|
213
|
+
| CRUD agents cloud (`DELETE`, renommage, purge des agents v1 périmés) | W5, demande opérateur | ❌ |
|
|
214
|
+
| État local multi-appairage (`connection.json` v3 : un `device`, une liste d'agents) | W2, trp#1625 | ❌ |
|
|
215
|
+
| Persistance de l'URL du cloud dans l'état d'appairage | toutes les commandes | ❌ (mesuré) |
|
|
216
|
+
| `connect` qui enchaîne l'attente d'approbation (supprime `await` du chemin nominal) | W1 friction | ❌ |
|
|
217
|
+
| Pull v2 (`verifyInbound` câblé + matérialisation + curseur de feed) | W7 — sans lui, la fédération est **unidirectionnelle** | ❌ |
|
|
218
|
+
| Application du quorum de récupération (aujourd'hui rapporté, jamais bloquant) | W6 | ❌ |
|
|
219
|
+
| Écran « appareils par humain » (l'admin voit, le propriétaire approuve) | W4 | ❌ |
|
|
220
|
+
|
|
221
|
+
## 7. Les arbitrages qui vous appartiennent (aucun n'est technique)
|
|
222
|
+
|
|
223
|
+
Repris de dec#159 — à trancher **avant** W3/W4, parce qu'irréversibles par nature :
|
|
224
|
+
|
|
225
|
+
1. **Horizon par défaut d'un nouveau membre** : rien / depuis l'adhésion / tout.
|
|
226
|
+
Recommandation technique : minimal (élargir reste toujours possible, reprendre jamais).
|
|
227
|
+
2. **Qui est custodian** des remises de clés : le premier appareil ? tout membre `admin` ?
|
|
228
|
+
un quorum ? — et l'exigence d'indépendance des appareils de récupération.
|
|
229
|
+
3. **Disponibilité** : accepter qu'un nouveau membre attende qu'un custodian soit en ligne,
|
|
230
|
+
ou financer une récupération explicitement détentrice de clés (coffre).
|
|
231
|
+
4. **Équivocation du cloud** : aucun témoin / gossip d'équipe / journal de transparence.
|
|
232
|
+
Le cloud hostile qui montre des rosters différents à deux humains reste indétectable
|
|
233
|
+
sans canal hors bande — c'est une limite à afficher, pas à taire.
|
|
234
|
+
|
|
235
|
+
## 8. Ordre de construction proposé
|
|
236
|
+
|
|
237
|
+
Chaque tranche est livrable et vérifiable seule ; les deux premières ne demandent aucun
|
|
238
|
+
arbitrage :
|
|
239
|
+
|
|
240
|
+
1. **Fondations sans arbitrage** — état multi-appairage (W2), URL persistée + URL
|
|
241
|
+
d'activation (§4), `connect` qui attend, CRUD agents (W5-2). *Débloque S2 et la demande
|
|
242
|
+
opérateur immédiate.*
|
|
243
|
+
2. **Pull v2** (W7-2) — la fédération devient bidirectionnelle ; sans lui, un deuxième
|
|
244
|
+
appareil n'aurait de toute façon rien à lire.
|
|
245
|
+
3. **Invitation d'humains** (W4-1..3, email inclus) — après l'arbitrage §7-1 au minimum.
|
|
246
|
+
4. **Remise d'epoch** (W3/W4-6/W6) — le gros œuvre, après les arbitrages §7-1/2/3.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
*Références : dec#154 (cloud = projection), dec#155 (relais sans contexte), dec#156 (v2
|
|
251
|
+
cassante), dec#158 (appairage deux niveaux), dec#159 (synthèse idéation, epoch grants),
|
|
252
|
+
dec#160 (six divergences de contrat, résolues), trp#1610 (connect appaire le cwd),
|
|
253
|
+
trp#1625 (connection.json singleton), critiques `CRITIQUE-codex.md` /
|
|
254
|
+
`CRITIQUE-claude-code.md` (worktrees de l'idéation du 2026-08-09).*
|