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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Fédération v2 — état local de connexion (pln#651 étape 3).
3
+ *
4
+ * Création propre, AUCUNE migration (dec#156) : ce module ne lit ni `cloud_sync` ni
5
+ * `BRAINCLAW_CLOUD_*`. Le chemin v1 a été démoli en étape 2, et le défaut vivant qu'il
6
+ * portait — la SEULE PRÉSENCE d'une variable d'environnement valant consentement au
7
+ * sync — ne doit pas se reconstituer ici. Le consentement est un fichier écrit par une
8
+ * cérémonie d'appairage explicite (étape 4), rien d'autre.
9
+ *
10
+ * ── OÙ VIT QUOI, ET POURQUOI ──────────────────────────────────────────────────
11
+ * `.brainclaw/coordination/federation/connection.json` (workspace, ce module)
12
+ * Le lien workspace ↔ cloud_project_id, l'identité d'appareil PUBLIQUE, les
13
+ * epochs connus, la position de sync, les états visibles. AUCUN SECRET.
14
+ *
15
+ * `~/.brainclaw/keys/` (federation-keyring.ts)
16
+ * Les clés privées. Hors du store, parce qu'un store de workspace se copie,
17
+ * se synchronise et — dans d'autres projets que celui-ci — se committe.
18
+ *
19
+ * La frontière est vérifiée par un test, pas seulement par cette phrase : le pack de
20
+ * l'étape 8 injecte une sentinelle et exige qu'aucun octet de clé privée n'atteigne
21
+ * `.brainclaw/`.
22
+ *
23
+ * ── LES TROIS ÉTATS SONT UNE EXIGENCE, PAS UN CONFORT (dec#154) ───────────────
24
+ * « Le Cloud est une projection et un relais ; le local est la source de vérité. » Une
25
+ * opération venue du Cloud se matérialise dans le journal local AVEC UN ÉTAT VISIBLE :
26
+ * pending / synced / conflict. Un état invisible transformerait le relais en autorité
27
+ * silencieuse — exactement ce que dec#154 refuse.
28
+ */
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { memoryDir, writeFileAtomic } from './io.js';
32
+ import { generateId, nowISO } from './ids.js';
33
+ import { heldEpochs } from './federation-keyring.js';
34
+ import { counters as outboxCounters } from './federation-outbox-v2.js';
35
+ import { logger } from './logger.js';
36
+ const CONNECTION_FILE = 'connection.json';
37
+ export const FEDERATION_STATE_SCHEMA = 'brainclaw.federation-connection/v2';
38
+ /** Nombre d'appareils de récupération exigés avant la première enveloppe (RFC §5.3). */
39
+ export const REQUIRED_RECOVERY_DEVICES = 2;
40
+ // ── Emplacement ───────────────────────────────────────────────────────────────
41
+ export function connectionStatePath(cwd = process.cwd()) {
42
+ return path.join(memoryDir(cwd), 'coordination', 'federation', CONNECTION_FILE);
43
+ }
44
+ // ── Lecture ───────────────────────────────────────────────────────────────────
45
+ /**
46
+ * Charge l'état de connexion, ou `undefined` si le workspace n'est pas appairé.
47
+ *
48
+ * FAIL-CLOSED SUR ÉTAT ILLISIBLE : un JSON corrompu renvoie `undefined` et journalise,
49
+ * il ne renvoie PAS un état par défaut. Un défaut fabriqué ferait croire à un appairage
50
+ * avec un `current_epoch` de 0, et l'appelant tenterait de sceller sous une clé
51
+ * inexistante. « Pas appairé » est une réponse sûre ; « appairé, epoch 0 » ne l'est pas.
52
+ */
53
+ export function loadConnectionState(cwd = process.cwd()) {
54
+ const filepath = connectionStatePath(cwd);
55
+ if (!fs.existsSync(filepath))
56
+ return undefined;
57
+ let raw;
58
+ try {
59
+ raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
60
+ }
61
+ catch (err) {
62
+ logger.warn(`État de connexion fédération illisible (${filepath}): ${err instanceof Error ? err.message : String(err)}`);
63
+ return undefined;
64
+ }
65
+ const state = raw;
66
+ if (state.schema !== FEDERATION_STATE_SCHEMA || !state.cloud_project_id || !state.device?.device_id) {
67
+ // Un schéma inconnu n'est PAS migré (dec#156) : la v1 est abandonnée, pas dépréciée.
68
+ logger.warn(`État de connexion fédération ignoré : schéma '${String(state.schema)}' non reconnu (attendu ${FEDERATION_STATE_SCHEMA}).`);
69
+ return undefined;
70
+ }
71
+ return normalizeState(state, cwd);
72
+ }
73
+ /**
74
+ * Réconcilie l'état déclaré avec le DISQUE.
75
+ *
76
+ * `known_epochs` dit ce que l'appareil croit détenir ; `heldEpochs()` lit ce qu'il
77
+ * détient réellement. Le désaccord n'est pas théorique : une restauration partielle de
78
+ * sauvegarde, ou un `disconnect` interrompu, produit exactement cela. Faire confiance au
79
+ * JSON conduirait à tenter un déchiffrement sous une clé absente et à rendre l'erreur au
80
+ * mauvais endroit — loin de la cause.
81
+ */
82
+ function normalizeState(state, cwd) {
83
+ const onDisk = heldEpochs(state.cloud_project_id);
84
+ const declared = state.keys?.known_epochs ?? [];
85
+ const missing = declared.filter((e) => !onDisk.includes(e));
86
+ if (missing.length > 0) {
87
+ logger.warn(`Trousseau incomplet pour ${state.cloud_project_id} : epoch(s) ${missing.join(', ')} déclaré(s) ` +
88
+ `mais absent(s) de ~/.brainclaw/keys/. Ces révisions ne sont pas déchiffrables sur cet appareil.`);
89
+ }
90
+ return {
91
+ ...state,
92
+ workspace_path: state.workspace_path ?? path.resolve(cwd),
93
+ peer_devices: state.peer_devices ?? [],
94
+ keys: {
95
+ current_epoch: state.keys?.current_epoch ?? 0,
96
+ // Le disque fait autorité sur ce qui est LISIBLE.
97
+ known_epochs: onDisk,
98
+ },
99
+ sync: {
100
+ feed_cursor: state.sync?.feed_cursor,
101
+ high_water: state.sync?.high_water ?? {},
102
+ last_pull_at: state.sync?.last_pull_at,
103
+ last_push_at: state.sync?.last_push_at,
104
+ },
105
+ counters: {
106
+ pending: state.counters?.pending ?? 0,
107
+ synced: state.counters?.synced ?? 0,
108
+ conflict: state.counters?.conflict ?? 0,
109
+ },
110
+ };
111
+ }
112
+ // ── Écriture ──────────────────────────────────────────────────────────────────
113
+ /**
114
+ * Persiste l'état de connexion de façon atomique.
115
+ *
116
+ * REFUSE D'ÉCRIRE UN SECRET : le contrôle ci-dessous n'est pas de la paranoïa décorative.
117
+ * `{...state, private_key}` compile, et une clé privée sérialisée dans un fichier du
118
+ * store serait ensuite copiée par tout ce qui copie un store. C'est le même raisonnement
119
+ * que les trois filets de l'étape 5 : le typage TypeScript est une BORNE INFÉRIEURE, pas
120
+ * une garantie d'exécution — chaque clé présente à l'exécution est sérialisée.
121
+ */
122
+ export function saveConnectionState(state, cwd = process.cwd()) {
123
+ assertNoSecret(state);
124
+ const next = { ...state, updated_at: nowISO() };
125
+ const filepath = connectionStatePath(cwd);
126
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
127
+ writeFileAtomic(filepath, `${JSON.stringify(next, null, 2)}\n`);
128
+ }
129
+ const SECRET_MARKERS = ['PRIVATE KEY', 'BEGIN OPENSSH', 'BEGIN RSA', 'BEGIN EC PARAMETERS'];
130
+ /**
131
+ * Refuse tout état contenant du matériel de clé privée, quel que soit le NOM du champ.
132
+ *
133
+ * Le contrôle porte sur le CONTENU sérialisé et non sur une liste de champs interdits :
134
+ * une liste de noms ne rattrape pas un champ ajouté demain, alors qu'un PEM privé porte
135
+ * toujours son en-tête. C'est le même choix que le filet 2 de l'étape 5 — fail-closed sur
136
+ * ce qui sort, pas allowlist sur ce qu'on a pensé à interdire.
137
+ */
138
+ function assertNoSecret(state) {
139
+ const serialized = JSON.stringify(state);
140
+ for (const marker of SECRET_MARKERS) {
141
+ if (serialized.includes(marker)) {
142
+ throw new Error(`Refus d'écrire l'état de connexion : du matériel de clé privée ('${marker}') s'y trouve. ` +
143
+ `Les secrets vont dans ~/.brainclaw/keys/ via federation-keyring.ts, jamais dans le store de workspace.`);
144
+ }
145
+ }
146
+ }
147
+ /**
148
+ * Crée l'état de connexion initial d'un workspace fraîchement appairé.
149
+ *
150
+ * `stage: 'pending'` et non `'active'` : créer l'état ne vaut pas approbation. Le passage
151
+ * à `active` appartient à la cérémonie de l'étape 4, après preuve de possession ET
152
+ * approbation humaine. Un état créé optimiste rejouerait le défaut de la v1 — un artefact
153
+ * local suffisant à déclencher du sync.
154
+ */
155
+ export function createConnectionState(params) {
156
+ const now = nowISO();
157
+ return {
158
+ schema: FEDERATION_STATE_SCHEMA,
159
+ cloud_project_id: params.cloudProjectId,
160
+ workspace_path: path.resolve(params.workspacePath ?? process.cwd()),
161
+ enrollment: { stage: 'pending', enrollment_id: params.enrollmentId, updated_at: now },
162
+ device: params.device,
163
+ peer_devices: [],
164
+ // 0 = « aucun epoch » et non « premier epoch ». Le premier epoch remis est le 1 ;
165
+ // sceller sous l'epoch 0 doit être impossible, pas silencieusement plausible.
166
+ keys: { current_epoch: 0, known_epochs: [] },
167
+ sync: { high_water: {} },
168
+ counters: { pending: 0, synced: 0, conflict: 0 },
169
+ created_at: now,
170
+ updated_at: now,
171
+ };
172
+ }
173
+ /**
174
+ * Identifiant d'appareil, indépendant de l'identité d'agent (RFC §5.1).
175
+ *
176
+ * Un agent peut tourner sur plusieurs machines et une machine porter plusieurs agents ;
177
+ * c'est l'APPAREIL qui détient une clé de déchiffrement et qu'on révoque en cas de perte.
178
+ * Réutiliser l'agent_id ferait qu'une révocation coupe l'agent partout à la fois.
179
+ */
180
+ export function newDeviceId() {
181
+ return generateId('federation_devices');
182
+ }
183
+ // ── Anti-rejeu ────────────────────────────────────────────────────────────────
184
+ /**
185
+ * Décide si une révision entrante est acceptable pour un objet donné.
186
+ *
187
+ * STRICTEMENT SUPÉRIEURE (RFC §6.5) : l'égalité est refusée ici, et le dédoublonnage
188
+ * d'une enveloppe déjà connue se fait par `idempotency_key` à l'étape 6 — deux
189
+ * mécanismes distincts pour deux questions distinctes. Confondre les deux ferait
190
+ * accepter un rejeu de même révision porteur d'un contenu différent.
191
+ */
192
+ export function acceptsRevision(state, objectId, incomingRev) {
193
+ const seen = state.sync.high_water[objectId];
194
+ return seen === undefined || incomingRev > seen;
195
+ }
196
+ /** Avance la barrière anti-rejeu. Ne régresse JAMAIS, même si l'appelant le demande. */
197
+ export function recordRevision(state, objectId, rev) {
198
+ const seen = state.sync.high_water[objectId];
199
+ if (seen !== undefined && rev <= seen)
200
+ return state;
201
+ return { ...state, sync: { ...state.sync, high_water: { ...state.sync.high_water, [objectId]: rev } } };
202
+ }
203
+ /**
204
+ * Un projet ne peut émettre sa PREMIÈRE enveloppe v2 qu'après l'enrôlement de deux
205
+ * appareils de récupération indépendamment attestés (RFC §5.3).
206
+ *
207
+ * POURQUOI CETTE PORTE EXISTE ICI, dans l'état et non dans la commande : un enrôlement
208
+ * par appareil sans scénario de perte produit des workspaces DÉFINITIVEMENT illisibles.
209
+ * Si tous les porteurs sont perdus, le passé scellé est irrécupérable par construction —
210
+ * aucun reset côté Cloud ne le restaure. La porte est donc au plus près de la donnée qui
211
+ * la conditionne, pour qu'un second appelant ne puisse pas l'oublier.
212
+ *
213
+ * Les appareils révoqués ne comptent pas : deux porteurs dont un révoqué n'offrent aucun
214
+ * chemin de remplacement.
215
+ */
216
+ export function recoveryReadiness(state) {
217
+ const all = [state.device, ...state.peer_devices];
218
+ const enrolled = all.filter((d) => d.recovery && !d.revoked_at).length;
219
+ if (enrolled >= REQUIRED_RECOVERY_DEVICES) {
220
+ return { ready: true, enrolled, required: REQUIRED_RECOVERY_DEVICES };
221
+ }
222
+ return {
223
+ ready: false,
224
+ enrolled,
225
+ required: REQUIRED_RECOVERY_DEVICES,
226
+ reason: `${enrolled}/${REQUIRED_RECOVERY_DEVICES} appareil(s) de récupération attesté(s). ` +
227
+ `Sans un second porteur, la perte de cet appareil rendrait le passé scellé irrécupérable — ` +
228
+ `aucune restauration côté cloud ne le ramènerait.`,
229
+ };
230
+ }
231
+ /**
232
+ * Ce que `brainclaw cloud status` rend — le critère de sortie « les trois états de sync
233
+ * sont observables par une commande ».
234
+ *
235
+ * DEUX SOURCES, ET LES DEUX SONT LE DISQUE, PAS LA DÉCLARATION :
236
+ * `readable_epochs` vient de `heldEpochs()` via la réconciliation du chargement ;
237
+ * `sync` vient du comptage réel de l'outbox, pas des compteurs de `connection.json`.
238
+ *
239
+ * Les compteurs persistés restent un cache d'affichage bon marché pour les appelants qui
240
+ * n'ont pas besoin d'exactitude. Un STATUT, lui, est consulté précisément quand on doute :
241
+ * s'il relisait un compteur que le code a lui-même incrémenté, il n'observerait rien et
242
+ * rassurerait à tort au pire moment.
243
+ */
244
+ export function summarizeConnection(cwd = process.cwd()) {
245
+ const state = loadConnectionState(cwd);
246
+ const sync = outboxCounters(cwd);
247
+ if (!state) {
248
+ return {
249
+ connected: false,
250
+ stage: 'unpaired',
251
+ current_epoch: 0,
252
+ readable_epochs: [],
253
+ sync,
254
+ recovery: { ready: false, enrolled: 0, required: REQUIRED_RECOVERY_DEVICES },
255
+ };
256
+ }
257
+ return {
258
+ connected: state.enrollment.stage === 'active',
259
+ cloud_project_id: state.cloud_project_id,
260
+ stage: state.enrollment.stage,
261
+ role: state.enrollment.role,
262
+ current_epoch: state.keys.current_epoch,
263
+ readable_epochs: state.keys.known_epochs,
264
+ device_fingerprint: state.device.x25519_fingerprint,
265
+ sync,
266
+ last_pull_at: state.sync.last_pull_at,
267
+ recovery: recoveryReadiness(state),
268
+ };
269
+ }
270
+ //# sourceMappingURL=federation-state.js.map
package/dist/core/ids.js CHANGED
@@ -24,6 +24,11 @@ const PREFIXES = {
24
24
  // prefix-based routing (dispatch_status). Canonical prefix is 'rtn'.
25
25
  runtime_note: 'rtn',
26
26
  runtime_notes: 'rtn',
27
+ // pln#651 étape 3 — identité d'APPAREIL de la fédération v2, distincte de l'identité
28
+ // d'agent. Entrée EXPLICITE et non laissée au fallback slice(0,3) : c'est ce fallback
29
+ // qui a produit la collision de can_b8d53d18 ('runtime_note' → 'run', déjà pris par
30
+ // agent_run). Il rend 'dev' ici par hasard, pas par contrat.
31
+ federation_devices: 'dev',
27
32
  };
28
33
  const ID_COUNTER_FILE = '.id-counter.json';
29
34
  function counterPath(cwd, preferredDirName = '.brainclaw') {
@@ -1175,22 +1175,6 @@ export const RemoteSyncSchema = z.object({
1175
1175
  ssh_key_path: z.string().optional(),
1176
1176
  sync_strategy: z.enum(['pull-only', 'push-pull', 'pr-based']).default('push-pull'),
1177
1177
  });
1178
- export const CloudSyncConfigSchema = z.object({
1179
- enabled: z.boolean().default(false),
1180
- endpoint: z.string().default('https://app.brainclaw.dev'),
1181
- api_key: z.string().optional(),
1182
- /** Remote project this bridge federates into (scopes signed runtime writes). */
1183
- project_id: z.string().optional(),
1184
- /** Approved remote agent identity used to sign runtime writes (pln#100). */
1185
- agent_id: z.string().optional(),
1186
- agent_name: z.string().optional(),
1187
- /**
1188
- * Fail-closed toggle: when true, the bridge refuses to push a runtime write
1189
- * unless it can sign it with an approved agent's Ed25519 key. Absent/false
1190
- * keeps existing API-key-only setups working (signing is additive).
1191
- */
1192
- require_signed: z.boolean().optional(),
1193
- });
1194
1178
  export const SessionSnapshotSchema = z.object({
1195
1179
  schema_version: z.number().int().positive().optional(),
1196
1180
  session_id: z.string(),
@@ -1520,7 +1504,6 @@ export const ConfigSchema = z.object({
1520
1504
  target_audience: z.enum(['human', 'agent']).optional().default('human'),
1521
1505
  openclaw_bridge: z.boolean().optional().default(false),
1522
1506
  remote_sync: RemoteSyncSchema.optional(),
1523
- cloud_sync: CloudSyncConfigSchema.optional(),
1524
1507
  telemetry: z.literal(false),
1525
1508
  allow_network: z.literal(false),
1526
1509
  redaction: RedactionConfigSchema,
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.21.0 on 2026-08-07T12:11:23.699Z
2
+ // Source: brainclaw v1.22.0 on 2026-08-08T16:03:19.158Z
3
3
  export const FACTS = {
4
- "version": "1.21.0",
5
- "generated_at": "2026-08-07T12:11:23.699Z",
4
+ "version": "1.22.0",
5
+ "generated_at": "2026-08-08T16:03:19.158Z",
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-07T12:11:22.030Z",
477
+ "generated_at": "2026-08-08T16:03:17.056Z",
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": 71,
486
+ "duration_ms_median": 73,
487
487
  "payload_chars_median": 1640,
488
488
  "payload_tokens_est_median": 410
489
489
  },
@@ -491,15 +491,15 @@ 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": 101,
495
- "payload_chars_median": 2625,
496
- "payload_tokens_est_median": 656
494
+ "duration_ms_median": 128,
495
+ "payload_chars_median": 2626,
496
+ "payload_tokens_est_median": 657
497
497
  },
498
498
  {
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": 11,
502
+ "duration_ms_median": 14,
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.21.0",
3
- "generated_at": "2026-08-07T12:11:23.699Z",
2
+ "version": "1.22.0",
3
+ "generated_at": "2026-08-08T16:03:19.158Z",
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-07T12:11:22.030Z",
475
+ "generated_at": "2026-08-08T16:03:17.056Z",
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": 71,
484
+ "duration_ms_median": 73,
485
485
  "payload_chars_median": 1640,
486
486
  "payload_tokens_est_median": 410
487
487
  },
@@ -489,15 +489,15 @@
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": 101,
493
- "payload_chars_median": 2625,
494
- "payload_tokens_est_median": 656
492
+ "duration_ms_median": 128,
493
+ "payload_chars_median": 2626,
494
+ "payload_tokens_est_median": 657
495
495
  },
496
496
  {
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": 11,
500
+ "duration_ms_median": 14,
501
501
  "payload_chars_median": 499,
502
502
  "payload_tokens_est_median": 125
503
503
  }
package/docs/cli.md CHANGED
@@ -1749,46 +1749,9 @@ brainclaw push --remote origin --message "chore: push memory state" --json
1749
1749
 
1750
1750
  ## Federation
1751
1751
 
1752
- The `federation` command group manages cloud signal exchange with `app.brainclaw.dev`. Federation requires `BRAINCLAW_CLOUD_API_KEY` to be set. It is a planned premium tier for cross-machine and cross-organization coordination.
1752
+ The v1 `brainclaw federation` command group and the whole cloud egress path (`app.brainclaw.dev` push/pull, `cloud_sync` config, `BRAINCLAW_CLOUD_*` env vars) were removed in wave 1 of dec#156 / pln#651. There is no migration: the v1 format is abandoned, not deprecated. The v2 federation surface will be introduced by pln#651 wave 3 alongside the pairing CLI (`brainclaw cloud connect`).
1753
1753
 
1754
- ### `brainclaw federation push <message>`
1755
-
1756
- Push a test signal to the cloud. The signal is sent from the current project and agent to a target project or broadcast address.
1757
-
1758
- | Option | Description |
1759
- |---|---|
1760
- | `--type <type>` | Signal type (default: `runtime_note`). Accepted values: `signal`, `handoff`, `candidate`, `runtime_note`, `board_snapshot` |
1761
- | `--to-project <project>` | Target project name (default: `broadcast`) |
1762
- | `--to-agent <agent>` | Target agent name |
1763
-
1764
- ```bash
1765
- brainclaw federation push "Auth rollout complete" --to-project lodestar
1766
- brainclaw federation push "Blocked on payments" --type runtime_note --to-agent copilot
1767
- ```
1768
-
1769
- ### `brainclaw federation pull`
1770
-
1771
- Pull signals from the cloud inbox for the current agent.
1772
-
1773
- | Option | Description |
1774
- |---|---|
1775
- | `--agent <name>` | Agent name to pull for (default: auto-detected) |
1776
- | `--since <date>` | Only pull signals after this ISO date |
1777
- | `--limit <n>` | Maximum number of signals to pull (default: 20) |
1778
-
1779
- ```bash
1780
- brainclaw federation pull
1781
- brainclaw federation pull --since 2026-04-01
1782
- brainclaw federation pull --agent copilot --limit 50
1783
- ```
1784
-
1785
- ### `brainclaw federation status`
1786
-
1787
- Check cloud federation configuration: shows the configured cloud URL, whether the API key is set, and pings the cloud health endpoint if configured.
1788
-
1789
- ```bash
1790
- brainclaw federation status
1791
- ```
1754
+ Local cross-project federation (see below) is unaffected.
1792
1755
 
1793
1756
  ---
1794
1757
 
@@ -2244,4 +2207,72 @@ Show brainclaw context volume stats — tokens injected per agent and per MCP to
2244
2207
  |---|---|
2245
2208
  | `--json` | Output as JSON |
2246
2209
 
2210
+ ### `brainclaw cloud status`
2211
+
2212
+ Show the federation v2 connection state for this workspace: linked cloud project, role,
2213
+ current key epoch, the epochs actually readable on this device, and the three sync states.
2214
+
2215
+ | Option | Description |
2216
+ |---|---|
2217
+ | `--json` | Output as JSON |
2218
+
2219
+ Sync state is **visible by design** (dec#154): a cloud-originated operation materializes in
2220
+ the local journal as `pending`, `synced` or `conflict`, and a conflict is presented for
2221
+ resolution rather than resolved by a silent last-write-wins. The counts are read from the
2222
+ outbox on disk, not from a cached counter — a status that echoed a number the code had
2223
+ itself incremented would reassure precisely when you are checking because you doubt.
2224
+
2225
+ `readable_epochs` likewise comes from the keyring on disk. It can be shorter than the
2226
+ epochs the state declares: a partial backup restore leaves that exact disagreement, and
2227
+ the honest answer is what can actually be decrypted here.
2228
+
2229
+ ### `brainclaw cloud connect <invite-code> --url <url> --agent <id>`
2230
+
2231
+ Join a cloud project. This is a **key ceremony**, not a config write: it claims the invite,
2232
+ proves possession of this agent's Ed25519 identity, and attests the device's X25519
2233
+ encryption key with that same identity.
2234
+
2235
+ | Option | Description |
2236
+ |---|---|
2237
+ | `--url <url>` | Cloud deployment address (required) |
2238
+ | `--agent <id>` | Opaque agent identifier to enroll, 4–64 chars (required) |
2239
+ | `--json` | Output as JSON |
2240
+
2241
+ The human copies **only the invite code**, then compares **two fingerprints** with what the
2242
+ approver sees on their screen. No API key, no PEM, no agent_id, no environment variable
2243
+ (dec#8). Both fingerprints are printed in full, never truncated — a 16-character comparison
2244
+ collides far more easily than it looks.
2245
+
2246
+ The attestation is what stops a **phantom member**. Without it, the Cloud — which
2247
+ orchestrates the pairing — could insert its own key into the envelope list: end-to-end
2248
+ encryption whose key exchange is arbitrated by the very party it claims to neutralize.
2249
+
2250
+ A refused pairing writes **nothing** locally, so re-running is safe and leaves no orphan.
2251
+
2252
+ ### `brainclaw cloud await --url <url>`
2253
+
2254
+ Observe the human approval and activate the local pairing.
2255
+
2256
+ Deliberately a **separate command**: approval depends on a person, whose delay is not
2257
+ bounded. A command that blocked indefinitely on a third party would be a poor citizen in a
2258
+ script, and an interrupted pairing must stay resumable. Polling does **not** mutate local
2259
+ state — reading a status must never change the workspace.
2260
+
2261
+ ### `brainclaw cloud disconnect --url <url>`
2262
+
2263
+ Remove the local authorization and request remote revocation.
2264
+
2265
+ | Option | Description |
2266
+ |---|---|
2267
+ | `--url <url>` | Cloud deployment address (required) |
2268
+ | `--forget-keys` | Also erase this project's epoch keys — its sealed past becomes unreadable here |
2269
+
2270
+ Local state flips to revoked **even if the cloud is unreachable**: otherwise a lost device
2271
+ would stay authorized for want of a network, the opposite of what revocation must
2272
+ guarantee.
2273
+
2274
+ What disconnect does **not** do, stated rather than left implied: it does not erase data
2275
+ already pulled and decrypted locally, nor what other devices already hold. It withdraws an
2276
+ authorization; it does not rewrite the past (RFC §5.2).
2277
+
2247
2278
  This keeps end-user installs aware of published npm releases without requiring a local tarball channel. To keep beta testers on a different channel, set `brainclaw_update_source` to `type: npm` with a different `dist_tag`, such as `prelaunch`.