brainclaw 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-cloud.js +121 -13
- package/dist/commands/cloud.js +534 -39
- package/dist/core/federation-emit.js +283 -0
- package/dist/core/federation-grant-transport.js +196 -0
- package/dist/core/federation-grant.js +223 -0
- package/dist/core/federation-keyring.js +39 -0
- package/dist/core/federation-opaque-ids.js +111 -0
- package/dist/core/federation-outbox-v2.js +36 -2
- package/dist/core/federation-pairing.js +87 -12
- package/dist/core/federation-pull.js +375 -0
- package/dist/core/federation-push.js +274 -0
- package/dist/core/federation-rotation.js +124 -0
- package/dist/core/federation-state.js +81 -6
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/design/federation-onboarding-usecases.md +254 -0
- package/docs/design/pairing-v3-brief.md +80 -0
- package/package.json +1 -1
package/dist/commands/cloud.js
CHANGED
|
@@ -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
|
|
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:
|
|
92
|
-
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
|
-
|
|
307
|
+
role: next.enrollment.role,
|
|
308
|
+
approved: true,
|
|
114
309
|
}, null, 2));
|
|
115
310
|
return;
|
|
116
311
|
}
|
|
117
|
-
|
|
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
|
-
*
|
|
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
|
|
150
|
-
const
|
|
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
|
-
|
|
339
|
+
reportApprovalTimeout();
|
|
153
340
|
return;
|
|
154
341
|
}
|
|
155
|
-
|
|
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
|
|
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
|