brainclaw 1.13.0 → 1.15.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/README.md +6 -252
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +184 -15
- package/dist/commands/doctor.js +98 -0
- package/dist/commands/harvest.js +8 -2
- package/dist/commands/mcp.js +67 -6
- package/dist/commands/session-start.js +16 -1
- package/dist/core/agent-registry.js +51 -3
- package/dist/core/assignment-sweeper.js +92 -11
- package/dist/core/claims.js +18 -0
- package/dist/core/facade-schema.js +12 -0
- package/dist/core/federation-cloud.js +142 -11
- package/dist/core/federation-outbox.js +292 -0
- package/dist/core/federation-signing.js +115 -0
- package/dist/core/gc-semantic.js +79 -0
- package/dist/core/hint-aging.js +188 -0
- package/dist/core/hygiene-policy.js +77 -0
- package/dist/core/io.js +6 -0
- package/dist/core/schema.js +33 -0
- package/dist/core/worktree.js +77 -4
- package/dist/facts.js +6 -6
- package/dist/facts.json +5 -5
- package/docs/concepts/dispatch-supervisor.md +393 -0
- package/docs/mcp-schema-changelog.md +18 -2
- package/package.json +1 -1
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import { loadConfig } from './config.js';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
|
+
import { buildCloudWriteHeaders, resolveCloudSigningIdentity, } from './federation-signing.js';
|
|
3
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
|
+
}
|
|
4
11
|
function resolveCloudConfig(cwd) {
|
|
5
12
|
const envApiUrl = process.env.BRAINCLAW_CLOUD_URL;
|
|
6
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;
|
|
7
16
|
let configEnabled = false;
|
|
8
17
|
let configEndpoint;
|
|
9
18
|
let configApiKey;
|
|
19
|
+
let configProjectId;
|
|
20
|
+
let configRequireSigned = false;
|
|
10
21
|
try {
|
|
11
22
|
const config = loadConfig(cwd);
|
|
12
23
|
if (config.cloud_sync) {
|
|
13
24
|
configEnabled = config.cloud_sync.enabled === true;
|
|
14
25
|
configEndpoint = config.cloud_sync.endpoint;
|
|
15
26
|
configApiKey = config.cloud_sync.api_key;
|
|
27
|
+
configProjectId = config.cloud_sync.project_id;
|
|
28
|
+
configRequireSigned = config.cloud_sync.require_signed === true;
|
|
16
29
|
}
|
|
17
30
|
}
|
|
18
31
|
catch {
|
|
@@ -24,7 +37,22 @@ function resolveCloudConfig(cwd) {
|
|
|
24
37
|
// Env-supplied key implies explicit opt-in; config flag is the alternative
|
|
25
38
|
const enabled = Boolean(envApiKey) || configEnabled;
|
|
26
39
|
const apiUrl = envApiUrl ?? configEndpoint ?? DEFAULT_API_URL;
|
|
27
|
-
|
|
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
|
+
});
|
|
28
56
|
}
|
|
29
57
|
export async function pushSignalToCloud(message, cwd) {
|
|
30
58
|
const cloud = resolveCloudConfig(cwd);
|
|
@@ -32,14 +60,17 @@ export async function pushSignalToCloud(message, cwd) {
|
|
|
32
60
|
logger.debug('Cloud not configured — skipping push');
|
|
33
61
|
return false;
|
|
34
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
|
+
}
|
|
35
69
|
try {
|
|
36
70
|
const response = await fetch(`${cloud.apiUrl}/api/v1/messages`, {
|
|
37
71
|
method: 'POST',
|
|
38
|
-
headers
|
|
39
|
-
|
|
40
|
-
'X-API-Key': cloud.apiKey,
|
|
41
|
-
},
|
|
42
|
-
body: JSON.stringify(message),
|
|
72
|
+
headers,
|
|
73
|
+
body,
|
|
43
74
|
});
|
|
44
75
|
if (!response.ok) {
|
|
45
76
|
logger.debug(`Cloud push failed: ${response.status} ${response.statusText}`);
|
|
@@ -83,14 +114,17 @@ export async function pushBoardToCloud(projectName, boardData, cwd) {
|
|
|
83
114
|
const cloud = resolveCloudConfig(cwd);
|
|
84
115
|
if (!cloud)
|
|
85
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
|
+
}
|
|
86
123
|
try {
|
|
87
124
|
const response = await fetch(`${cloud.apiUrl}/api/v1/board/${encodeURIComponent(projectName)}`, {
|
|
88
125
|
method: 'POST',
|
|
89
|
-
headers
|
|
90
|
-
|
|
91
|
-
'X-API-Key': cloud.apiKey,
|
|
92
|
-
},
|
|
93
|
-
body: JSON.stringify(boardData),
|
|
126
|
+
headers,
|
|
127
|
+
body,
|
|
94
128
|
});
|
|
95
129
|
return response.ok;
|
|
96
130
|
}
|
|
@@ -98,6 +132,35 @@ export async function pushBoardToCloud(projectName, boardData, cwd) {
|
|
|
98
132
|
return false;
|
|
99
133
|
}
|
|
100
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
|
+
}
|
|
101
164
|
export function isCloudConfigured(cwd) {
|
|
102
165
|
return resolveCloudConfig(cwd) !== undefined;
|
|
103
166
|
}
|
|
@@ -111,4 +174,72 @@ export function isCloudSyncEnabled(cwd) {
|
|
|
111
174
|
const cloud = resolveCloudConfig(cwd);
|
|
112
175
|
return cloud !== undefined && cloud.enabled;
|
|
113
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
|
+
}
|
|
114
245
|
//# sourceMappingURL=federation-cloud.js.map
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federation outbox — durable queue for signed edge → cloud runtime writes
|
|
3
|
+
* (pln#101 Phase 2, increment 1: CLAIM). Design of record: decision dec#123.
|
|
4
|
+
*
|
|
5
|
+
* Layout under `.brainclaw/coordination/federation/`:
|
|
6
|
+
* outbox/<id>@r<rev>.json — pending, awaiting push
|
|
7
|
+
* sent/<id>@r<rev>.json — archived after a successful/superseded push (the marker)
|
|
8
|
+
* parked/<id>@r<rev>.json — dead-letter (conflict / persistent 4xx)
|
|
9
|
+
*
|
|
10
|
+
* Enqueue is DIFF-BASED at the claim persistence chokepoint (saveClaimUnlocked),
|
|
11
|
+
* which runs under the store mutation mutex, so per-claim rev reservation is
|
|
12
|
+
* serialized for free (no separate lock). Only lifecycle transitions enqueue;
|
|
13
|
+
* mid-life saves (same status) and cloud-origin materialization (suppressEnqueue)
|
|
14
|
+
* do not. All work here is local file I/O — never network.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { nowISO } from './ids.js';
|
|
22
|
+
import { resolveEntityDir } from './io.js';
|
|
23
|
+
import { isCloudSyncEnabled } from './federation-cloud.js';
|
|
24
|
+
import { logger } from './logger.js';
|
|
25
|
+
// ── Directories ─────────────────────────────────────────────────────────────
|
|
26
|
+
function federationSubdir(sub, cwd, mode) {
|
|
27
|
+
return resolveEntityDir(`federation/${sub}`, cwd ?? process.cwd(), mode);
|
|
28
|
+
}
|
|
29
|
+
function ensureDir(dir) {
|
|
30
|
+
if (!fs.existsSync(dir))
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
// ── Canonical content hash (edge is the sole computer; cloud stores+compares) ──
|
|
34
|
+
/** Deterministic JSON with recursively sorted object keys. */
|
|
35
|
+
function stableStringify(value) {
|
|
36
|
+
if (value === null || typeof value !== 'object')
|
|
37
|
+
return JSON.stringify(value);
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
40
|
+
const obj = value;
|
|
41
|
+
const keys = Object.keys(obj).sort();
|
|
42
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Canonical content hash of a claim's SEMANTIC body — excludes the rev, all
|
|
46
|
+
* volatile/local-only fields (user, model, adopted_at, enqueue metadata), and
|
|
47
|
+
* the signature timestamp. Same-rev + different hash ⇒ genuinely divergent
|
|
48
|
+
* bodies (a conflict the cloud must not silently absorb).
|
|
49
|
+
*/
|
|
50
|
+
export function claimContentHash(claim) {
|
|
51
|
+
// Identity fields (agent_id/agent_name) are excluded: they are constant over a
|
|
52
|
+
// claim's life and the local↔cloud agent-id mapping is a transport concern,
|
|
53
|
+
// not claim content. What matters for divergence detection is the lifecycle body.
|
|
54
|
+
const body = {
|
|
55
|
+
id: claim.id,
|
|
56
|
+
status: claim.status,
|
|
57
|
+
scope: claim.scope,
|
|
58
|
+
description: claim.description,
|
|
59
|
+
plan_id: claim.plan_id ?? null,
|
|
60
|
+
host_id: claim.host_id ?? null,
|
|
61
|
+
session_id: claim.session_id ?? null,
|
|
62
|
+
expires_at: claim.expires_at ?? null,
|
|
63
|
+
worktree_path: claim.worktree_path ?? null,
|
|
64
|
+
created_at: claim.created_at,
|
|
65
|
+
released_at: claim.released_at ?? null,
|
|
66
|
+
};
|
|
67
|
+
return crypto.createHash('sha256').update(stableStringify(body)).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
function buildClaimPayload(claim, rev, contentHash) {
|
|
70
|
+
return {
|
|
71
|
+
id: claim.id,
|
|
72
|
+
rev,
|
|
73
|
+
agent_name: claim.agent,
|
|
74
|
+
scope: claim.scope,
|
|
75
|
+
description: claim.description,
|
|
76
|
+
status: claim.status,
|
|
77
|
+
plan_id: claim.plan_id ?? null,
|
|
78
|
+
host_id: claim.host_id ?? null,
|
|
79
|
+
session_id: claim.session_id ?? null,
|
|
80
|
+
expires_at: claim.expires_at ?? null,
|
|
81
|
+
worktree_path: claim.worktree_path ?? null,
|
|
82
|
+
created_at: claim.created_at,
|
|
83
|
+
released_at: claim.released_at ?? null,
|
|
84
|
+
content_hash: contentHash,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// ── Rev reservation (serialized by the caller's store mutex) ───────────────────
|
|
88
|
+
const REV_RE = /@r(\d+)\.json$/;
|
|
89
|
+
function revsForId(id, dir) {
|
|
90
|
+
if (!fs.existsSync(dir))
|
|
91
|
+
return [];
|
|
92
|
+
const revs = [];
|
|
93
|
+
for (const name of fs.readdirSync(dir)) {
|
|
94
|
+
if (!name.startsWith(`${id}@r`))
|
|
95
|
+
continue;
|
|
96
|
+
const m = name.match(REV_RE);
|
|
97
|
+
if (m)
|
|
98
|
+
revs.push(parseInt(m[1], 10));
|
|
99
|
+
}
|
|
100
|
+
return revs;
|
|
101
|
+
}
|
|
102
|
+
/** Highest rev already reserved for this id across outbox + sent (0 if none). */
|
|
103
|
+
function maxRevForId(id, cwd) {
|
|
104
|
+
const all = [
|
|
105
|
+
...revsForId(id, federationSubdir('outbox', cwd, 'read')),
|
|
106
|
+
...revsForId(id, federationSubdir('sent', cwd, 'read')),
|
|
107
|
+
];
|
|
108
|
+
return all.length ? Math.max(...all) : 0;
|
|
109
|
+
}
|
|
110
|
+
function recordFilename(id, rev) {
|
|
111
|
+
return `${id}@r${rev}.json`;
|
|
112
|
+
}
|
|
113
|
+
function writeRecordAtomic(dir, filename, record) {
|
|
114
|
+
ensureDir(dir);
|
|
115
|
+
const finalPath = path.join(dir, filename);
|
|
116
|
+
const tmpPath = path.join(dir, `.${filename}.${process.pid}.tmp`);
|
|
117
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf-8');
|
|
118
|
+
fs.renameSync(tmpPath, finalPath);
|
|
119
|
+
}
|
|
120
|
+
// ── Enablement gate (memoized per cwd — resolve config once per process) ───────
|
|
121
|
+
const enabledCache = new Map();
|
|
122
|
+
function syncEnabledCached(cwd) {
|
|
123
|
+
const key = cwd ?? process.cwd();
|
|
124
|
+
const cached = enabledCache.get(key);
|
|
125
|
+
if (cached !== undefined)
|
|
126
|
+
return cached;
|
|
127
|
+
let value = false;
|
|
128
|
+
try {
|
|
129
|
+
value = isCloudSyncEnabled(cwd);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Missing/unreadable config → treat federation as disabled.
|
|
133
|
+
}
|
|
134
|
+
enabledCache.set(key, value);
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
/** Test/hook seam: drop the memoized enablement (config changed on disk). */
|
|
138
|
+
export function clearFederationEnablementCache() {
|
|
139
|
+
enabledCache.clear();
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Cheap gate for the persistence seam: is a federation enqueue possibly needed?
|
|
143
|
+
* Lets the caller skip the prev-status load entirely when not federating.
|
|
144
|
+
*/
|
|
145
|
+
export function isFederationEnqueueActive(cwd, suppress) {
|
|
146
|
+
return !suppress && syncEnabledCached(cwd);
|
|
147
|
+
}
|
|
148
|
+
// ── Enqueue (the diff-based seam entry point) ──────────────────────────────────
|
|
149
|
+
/**
|
|
150
|
+
* Enqueue a claim lifecycle transition for cloud sync, if it qualifies.
|
|
151
|
+
*
|
|
152
|
+
* Called from saveClaimUnlocked (under the store mutation mutex). Enqueues iff:
|
|
153
|
+
* cloud sync is enabled, not suppressed (cloud-origin materialization), and the
|
|
154
|
+
* save is a real transition — a create, or a status change. Mid-life saves that
|
|
155
|
+
* do not change status do NOT enqueue (dec#123). Never throws to the caller.
|
|
156
|
+
*
|
|
157
|
+
* Returns the reserved rev, or null when nothing was enqueued.
|
|
158
|
+
*/
|
|
159
|
+
export function maybeEnqueueClaimTransition(claim, prevStatus, created, cwd, suppress) {
|
|
160
|
+
try {
|
|
161
|
+
if (suppress)
|
|
162
|
+
return null;
|
|
163
|
+
if (!syncEnabledCached(cwd))
|
|
164
|
+
return null;
|
|
165
|
+
const isTransition = created || prevStatus !== claim.status;
|
|
166
|
+
if (!isTransition)
|
|
167
|
+
return null;
|
|
168
|
+
const rev = maxRevForId(claim.id, cwd) + 1;
|
|
169
|
+
const contentHash = claimContentHash(claim);
|
|
170
|
+
const record = {
|
|
171
|
+
op: 'upsert_claim',
|
|
172
|
+
entity_type: 'claim',
|
|
173
|
+
entity_id: claim.id,
|
|
174
|
+
rev,
|
|
175
|
+
from_status: prevStatus ?? null,
|
|
176
|
+
to_status: claim.status,
|
|
177
|
+
content_hash: contentHash,
|
|
178
|
+
payload: buildClaimPayload(claim, rev, contentHash),
|
|
179
|
+
enqueued_at: nowISO(),
|
|
180
|
+
attempts: 0,
|
|
181
|
+
last_status: null,
|
|
182
|
+
last_error: null,
|
|
183
|
+
last_attempt_at: null,
|
|
184
|
+
};
|
|
185
|
+
writeRecordAtomic(federationSubdir('outbox', cwd, 'write'), recordFilename(claim.id, rev), record);
|
|
186
|
+
logger.debug(`Federation outbox: enqueued claim ${claim.id}@r${rev} (${prevStatus ?? 'new'}→${claim.status})`);
|
|
187
|
+
return rev;
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
// Federation bookkeeping must never break a claim write.
|
|
191
|
+
logger.debug('Federation outbox enqueue failed (non-fatal):', err);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// ── Drain-side helpers (used by `brainclaw federation sync`) ───────────────────
|
|
196
|
+
function loadRecordsFrom(dir) {
|
|
197
|
+
if (!fs.existsSync(dir))
|
|
198
|
+
return [];
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const name of fs.readdirSync(dir)) {
|
|
201
|
+
if (!name.endsWith('.json') || name.startsWith('.'))
|
|
202
|
+
continue;
|
|
203
|
+
const filepath = path.join(dir, name);
|
|
204
|
+
try {
|
|
205
|
+
out.push({ record: JSON.parse(fs.readFileSync(filepath, 'utf-8')), filepath });
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
logger.debug(`Federation outbox: skipping unreadable record ${name}:`, err);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
export function listOutboxRecords(cwd) {
|
|
214
|
+
return loadRecordsFrom(federationSubdir('outbox', cwd, 'read'))
|
|
215
|
+
.sort((a, b) => (a.record.entity_id.localeCompare(b.record.entity_id)) || (a.record.rev - b.record.rev));
|
|
216
|
+
}
|
|
217
|
+
/** Highest rev already archived in sent/ for an id (the sync marker). 0 if none. */
|
|
218
|
+
export function sentMarkerRev(id, cwd) {
|
|
219
|
+
const revs = revsForId(id, federationSubdir('sent', cwd, 'read'));
|
|
220
|
+
return revs.length ? Math.max(...revs) : 0;
|
|
221
|
+
}
|
|
222
|
+
function removeFileIfExists(filepath) {
|
|
223
|
+
try {
|
|
224
|
+
if (fs.existsSync(filepath))
|
|
225
|
+
fs.unlinkSync(filepath);
|
|
226
|
+
}
|
|
227
|
+
catch { /* best-effort */ }
|
|
228
|
+
}
|
|
229
|
+
/** Archive a record from outbox → sent (success or superseded). */
|
|
230
|
+
export function archiveToSent(rec, meta, cwd) {
|
|
231
|
+
const sentDir = federationSubdir('sent', cwd, 'write');
|
|
232
|
+
const archived = {
|
|
233
|
+
...rec.record,
|
|
234
|
+
synced_at: nowISO(),
|
|
235
|
+
http_status: meta.http_status,
|
|
236
|
+
};
|
|
237
|
+
writeRecordAtomic(sentDir, recordFilename(rec.record.entity_id, rec.record.rev), archived);
|
|
238
|
+
removeFileIfExists(rec.filepath);
|
|
239
|
+
}
|
|
240
|
+
/** Move a record from outbox → parked (dead-letter). */
|
|
241
|
+
export function parkRecord(rec, reason, cwd) {
|
|
242
|
+
const parkedDir = federationSubdir('parked', cwd, 'write');
|
|
243
|
+
const parked = {
|
|
244
|
+
...rec.record,
|
|
245
|
+
parked_at: nowISO(),
|
|
246
|
+
parked_reason: reason,
|
|
247
|
+
};
|
|
248
|
+
writeRecordAtomic(parkedDir, recordFilename(rec.record.entity_id, rec.record.rev), parked);
|
|
249
|
+
removeFileIfExists(rec.filepath);
|
|
250
|
+
}
|
|
251
|
+
/** Persist an updated attempt count / last error on a still-pending record. */
|
|
252
|
+
export function recordAttempt(rec, meta, cwd) {
|
|
253
|
+
const updated = {
|
|
254
|
+
...rec.record,
|
|
255
|
+
attempts: rec.record.attempts + 1,
|
|
256
|
+
last_status: meta.http_status,
|
|
257
|
+
last_error: meta.error,
|
|
258
|
+
last_attempt_at: nowISO(),
|
|
259
|
+
};
|
|
260
|
+
writeRecordAtomic(federationSubdir('outbox', cwd, 'write'), recordFilename(rec.record.entity_id, rec.record.rev), updated);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Startup reconcile before a drain: for each outbox record, if sent/ already
|
|
264
|
+
* holds a rev >= this one, this record is already superseded — drop it. If the
|
|
265
|
+
* SAME rev exists in sent with a different content_hash, park it (divergence).
|
|
266
|
+
*/
|
|
267
|
+
export function reconcileOutbox(cwd) {
|
|
268
|
+
const result = { dropped: 0, parked: 0 };
|
|
269
|
+
const sentDir = federationSubdir('sent', cwd, 'read');
|
|
270
|
+
for (const rec of listOutboxRecords(cwd)) {
|
|
271
|
+
const sentRev = sentMarkerRev(rec.record.entity_id, cwd);
|
|
272
|
+
if (sentRev < rec.record.rev)
|
|
273
|
+
continue;
|
|
274
|
+
// A sent record at >= this rev exists. If the SAME rev has a different hash, park.
|
|
275
|
+
const sameRevPath = path.join(sentDir, recordFilename(rec.record.entity_id, rec.record.rev));
|
|
276
|
+
if (fs.existsSync(sameRevPath)) {
|
|
277
|
+
try {
|
|
278
|
+
const sent = JSON.parse(fs.readFileSync(sameRevPath, 'utf-8'));
|
|
279
|
+
if (sent.content_hash !== rec.record.content_hash) {
|
|
280
|
+
parkRecord(rec, 'reconcile: same rev already sent with a different content_hash', cwd);
|
|
281
|
+
result.parked++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch { /* fall through to drop */ }
|
|
286
|
+
}
|
|
287
|
+
removeFileIfExists(rec.filepath);
|
|
288
|
+
result.dropped++;
|
|
289
|
+
}
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
//# sourceMappingURL=federation-outbox.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 request signing for the Brainclaw Cloud federation bridge (pln#100).
|
|
3
|
+
*
|
|
4
|
+
* The cloud verifier (brainclaw-cloud/src/middleware/signature.ts) expects three
|
|
5
|
+
* headers on a signed runtime write:
|
|
6
|
+
* X-Agent-Id: the cloud agent id whose public_key_pem is stored in D1
|
|
7
|
+
* X-Agent-Signature: base64 Ed25519 signature over (body + timestamp)
|
|
8
|
+
* X-Agent-Timestamp: ISO-8601 timestamp (5-minute replay window)
|
|
9
|
+
*
|
|
10
|
+
* The signing key is the agent's local Ed25519 private key managed by
|
|
11
|
+
* agent-registry (~/.brainclaw/keys/<id>.ed25519.pem). Its SPKI public-key PEM
|
|
12
|
+
* is what gets registered with the cloud, and sha256(pem) is the fingerprint
|
|
13
|
+
* both sides compute — so a local↔remote fingerprint match proves the same key.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import { loadConfig } from './config.js';
|
|
19
|
+
import { loadAgentSigningKey, resolveRegisteredAgentIdentity } from './agent-registry.js';
|
|
20
|
+
import { logger } from './logger.js';
|
|
21
|
+
export const AGENT_ID_HEADER = 'X-Agent-Id';
|
|
22
|
+
export const AGENT_SIGNATURE_HEADER = 'X-Agent-Signature';
|
|
23
|
+
export const AGENT_TIMESTAMP_HEADER = 'X-Agent-Timestamp';
|
|
24
|
+
/**
|
|
25
|
+
* Sign a request body with an Ed25519 private key, producing the cloud's
|
|
26
|
+
* signature headers. The signed message is exactly `body + timestamp` (UTF-8),
|
|
27
|
+
* mirroring the verifier. Node signs Ed25519 with a null algorithm.
|
|
28
|
+
*/
|
|
29
|
+
export function signCloudBody(body, params) {
|
|
30
|
+
const timestamp = params.timestamp ?? new Date().toISOString();
|
|
31
|
+
const privateKey = crypto.createPrivateKey(params.privateKeyPem);
|
|
32
|
+
const signature = crypto.sign(null, Buffer.from(body + timestamp, 'utf-8'), privateKey);
|
|
33
|
+
return {
|
|
34
|
+
[AGENT_ID_HEADER]: params.agentId,
|
|
35
|
+
[AGENT_SIGNATURE_HEADER]: signature.toString('base64'),
|
|
36
|
+
[AGENT_TIMESTAMP_HEADER]: timestamp,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build the outgoing header set for a cloud runtime write.
|
|
41
|
+
*
|
|
42
|
+
* - Always includes Content-Type + X-API-Key.
|
|
43
|
+
* - Adds the Ed25519 signature headers when a signing identity is available.
|
|
44
|
+
* - Returns `undefined` (fail-closed) when `requireSigned` is set but no signing
|
|
45
|
+
* identity is available — the caller MUST NOT send the request in that case.
|
|
46
|
+
*
|
|
47
|
+
* Pure and dependency-injected (no filesystem/network) so it is unit-testable.
|
|
48
|
+
*/
|
|
49
|
+
export function buildCloudWriteHeaders(body, opts) {
|
|
50
|
+
const headers = {
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
'X-API-Key': opts.apiKey,
|
|
53
|
+
};
|
|
54
|
+
if (opts.signing) {
|
|
55
|
+
Object.assign(headers, signCloudBody(body, {
|
|
56
|
+
agentId: opts.signing.cloudAgentId,
|
|
57
|
+
privateKeyPem: opts.signing.privateKeyPem,
|
|
58
|
+
timestamp: opts.timestamp,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
else if (opts.requireSigned) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return headers;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the approved agent identity used to sign runtime writes.
|
|
68
|
+
*
|
|
69
|
+
* The private key is loaded from the LOCAL brainclaw identity (resolved by
|
|
70
|
+
* configured agent name / current-agent), while `cloudAgentId` (the value for
|
|
71
|
+
* X-Agent-Id) comes from the configured cloud agent id when set — the cloud
|
|
72
|
+
* assigns its own id at registration, distinct from the local identity id. When
|
|
73
|
+
* no cloud id is configured (self-hosted / id-preserving), the local id is used.
|
|
74
|
+
*
|
|
75
|
+
* Returns undefined when no agent is configured or no local Ed25519 key exists.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveCloudSigningIdentity(cwd, env = process.env) {
|
|
78
|
+
let cfgAgentId;
|
|
79
|
+
let cfgAgentName;
|
|
80
|
+
try {
|
|
81
|
+
const config = loadConfig(cwd);
|
|
82
|
+
cfgAgentId = config.cloud_sync?.agent_id;
|
|
83
|
+
cfgAgentName = config.cloud_sync?.agent_name;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// No project config — fall back to env only.
|
|
87
|
+
}
|
|
88
|
+
// X-Agent-Id must be the CLOUD agent id, which is distinct from the local
|
|
89
|
+
// identity id. BRAINCLAW_AGENT_ID is already the LOCAL id everywhere in
|
|
90
|
+
// brainclaw (current-agent resolution, etc.), so a dedicated
|
|
91
|
+
// BRAINCLAW_CLOUD_AGENT_ID override avoids sending the local id as the cloud
|
|
92
|
+
// header when a session exports BRAINCLAW_AGENT_ID (review finding, pln#100).
|
|
93
|
+
const cloudAgentId = (env.BRAINCLAW_CLOUD_AGENT_ID?.trim() || cfgAgentId || '').trim() || undefined;
|
|
94
|
+
const agentName = (env.BRAINCLAW_AGENT_NAME?.trim() || env.BRAINCLAW_AGENT?.trim() || cfgAgentName || '').trim() || undefined;
|
|
95
|
+
// Resolve the LOCAL identity backing the private key: prefer name, then fall
|
|
96
|
+
// back to a local-id match (self-hosted), then the current agent.
|
|
97
|
+
const identity = resolveRegisteredAgentIdentity({ agentName, cwd, env, allowCurrent: true, allowEnv: true }) ??
|
|
98
|
+
(cloudAgentId ? resolveRegisteredAgentIdentity({ agentId: cloudAgentId, cwd, env }) : undefined);
|
|
99
|
+
if (!identity) {
|
|
100
|
+
logger.debug('Cloud signing: no local agent identity resolved.');
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const key = loadAgentSigningKey(identity.agent_id, env);
|
|
104
|
+
if (!key) {
|
|
105
|
+
logger.debug(`Cloud signing: agent '${identity.agent_name}' has no local Ed25519 key.`);
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
cloudAgentId: cloudAgentId ?? identity.agent_id,
|
|
110
|
+
localAgentId: identity.agent_id,
|
|
111
|
+
agentName: identity.agent_name,
|
|
112
|
+
...key,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=federation-signing.js.map
|