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