brainclaw 1.21.0 → 1.23.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 (50) 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 +63 -0
  4. package/dist/cli.js +2 -3
  5. package/dist/commands/cloud.js +198 -0
  6. package/dist/commands/loops-handlers.js +0 -1
  7. package/dist/commands/mcp-catalog.js +24 -256
  8. package/dist/commands/mcp-read-handlers.js +5 -1
  9. package/dist/commands/mcp-schemas.generated.js +811 -1
  10. package/dist/commands/mcp-write-coordination.js +16 -7
  11. package/dist/commands/mcp.js +45 -1
  12. package/dist/commands/memory-confirm.js +83 -0
  13. package/dist/commands/session-end.js +0 -102
  14. package/dist/commands/session-start.js +0 -23
  15. package/dist/commands/switch.js +24 -2
  16. package/dist/core/assignment-request-schema.js +112 -0
  17. package/dist/core/capture-schema.js +62 -0
  18. package/dist/core/claim-request-schema.js +72 -0
  19. package/dist/core/claims.js +0 -18
  20. package/dist/core/code-map/aggregate.js +36 -1
  21. package/dist/core/coordination.js +1 -3
  22. package/dist/core/federation-attestation.js +96 -0
  23. package/dist/core/federation-canonical.js +95 -0
  24. package/dist/core/federation-hpke.js +213 -0
  25. package/dist/core/federation-inbound.js +187 -0
  26. package/dist/core/federation-keyring.js +241 -0
  27. package/dist/core/federation-message.js +5 -5
  28. package/dist/core/federation-outbox-v2.js +125 -0
  29. package/dist/core/federation-pairing.js +213 -0
  30. package/dist/core/federation-projection.js +336 -0
  31. package/dist/core/federation-relay.js +223 -0
  32. package/dist/core/federation-state.js +270 -0
  33. package/dist/core/ids.js +5 -0
  34. package/dist/core/schema.js +0 -17
  35. package/dist/core/sequence-request-schema.js +93 -0
  36. package/dist/core/session-request-schema.js +90 -0
  37. package/dist/core/step-request-schema.js +112 -0
  38. package/dist/core/store-resolution.js +34 -5
  39. package/dist/core/warnings.js +37 -0
  40. package/dist/facts.js +9 -9
  41. package/dist/facts.json +8 -8
  42. package/docs/cli.md +70 -39
  43. package/docs/concepts/federation-v2-rfc.md +275 -0
  44. package/docs/index.md +1 -0
  45. package/docs/integrations/mcp.md +1 -1
  46. package/package.json +2 -2
  47. package/dist/cli/register-federation.js +0 -258
  48. package/dist/core/federation-cloud.js +0 -245
  49. package/dist/core/federation-outbox.js +0 -292
  50. package/dist/core/federation-signing.js +0 -115
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Commandes cloud matérialisées localement — le RELAIS (pln#651 étape 7, dec#154).
3
+ *
4
+ * ── CE QUI SURVIT INTACT AU CHIFFREMENT ───────────────────────────────────────
5
+ * À écrire d'emblée, pour qu'on ne « résolve » pas un faux problème : `base_rev` compare
6
+ * un IDENTIFIANT DE RÉVISION, pas du contenu. L'idempotence, `operation_id`, la
7
+ * provenance et l'audit ne référencent que des ids et des actions. Rien de tout cela n'a
8
+ * besoin de lire le clair, et le chiffrement ne gêne donc en rien ce module.
9
+ *
10
+ * LE RELAIS N'ÉCRIT JAMAIS DE CONTENU. Le contenu reste en écriture depuis le local
11
+ * uniquement. Une commande venue du dashboard porte sur des MÉTADONNÉES : priorité,
12
+ * ordre, rang, statut, métadonnées de roadmap. C'est la conséquence directe de dec#154 —
13
+ * « le Cloud est une projection et un relais ; le local est la source de vérité ».
14
+ *
15
+ * ── LA TROISIÈME CLASSE D'APPELANTS (dec#155) ─────────────────────────────────
16
+ * Le relais cloud n'a NI session, NI cwd, NI contexte ambiant : seulement un id d'entité
17
+ * et un `base_rev`. C'est le cas le plus pur du routage autoritatif par l'entité, déjà en
18
+ * place côté core depuis la 1.21.0.
19
+ *
20
+ * NE PAS RÉINVENTER DE RÉSOLUTION AMBIANTE POUR LUI. La tentation est réelle — « si le
21
+ * projet n'est pas précisé, prendre le projet actif » — et c'est exactement la dérive que
22
+ * pln#648/649 ont corrigée pour le routage local. Une commande sans cible résoluble est
23
+ * REFUSÉE, jamais devinée.
24
+ *
25
+ * ── LES CONFLITS ──────────────────────────────────────────────────────────────
26
+ * Présentés avec une proposition de résolution, JAMAIS de last-write-wins silencieux. Et
27
+ * c'est le contrat de refus de T3 exprimé sur un autre transport : une divergence PROUVÉE
28
+ * refuse et nomme ; une ABSENCE retombe sur la réponse ambiante. Le même mécanisme, pas
29
+ * un second.
30
+ */
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { z } from 'zod';
34
+ import { memoryDir, writeFileAtomic } from './io.js';
35
+ import { nowISO } from './ids.js';
36
+ import { canonicalJson } from './federation-canonical.js';
37
+ import { logger } from './logger.js';
38
+ const JOURNAL_DIR = ['coordination', 'federation', 'commands'];
39
+ /**
40
+ * Les seuls champs qu'une commande cloud peut toucher.
41
+ *
42
+ * LISTE FERMÉE ET NON EXTENSIBLE PAR CONFIGURATION : c'est la traduction exécutable de
43
+ * « le relais n'écrit jamais de contenu ». Ajouter `text` ou `description` ici ferait du
44
+ * Cloud une source d'écriture de contenu et retournerait dec#154.
45
+ */
46
+ export const RELAYABLE_FIELDS = ['priority', 'rank', 'status'];
47
+ export const CloudCommandSchema = z.object({
48
+ /** Identité de l'opération, créée par l'émetteur et REJOUÉE à l'identique en cas de retry. */
49
+ operation_id: z.string().min(1),
50
+ /** Cible : un id d'entité opaque. Pas de projet, pas de cwd, pas de session (dec#155). */
51
+ object_id: z.string().min(1),
52
+ /** Révision sur laquelle l'émetteur s'appuie. Un décalage produit un conflit VISIBLE. */
53
+ base_rev: z.number().int().nonnegative(),
54
+ field: z.enum(RELAYABLE_FIELDS),
55
+ value: z.union([z.string(), z.number()]),
56
+ /** Identité Ed25519 de l'émetteur — audité, pas cru sur parole. */
57
+ issued_by: z.string().min(1),
58
+ issued_at: z.string().min(1),
59
+ }).strict();
60
+ function journalDir(cwd) {
61
+ return path.join(memoryDir(cwd), ...JOURNAL_DIR);
62
+ }
63
+ /**
64
+ * Le journal est indexé par `operation_id` — c'est ce qui rend le rejeu inoffensif.
65
+ *
66
+ * Un index par (object_id, base_rev) ne suffirait pas : deux commandes distinctes peuvent
67
+ * légitimement viser la même révision d'un même objet (changer la priorité, puis le rang).
68
+ */
69
+ function entryPath(cwd, operationId) {
70
+ // Le nom de fichier est assaini : un operation_id venu du réseau ne doit pas pouvoir
71
+ // écrire hors du répertoire du journal via '../'.
72
+ const safe = operationId.replace(/[^A-Za-z0-9_.-]/g, '_');
73
+ return path.join(journalDir(cwd), `${safe}.json`);
74
+ }
75
+ export function loadJournalEntry(cwd, operationId) {
76
+ const filepath = entryPath(cwd, operationId);
77
+ if (!fs.existsSync(filepath))
78
+ return undefined;
79
+ try {
80
+ return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
81
+ }
82
+ catch (err) {
83
+ // Une entrée illisible n'est PAS traitée comme absente : la traiter ainsi ferait
84
+ // réappliquer une commande déjà appliquée, ce que tout ce module cherche à empêcher.
85
+ logger.warn(`Entrée de journal illisible (${operationId}) : ${err instanceof Error ? err.message : String(err)}`);
86
+ // `cause` conservée : sans elle, un opérateur voit « journal corrompu » sans savoir
87
+ // si c'est un JSON tronqué, un encodage cassé ou un disque plein.
88
+ throw new Error(`Journal corrompu pour l'opération ${operationId} — refus d'appliquer à l'aveugle.`, { cause: err });
89
+ }
90
+ }
91
+ function writeEntry(cwd, entry) {
92
+ const filepath = entryPath(cwd, entry.operation_id);
93
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
94
+ writeFileAtomic(filepath, `${JSON.stringify(entry, null, 2)}\n`);
95
+ }
96
+ /**
97
+ * Applique une commande cloud au journal local.
98
+ *
99
+ * `resolveLocalRev` est INJECTÉ plutôt que lu ici : ce module ne connaît pas le stockage
100
+ * des entités, et lui donner cette connaissance en ferait un second chemin d'écriture sur
101
+ * la mémoire — précisément ce que « le relais n'écrit jamais de contenu » interdit. Il
102
+ * écrit le journal ; c'est l'appelant qui, à partir du journal, applique un changement de
103
+ * métadonnée par la voie locale normale.
104
+ *
105
+ * TROIS ISSUES ET AUCUN ÉCRASEMENT SILENCIEUX :
106
+ * applied — base_rev correspond, l'effet est enregistré ;
107
+ * duplicate — operation_id déjà connu, aucun second effet (idempotence) ;
108
+ * conflict — base_rev périmé : l'entrée est écrite en état `conflict` AVEC une
109
+ * proposition, et attend une décision humaine.
110
+ */
111
+ export function applyCloudCommand(params) {
112
+ const parsed = CloudCommandSchema.safeParse(params.raw);
113
+ if (!parsed.success) {
114
+ return { status: 'refused', reason: `commande invalide : ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` };
115
+ }
116
+ const cmd = parsed.data;
117
+ // IDEMPOTENCE D'ABORD. Rejouer une commande déjà appliquée doit être un no-op, y compris
118
+ // si la révision locale a changé entre-temps — sinon un retry réseau produirait un
119
+ // conflit fantôme sur une opération pourtant déjà réussie.
120
+ const existing = loadJournalEntry(params.cwd, cmd.operation_id);
121
+ if (existing) {
122
+ return { status: 'duplicate', entry: existing };
123
+ }
124
+ const localRev = params.resolveLocalRev(cmd.object_id);
125
+ if (localRev === undefined) {
126
+ // TROISIÈME CLASSE D'APPELANTS (dec#155) : pas de repli ambiant. Un objet inconnu est
127
+ // refusé et nommé, jamais rattaché au « projet actif » par défaut.
128
+ return { status: 'refused', reason: `objet '${cmd.object_id}' inconnu localement — aucune résolution ambiante pour le relais cloud (dec#155).` };
129
+ }
130
+ const base = {
131
+ operation_id: cmd.operation_id,
132
+ object_id: cmd.object_id,
133
+ base_rev: cmd.base_rev,
134
+ field: cmd.field,
135
+ value: cmd.value,
136
+ issued_by: cmd.issued_by,
137
+ issued_at: cmd.issued_at,
138
+ materialized_at: nowISO(),
139
+ };
140
+ if (cmd.base_rev !== localRev) {
141
+ // CONFLIT VISIBLE, jamais un écrasement. La proposition est formulée ici parce que
142
+ // c'est ici qu'on connaît les deux révisions ; la rendre plus tard obligerait
143
+ // l'interface à re-déduire ce que le journal savait déjà.
144
+ const entry = {
145
+ ...base,
146
+ state: 'conflict',
147
+ conflict: {
148
+ local_rev: localRev,
149
+ proposal: localRev > cmd.base_rev
150
+ ? `Le local a avancé (rév. ${localRev} > ${cmd.base_rev}). Rejouer la commande sur la révision ${localRev}, ou l'abandonner si le changement local la rend caduque.`
151
+ : `La commande s'appuie sur une révision (${cmd.base_rev}) postérieure au local (${localRev}) — le local a probablement été restauré. Vérifier avant d'appliquer.`,
152
+ },
153
+ };
154
+ writeEntry(params.cwd, entry);
155
+ return { status: 'conflict', entry };
156
+ }
157
+ const entry = { ...base, state: 'pending' };
158
+ writeEntry(params.cwd, entry);
159
+ return { status: 'applied', entry };
160
+ }
161
+ /** Marque une entrée synchronisée une fois l'effet local réellement appliqué. */
162
+ export function markCommandSynced(cwd, operationId) {
163
+ const entry = loadJournalEntry(cwd, operationId);
164
+ if (!entry)
165
+ return false;
166
+ writeEntry(cwd, { ...entry, state: 'synced' });
167
+ return true;
168
+ }
169
+ /**
170
+ * Résout un conflit par une DÉCISION EXPLICITE.
171
+ *
172
+ * Il n'existe volontairement aucune résolution automatique : dec#154 dit « jamais de
173
+ * last-write-wins silencieux », et un mode « auto » finirait par être le défaut.
174
+ */
175
+ export function resolveCommandConflict(params) {
176
+ const entry = loadJournalEntry(params.cwd, params.operationId);
177
+ if (!entry || entry.state !== 'conflict')
178
+ return undefined;
179
+ const resolved = {
180
+ ...entry,
181
+ state: params.decision === 'accept' ? 'pending' : 'synced',
182
+ conflict: undefined,
183
+ };
184
+ writeEntry(params.cwd, resolved);
185
+ return resolved;
186
+ }
187
+ /** Entrées du journal dans un état donné — sert à rendre les conflits visibles. */
188
+ export function listCommands(cwd, state) {
189
+ const dir = journalDir(cwd);
190
+ if (!fs.existsSync(dir))
191
+ return [];
192
+ const out = [];
193
+ for (const name of fs.readdirSync(dir)) {
194
+ if (!name.endsWith('.json'))
195
+ continue;
196
+ try {
197
+ const entry = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
198
+ if (!state || entry.state === state)
199
+ out.push(entry);
200
+ }
201
+ catch {
202
+ logger.warn(`Entrée de journal ignorée (illisible) : ${name}`);
203
+ }
204
+ }
205
+ return out.sort((a, b) => a.materialized_at.localeCompare(b.materialized_at));
206
+ }
207
+ /**
208
+ * Empreinte d'audit d'une commande — ce qui a été demandé, par qui, sur quelle révision.
209
+ *
210
+ * Calculée sur les octets CANONIQUES pour qu'elle soit reproductible des deux côtés : un
211
+ * audit qu'on ne peut pas recalculer identiquement ne prouve rien.
212
+ */
213
+ export function commandAuditDigest(cmd) {
214
+ return canonicalJson({
215
+ operation_id: cmd.operation_id,
216
+ object_id: cmd.object_id,
217
+ base_rev: cmd.base_rev,
218
+ field: cmd.field,
219
+ value: cmd.value,
220
+ issued_by: cmd.issued_by,
221
+ });
222
+ }
223
+ //# sourceMappingURL=federation-relay.js.map
@@ -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,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Schémas zod des entrées de la famille SÉQUENCE — `bclaw_create_sequence` et
3
+ * `bclaw_update_sequence` (pln#599 batch 2, première famille composite).
4
+ *
5
+ * ── CE QUI CHANGE PAR RAPPORT À LA FAMILLE CAPTURE ────────────────────────────
6
+ * C'est la première famille COMPOSITE : les deux outils partagent un objet imbriqué,
7
+ * l'item de séquence, dont le schéma était jusqu'ici une constante JSON manuelle
8
+ * (`SEQUENCE_ITEM_INPUT_SCHEMA`) réutilisée à deux endroits.
9
+ *
10
+ * La duplication d'un sous-schéma est exactement ce qui a produit trp#180 — les tableaux
11
+ * de `bclaw_loop` sans `items` — parce qu'un des deux exemplaires avait été corrigé et pas
12
+ * l'autre. Le dériver d'une source zod unique supprime la classe entière.
13
+ *
14
+ * ── CONTRAINTE INCHANGÉE : FINGERPRINT IDENTIQUE ──────────────────────────────
15
+ * Le JSON Schema produit doit être byte-identique à celui écrit à la main. Les
16
+ * `minLength: 1` et `minimum: 1` du schéma d'item sont donc reproduits tels quels, y
17
+ * compris là où ils paraissent redondants : ce sont des contraintes qu'un client peut déjà
18
+ * avoir apprises, et les retirer serait un changement de contrat déguisé en migration.
19
+ *
20
+ * `status` reste une chaîne libre et non un enum, pour la même raison. Le resserrer est un
21
+ * changement de surface qui mérite sa propre décision, pas un effet de bord.
22
+ */
23
+ import { z } from 'zod';
24
+ /**
25
+ * Item de lane. Source UNIQUE — les deux outils de la famille la partagent, là où le
26
+ * schéma manuel existait en un exemplaire réutilisé par référence mais impossible à
27
+ * valider contre le code qui le consomme.
28
+ */
29
+ export const SequenceItemInputSchema = z
30
+ .object({
31
+ planId: z.string().min(1).describe('Plan item ID referenced by this sequence item.'),
32
+ stepId: z
33
+ .string()
34
+ .min(1)
35
+ .describe('Optional plan step ID inside planId for step-level dispatch/readiness.')
36
+ .optional(),
37
+ // REQUIS, comme dans le schema d'origine. Le rendre optionnel etait un
38
+ // ASSOUPLISSEMENT du contrat — un appel sans rank aurait ete accepte par le schema
39
+ // publie puis rejete plus loin. La garde de gouvernance l'a detecte via le
40
+ // fingerprint.
41
+ rank: z
42
+ .number()
43
+ .min(1)
44
+ .describe('Positive integer ordering key. Ranks must be unique within a sequence.'),
45
+ hard_after: z
46
+ .array(z.string())
47
+ .describe('Sequence item planId values that must complete before this item becomes ready.')
48
+ .optional(),
49
+ soft_after: z
50
+ .array(z.string())
51
+ .describe('Advisory predecessor planId values; they inform ordering but do not block readiness.')
52
+ .optional(),
53
+ lane: z
54
+ .string()
55
+ .describe('Optional lane label used for parallel dispatch grouping and filtering.')
56
+ .optional(),
57
+ scope_hint: z
58
+ .string()
59
+ .describe('Optional file/path scope hint for claim and brief generation.')
60
+ .optional(),
61
+ rationale: z
62
+ .string()
63
+ .describe('Optional explanation for this item or dependency placement.')
64
+ .optional(),
65
+ })
66
+ .describe('Sequence lane item. planId is required; stepId optionally narrows dispatch/readiness to a specific plan step.');
67
+ /** Identité de l'appelant — commune aux deux outils, comme dans la famille capture. */
68
+ const CallerIdentity = {
69
+ agent: z.string().describe('Agent name.').optional(),
70
+ agentId: z.string().describe('Registered agent id.').optional(),
71
+ };
72
+ export const CreateSequenceRequestSchema = z.object({
73
+ name: z.string().describe('Sequence name.'),
74
+ description: z.string().describe('Optional sequence description.').optional(),
75
+ // Chaîne libre, PAS un enum : c'est l'état publié. Le resserrer mérite sa propre
76
+ // décision, pas un effet de bord de migration.
77
+ status: z.string().describe('Status: draft, active, archived.').optional(),
78
+ owner: z.string().describe('Optional sequence owner.').optional(),
79
+ items: z.array(SequenceItemInputSchema).describe('Sequence items in rank order.').optional(),
80
+ tags: z.array(z.string()).describe('Optional tags.').optional(),
81
+ ...CallerIdentity,
82
+ });
83
+ export const UpdateSequenceRequestSchema = z.object({
84
+ id: z.string().describe('Sequence ID or short label.'),
85
+ name: z.string().describe('Optional new sequence name.').optional(),
86
+ description: z.string().describe('Optional new description.').optional(),
87
+ status: z.string().describe('Status: draft, active, archived.').optional(),
88
+ owner: z.string().describe('Optional sequence owner.').optional(),
89
+ items: z.array(SequenceItemInputSchema).describe('Optional replacement items array.').optional(),
90
+ tags: z.array(z.string()).describe('Optional replacement tags.').optional(),
91
+ ...CallerIdentity,
92
+ });
93
+ //# sourceMappingURL=sequence-request-schema.js.map