brainclaw 1.20.4 → 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/export.js +3 -3
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp-write-claims.js +162 -46
- package/dist/commands/mcp-write-entities.js +67 -0
- package/dist/commands/mcp.js +64 -1
- package/dist/commands/session-end.js +0 -102
- package/dist/commands/session-start.js +0 -23
- package/dist/commands/switch.js +41 -12
- package/dist/core/actions.js +25 -1
- package/dist/core/agent-files.js +19 -0
- package/dist/core/agentruns.js +68 -10
- package/dist/core/assignments.js +94 -19
- package/dist/core/claims.js +13 -24
- package/dist/core/config.js +58 -0
- package/dist/core/context-diff.js +28 -11
- package/dist/core/coordination.js +1 -3
- package/dist/core/entity-locator.js +404 -0
- 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/identity.js +9 -1
- package/dist/core/ids.js +5 -0
- package/dist/core/io.js +39 -1
- package/dist/core/operations/relocate.js +40 -10
- package/dist/core/schema.js +24 -17
- package/dist/core/sequence.js +47 -6
- package/dist/core/store-resolution.js +99 -26
- package/dist/core/workspace-projects.js +23 -2
- package/dist/core/worktree.js +59 -1
- package/dist/facts.js +7 -7
- package/dist/facts.json +6 -6
- package/docs/cli.md +73 -40
- 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
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
export function registerFederationCommands(program) {
|
|
2
|
-
// --- federation cloud ---
|
|
3
|
-
const federationCmd = program
|
|
4
|
-
.command('federation')
|
|
5
|
-
.description('Cloud federation — sync signals with app.brainclaw.dev');
|
|
6
|
-
federationCmd
|
|
7
|
-
.command('push <message>')
|
|
8
|
-
.description('Push a test signal to the cloud')
|
|
9
|
-
.option('--type <type>', 'Signal type', 'runtime_note')
|
|
10
|
-
.option('--to-project <project>', 'Target project name')
|
|
11
|
-
.option('--to-agent <agent>', 'Target agent name')
|
|
12
|
-
.action(async (message, options) => {
|
|
13
|
-
const { pushSignalToCloud, isCloudConfigured } = await import('../core/federation-cloud.js');
|
|
14
|
-
const { createFederationMessage } = await import('../core/federation-message.js');
|
|
15
|
-
const { loadConfig } = await import('../core/config.js');
|
|
16
|
-
const { resolveCurrentAgentName } = await import('../core/agent-registry.js');
|
|
17
|
-
if (!isCloudConfigured()) {
|
|
18
|
-
console.error('Error: cloud not configured. Set BRAINCLAW_CLOUD_API_KEY env var.');
|
|
19
|
-
process.exit(1);
|
|
20
|
-
}
|
|
21
|
-
const config = loadConfig();
|
|
22
|
-
const agent = resolveCurrentAgentName() ?? 'unknown';
|
|
23
|
-
const msg = createFederationMessage({
|
|
24
|
-
version: 1,
|
|
25
|
-
from: { project_name: config.project_name, project_path: process.cwd(), agent_name: agent },
|
|
26
|
-
to: {
|
|
27
|
-
project_name: options.toProject ?? 'broadcast',
|
|
28
|
-
project_path: '',
|
|
29
|
-
// Wire --to-agent into the message (was declared but dropped, so every
|
|
30
|
-
// push went out as a broadcast with to_agent NULL — found during the
|
|
31
|
-
// cross-machine E2E, pln#365). Omitted → undefined → broadcast, as before.
|
|
32
|
-
...(options.toAgent ? { agent_name: options.toAgent } : {}),
|
|
33
|
-
},
|
|
34
|
-
type: options.type,
|
|
35
|
-
payload: { text: message },
|
|
36
|
-
});
|
|
37
|
-
const ok = await pushSignalToCloud(msg);
|
|
38
|
-
if (ok) {
|
|
39
|
-
console.log(`✔ Signal pushed to cloud: [${msg.id}] ${message}`);
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
console.error('Error: failed to push signal to cloud.');
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
federationCmd
|
|
47
|
-
.command('pull')
|
|
48
|
-
.description('Pull signals from the cloud inbox')
|
|
49
|
-
.option('--agent <name>', 'Agent name to pull for')
|
|
50
|
-
.option('--since <date>', 'Only pull signals after this ISO date')
|
|
51
|
-
.option('--limit <n>', 'Max signals to pull', '20')
|
|
52
|
-
.action(async (options) => {
|
|
53
|
-
const { pullSignalsFromCloud, isCloudConfigured } = await import('../core/federation-cloud.js');
|
|
54
|
-
const { resolveCurrentAgentName } = await import('../core/agent-registry.js');
|
|
55
|
-
if (!isCloudConfigured()) {
|
|
56
|
-
console.error('Error: cloud not configured. Set BRAINCLAW_CLOUD_API_KEY env var.');
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
const agent = options.agent ?? resolveCurrentAgentName() ?? 'unknown';
|
|
60
|
-
const signals = await pullSignalsFromCloud(agent, {
|
|
61
|
-
since: options.since,
|
|
62
|
-
limit: parseInt(options.limit, 10),
|
|
63
|
-
});
|
|
64
|
-
if (signals.length === 0) {
|
|
65
|
-
console.log('No signals in cloud inbox.');
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
console.log(`${signals.length} signal(s) from cloud:\n`);
|
|
69
|
-
for (const s of signals) {
|
|
70
|
-
const payload = typeof s.payload === 'object' && s.payload !== null ? s.payload.text ?? JSON.stringify(s.payload) : String(s.payload);
|
|
71
|
-
console.log(` [${s.id}] ${s.type} from ${s.from.project_name}/${s.from.agent_name}`);
|
|
72
|
-
console.log(` ${String(payload).slice(0, 120)}`);
|
|
73
|
-
console.log(` ${s.created_at}\n`);
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
federationCmd
|
|
77
|
-
.command('status')
|
|
78
|
-
.description('Diagnose cloud federation: config, health, signing identity, approved agent')
|
|
79
|
-
.action(async () => {
|
|
80
|
-
const { diagnoseCloudBridge } = await import('../core/federation-cloud.js');
|
|
81
|
-
const d = await diagnoseCloudBridge();
|
|
82
|
-
const yn = (b) => (b ? 'yes' : 'no');
|
|
83
|
-
console.log(`Cloud URL: ${d.apiUrl}`);
|
|
84
|
-
console.log(`API Key: ${process.env.BRAINCLAW_CLOUD_API_KEY ? '***configured***' : (d.configured ? 'from config' : 'NOT SET')}`);
|
|
85
|
-
console.log(`Configured: ${yn(d.configured)}`);
|
|
86
|
-
console.log(`Opted-in: ${yn(d.enabled)}`);
|
|
87
|
-
console.log(`Project: ${d.projectId ?? '(none)'}`);
|
|
88
|
-
console.log(`Require signed writes: ${yn(d.requireSigned)}`);
|
|
89
|
-
if (d.health) {
|
|
90
|
-
console.log(d.health.ok
|
|
91
|
-
? `Cloud status: ${d.health.status} (v${d.health.version})`
|
|
92
|
-
: `Cloud unreachable: ${d.health.error ?? 'unknown error'}`);
|
|
93
|
-
}
|
|
94
|
-
if (d.signing.available) {
|
|
95
|
-
console.log('\nSigning identity:');
|
|
96
|
-
console.log(` Agent: ${d.signing.agentName} [${d.signing.cloudAgentId}]`);
|
|
97
|
-
console.log(` Key present: yes`);
|
|
98
|
-
console.log(` Fingerprint: ${d.signing.fingerprint.slice(0, 16)}…`);
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
console.log(`\nSigning identity: unavailable — ${d.signing.reason}`);
|
|
102
|
-
}
|
|
103
|
-
if (d.approvedAgent) {
|
|
104
|
-
console.log('\nApproved agent (cloud):');
|
|
105
|
-
if (d.approvedAgent.found) {
|
|
106
|
-
console.log(` Status: ${d.approvedAgent.status ?? '(unknown)'}`);
|
|
107
|
-
console.log(` Trust: ${d.approvedAgent.trustLevel ?? '(unknown)'}`);
|
|
108
|
-
console.log(` Key match: ${d.approvedAgent.fingerprintMatch ? 'yes ✔' : 'NO ✗ (local key does not match the registered key)'}`);
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
console.log(` Not found${d.approvedAgent.error ? ` — ${d.approvedAgent.error}` : ''}`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (d.requireSigned && !d.signing.available) {
|
|
115
|
-
console.log('\n⚠ require_signed is set but no signing identity is available — the bridge will refuse to push (fail-closed).');
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
federationCmd
|
|
119
|
-
.command('identity')
|
|
120
|
-
.description('Show this agent\'s federation signing identity (public key to approve in the cloud UI)')
|
|
121
|
-
.option('--agent <name>', 'Agent name (defaults to the current agent)')
|
|
122
|
-
.option('--json', 'Output as JSON')
|
|
123
|
-
.action(async (options) => {
|
|
124
|
-
const { resolveOrAutoRegisterAgentIdentity, ensureAgentSigningKey, resolveCurrentAgentName } = await import('../core/agent-registry.js');
|
|
125
|
-
const agentName = options.agent ?? resolveCurrentAgentName();
|
|
126
|
-
const { identity } = resolveOrAutoRegisterAgentIdentity({ agentName, cwd: process.cwd() });
|
|
127
|
-
const key = ensureAgentSigningKey(identity.agent_id);
|
|
128
|
-
if (options.json) {
|
|
129
|
-
console.log(JSON.stringify({
|
|
130
|
-
agent_name: identity.agent_name,
|
|
131
|
-
local_agent_id: identity.agent_id,
|
|
132
|
-
fingerprint: key.fingerprint,
|
|
133
|
-
public_key_pem: key.publicKeyPem,
|
|
134
|
-
}, null, 2));
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
console.log(`Agent name: ${identity.agent_name}`);
|
|
138
|
-
console.log(`Local agent id: ${identity.agent_id}`);
|
|
139
|
-
console.log(`Fingerprint: ${key.fingerprint}`);
|
|
140
|
-
console.log('\nPublic key — paste into the cloud UI (project → Agents → Register / approve an agent):\n');
|
|
141
|
-
console.log(key.publicKeyPem.trim());
|
|
142
|
-
console.log('\nThen, on this machine, configure the bridge with the cloud agent id shown after approval:');
|
|
143
|
-
console.log(` export BRAINCLAW_AGENT_NAME=${identity.agent_name}`);
|
|
144
|
-
console.log(' export BRAINCLAW_CLOUD_AGENT_ID=<agt_... returned by the UI>');
|
|
145
|
-
console.log(' (plus BRAINCLAW_CLOUD_API_KEY, BRAINCLAW_PROJECT_ID) — then run `brainclaw federation status`.');
|
|
146
|
-
});
|
|
147
|
-
federationCmd
|
|
148
|
-
.command('sync')
|
|
149
|
-
.description('Drain the federation outbox — push signed claim upserts to the cloud')
|
|
150
|
-
.option('--entity <type>', 'Entity type to sync (increment 1: claim)', 'claim')
|
|
151
|
-
.option('--limit <n>', 'Max records to push this run')
|
|
152
|
-
.option('--dry-run', 'Reconcile + list pending records without any network calls')
|
|
153
|
-
.option('--json', 'Output as JSON')
|
|
154
|
-
.action(async (options) => {
|
|
155
|
-
const outbox = await import('../core/federation-outbox.js');
|
|
156
|
-
const { pushClaimToCloud, isCloudConfigured } = await import('../core/federation-cloud.js');
|
|
157
|
-
const cwd = process.cwd();
|
|
158
|
-
const PARK_AFTER = 5;
|
|
159
|
-
const reconciled = outbox.reconcileOutbox(cwd);
|
|
160
|
-
let records = outbox.listOutboxRecords(cwd);
|
|
161
|
-
if (options.limit)
|
|
162
|
-
records = records.slice(0, parseInt(options.limit, 10));
|
|
163
|
-
const counts = { synced: 0, superseded: 0, parked: reconciled.parked, dropped: reconciled.dropped, retry: 0 };
|
|
164
|
-
const lines = [];
|
|
165
|
-
const emit = (line) => { lines.push(line); if (!options.json)
|
|
166
|
-
console.log(line); };
|
|
167
|
-
if (options.dryRun) {
|
|
168
|
-
for (const r of records)
|
|
169
|
-
emit(`pending ${r.record.entity_type} ${r.record.entity_id} r${r.record.rev} (${r.record.to_status})`);
|
|
170
|
-
const out = { dry_run: true, reconciled, pending: records.length, records: lines };
|
|
171
|
-
if (options.json)
|
|
172
|
-
console.log(JSON.stringify(out, null, 2));
|
|
173
|
-
else
|
|
174
|
-
console.log(`\npending=${records.length} reconciled_dropped=${reconciled.dropped} reconciled_parked=${reconciled.parked}`);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
if (!isCloudConfigured(cwd)) {
|
|
178
|
-
console.error('Error: cloud not configured (set BRAINCLAW_CLOUD_API_KEY or cloud_sync). Records left in outbox.');
|
|
179
|
-
process.exit(3);
|
|
180
|
-
}
|
|
181
|
-
let failClosed = false;
|
|
182
|
-
for (const r of records) {
|
|
183
|
-
const res = await pushClaimToCloud(r.record.payload, cwd);
|
|
184
|
-
const tag = `${r.record.entity_id} r${r.record.rev}`;
|
|
185
|
-
if (res.kind === 'not_configured' || res.kind === 'fail_closed') {
|
|
186
|
-
failClosed = true;
|
|
187
|
-
emit(`fail-closed ${tag} (${res.kind}) — not sent`);
|
|
188
|
-
break; // same config for all remaining records
|
|
189
|
-
}
|
|
190
|
-
if (res.kind === 'network_error') {
|
|
191
|
-
const attempts = r.record.attempts + 1;
|
|
192
|
-
if (attempts >= PARK_AFTER) {
|
|
193
|
-
outbox.parkRecord(r, `network error x${attempts}: ${res.error}`, cwd);
|
|
194
|
-
counts.parked++;
|
|
195
|
-
emit(`park ${tag} (network x${attempts}: ${res.error})`);
|
|
196
|
-
}
|
|
197
|
-
else {
|
|
198
|
-
outbox.recordAttempt(r, { http_status: null, error: res.error }, cwd);
|
|
199
|
-
counts.retry++;
|
|
200
|
-
emit(`retry ${tag} (network: ${res.error})`);
|
|
201
|
-
}
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
const { httpStatus, code } = res;
|
|
205
|
-
if (httpStatus === 200 || httpStatus === 201) {
|
|
206
|
-
outbox.archiveToSent(r, { http_status: httpStatus }, cwd);
|
|
207
|
-
counts.synced++;
|
|
208
|
-
emit(`pushed ${tag} → ${httpStatus}`);
|
|
209
|
-
}
|
|
210
|
-
else if (httpStatus === 409 && (code === 'STALE' || code === 'stale_version')) {
|
|
211
|
-
outbox.archiveToSent(r, { http_status: httpStatus }, cwd);
|
|
212
|
-
counts.superseded++;
|
|
213
|
-
emit(`superseded ${tag} → 409 ${code} (cloud has a newer rev)`);
|
|
214
|
-
}
|
|
215
|
-
else if (httpStatus === 409) {
|
|
216
|
-
outbox.parkRecord(r, `409 ${code ?? 'conflict'}`, cwd);
|
|
217
|
-
counts.parked++;
|
|
218
|
-
emit(`PARK ${tag} → 409 ${code ?? 'conflict'} (divergence — inspect)`);
|
|
219
|
-
}
|
|
220
|
-
else if (httpStatus === 403) {
|
|
221
|
-
outbox.parkRecord(r, `403 ${code ?? 'forbidden'}`, cwd);
|
|
222
|
-
counts.parked++;
|
|
223
|
-
emit(`PARK ${tag} → 403 ${code ?? 'forbidden'}`);
|
|
224
|
-
}
|
|
225
|
-
else if (httpStatus >= 500) {
|
|
226
|
-
const attempts = r.record.attempts + 1;
|
|
227
|
-
if (attempts >= PARK_AFTER) {
|
|
228
|
-
outbox.parkRecord(r, `5xx x${attempts} (last ${httpStatus})`, cwd);
|
|
229
|
-
counts.parked++;
|
|
230
|
-
emit(`park ${tag} → ${httpStatus} (x${attempts})`);
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
outbox.recordAttempt(r, { http_status: httpStatus, error: null }, cwd);
|
|
234
|
-
counts.retry++;
|
|
235
|
-
emit(`retry ${tag} → ${httpStatus}`);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
else {
|
|
239
|
-
outbox.parkRecord(r, `${httpStatus} ${code ?? 'client error'}`, cwd);
|
|
240
|
-
counts.parked++;
|
|
241
|
-
emit(`PARK ${tag} → ${httpStatus} ${code ?? ''}`.trim());
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
const summary = `synced=${counts.synced} superseded=${counts.superseded} retry=${counts.retry} parked=${counts.parked} dropped=${counts.dropped}`;
|
|
245
|
-
if (options.json)
|
|
246
|
-
console.log(JSON.stringify({ ...counts, fail_closed: failClosed, records: lines }, null, 2));
|
|
247
|
-
else
|
|
248
|
-
console.log(`\n${summary}`);
|
|
249
|
-
if (failClosed)
|
|
250
|
-
process.exit(3);
|
|
251
|
-
if (counts.parked > 0)
|
|
252
|
-
process.exit(2);
|
|
253
|
-
if (counts.retry > 0)
|
|
254
|
-
process.exit(1);
|
|
255
|
-
// exit 0
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
//# sourceMappingURL=register-federation.js.map
|
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
import { loadConfig } from './config.js';
|
|
2
|
-
import { logger } from './logger.js';
|
|
3
|
-
import { buildCloudWriteHeaders, resolveCloudSigningIdentity, } from './federation-signing.js';
|
|
4
|
-
const DEFAULT_API_URL = 'https://app.brainclaw.dev';
|
|
5
|
-
function envFlag(value) {
|
|
6
|
-
if (!value)
|
|
7
|
-
return false;
|
|
8
|
-
const v = value.trim().toLowerCase();
|
|
9
|
-
return v === '1' || v === 'true' || v === 'yes';
|
|
10
|
-
}
|
|
11
|
-
function resolveCloudConfig(cwd) {
|
|
12
|
-
const envApiUrl = process.env.BRAINCLAW_CLOUD_URL;
|
|
13
|
-
const envApiKey = process.env.BRAINCLAW_CLOUD_API_KEY;
|
|
14
|
-
const envProjectId = process.env.BRAINCLAW_PROJECT_ID;
|
|
15
|
-
const envRequireSigned = process.env.BRAINCLAW_CLOUD_REQUIRE_SIGNED;
|
|
16
|
-
let configEnabled = false;
|
|
17
|
-
let configEndpoint;
|
|
18
|
-
let configApiKey;
|
|
19
|
-
let configProjectId;
|
|
20
|
-
let configRequireSigned = false;
|
|
21
|
-
try {
|
|
22
|
-
const config = loadConfig(cwd);
|
|
23
|
-
if (config.cloud_sync) {
|
|
24
|
-
configEnabled = config.cloud_sync.enabled === true;
|
|
25
|
-
configEndpoint = config.cloud_sync.endpoint;
|
|
26
|
-
configApiKey = config.cloud_sync.api_key;
|
|
27
|
-
configProjectId = config.cloud_sync.project_id;
|
|
28
|
-
configRequireSigned = config.cloud_sync.require_signed === true;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
// No config available — fall back to env only
|
|
33
|
-
}
|
|
34
|
-
const apiKey = envApiKey ?? configApiKey;
|
|
35
|
-
if (!apiKey)
|
|
36
|
-
return undefined;
|
|
37
|
-
// Env-supplied key implies explicit opt-in; config flag is the alternative
|
|
38
|
-
const enabled = Boolean(envApiKey) || configEnabled;
|
|
39
|
-
const apiUrl = envApiUrl ?? configEndpoint ?? DEFAULT_API_URL;
|
|
40
|
-
const projectId = envProjectId?.trim() || configProjectId;
|
|
41
|
-
const requireSigned = envRequireSigned !== undefined ? envFlag(envRequireSigned) : configRequireSigned;
|
|
42
|
-
return { apiUrl, apiKey, enabled, projectId, requireSigned };
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Build the outgoing headers for a runtime write, signing when possible.
|
|
46
|
-
* Returns undefined when `require_signed` is set but no signing identity is
|
|
47
|
-
* available — the caller must NOT send the request (fail-closed).
|
|
48
|
-
*/
|
|
49
|
-
function writeHeaders(body, cloud, cwd) {
|
|
50
|
-
const signing = resolveCloudSigningIdentity(cwd);
|
|
51
|
-
return buildCloudWriteHeaders(body, {
|
|
52
|
-
apiKey: cloud.apiKey,
|
|
53
|
-
signing,
|
|
54
|
-
requireSigned: cloud.requireSigned,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
export async function pushSignalToCloud(message, cwd) {
|
|
58
|
-
const cloud = resolveCloudConfig(cwd);
|
|
59
|
-
if (!cloud) {
|
|
60
|
-
logger.debug('Cloud not configured — skipping push');
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
const body = JSON.stringify(message);
|
|
64
|
-
const headers = writeHeaders(body, cloud, cwd);
|
|
65
|
-
if (!headers) {
|
|
66
|
-
logger.warn('Cloud push refused: require_signed is set but no approved signing identity/key is available (fail-closed).');
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
try {
|
|
70
|
-
const response = await fetch(`${cloud.apiUrl}/api/v1/messages`, {
|
|
71
|
-
method: 'POST',
|
|
72
|
-
headers,
|
|
73
|
-
body,
|
|
74
|
-
});
|
|
75
|
-
if (!response.ok) {
|
|
76
|
-
logger.debug(`Cloud push failed: ${response.status} ${response.statusText}`);
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
logger.debug('Cloud push error:', err);
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
export async function pullSignalsFromCloud(agentName, options, cwd) {
|
|
87
|
-
const cloud = resolveCloudConfig(cwd);
|
|
88
|
-
if (!cloud) {
|
|
89
|
-
return [];
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
const params = new URLSearchParams();
|
|
93
|
-
if (options?.since)
|
|
94
|
-
params.set('since', options.since);
|
|
95
|
-
if (options?.limit)
|
|
96
|
-
params.set('limit', String(options.limit));
|
|
97
|
-
const url = `${cloud.apiUrl}/api/v1/inbox/${encodeURIComponent(agentName)}?${params}`;
|
|
98
|
-
const response = await fetch(url, {
|
|
99
|
-
headers: { 'X-API-Key': cloud.apiKey },
|
|
100
|
-
});
|
|
101
|
-
if (!response.ok) {
|
|
102
|
-
logger.debug(`Cloud pull failed: ${response.status}`);
|
|
103
|
-
return [];
|
|
104
|
-
}
|
|
105
|
-
const data = (await response.json());
|
|
106
|
-
return data.messages ?? [];
|
|
107
|
-
}
|
|
108
|
-
catch (err) {
|
|
109
|
-
logger.debug('Cloud pull error:', err);
|
|
110
|
-
return [];
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
export async function pushBoardToCloud(projectName, boardData, cwd) {
|
|
114
|
-
const cloud = resolveCloudConfig(cwd);
|
|
115
|
-
if (!cloud)
|
|
116
|
-
return false;
|
|
117
|
-
const body = JSON.stringify(boardData);
|
|
118
|
-
const headers = writeHeaders(body, cloud, cwd);
|
|
119
|
-
if (!headers) {
|
|
120
|
-
logger.warn('Cloud board push refused: require_signed is set but no approved signing identity/key is available (fail-closed).');
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
try {
|
|
124
|
-
const response = await fetch(`${cloud.apiUrl}/api/v1/board/${encodeURIComponent(projectName)}`, {
|
|
125
|
-
method: 'POST',
|
|
126
|
-
headers,
|
|
127
|
-
body,
|
|
128
|
-
});
|
|
129
|
-
return response.ok;
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
export async function pushClaimToCloud(payload, cwd) {
|
|
136
|
-
const cloud = resolveCloudConfig(cwd);
|
|
137
|
-
if (!cloud)
|
|
138
|
-
return { kind: 'not_configured' };
|
|
139
|
-
const body = JSON.stringify(payload);
|
|
140
|
-
const headers = writeHeaders(body, cloud, cwd);
|
|
141
|
-
if (!headers)
|
|
142
|
-
return { kind: 'fail_closed' };
|
|
143
|
-
try {
|
|
144
|
-
const response = await fetch(`${cloud.apiUrl}/api/v1/claims/${encodeURIComponent(payload.id)}`, {
|
|
145
|
-
method: 'PUT',
|
|
146
|
-
headers,
|
|
147
|
-
body,
|
|
148
|
-
});
|
|
149
|
-
let code = null;
|
|
150
|
-
try {
|
|
151
|
-
const data = (await response.json());
|
|
152
|
-
const raw = data.code ?? data.status;
|
|
153
|
-
code = typeof raw === 'string' ? raw : null;
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
// Non-JSON body — leave code null; the HTTP status still classifies it.
|
|
157
|
-
}
|
|
158
|
-
return { kind: 'response', httpStatus: response.status, code };
|
|
159
|
-
}
|
|
160
|
-
catch (err) {
|
|
161
|
-
return { kind: 'network_error', error: err.message };
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
export function isCloudConfigured(cwd) {
|
|
165
|
-
return resolveCloudConfig(cwd) !== undefined;
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Returns true when cloud sync is both configured AND explicitly opted-in.
|
|
169
|
-
* Use this gate for automatic lifecycle hooks (session-start pull, session-end push).
|
|
170
|
-
* `isCloudConfigured` alone does NOT imply opt-in — a stale config api_key without
|
|
171
|
-
* `cloud_sync.enabled=true` and without the BRAINCLAW_CLOUD_API_KEY env var stays inert.
|
|
172
|
-
*/
|
|
173
|
-
export function isCloudSyncEnabled(cwd) {
|
|
174
|
-
const cloud = resolveCloudConfig(cwd);
|
|
175
|
-
return cloud !== undefined && cloud.enabled;
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Startup diagnostics for the cloud bridge: remote health, resolved signing
|
|
179
|
-
* identity, approved-agent lookup, and a local↔remote key fingerprint match.
|
|
180
|
-
* Never throws — every probe degrades to an error field so the CLI can print a
|
|
181
|
-
* complete report.
|
|
182
|
-
*/
|
|
183
|
-
export async function diagnoseCloudBridge(cwd) {
|
|
184
|
-
const cloud = resolveCloudConfig(cwd);
|
|
185
|
-
const apiUrl = cloud?.apiUrl ?? process.env.BRAINCLAW_CLOUD_URL ?? DEFAULT_API_URL;
|
|
186
|
-
const signingIdentity = resolveCloudSigningIdentity(cwd);
|
|
187
|
-
const diag = {
|
|
188
|
-
configured: Boolean(cloud),
|
|
189
|
-
enabled: cloud?.enabled ?? false,
|
|
190
|
-
apiUrl,
|
|
191
|
-
projectId: cloud?.projectId,
|
|
192
|
-
requireSigned: cloud?.requireSigned ?? false,
|
|
193
|
-
signing: signingIdentity
|
|
194
|
-
? {
|
|
195
|
-
available: true,
|
|
196
|
-
cloudAgentId: signingIdentity.cloudAgentId,
|
|
197
|
-
agentName: signingIdentity.agentName,
|
|
198
|
-
fingerprint: signingIdentity.fingerprint,
|
|
199
|
-
}
|
|
200
|
-
: {
|
|
201
|
-
available: false,
|
|
202
|
-
reason: 'No approved agent id/name configured, or the agent has no local Ed25519 key. '
|
|
203
|
-
+ 'Register the agent and its key with the cloud first.',
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
if (!cloud)
|
|
207
|
-
return diag;
|
|
208
|
-
// Remote health
|
|
209
|
-
try {
|
|
210
|
-
const res = await fetch(`${apiUrl}/api/v1/health`);
|
|
211
|
-
const data = (await res.json());
|
|
212
|
-
diag.health = { ok: res.ok, status: String(data.status ?? ''), version: String(data.version ?? '') };
|
|
213
|
-
}
|
|
214
|
-
catch (e) {
|
|
215
|
-
diag.health = { ok: false, error: e.message };
|
|
216
|
-
}
|
|
217
|
-
// Approved-agent lookup + fingerprint match
|
|
218
|
-
if (signingIdentity) {
|
|
219
|
-
try {
|
|
220
|
-
const res = await fetch(`${apiUrl}/api/v1/agents/${encodeURIComponent(signingIdentity.cloudAgentId)}`, {
|
|
221
|
-
headers: { 'X-API-Key': cloud.apiKey },
|
|
222
|
-
});
|
|
223
|
-
if (res.ok) {
|
|
224
|
-
const data = (await res.json());
|
|
225
|
-
const agent = data.agent;
|
|
226
|
-
diag.approvedAgent = {
|
|
227
|
-
found: Boolean(agent),
|
|
228
|
-
status: agent?.status,
|
|
229
|
-
trustLevel: agent?.trust_level,
|
|
230
|
-
fingerprintMatch: agent?.key_fingerprint
|
|
231
|
-
? agent.key_fingerprint === signingIdentity.fingerprint
|
|
232
|
-
: false,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
else {
|
|
236
|
-
diag.approvedAgent = { found: false, error: `HTTP ${res.status}` };
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
catch (e) {
|
|
240
|
-
diag.approvedAgent = { found: false, error: e.message };
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return diag;
|
|
244
|
-
}
|
|
245
|
-
//# sourceMappingURL=federation-cloud.js.map
|