brainclaw 1.21.0 → 1.22.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.
Binary file
@@ -0,0 +1,63 @@
1
+ import { runCloudStatus, runCloudConnect, runCloudAwait, runCloudDisconnect, } from '../commands/cloud.js';
2
+ /** Adresse du cloud. Fournie par `--url`, sans quoi la commande demande de la préciser. */
3
+ const DEFAULT_URL_HINT = 'https://<votre-déploiement>.workers.dev';
4
+ function fail(message) {
5
+ console.error(message);
6
+ process.exit(1);
7
+ }
8
+ /**
9
+ * Fédération v2 (pln#651 étapes 3 et 4).
10
+ *
11
+ * `connect` est une CÉRÉMONIE DE CLÉS, pas une écriture de configuration — voir
12
+ * src/core/federation-pairing.ts. L'humain ne manipule qu'un code d'invitation et compare
13
+ * deux empreintes ; aucune clé d'API, aucun PEM, aucune variable d'environnement (dec#8).
14
+ */
15
+ export function registerCloudCommands(program) {
16
+ const cloud = program
17
+ .command('cloud')
18
+ .description('Fédération cloud v2 : appairage attesté, état de connexion, epochs de clés');
19
+ cloud
20
+ .command('status')
21
+ .description("Affiche le projet lié, le rôle, l'epoch de clé courant et les trois états de sync")
22
+ .option('--json', 'Sortie JSON')
23
+ .action((options) => {
24
+ runCloudStatus({ json: options.json });
25
+ });
26
+ cloud
27
+ .command('connect <invite-code>')
28
+ .description("Rejoint un projet cloud : réclame l'invitation, prouve la possession de l'identité et atteste la clé de chiffrement de cet appareil")
29
+ .requiredOption('--url <url>', `Adresse du déploiement cloud (ex. ${DEFAULT_URL_HINT})`)
30
+ .requiredOption('--agent <id>', "Identifiant d'agent à enrôler (opaque, 4 à 64 caractères)")
31
+ .option('--json', 'Sortie JSON')
32
+ .action(async (inviteCode, options) => {
33
+ await runCloudConnect({
34
+ inviteCode,
35
+ url: options.url,
36
+ agentId: options.agent,
37
+ json: options.json,
38
+ }).catch((err) => fail(`Erreur : ${err instanceof Error ? err.message : String(err)}`));
39
+ });
40
+ cloud
41
+ .command('await')
42
+ .description("Constate l'approbation humaine et active l'appairage local")
43
+ // Commande DISTINCTE de connect : l'approbation dépend d'un humain dont le délai
44
+ // n'est pas borné. Bloquer indéfiniment sur un tiers ferait un mauvais citoyen dans
45
+ // un script, et une interruption doit rester reprenable.
46
+ .requiredOption('--url <url>', 'Adresse du déploiement cloud')
47
+ .action(async (options) => {
48
+ await runCloudAwait({ url: options.url })
49
+ .catch((err) => fail(`Erreur : ${err instanceof Error ? err.message : String(err)}`));
50
+ });
51
+ cloud
52
+ .command('disconnect')
53
+ .description("Retire l'autorisation locale et demande la révocation distante")
54
+ .requiredOption('--url <url>', 'Adresse du déploiement cloud')
55
+ // Effacer le trousseau rend DÉFINITIVEMENT illisible tout ce qui a été scellé sous
56
+ // ces epochs. Ce n'est donc pas le défaut : on le demande explicitement.
57
+ .option('--forget-keys', "Efface aussi les clés d'epoch de ce projet (le passé scellé devient illisible ici)")
58
+ .action(async (options) => {
59
+ await runCloudDisconnect({ url: options.url, forgetKeys: options.forgetKeys })
60
+ .catch((err) => fail(`Erreur : ${err instanceof Error ? err.message : String(err)}`));
61
+ });
62
+ }
63
+ //# sourceMappingURL=register-cloud.js.map
package/dist/cli.js CHANGED
@@ -13,8 +13,8 @@ import { registerPlanningCommands } from './cli/register-planning.js';
13
13
  import { registerCoordinationCommands } from './cli/register-coordination.js';
14
14
  import { registerReviewCommands } from './cli/register-review.js';
15
15
  import { registerMemoryContextCommands } from './cli/register-memory-context.js';
16
- import { registerFederationCommands } from './cli/register-federation.js';
17
16
  import { registerCodeMapCommands } from './cli/register-code-map.js';
17
+ import { registerCloudCommands } from './cli/register-cloud.js';
18
18
  const program = new Command();
19
19
  function parseLeadingGlobalOptions(argv) {
20
20
  const result = {};
@@ -171,8 +171,8 @@ registerPlanningCommands(program);
171
171
  registerCoordinationCommands(program);
172
172
  registerReviewCommands(program);
173
173
  registerMemoryContextCommands(program);
174
- registerFederationCommands(program);
175
174
  registerCodeMapCommands(program);
175
+ registerCloudCommands(program);
176
176
  // ─── Command-order shim (pln#622 PR5) ────────────────────────────────────────
177
177
  // Commander renders `--help` in registration order. The per-family register
178
178
  // modules above would otherwise reorder the help output relative to the
@@ -290,7 +290,6 @@ const ORIGINAL_COMMAND_ORDER = [
290
290
  'switch',
291
291
  'who',
292
292
  'worktree',
293
- 'federation',
294
293
  // Gated by BRAINCLAW_ENABLE_CODEV: listed here so the order is right in both
295
294
  // modes — when not registered they are simply absent from the live array and
296
295
  // the sort skips them naturally.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `brainclaw cloud connect / status / disconnect` — fédération v2 (pln#651 étapes 3 et 4).
3
+ *
4
+ * `connect` n'est pas une écriture de configuration : c'est une CÉRÉMONIE DE CLÉS. Elle
5
+ * réclame une invitation, prouve la possession de l'identité Ed25519, atteste la clé de
6
+ * chiffrement X25519 de l'appareil, puis attend une approbation humaine qui compare des
7
+ * empreintes. Ce qui la rend indissociable de la distribution des clés est écrit dans
8
+ * src/core/federation-pairing.ts.
9
+ *
10
+ * L'HUMAIN NE COPIE QU'UN CODE D'INVITATION, et compare deux empreintes. Aucune clé
11
+ * d'API, aucun PEM, aucun agent_id, aucune variable d'environnement (dec#8).
12
+ *
13
+ * dec#154 exige que l'état de sync soit VISIBLE : un état pending/synced/conflict que seul
14
+ * le code consulte transformerait le relais cloud en autorité silencieuse. C'est ce que
15
+ * `status` rend.
16
+ */
17
+ import { summarizeConnection, loadConnectionState, saveConnectionState } from '../core/federation-state.js';
18
+ import { forgetProjectEpochs } from '../core/federation-keyring.js';
19
+ import { beginPairing, checkPairingApproval, completePairing, requestRevocation, PairingError, } from '../core/federation-pairing.js';
20
+ import { resolveEffectiveCwd } from '../core/store-resolution.js';
21
+ import { nowISO } from '../core/ids.js';
22
+ export function runCloudStatus(options = {}) {
23
+ const cwd = options.cwd ?? resolveEffectiveCwd();
24
+ const summary = summarizeConnection(cwd);
25
+ if (options.json) {
26
+ console.log(JSON.stringify(summary, null, 2));
27
+ return;
28
+ }
29
+ if (summary.stage === 'unpaired') {
30
+ console.log('Cloud : non appairé.');
31
+ console.log(" Aucun sync n'a lieu tant qu'aucun appairage n'a été fait explicitement.");
32
+ // Énoncé volontairement, parce que c'est le défaut que la v2 ferme : en v1 la seule
33
+ // présence de BRAINCLAW_CLOUD_API_KEY valait consentement, et jusqu'à 100 signaux
34
+ // étaient tirés puis écrits dans le store local à chaque démarrage de session.
35
+ return;
36
+ }
37
+ console.log(`Cloud : ${summary.stage}${summary.connected ? '' : ' (pas encore actif)'}`);
38
+ console.log(` Projet : ${summary.cloud_project_id ?? '—'}`);
39
+ console.log(` Rôle : ${summary.role ?? '—'}`);
40
+ console.log(` Appareil : ${summary.device_fingerprint?.slice(0, 16) ?? '—'}…`);
41
+ console.log(` Epoch : ${summary.current_epoch} (lisibles : ${summary.readable_epochs.join(', ') || 'aucun'})`);
42
+ console.log(` Sync : ${summary.sync.pending} en attente · ${summary.sync.synced} synchronisé(s) · ${summary.sync.conflict} conflit(s)`);
43
+ if (summary.last_pull_at)
44
+ console.log(` Dernier pull : ${summary.last_pull_at}`);
45
+ if (!summary.recovery.ready) {
46
+ console.log('');
47
+ console.log(` ⚠ Récupération : ${summary.recovery.reason}`);
48
+ }
49
+ if (summary.sync.conflict > 0) {
50
+ console.log('');
51
+ // Un conflit ne se résout JAMAIS par last-write-wins silencieux (dec#154) : il est
52
+ // présenté, avec une proposition, et attend une décision.
53
+ console.log(` ${summary.sync.conflict} opération(s) en conflit attendent une résolution explicite.`);
54
+ }
55
+ }
56
+ // ── Transport HTTP ────────────────────────────────────────────────────────────
57
+ /**
58
+ * Transport réel. Injecté partout ailleurs pour que la cérémonie soit exerçable sans
59
+ * réseau — les tests font tourner un cloud simulé qui VÉRIFIE réellement les signatures,
60
+ * ce qu'un serveur acceptant tout ne prouverait pas.
61
+ */
62
+ export function httpTransport(baseUrl) {
63
+ const url = (p) => `${baseUrl.replace(/\/+$/, '')}${p}`;
64
+ const call = async (method, path, body) => {
65
+ const res = await fetch(url(path), {
66
+ method,
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: body === undefined ? undefined : JSON.stringify(body),
69
+ });
70
+ // Une réponse non-JSON (page d'erreur de proxy, 502 HTML) ne doit pas lever une
71
+ // exception de parsing qui masquerait le vrai statut HTTP — c'est lui qui porte
72
+ // l'information utile.
73
+ let parsed = {};
74
+ try {
75
+ parsed = (await res.json());
76
+ }
77
+ catch { /* corps non-JSON */ }
78
+ return { status: res.status, body: parsed };
79
+ };
80
+ return {
81
+ post: (path, body) => call('POST', path, body),
82
+ get: (path) => call('GET', path),
83
+ };
84
+ }
85
+ export async function runCloudConnect(options) {
86
+ const cwd = options.cwd ?? resolveEffectiveCwd();
87
+ const transport = options.transport ?? httpTransport(options.url);
88
+ let handle;
89
+ try {
90
+ handle = await beginPairing({
91
+ inviteCode: options.inviteCode,
92
+ agentId: options.agentId,
93
+ transport,
94
+ cwd,
95
+ });
96
+ }
97
+ catch (err) {
98
+ if (err instanceof PairingError) {
99
+ console.error(`Appairage interrompu à l'étape « ${err.stage} » : ${err.message}`);
100
+ // Fail-closed : un appairage refusé ne laisse aucune trace locale, donc relancer la
101
+ // commande est sûr et ne laisse pas d'orphelin à nettoyer.
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ throw err;
106
+ }
107
+ if (options.json) {
108
+ console.log(JSON.stringify({
109
+ enrollment_id: handle.enrollment_id,
110
+ cloud_project_id: handle.cloud_project_id,
111
+ device_id: handle.device.device_id,
112
+ fingerprints: handle.fingerprints,
113
+ awaiting: 'human_approval',
114
+ }, null, 2));
115
+ return;
116
+ }
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");
133
+ }
134
+ /**
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.
140
+ */
141
+ export async function runCloudAwait(options) {
142
+ const cwd = options.cwd ?? resolveEffectiveCwd();
143
+ const state = loadConnectionState(cwd);
144
+ if (!state?.enrollment.enrollment_id) {
145
+ console.error("Aucun appairage en cours sur ce workspace. Lancez d'abord : brainclaw cloud connect");
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ const transport = options.transport ?? httpTransport(options.url);
150
+ const result = await checkPairingApproval({ enrollmentId: state.enrollment.enrollment_id, transport, cwd });
151
+ if (!result.approved) {
152
+ console.log(`Toujours en attente (état distant : ${result.state}).`);
153
+ return;
154
+ }
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.");
163
+ }
164
+ export async function runCloudDisconnect(options) {
165
+ const cwd = options.cwd ?? resolveEffectiveCwd();
166
+ const state = loadConnectionState(cwd);
167
+ if (!state) {
168
+ console.log("Ce workspace n'est pas appairé — rien à déconnecter.");
169
+ return;
170
+ }
171
+ let revocationNote = "aucune révocation distante demandée (pas d'enrôlement connu)";
172
+ if (state.enrollment.enrollment_id) {
173
+ const transport = options.transport ?? httpTransport(options.url);
174
+ const res = await requestRevocation({ enrollmentId: state.enrollment.enrollment_id, transport });
175
+ revocationNote = res.revoked
176
+ ? 'autorisation distante révoquée'
177
+ : `révocation distante NON confirmée (${res.detail ?? 'raison inconnue'})`;
178
+ }
179
+ // L'état local passe en 'revoked' MÊME SI le cloud est injoignable : sinon un appareil
180
+ // perdu resterait autorisé faute de réseau, exactement l'inverse de ce qu'une
181
+ // révocation doit garantir.
182
+ saveConnectionState({ ...state, enrollment: { ...state.enrollment, stage: 'revoked', updated_at: nowISO() } }, cwd);
183
+ let keysNote = "trousseau conservé (utilisez --forget-keys pour l'effacer)";
184
+ if (options.forgetKeys) {
185
+ const removed = forgetProjectEpochs(state.cloud_project_id);
186
+ keysNote = `${removed} clé(s) d'epoch effacée(s) — le passé scellé de ce projet devient illisible ici`;
187
+ }
188
+ console.log('Déconnecté.');
189
+ console.log(` ${revocationNote}`);
190
+ console.log(` ${keysNote}`);
191
+ console.log('');
192
+ // Énoncé plutôt que tu : prétendre l'inverse serait une promesse que la cryptographie
193
+ // ne tient pas (RFC §5.2).
194
+ console.log(" Ce qui N'EST PAS effacé : les données déjà tirées et déchiffrées localement,");
195
+ console.log(" et ce que d'autres appareils détiennent déjà. Un disconnect retire une");
196
+ console.log(' autorisation ; il ne réécrit pas le passé.');
197
+ }
198
+ //# sourceMappingURL=cloud.js.map
@@ -28,7 +28,6 @@ import { loadState, persistState } from '../core/state.js';
28
28
  import { listArchivedCandidates, listCandidates } from '../core/candidates.js';
29
29
  import { createFederationMessage } from '../core/federation-message.js';
30
30
  import { pushSignal } from '../core/federation-transport.js';
31
- import { pushSignalToCloud, isCloudSyncEnabled } from '../core/federation-cloud.js';
32
31
  import { loadConfig } from '../core/config.js';
33
32
  import { resolveCrossProjectLinks } from '../core/cross-project.js';
34
33
  import { createCandidateFromInput } from './reflect.js';
@@ -351,24 +350,6 @@ export async function endSession(options = {}) {
351
350
  if (pushedSignals > 0 && !options.json) {
352
351
  console.log(`✔ Pushed ${pushedSignals} signal(s) to linked projects`);
353
352
  }
354
- // Cloud federation push (Phase 1 — opt-in via cloud_sync.enabled)
355
- let pushedCloudSignals = 0;
356
- if (isCloudSyncEnabled(options.cwd)) {
357
- try {
358
- pushedCloudSignals = await pushSessionCloudSignals({
359
- sessionId,
360
- actor,
361
- sessionNotes,
362
- cwd: options.cwd,
363
- });
364
- }
365
- catch {
366
- // Non-fatal — cloud push failure should not block session end
367
- }
368
- }
369
- if (pushedCloudSignals > 0 && !options.json) {
370
- console.log(`✔ Pushed ${pushedCloudSignals} signal(s) to cloud`);
371
- }
372
353
  appendAuditEntry({
373
354
  action: 'session_end',
374
355
  actor: actor.agent,
@@ -509,89 +490,6 @@ function pushSessionFederationSignals(input) {
509
490
  }
510
491
  return pushed;
511
492
  }
512
- /**
513
- * Push session-scoped handoffs / candidates / runtime_notes to the cloud federation.
514
- * Skips entities with visibility = 'machine' or 'private' — only 'shared' (default) goes out.
515
- * Failures per-entity are swallowed so a single bad fetch does not abort the rest.
516
- */
517
- async function pushSessionCloudSignals(input) {
518
- const cwd = input.cwd ?? process.cwd();
519
- const config = loadConfig(cwd);
520
- const fromProjectName = config.project_name ?? path.basename(cwd);
521
- const currentState = loadState(cwd);
522
- const sessionHandoffs = currentState.open_handoffs.filter((handoff) => handoff.session_id === input.sessionId);
523
- const sessionCandidates = [
524
- ...listCandidates(undefined, cwd),
525
- ...listArchivedCandidates('accepted', cwd),
526
- ...listArchivedCandidates('rejected', cwd),
527
- ].filter((candidate) => candidate.session_id === input.sessionId);
528
- const sessionRuntimeNotes = input.sessionNotes.filter((note) => note.session_id === input.sessionId);
529
- // Conservative cloud-push gate (review finding 2026-05-15, finalized via
530
- // pln#365 finalization 2026-05-15):
531
- //
532
- // All four signal-bearing schemas now carry a `visibility` field:
533
- // - RuntimeNoteSchema (schema.ts:899) — defaults to 'shared'
534
- // - TrapSchema (schema.ts:184) — defaults to 'shared'
535
- // - HandoffSchema (schema.ts:~248) — optional, no default (opt-in)
536
- // - CandidateSchema (schema.ts:~619) — optional, no default (opt-in)
537
- //
538
- // Handoffs and candidates are opt-in because their text / snapshot.diff
539
- // can carry per-host secrets. An agent must explicitly set
540
- // `visibility: 'shared'` to push such an entity to cloud. RuntimeNotes
541
- // default to shared since they're already the lightest-weight signal.
542
- //
543
- // The gate below is intentionally literal — `entity.visibility === 'shared'`.
544
- // Undefined or absent visibility means "stay local" regardless of cloud_sync.
545
- const isExplicitlyShared = (entity) => {
546
- return entity.visibility === 'shared';
547
- };
548
- let pushed = 0;
549
- const pushOne = async (entityType, entity) => {
550
- const message = createFederationMessage({
551
- version: 1,
552
- from: {
553
- project_id: input.actor.project_id ?? config.project_id,
554
- project_name: fromProjectName,
555
- project_path: cwd,
556
- agent_name: input.actor.agent,
557
- agent_id: input.actor.agent_id,
558
- host_id: input.actor.host_id,
559
- },
560
- to: {
561
- // Cloud is a broadcast bus — no specific target project at this layer.
562
- project_name: 'broadcast',
563
- project_path: '',
564
- },
565
- type: entityType,
566
- payload: entity,
567
- causal_parent: input.sessionId,
568
- });
569
- try {
570
- const ok = await pushSignalToCloud(message, cwd);
571
- if (ok)
572
- pushed++;
573
- }
574
- catch {
575
- // Per-entity failure should not abort the loop
576
- }
577
- };
578
- for (const handoff of sessionHandoffs) {
579
- if (!isExplicitlyShared(handoff))
580
- continue;
581
- await pushOne('handoff', handoff);
582
- }
583
- for (const candidate of sessionCandidates) {
584
- if (!isExplicitlyShared(candidate))
585
- continue;
586
- await pushOne('candidate', candidate);
587
- }
588
- for (const note of sessionRuntimeNotes) {
589
- if (!isExplicitlyShared(note))
590
- continue;
591
- await pushOne('runtime_note', note);
592
- }
593
- return pushed;
594
- }
595
493
  function resolvePublisherLink(target, publisherLinks, entityType, cwd) {
596
494
  const normalized = target.trim().toLowerCase();
597
495
  if (!normalized)
@@ -24,7 +24,6 @@ import { toWarningDetail } from '../core/warnings.js';
24
24
  import { loadHygienePolicy } from '../core/hygiene-policy.js';
25
25
  import { maybeCreateCheckpoint } from '../core/events/checkpoint.js';
26
26
  import { pullSignalsFromLinkedProjects, markSignalProcessed } from '../core/federation-transport.js';
27
- import { pullSignalsFromCloud, isCloudSyncEnabled } from '../core/federation-cloud.js';
28
27
  import { materializeFederationSignal } from '../core/federation-materialize.js';
29
28
  function sessionsDir(cwd) {
30
29
  return resolveEntityDir('sessions', cwd ?? process.cwd(), 'read');
@@ -340,28 +339,6 @@ export async function startSession(options = {}) {
340
339
  }
341
340
  catch { /* Non-fatal — federation pull failure should not block session start */ }
342
341
  }
343
- // Materialize incoming federation signals from cloud (Phase 1 — opt-in via cloud_sync.enabled)
344
- if (maintenanceMode === 'full' && isCloudSyncEnabled(options.cwd)) {
345
- try {
346
- const cloudSignals = await pullSignalsFromCloud(actor.agent, { limit: 100 }, options.cwd);
347
- let cloudMaterialized = 0;
348
- for (const signal of cloudSignals) {
349
- try {
350
- if (materializeFederationSignal(signal, options.cwd)) {
351
- cloudMaterialized++;
352
- }
353
- // No markSignalProcessed for cloud signals — cloud-side tracks delivery via the
354
- // inbox endpoint's own state (per-agent read cursor). If the cloud returns the
355
- // same signal twice, the idempotency_key field allows future dedup at materialize time.
356
- }
357
- catch { /* skip this signal — do not block session start */ }
358
- }
359
- if (cloudMaterialized > 0) {
360
- console.log(`✔ Materialized ${cloudMaterialized} federation signal(s) from cloud`);
361
- }
362
- }
363
- catch { /* Non-fatal — cloud pull failure should not block session start */ }
364
- }
365
342
  return {
366
343
  ...snapshot,
367
344
  ...(agentGitHygiene.isGitRepo && (agentGitHygiene.missingGitignorePaths.length > 0 || agentGitHygiene.trackedPaths.length > 0)
@@ -16,7 +16,6 @@ import { loadState, persistState } from './state.js';
16
16
  import { createRuntimeEvent } from './events.js';
17
17
  import { latestActivityMs, readHeartbeat } from './runtime-signals.js';
18
18
  import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
19
- import { maybeEnqueueClaimTransition, isFederationEnqueueActive } from './federation-outbox.js';
20
19
  /** Parse duration string like '4h', '30m' to ms. */
21
20
  function parseTtl(value) {
22
21
  const match = /^(\d+)([mhd])$/i.exec(value.trim());
@@ -79,26 +78,9 @@ function saveClaimUnlocked(claim, cwd, options) {
79
78
  // pln#568 (I2): journal the post-image BEFORE the projection write, so a
80
79
  // crash can only leave the journal ahead of the projection, never behind.
81
80
  const created = !store.exists(parsed.id);
82
- // Federation (pln#101): capture the PREVIOUS status BEFORE the write so we can
83
- // diff it after (create or active↔terminal transition ⇒ enqueue for cloud
84
- // sync). Only pay the prev-load when federation is actually active; this whole
85
- // block runs under the store mutation mutex, which serializes rev reservation.
86
- const fedActive = isFederationEnqueueActive(cwd, options?.federation?.suppressEnqueue);
87
- let fedPrevStatus;
88
- if (fedActive) {
89
- try {
90
- fedPrevStatus = loadClaimFromAnyDir(parsed.id, cwd).status;
91
- }
92
- catch {
93
- fedPrevStatus = undefined;
94
- }
95
- }
96
81
  emitRegistryPostImage('claim', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
97
82
  registryFaultPoint('after_registry_journal');
98
83
  store.save(parsed);
99
- if (fedActive) {
100
- maybeEnqueueClaimTransition(parsed, fedPrevStatus, fedPrevStatus === undefined, cwd, options?.federation?.suppressEnqueue);
101
- }
102
84
  const writeDir = claimsDir(cwd, 'write');
103
85
  for (const dirPath of claimDirs(cwd)) {
104
86
  if (dirPath === writeDir)
@@ -300,9 +300,7 @@ function buildIncomingSignalsSummary(cwd) {
300
300
  try {
301
301
  const fedSignals = pullSignalsFromLinkedProjects(cwd);
302
302
  for (const sig of fedSignals) {
303
- const payloadPreview = typeof sig.payload === 'string'
304
- ? sig.payload.slice(0, 120)
305
- : JSON.stringify(sig.payload).slice(0, 120);
303
+ const payloadPreview = JSON.stringify(sig.payload).slice(0, 120);
306
304
  incomingSignals.push({
307
305
  id: sig.id,
308
306
  entity_type: sig.type,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Contrat d'attestation de clé, côté CLIENT (pln#651 étape 4, RFC §5.2).
3
+ *
4
+ * ── CE FICHIER A UN JUMEAU, ET C'EST DÉLIBÉRÉ ─────────────────────────────────
5
+ * Le même contrat existe côté Cloud dans `brainclaw-cloud/src/lib/attestation.ts`.
6
+ * Les deux DOIVENT produire octet pour octet la même chaîne : le CLI signe, le Worker
7
+ * vérifie. Ils ne peuvent pas partager un module — deux dépôts, deux runtimes (Node ici,
8
+ * Workers là-bas) — donc la duplication est assumée et le gel de la forme est verrouillé
9
+ * DES DEUX CÔTÉS par un test sur le littéral exact.
10
+ *
11
+ * Ce n'est pas une précaution théorique. La première livraison côté Cloud reconstruisait
12
+ * `created_at` avec l'horloge du serveur au moment de l'approbation : la signature portait
13
+ * sur d'autres octets que ceux vérifiés, et AUCUN appairage ne pouvait aboutir. Le défaut
14
+ * a survécu à un typecheck vert parce que rien n'exerçait les deux côtés ensemble.
15
+ *
16
+ * RÈGLE QUI EN DÉCOULE, valable au-delà de ce fichier : tout champ couvert par une
17
+ * signature doit venir du signataire, ou d'une valeur qu'il connaît déjà. Un champ que le
18
+ * vérificateur fabrique lui-même ne peut pas être signé.
19
+ */
20
+ import crypto from 'node:crypto';
21
+ /**
22
+ * Payload canonique de l'attestation.
23
+ *
24
+ * LA FORME EST GELÉE. Changer l'ordre des clés ou ajouter un champ invalide toutes les
25
+ * attestations déjà émises — ce qui est voulu, mais doit être un acte délibéré, jamais
26
+ * l'effet de bord d'une refactorisation. Le test de gel existe pour cela.
27
+ */
28
+ export function attestationPayload(input) {
29
+ return JSON.stringify({
30
+ v: 1,
31
+ kind: 'brainclaw.federation.v2.key_attestation',
32
+ enrollment_id: input.enrollment_id,
33
+ project_id: input.project_id,
34
+ agent_id: input.agent_id,
35
+ key_type: input.key_type,
36
+ key_purpose: input.key_purpose,
37
+ key_fingerprint: input.key_fingerprint,
38
+ key_epoch: input.key_epoch,
39
+ created_at: input.created_at,
40
+ });
41
+ }
42
+ /**
43
+ * Empreinte canonique d'un PEM — MÊME RÈGLE que `fingerprintPublicKeyPem` d'agent-registry
44
+ * et que `fingerprintPem` côté Cloud.
45
+ *
46
+ * Le retrait des CR et le trim ne sont pas cosmétiques : le même PEM traversant un champ
47
+ * JSON ou un éditeur Windows ressort avec un CRLF ou un saut de ligne final. Sans
48
+ * canonicalisation, deux représentations de LA MÊME clé donnent deux empreintes
49
+ * différentes — et la comparaison locale↔distante, qui EST la preuve d'identité de la
50
+ * clé, échouerait sur une différence invisible à l'œil.
51
+ */
52
+ export function fingerprintPem(pem) {
53
+ return crypto.createHash('sha256').update(pem.replace(/\r/g, '').trim()).digest('hex');
54
+ }
55
+ /**
56
+ * Signe des octets avec une clé privée Ed25519 au format PEM, en base64.
57
+ *
58
+ * Ed25519 ne prend pas d'algorithme de hachage séparé — d'où le `null` en premier
59
+ * argument, qui n'est pas un oubli : passer un digest ici lèverait.
60
+ */
61
+ export function signEd25519(privateKeyPem, message) {
62
+ const key = crypto.createPrivateKey(privateKeyPem);
63
+ return crypto.sign(null, Buffer.from(message), key).toString('base64');
64
+ }
65
+ /**
66
+ * Construit et signe l'attestation liant la clé de chiffrement X25519 de l'appareil à son
67
+ * identité Ed25519.
68
+ *
69
+ * C'EST LA PIÈCE QUI EMPÊCHE LE MEMBRE FANTÔME. Sans elle, le Cloud — qui orchestre
70
+ * l'appairage — pourrait insérer sa propre clé dans la liste d'enveloppement : un
71
+ * chiffrement de bout en bout dont l'échange de clés serait arbitré par la partie même
72
+ * qu'il prétend neutraliser.
73
+ *
74
+ * Retourne aussi l'horodatage, que l'appelant DOIT transmettre au serveur : sans lui, le
75
+ * vérificateur ne peut pas reconstruire les octets signés.
76
+ */
77
+ export function buildKeyAttestation(params) {
78
+ const keyFingerprint = fingerprintPem(params.encryptionPublicKeyPem);
79
+ const payload = attestationPayload({
80
+ enrollment_id: params.enrollmentId,
81
+ project_id: params.projectId,
82
+ agent_id: params.agentId,
83
+ key_type: 'encryption',
84
+ key_purpose: 'envelope',
85
+ key_fingerprint: keyFingerprint,
86
+ key_epoch: params.keyEpoch ?? 1,
87
+ created_at: params.createdAt,
88
+ });
89
+ return {
90
+ payload,
91
+ signature: signEd25519(params.identityPrivateKeyPem, new TextEncoder().encode(payload)),
92
+ created_at: params.createdAt,
93
+ key_fingerprint: keyFingerprint,
94
+ };
95
+ }
96
+ //# sourceMappingURL=federation-attestation.js.map