brainclaw 1.22.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.
Files changed (38) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-capture.js +15 -0
  3. package/dist/cli/register-cloud.js +121 -13
  4. package/dist/commands/cloud.js +534 -39
  5. package/dist/commands/loops-handlers.js +0 -1
  6. package/dist/commands/mcp-catalog.js +24 -256
  7. package/dist/commands/mcp-read-handlers.js +5 -1
  8. package/dist/commands/mcp-schemas.generated.js +811 -1
  9. package/dist/commands/mcp-write-coordination.js +16 -7
  10. package/dist/commands/mcp.js +45 -1
  11. package/dist/commands/memory-confirm.js +83 -0
  12. package/dist/commands/switch.js +24 -2
  13. package/dist/core/assignment-request-schema.js +112 -0
  14. package/dist/core/capture-schema.js +62 -0
  15. package/dist/core/claim-request-schema.js +72 -0
  16. package/dist/core/code-map/aggregate.js +36 -1
  17. package/dist/core/federation-emit.js +283 -0
  18. package/dist/core/federation-grant-transport.js +196 -0
  19. package/dist/core/federation-grant.js +223 -0
  20. package/dist/core/federation-keyring.js +39 -0
  21. package/dist/core/federation-opaque-ids.js +111 -0
  22. package/dist/core/federation-outbox-v2.js +36 -2
  23. package/dist/core/federation-pairing.js +87 -12
  24. package/dist/core/federation-pull.js +375 -0
  25. package/dist/core/federation-push.js +274 -0
  26. package/dist/core/federation-rotation.js +124 -0
  27. package/dist/core/federation-state.js +81 -6
  28. package/dist/core/sequence-request-schema.js +93 -0
  29. package/dist/core/session-request-schema.js +90 -0
  30. package/dist/core/step-request-schema.js +112 -0
  31. package/dist/core/store-resolution.js +34 -5
  32. package/dist/core/warnings.js +37 -0
  33. package/dist/facts.js +7 -7
  34. package/dist/facts.json +6 -6
  35. package/docs/design/federation-onboarding-usecases.md +254 -0
  36. package/docs/design/pairing-v3-brief.md +80 -0
  37. package/docs/integrations/mcp.md +1 -1
  38. package/package.json +1 -1
@@ -19,6 +19,87 @@ import { forgetProjectEpochs } from '../core/federation-keyring.js';
19
19
  import { beginPairing, checkPairingApproval, completePairing, requestRevocation, PairingError, } from '../core/federation-pairing.js';
20
20
  import { resolveEffectiveCwd } from '../core/store-resolution.js';
21
21
  import { nowISO } from '../core/ids.js';
22
+ /** Une invitation n'est volontairement valable que quinze minutes. */
23
+ export const INVITATION_TTL_MS = 15 * 60 * 1_000;
24
+ export const APPROVAL_POLL_INTERVAL_MS = 5_000;
25
+ export const AGENT_ID_PATTERN = /^[a-zA-Z0-9_-]{4,64}$/;
26
+ const ACTIVATION_URL_FORM = 'https://app.brainclaw.dev/a#<code>';
27
+ const DEFAULT_CLOUD_URL_FORM = 'https://<votre-déploiement>.workers.dev';
28
+ /**
29
+ * Accepte le code historique ou l'URL d'activation. Une URL est délibérément limitée
30
+ * à `/a#<code>` : cela évite de prendre un code depuis une URL de route API ou de le
31
+ * transmettre par erreur dans un chemin ou une query string.
32
+ */
33
+ export function parseActivationInput(input) {
34
+ const value = input.trim();
35
+ if (!value) {
36
+ throw new Error(`Code ou URL d'activation attendu (ex. ${ACTIVATION_URL_FORM}).`);
37
+ }
38
+ if (!/^[a-z][a-z\d+.-]*:/i.test(value))
39
+ return { inviteCode: value };
40
+ let parsed;
41
+ try {
42
+ parsed = new URL(value);
43
+ }
44
+ catch {
45
+ throw new Error(`URL d'activation invalide. Forme attendue : ${ACTIVATION_URL_FORM}.`);
46
+ }
47
+ if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
48
+ || parsed.pathname !== '/a'
49
+ || parsed.search
50
+ || parsed.username
51
+ || parsed.password
52
+ || !parsed.hash.slice(1)) {
53
+ throw new Error(`URL d'activation invalide. Forme attendue : ${ACTIVATION_URL_FORM}.`);
54
+ }
55
+ let inviteCode;
56
+ try {
57
+ inviteCode = decodeURIComponent(parsed.hash.slice(1));
58
+ }
59
+ catch {
60
+ throw new Error(`Code d'activation invalide dans l'URL. Forme attendue : ${ACTIVATION_URL_FORM}.`);
61
+ }
62
+ if (!inviteCode) {
63
+ throw new Error(`Code d'activation manquant dans l'URL. Forme attendue : ${ACTIVATION_URL_FORM}.`);
64
+ }
65
+ return { inviteCode, url: parsed.origin };
66
+ }
67
+ /** Valide avant toute construction ou utilisation de transport réseau. */
68
+ export function validateAgentId(agentId) {
69
+ if (!AGENT_ID_PATTERN.test(agentId)) {
70
+ throw new Error("Identifiant d'agent invalide : utilisez 4 à 64 caractères parmi a-z, A-Z, 0-9, _ et -.");
71
+ }
72
+ return agentId;
73
+ }
74
+ /**
75
+ * Normalise l'origine de déploiement et n'accepte jamais un fragment : le code
76
+ * d'invitation ne doit pas pouvoir passer au transport HTTP.
77
+ */
78
+ export function normalizeCloudUrl(value) {
79
+ let parsed;
80
+ try {
81
+ parsed = new URL(value);
82
+ }
83
+ catch {
84
+ throw new Error(`Adresse cloud invalide. Utilisez une origine HTTPS, par ex. ${DEFAULT_CLOUD_URL_FORM}.`);
85
+ }
86
+ if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
87
+ || parsed.username
88
+ || parsed.password
89
+ || parsed.search
90
+ || parsed.hash) {
91
+ throw new Error(`Adresse cloud invalide. Utilisez une origine HTTPS, par ex. ${DEFAULT_CLOUD_URL_FORM}.`);
92
+ }
93
+ return parsed.origin;
94
+ }
95
+ /** Résout l'option explicite, sinon l'origine mémorisée lors de l'appairage v3. */
96
+ export function resolveCloudUrl(explicitUrl, state) {
97
+ const url = explicitUrl ?? state?.cloud_url;
98
+ if (!url) {
99
+ throw new Error(`Adresse cloud inconnue. Donnez --url ${DEFAULT_CLOUD_URL_FORM} ou utilisez l'URL d'activation ${ACTIVATION_URL_FORM}.`);
100
+ }
101
+ return normalizeCloudUrl(url);
102
+ }
22
103
  export function runCloudStatus(options = {}) {
23
104
  const cwd = options.cwd ?? resolveEffectiveCwd();
24
105
  const summary = summarizeConnection(cwd);
@@ -36,9 +117,19 @@ export function runCloudStatus(options = {}) {
36
117
  }
37
118
  console.log(`Cloud : ${summary.stage}${summary.connected ? '' : ' (pas encore actif)'}`);
38
119
  console.log(` Projet : ${summary.cloud_project_id ?? '—'}`);
39
- console.log(` Rôle : ${summary.role ?? '—'}`);
40
120
  console.log(` Appareil : ${summary.device_fingerprint?.slice(0, 16) ?? '—'}…`);
41
121
  console.log(` Epoch : ${summary.current_epoch} (lisibles : ${summary.readable_epochs.join(', ') || 'aucun'})`);
122
+ // Les agents appairés, un par ligne : c'est ce qu'un singleton v2 ne pouvait pas montrer.
123
+ if (summary.pairings.length > 0) {
124
+ console.log(` Agents :`);
125
+ for (const p of summary.pairings) {
126
+ const active = p.stage === 'active' ? '' : ` (${p.stage})`;
127
+ console.log(` • ${p.agent_id}${p.role ? ` [${p.role}]` : ''}${active}`);
128
+ }
129
+ }
130
+ else {
131
+ console.log(` Rôle : ${summary.role ?? '—'}`);
132
+ }
42
133
  console.log(` Sync : ${summary.sync.pending} en attente · ${summary.sync.synced} synchronisé(s) · ${summary.sync.conflict} conflit(s)`);
43
134
  if (summary.last_pull_at)
44
135
  console.log(` Dernier pull : ${summary.last_pull_at}`);
@@ -82,14 +173,72 @@ export function httpTransport(baseUrl) {
82
173
  get: (path) => call('GET', path),
83
174
  };
84
175
  }
176
+ const sleepFor = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
177
+ /** Affichage concis du délai restant, sans prétendre connaître l'heure serveur. */
178
+ export function formatApprovalTimeRemaining(remainingMs) {
179
+ const seconds = Math.max(0, Math.ceil(remainingMs / 1_000));
180
+ const minutes = Math.floor(seconds / 60);
181
+ return `${minutes} min ${String(seconds % 60).padStart(2, '0')} s`;
182
+ }
183
+ /**
184
+ * L'approbation est humaine, mais son attente est bornée par le TTL de l'invitation.
185
+ * Le transport et l'horloge sont injectables afin que le comportement temporel reste
186
+ * testable sans requête ni temporisation réelle.
187
+ */
188
+ export async function waitForPairingApproval(options) {
189
+ const now = options.now ?? Date.now;
190
+ const sleep = options.sleep ?? sleepFor;
191
+ const timeoutMs = Math.max(0, Math.min(options.timeoutMs ?? INVITATION_TTL_MS, INVITATION_TTL_MS));
192
+ const pollIntervalMs = Math.max(0, options.pollIntervalMs ?? APPROVAL_POLL_INTERVAL_MS);
193
+ const deadline = now() + timeoutMs;
194
+ while (true) {
195
+ const result = await checkPairingApproval({
196
+ enrollmentId: options.enrollmentId,
197
+ transport: options.transport,
198
+ cwd: options.cwd,
199
+ });
200
+ if (result.approved)
201
+ return { ...result, timedOut: false };
202
+ const remainingMs = deadline - now();
203
+ if (remainingMs <= 0)
204
+ return { ...result, timedOut: true };
205
+ options.onPending?.(result.state, remainingMs);
206
+ await sleep(Math.min(pollIntervalMs, remainingMs));
207
+ }
208
+ }
209
+ function reportApprovalTimeout() {
210
+ console.error("L'approbation n'est pas arrivée avant l'expiration de l'invitation (15 min).");
211
+ console.error("Relancez `brainclaw cloud connect` avec une nouvelle URL d'activation. `cloud await` reste disponible après une interruption.");
212
+ process.exitCode = 1;
213
+ }
214
+ function completeApprovedPairing(cwd, role) {
215
+ const next = completePairing({ role, cwd });
216
+ console.log(`Appairage approuvé. Rôle : ${next.enrollment.role ?? '—'}.`);
217
+ console.log('');
218
+ // Le premier pull est en LECTURE SEULE et non destructif (RFC §5.2 phase 4) : rien
219
+ // n'est matérialisé dans la mémoire locale tant que la vérification d'origine n'existe
220
+ // pas. Le dire évite qu'on croie la synchronisation déjà active.
221
+ console.log(" Aucune donnée n'est encore matérialisée : la vérification à la réception");
222
+ console.log(" (signature d'origine, anti-rejeu) est livrée à l'étape suivante.");
223
+ }
85
224
  export async function runCloudConnect(options) {
86
225
  const cwd = options.cwd ?? resolveEffectiveCwd();
87
- const transport = options.transport ?? httpTransport(options.url);
226
+ const activation = parseActivationInput(options.inviteCode);
227
+ // Cette validation doit rester avant le moindre appel du transport : un identifiant
228
+ // invalide ne doit jamais consommer une invitation ni créer une candidature distante.
229
+ const agentId = validateAgentId(options.agentId);
230
+ const existing = loadConnectionState(cwd);
231
+ const explicitUrl = options.url ? normalizeCloudUrl(options.url) : undefined;
232
+ if (activation.url && explicitUrl && activation.url !== explicitUrl) {
233
+ throw new Error("L'origine de `--url` ne correspond pas à celle de l'URL d'activation.");
234
+ }
235
+ const url = activation.url ?? resolveCloudUrl(explicitUrl, existing);
236
+ const transport = options.transport ?? httpTransport(url);
88
237
  let handle;
89
238
  try {
90
239
  handle = await beginPairing({
91
- inviteCode: options.inviteCode,
92
- agentId: options.agentId,
240
+ inviteCode: activation.inviteCode,
241
+ agentId,
93
242
  transport,
94
243
  cwd,
95
244
  });
@@ -104,41 +253,69 @@ export async function runCloudConnect(options) {
104
253
  }
105
254
  throw err;
106
255
  }
256
+ const pending = loadConnectionState(cwd);
257
+ if (!pending)
258
+ throw new Error("L'état d'appairage n'a pas pu être enregistré localement.");
259
+ // V3 mémorise l'ORIGINE uniquement, jamais l'URL d'activation ni son fragment/code.
260
+ saveConnectionState({ ...pending, cloud_url: url }, cwd);
261
+ if (!options.json) {
262
+ console.log('Preuve de possession acceptée. Attestation de clé enregistrée.');
263
+ console.log('');
264
+ console.log(' Projet cloud : ' + handle.cloud_project_id);
265
+ console.log(' Enrôlement : ' + handle.enrollment_id);
266
+ console.log(' Workspace : ' + cwd);
267
+ console.log('');
268
+ console.log(" Empreinte d'identité (Ed25519) : " + handle.fingerprints.identity);
269
+ console.log(' Empreinte de chiffrement (X25519) : ' + handle.fingerprints.encryption);
270
+ console.log('');
271
+ console.log(' → Faites vérifier CES DEUX EMPREINTES par la personne qui approuve.');
272
+ console.log(" Si elles diffèrent de ce qu'elle voit, REFUSEZ : quelqu'un s'est interposé.");
273
+ console.log('');
274
+ console.log(" En attente d'approbation humaine (expiration de l'invitation dans 15 min 00 s).");
275
+ }
276
+ const result = await waitForPairingApproval({
277
+ enrollmentId: handle.enrollment_id,
278
+ transport,
279
+ cwd,
280
+ now: options.now,
281
+ sleep: options.sleep,
282
+ pollIntervalMs: options.pollIntervalMs,
283
+ timeoutMs: options.timeoutMs,
284
+ onPending: options.json
285
+ ? undefined
286
+ : (state, remainingMs) => console.log(` Toujours en attente (${state}) — expiration dans ${formatApprovalTimeRemaining(remainingMs)}.`),
287
+ });
288
+ if (!result.approved) {
289
+ if (options.json) {
290
+ console.log(JSON.stringify({
291
+ enrollment_id: handle.enrollment_id,
292
+ cloud_project_id: handle.cloud_project_id,
293
+ awaiting: result.state,
294
+ expired: result.timedOut,
295
+ }, null, 2));
296
+ }
297
+ reportApprovalTimeout();
298
+ return;
299
+ }
107
300
  if (options.json) {
301
+ const next = completePairing({ role: result.role, cwd });
108
302
  console.log(JSON.stringify({
109
303
  enrollment_id: handle.enrollment_id,
110
304
  cloud_project_id: handle.cloud_project_id,
111
305
  device_id: handle.device.device_id,
112
306
  fingerprints: handle.fingerprints,
113
- awaiting: 'human_approval',
307
+ role: next.enrollment.role,
308
+ approved: true,
114
309
  }, null, 2));
115
310
  return;
116
311
  }
117
- console.log('Preuve de possession acceptée. Attestation de clé enregistrée.');
118
- console.log('');
119
- console.log(' Projet cloud : ' + handle.cloud_project_id);
120
- console.log(' Enrôlement : ' + handle.enrollment_id);
121
- console.log('');
122
- // CES DEUX EMPREINTES SONT LE CŒUR DE LA CÉRÉMONIE. La personne qui approuve voit les
123
- // mêmes à l'écran ; leur comparaison hors bande est ce qui ferme l'attaque de l'homme
124
- // du milieu sur l'appairage. Affichées EN ENTIER, pas tronquées : une comparaison sur
125
- // 16 caractères se collisionne bien plus facilement qu'elle n'en a l'air.
126
- console.log(" Empreinte d'identité (Ed25519) : " + handle.fingerprints.identity);
127
- console.log(' Empreinte de chiffrement (X25519) : ' + handle.fingerprints.encryption);
128
- console.log('');
129
- console.log(' → Faites vérifier CES DEUX EMPREINTES par la personne qui approuve.');
130
- console.log(" Si elles diffèrent de ce qu'elle voit, REFUSEZ : quelqu'un s'est interposé.");
131
- console.log('');
132
- console.log(" En attente d'approbation humaine. Constatez-la avec : brainclaw cloud await");
312
+ completeApprovedPairing(cwd, result.role);
133
313
  }
134
314
  /**
135
- * Constate l'approbation et bascule l'état local en actif.
136
- *
137
- * SÉPARÉ DE `connect` : l'approbation dépend d'un humain, dont le délai n'est pas borné.
138
- * Une commande qui bloquerait indéfiniment sur un tiers serait un mauvais citoyen dans un
139
- * script, et un appairage interrompu par un Ctrl-C doit rester reprenable.
315
+ * Reprend un appairage interrompu. Comme `connect`, l'attente est bornée au TTL de
316
+ * quinze minutes : elle ne bloque donc pas indéfiniment un terminal ou un script.
140
317
  */
141
- export async function runCloudAwait(options) {
318
+ export async function runCloudAwait(options = {}) {
142
319
  const cwd = options.cwd ?? resolveEffectiveCwd();
143
320
  const state = loadConnectionState(cwd);
144
321
  if (!state?.enrollment.enrollment_id) {
@@ -146,20 +323,23 @@ export async function runCloudAwait(options) {
146
323
  process.exitCode = 1;
147
324
  return;
148
325
  }
149
- const transport = options.transport ?? httpTransport(options.url);
150
- const result = await checkPairingApproval({ enrollmentId: state.enrollment.enrollment_id, transport, cwd });
326
+ const url = resolveCloudUrl(options.url, state);
327
+ const transport = options.transport ?? httpTransport(url);
328
+ const result = await waitForPairingApproval({
329
+ enrollmentId: state.enrollment.enrollment_id,
330
+ transport,
331
+ cwd,
332
+ now: options.now,
333
+ sleep: options.sleep,
334
+ pollIntervalMs: options.pollIntervalMs,
335
+ timeoutMs: options.timeoutMs,
336
+ onPending: (remoteState, remainingMs) => console.log(`Toujours en attente (état distant : ${remoteState}) — expiration dans ${formatApprovalTimeRemaining(remainingMs)}.`),
337
+ });
151
338
  if (!result.approved) {
152
- console.log(`Toujours en attente (état distant : ${result.state}).`);
339
+ reportApprovalTimeout();
153
340
  return;
154
341
  }
155
- const next = completePairing({ role: result.role, cwd });
156
- console.log(`Appairage approuvé. Rôle : ${next.enrollment.role ?? '—'}.`);
157
- console.log('');
158
- // Le premier pull est en LECTURE SEULE et non destructif (RFC §5.2 phase 4) : rien
159
- // n'est matérialisé dans la mémoire locale tant que la vérification d'origine n'existe
160
- // pas. Le dire évite qu'on croie la synchronisation déjà active.
161
- console.log(" Aucune donnée n'est encore matérialisée : la vérification à la réception");
162
- console.log(" (signature d'origine, anti-rejeu) est livrée à l'étape suivante.");
342
+ completeApprovedPairing(cwd, result.role);
163
343
  }
164
344
  export async function runCloudDisconnect(options) {
165
345
  const cwd = options.cwd ?? resolveEffectiveCwd();
@@ -170,7 +350,8 @@ export async function runCloudDisconnect(options) {
170
350
  }
171
351
  let revocationNote = "aucune révocation distante demandée (pas d'enrôlement connu)";
172
352
  if (state.enrollment.enrollment_id) {
173
- const transport = options.transport ?? httpTransport(options.url);
353
+ const url = resolveCloudUrl(options.url, state);
354
+ const transport = options.transport ?? httpTransport(url);
174
355
  const res = await requestRevocation({ enrollmentId: state.enrollment.enrollment_id, transport });
175
356
  revocationNote = res.revoked
176
357
  ? 'autorisation distante révoquée'
@@ -195,4 +376,318 @@ export async function runCloudDisconnect(options) {
195
376
  console.log(" et ce que d'autres appareils détiennent déjà. Un disconnect retire une");
196
377
  console.log(' autorisation ; il ne réécrit pas le passé.');
197
378
  }
379
+ /**
380
+ * Résout la clé d'API d'ingestion : option explicite, sinon `BRAINCLAW_CLOUD_API_KEY`.
381
+ *
382
+ * ── UNE BÉQUILLE, ET ELLE EST NOMMÉE COMME TELLE ──────────────────────────────
383
+ * dec#8 bannit les clés d'API du parcours d'appairage : l'humain ne manipule qu'un code
384
+ * d'invitation et compare des empreintes. Mais l'endpoint d'ingestion en exige une (401
385
+ * « Missing API key »), et l'appairage attesté n'en produit aucune — mesuré en production
386
+ * le 2026-08-10, après un push qui a scellé 1999 enveloppes pour les voir toutes refusées.
387
+ *
388
+ * L'appareil possède pourtant DÉJÀ de quoi s'authentifier : son identité Ed25519 est
389
+ * attestée côté cloud, et chaque enveloppe porte sa signature — une preuve plus forte
390
+ * qu'un jeton porteur, qui ne prouve que sa propre détention. La vraie correction est que
391
+ * l'ingestion accepte cette signature ; ce paramètre est une transition, pas le modèle.
392
+ *
393
+ * La variable d'environnement existe pour que la clé n'ait à passer ni par l'historique du
394
+ * shell ni par un fichier de configuration versionné.
395
+ */
396
+ export function resolveCloudApiKey(explicit) {
397
+ return explicit ?? process.env['BRAINCLAW_CLOUD_API_KEY'] ?? undefined;
398
+ }
399
+ /** Réception v2 : delta reçu et écritures locales sont affichés séparément. */
400
+ export async function runCloudPull(options = {}) {
401
+ const cwd = options.cwd ?? resolveEffectiveCwd();
402
+ const { pullFederationDelta } = await import('../core/federation-pull.js');
403
+ const url = resolveCloudUrl(options.url, loadConnectionState(cwd));
404
+ const pulled = await pullFederationDelta({ cwd, url, limit: options.limit, apiKey: resolveCloudApiKey(options.apiKey) });
405
+ if (options.json) {
406
+ console.log(JSON.stringify(pulled, null, 2));
407
+ return;
408
+ }
409
+ console.log('Réception fédérée');
410
+ console.log(` reçues : ${pulled.received}`);
411
+ console.log(` vérifiées : ${pulled.verified}`);
412
+ console.log(` matérialisées : ${pulled.materialized}`);
413
+ if (pulled.unreadable_epoch_absent.length) {
414
+ console.log(` ILLISIBLES : ${pulled.unreadable_epoch_absent.length} — conservées, jamais jetées`);
415
+ for (const item of pulled.unreadable_epoch_absent)
416
+ console.log(` epoch ${item.key_epoch ?? '?'} — ${item.reason}`);
417
+ }
418
+ if (pulled.deferred.length) {
419
+ console.log(` différées : ${pulled.deferred.length}`);
420
+ for (const item of pulled.deferred)
421
+ console.log(` ${item.idempotency_key ?? 'sans clé'} — ${item.reason}`);
422
+ }
423
+ if (pulled.rejected.length) {
424
+ console.log(` REJETÉES : ${pulled.rejected.length}`);
425
+ for (const item of pulled.rejected)
426
+ console.log(` ${item.idempotency_key ?? 'sans clé'} — ${item.reason}`);
427
+ }
428
+ // Les remises de clés sont affichées SÉPARÉMENT des enveloppes : recevoir une clé et
429
+ // matérialiser un objet sont deux événements distincts, et les confondre laisserait
430
+ // croire qu'un pull « vide » n'a rien fait alors qu'il vient d'ouvrir tout un epoch.
431
+ if (pulled.epoch_keys_received?.length) {
432
+ console.log(` clés d'epoch : ${pulled.epoch_keys_received.join(', ')} — reçues et rangées`);
433
+ }
434
+ if (pulled.epoch_keys_rejected?.length) {
435
+ console.log(` REMISES REFUSÉES : ${pulled.epoch_keys_rejected.length}`);
436
+ for (const item of pulled.epoch_keys_rejected) {
437
+ console.log(` epoch ${item.epoch ?? '?'} — ${item.reason}: ${item.detail}`);
438
+ }
439
+ }
440
+ console.log(` curseur feed : ${pulled.feed_cursor ?? 'inchangé'}`);
441
+ console.log('');
442
+ console.log(` ⚠ ${pulled.roster_limitation}`);
443
+ }
444
+ /**
445
+ * `brainclaw cloud grant` — remet des clés d'epoch à un appareil approuvé (pln#658).
446
+ *
447
+ * ── L'HORIZON EST UN CHOIX EXPLICITE (dec#163 §1) ─────────────────────────────
448
+ * `--horizon all` pour un autre appareil DE LA MÊME PERSONNE (tout l'historique détenu) ;
449
+ * `--horizon current` pour un MEMBRE INVITÉ (à-partir-de-maintenant). Le défaut est
450
+ * `current` : le moins surprenant, et l'extension reste un acte délibéré et tracé.
451
+ *
452
+ * ── CE QUE CETTE COMMANDE NE PROMET PAS ───────────────────────────────────────
453
+ * Elle ne rend pas un epoch qu'on ne détient pas. Un epoch non détenu est SIGNALÉ comme
454
+ * ignoré, jamais silencieusement omis — sinon l'opérateur croirait le destinataire équipé.
455
+ */
456
+ export async function runCloudGrant(options) {
457
+ const cwd = options.cwd ?? resolveEffectiveCwd();
458
+ const state = loadConnectionState(cwd);
459
+ if (!state)
460
+ throw new Error("Aucun appairage local : rien à remettre.");
461
+ const url = resolveCloudUrl(options.url, state);
462
+ const { grantEpochsToDevice } = await import('../core/federation-grant-transport.js');
463
+ const { heldEpochs } = await import('../core/federation-keyring.js');
464
+ const { resolveCurrentAgentIdentity } = await import('../core/agent-registry.js');
465
+ const held = heldEpochs(state.cloud_project_id);
466
+ if (held.length === 0) {
467
+ throw new Error("Cet appareil ne détient AUCUNE clé d'epoch : il ne peut rien remettre. " +
468
+ 'Seul un détenteur actif est custodian (dec#163 §2).');
469
+ }
470
+ const epochs = options.epochs?.length
471
+ ? options.epochs
472
+ : options.horizon === 'all' ? held : [held[held.length - 1]];
473
+ // La cible doit être RÉSOLUE contre le cloud (clé X25519 attestée), pas devinée. Sans
474
+ // attestation, une remise partirait vers une clé que personne ne contrôle.
475
+ const target = await resolveGrantTarget(url, state.cloud_project_id, options.to);
476
+ const identity = resolveCurrentAgentIdentity();
477
+ if (!identity) {
478
+ throw new Error("Aucune identité d'agent résolue sur cet appareil : un custodian SIGNE sa remise, " +
479
+ 'sans quoi le destinataire la refusera (non_custodian).');
480
+ }
481
+ const outcome = await grantEpochsToDevice({
482
+ cwd, url, target, epochs,
483
+ custodianAgentId: identity.agent_id,
484
+ });
485
+ if (options.json) {
486
+ console.log(JSON.stringify({ ...outcome, horizon: options.horizon ?? 'current' }, null, 2));
487
+ return;
488
+ }
489
+ console.log(`Remise de clés vers ${options.to}`);
490
+ console.log(` remis : ${outcome.granted.length ? outcome.granted.join(', ') : 'aucun'}`);
491
+ if (outcome.skipped.length) {
492
+ console.log(` IGNORÉS : ${outcome.skipped.length}`);
493
+ for (const s of outcome.skipped)
494
+ console.log(` epoch ${s.epoch} — ${s.reason}`);
495
+ }
496
+ console.log('');
497
+ console.log(" ⚠ Le destinataire doit lancer `brainclaw cloud pull` pour ranger ces clés.");
498
+ }
499
+ /**
500
+ * `brainclaw cloud rotate` — ferme la lecture FUTURE à un appareil révoqué (pln#658).
501
+ *
502
+ * Le refus de quorum n'est pas un mur : il NOMME le remède (appairer un second appareil,
503
+ * ou consentir explicitement). Un blocage sans issue ferait chercher le drapeau `--force`
504
+ * en premier, ce qui est exactement l'inverse du but.
505
+ */
506
+ export async function runCloudRotate(options = {}) {
507
+ const cwd = options.cwd ?? resolveEffectiveCwd();
508
+ const { rotateEpoch } = await import('../core/federation-rotation.js');
509
+ const outcome = rotateEpoch({ cwd, force: options.force });
510
+ if (options.json) {
511
+ console.log(JSON.stringify(outcome, null, 2));
512
+ if (!outcome.ok)
513
+ process.exitCode = 1;
514
+ return;
515
+ }
516
+ if (!outcome.ok) {
517
+ console.log(`Rotation refusée — ${outcome.reason}`);
518
+ console.log(` ${outcome.detail}`);
519
+ console.log('');
520
+ console.log(` Pour lever ce refus : ${outcome.remedy}`);
521
+ process.exitCode = 1;
522
+ return;
523
+ }
524
+ console.log(`Rotation d'epoch : ${outcome.previous_epoch} → ${outcome.new_epoch}`);
525
+ console.log(` empreinte : ${outcome.new_epoch_fingerprint}`);
526
+ console.log(` encore lisibles : ${outcome.readable_epochs.join(', ')}`);
527
+ console.log('');
528
+ console.log(` ⚠ ${outcome.forward_only_notice}`);
529
+ console.log('');
530
+ console.log(' Prochaine étape : remettre le nouvel epoch aux lecteurs légitimes');
531
+ console.log(` brainclaw cloud grant <agentId> --epoch ${outcome.new_epoch}`);
532
+ }
533
+ /** `brainclaw cloud accept-solo-risk` — consentement PERSISTÉ au risque solo (dec#163 §4). */
534
+ export async function runCloudAcceptSoloRisk(options = {}) {
535
+ const cwd = options.cwd ?? resolveEffectiveCwd();
536
+ const { acceptSoloRecoveryRisk, soloConsentStatement } = await import('../core/federation-rotation.js');
537
+ const consent = acceptSoloRecoveryRisk(cwd);
538
+ if (options.json) {
539
+ console.log(JSON.stringify(consent, null, 2));
540
+ return;
541
+ }
542
+ console.log('Risque solo accepté et consigné.');
543
+ console.log(` accepté le : ${consent.accepted_at}`);
544
+ console.log('');
545
+ console.log(` « ${soloConsentStatement()} »`);
546
+ }
547
+ /**
548
+ * `brainclaw cloud grant-web` — remet des clés d'epoch à une SESSION NAVIGATEUR (dec#165).
549
+ *
550
+ * ── OÙ EST LA CÉRÉMONIE ──────────────────────────────────────────────────────
551
+ * Le navigateur AFFICHE l'empreinte de sa clé de session ; l'humain la RECOPIE ici. L'acte
552
+ * de recopier EST la comparaison : une empreinte tapée qui ne correspond à aucune clé
553
+ * enregistrée échoue, et une clé enregistrée par un tiers a une empreinte que l'humain n'a
554
+ * pas sous les yeux. On réaffiche l'empreinte complète et l'étiquette avant de sceller,
555
+ * pour que la dernière vérification soit possible.
556
+ *
557
+ * Même protocole de remise que pln#658 — seule la CIBLE change (clé de session web au lieu
558
+ * d'un appareil attesté). Le cloud relaie sans lire, le navigateur vérifie tout.
559
+ */
560
+ export async function runCloudGrantWeb(options) {
561
+ const cwd = options.cwd ?? resolveEffectiveCwd();
562
+ const state = loadConnectionState(cwd);
563
+ if (!state)
564
+ throw new Error("Aucun appairage local : rien à remettre.");
565
+ const url = resolveCloudUrl(options.url, state);
566
+ const apiKey = resolveCloudApiKey(options.apiKey);
567
+ const wanted = options.fingerprint.trim().toLowerCase();
568
+ if (!/^[a-f0-9]{16,64}$/.test(wanted)) {
569
+ throw new Error("Empreinte invalide : recopiez au moins 16 caractères hexadécimaux depuis le navigateur.");
570
+ }
571
+ // Les clés de session web ACTIVES du projet — la clé publique est résolue AU CLOUD
572
+ // derrière l'empreinte recopiée, jamais fournie par la ligne de commande.
573
+ const res = await fetch(`${url}/api/v1/projects/${encodeURIComponent(state.cloud_project_id)}/web-keys`, { headers: apiKey ? { accept: 'application/json', authorization: `Bearer ${apiKey}` } : { accept: 'application/json' } });
574
+ if (!res.ok)
575
+ throw new Error(`Lecture des clés de session impossible (HTTP ${res.status}).`);
576
+ const body = (await res.json());
577
+ const matches = (body.web_keys ?? []).filter((k) => k.fingerprint.startsWith(wanted));
578
+ if (matches.length === 0) {
579
+ throw new Error("Aucune clé de session active ne porte cette empreinte. Vérifiez que le navigateur a bien " +
580
+ "enregistré sa clé (bouton « Déverrouiller le contenu ») et recopiez l'empreinte affichée.");
581
+ }
582
+ if (matches.length > 1) {
583
+ throw new Error('Empreinte ambiguë : plusieurs clés correspondent — recopiez-la en entier.');
584
+ }
585
+ const webKey = matches[0];
586
+ const { grantEpochsToDevice } = await import('../core/federation-grant-transport.js');
587
+ const { heldEpochs } = await import('../core/federation-keyring.js');
588
+ const held = heldEpochs(state.cloud_project_id);
589
+ if (held.length === 0) {
590
+ throw new Error("Cet appareil ne détient aucune clé d'epoch : seul un détenteur actif est custodian (dec#163 §2).");
591
+ }
592
+ const epochs = options.epochs?.length
593
+ ? options.epochs
594
+ : options.horizon === 'all' ? held : [held[held.length - 1]];
595
+ // Le SIGNATAIRE est l'agent APPAIRÉ — pas celui du registre local du workspace, qui n'a
596
+ // aucun rapport avec l'identité que le cloud a attestée (même correction que l'émission).
597
+ const custodianAgentId = state.pairings?.find((p) => p.stage === 'active')?.agent_id;
598
+ if (!custodianAgentId) {
599
+ throw new Error('Aucun agent appairé actif : le custodian signe son manifeste, sans quoi le navigateur le refusera.');
600
+ }
601
+ const outcome = await grantEpochsToDevice({
602
+ cwd, url, apiKey,
603
+ target: {
604
+ deviceId: `web:${webKey.id}`,
605
+ x25519PublicKeyPem: webKey.public_key_pem,
606
+ x25519Fingerprint: webKey.fingerprint,
607
+ active: true, attested: true, canRead: true,
608
+ authorizedEpochs: epochs,
609
+ },
610
+ epochs,
611
+ custodianAgentId,
612
+ });
613
+ if (options.json) {
614
+ console.log(JSON.stringify({ ...outcome, target_fingerprint: webKey.fingerprint }, null, 2));
615
+ return;
616
+ }
617
+ console.log('Remise de clés vers une session navigateur');
618
+ console.log(` empreinte cible : ${webKey.fingerprint}`);
619
+ if (webKey.label)
620
+ console.log(` étiquette : ${webKey.label}`);
621
+ console.log('');
622
+ console.log(' ⚠ VÉRIFIEZ que cette empreinte est EXACTEMENT celle affichée dans le navigateur.');
623
+ console.log(' Si elle diffère, révoquez la clé depuis la page du projet : quelqu\'un s\'est interposé.');
624
+ console.log('');
625
+ console.log(` remis : ${outcome.granted.length ? outcome.granted.join(', ') : 'aucun'}`);
626
+ if (outcome.skipped.length) {
627
+ console.log(` IGNORÉS : ${outcome.skipped.length}`);
628
+ for (const s of outcome.skipped)
629
+ console.log(` epoch ${s.epoch} — ${s.reason}`);
630
+ }
631
+ console.log('');
632
+ console.log(' Le navigateur peut maintenant cliquer « Déverrouiller » à nouveau : il vérifiera');
633
+ console.log(' la signature du custodian et déchiffrera localement. Le serveur, lui, ne peut toujours rien lire.');
634
+ }
635
+ /** Résout la cible depuis le roster attesté du cloud — jamais depuis une saisie humaine. */
636
+ async function resolveGrantTarget(url, cloudProjectId, deviceId) {
637
+ const res = await fetch(`${url.replace(/\/+$/, '')}/api/v1/projects/${encodeURIComponent(cloudProjectId)}/enrollments`, { headers: { accept: 'application/json' } });
638
+ if (!res.ok)
639
+ throw new Error(`Impossible de lire les appairages (HTTP ${res.status}).`);
640
+ const body = (await res.json());
641
+ const row = (body.enrollments ?? []).find((e) => e['claimed_by_agent_id'] === deviceId || e['approved_agent_id'] === deviceId);
642
+ if (!row)
643
+ throw new Error(`Aucun appairage trouvé pour '${deviceId}' dans ce projet.`);
644
+ if (row['state'] !== 'active') {
645
+ throw new Error(`L'appairage de '${deviceId}' n'est pas actif (${String(row['state'])}) — remise refusée.`);
646
+ }
647
+ const pem = typeof row['encryption_public_key_pem'] === 'string' ? row['encryption_public_key_pem'] : null;
648
+ const fp = typeof row['encryption_key_fingerprint'] === 'string' ? row['encryption_key_fingerprint'] : null;
649
+ if (!pem || !fp) {
650
+ throw new Error(`'${deviceId}' n'a pas de clé de chiffrement attestée : rien ne peut lui être remis ` +
651
+ "tant que son appairage n'a pas été approuvé avec comparaison d'empreintes.");
652
+ }
653
+ return {
654
+ deviceId, x25519PublicKeyPem: pem, x25519Fingerprint: fp,
655
+ active: true, attested: true, canRead: true,
656
+ // L'horizon est appliqué par l'appelant : la liste passée EST l'autorisation.
657
+ authorizedEpochs: [],
658
+ };
659
+ }
660
+ /**
661
+ * `brainclaw cloud push` — projette les plans et la mémoire projet vers le cloud.
662
+ * Les états de mise en file et d'envoi sont volontairement affichés séparément.
663
+ */ export async function runCloudPush(options = {}) {
664
+ const cwd = options.cwd ?? resolveEffectiveCwd();
665
+ const { emitProjections } = await import('../core/federation-emit.js');
666
+ const { pushPending } = await import('../core/federation-push.js');
667
+ const url = resolveCloudUrl(options.url, loadConnectionState(cwd));
668
+ const emitted = emitProjections({ cwd, dryRun: options.dryRun });
669
+ const pushed = await pushPending({ cwd, url, dryRun: options.dryRun, limit: options.limit, apiKey: resolveCloudApiKey(options.apiKey) });
670
+ if (options.json) {
671
+ console.log(JSON.stringify({ emitted, pushed, dry_run: Boolean(options.dryRun) }, null, 2));
672
+ return;
673
+ }
674
+ const mode = options.dryRun ? ' (simulation — rien n\'a été écrit ni envoyé)' : '';
675
+ console.log(`Projection${mode}`);
676
+ console.log(` collectés : ${emitted.collected}`);
677
+ console.log(` mis en file : ${emitted.enqueued}`);
678
+ console.log(` déjà en file : ${emitted.skipped_duplicate}`);
679
+ if (emitted.refused > 0) {
680
+ console.log(` REFUSÉS : ${emitted.refused}`);
681
+ for (const r of emitted.refusals)
682
+ console.log(` ${r.kind} ${r.id} — ${r.reason}`);
683
+ }
684
+ console.log(` envoyés : ${pushed.sent} / ${pushed.attempted}`);
685
+ if (pushed.conflicts > 0)
686
+ console.log(` CONFLITS : ${pushed.conflicts} (révision périmée — un renvoi échouerait pareil)`);
687
+ if (pushed.failed > 0) {
688
+ console.log(` échecs : ${pushed.failed} — restent en attente, la tentative est comptée`);
689
+ for (const e of pushed.errors)
690
+ console.log(` ${e.idempotency_key} — ${e.status ?? 'réseau'} ${e.reason}`);
691
+ }
692
+ }
198
693
  //# sourceMappingURL=cloud.js.map
@@ -302,7 +302,6 @@ export async function handleBclawLoop(options) {
302
302
  slot_id: req.slot_id,
303
303
  role: req.role,
304
304
  input: req.input,
305
- dispatch: req.dispatch,
306
305
  assignment_id: req.assignment_id,
307
306
  claim_id: req.claim_id,
308
307
  actor,