brainclaw 1.14.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 -260
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +182 -14
- package/dist/commands/mcp.js +18 -4
- package/dist/core/agent-registry.js +51 -3
- 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/io.js +6 -0
- package/dist/core/schema.js +11 -0
- package/dist/core/worktree.js +25 -4
- package/dist/facts.js +5 -5
- package/dist/facts.json +4 -4
- package/docs/mcp-schema-changelog.md +18 -2
- package/package.json +1 -1
|
@@ -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
|
package/dist/core/io.js
CHANGED
|
@@ -34,6 +34,12 @@ const ENTITY_DIR_MAP = {
|
|
|
34
34
|
'runtime': 'coordination/runtime',
|
|
35
35
|
'runtime-hosts': 'coordination/runtime-hosts',
|
|
36
36
|
'runtime-private': 'coordination/runtime-private',
|
|
37
|
+
// federation/ — outbound cloud sync queue (pln#101 Phase 2): durable outbox,
|
|
38
|
+
// archived 'sent' markers, and 'parked' dead-letters.
|
|
39
|
+
'federation': 'coordination/federation',
|
|
40
|
+
'federation/outbox': 'coordination/federation/outbox',
|
|
41
|
+
'federation/sent': 'coordination/federation/sent',
|
|
42
|
+
'federation/parked': 'coordination/federation/parked',
|
|
37
43
|
'surface-tasks': 'coordination/surface-tasks',
|
|
38
44
|
'assignments': 'coordination/assignments',
|
|
39
45
|
'runs': 'coordination/runs',
|
package/dist/core/schema.js
CHANGED
|
@@ -1079,6 +1079,17 @@ export const CloudSyncConfigSchema = z.object({
|
|
|
1079
1079
|
enabled: z.boolean().default(false),
|
|
1080
1080
|
endpoint: z.string().default('https://app.brainclaw.dev'),
|
|
1081
1081
|
api_key: z.string().optional(),
|
|
1082
|
+
/** Remote project this bridge federates into (scopes signed runtime writes). */
|
|
1083
|
+
project_id: z.string().optional(),
|
|
1084
|
+
/** Approved remote agent identity used to sign runtime writes (pln#100). */
|
|
1085
|
+
agent_id: z.string().optional(),
|
|
1086
|
+
agent_name: z.string().optional(),
|
|
1087
|
+
/**
|
|
1088
|
+
* Fail-closed toggle: when true, the bridge refuses to push a runtime write
|
|
1089
|
+
* unless it can sign it with an approved agent's Ed25519 key. Absent/false
|
|
1090
|
+
* keeps existing API-key-only setups working (signing is additive).
|
|
1091
|
+
*/
|
|
1092
|
+
require_signed: z.boolean().optional(),
|
|
1082
1093
|
});
|
|
1083
1094
|
export const SessionSnapshotSchema = z.object({
|
|
1084
1095
|
schema_version: z.number().int().positive().optional(),
|
package/dist/core/worktree.js
CHANGED
|
@@ -25,17 +25,38 @@ function gitPath(p) {
|
|
|
25
25
|
* landed on the dot before `astro`, yielding `…IntegrationHubPage.` — a trailing
|
|
26
26
|
* dot git rejects (`fatal: not a valid branch name`). Truncating first, then
|
|
27
27
|
* stripping, guarantees the cap can never re-introduce an invalid ref.
|
|
28
|
+
*
|
|
29
|
+
* trp#950 (dogfood 2026-07-15): a plain truncation makes two DISTINCT scopes
|
|
30
|
+
* that share a >48-char prefix collapse to the SAME branch → same worktree path
|
|
31
|
+
* → the second claim/assign is refused. When (and only when) the cleaned slug
|
|
32
|
+
* exceeds the cap, a deterministic 8-char digest of the FULL cleaned slug is
|
|
33
|
+
* appended so distinct scopes diverge, while the same scope stays stable
|
|
34
|
+
* (resume/re-assign still resolves its worktree). Short scopes are unchanged.
|
|
35
|
+
* 8 hex chars = 32 bits: comfortably collision-safe for the realistic case
|
|
36
|
+
* (a handful of scopes sharing a deep directory prefix) while keeping a
|
|
37
|
+
* 39-char readable head.
|
|
28
38
|
*/
|
|
39
|
+
const BRANCH_COMPONENT_CAP = 48;
|
|
29
40
|
export function sanitizeBranchComponent(raw, fallback = 'scope') {
|
|
30
|
-
|
|
41
|
+
const cleaned = raw
|
|
31
42
|
.replace(/[\s~^:?*[\]\\]/g, '-') // chars forbidden by check-ref-format
|
|
32
43
|
.replace(/@\{/g, '-') // reflog syntax
|
|
33
44
|
.replace(/\.\.+/g, '.') // no double dots
|
|
34
45
|
.replace(/[^a-zA-Z0-9._-]/g, '-') // conservative whitelist for the rest
|
|
35
46
|
.replace(/-+/g, '-') // collapse dashes
|
|
36
|
-
.replace(/^[.-]+/, '') // no leading dot/dash
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
.replace(/^[.-]+/, ''); // no leading dot/dash
|
|
48
|
+
let slug;
|
|
49
|
+
if (cleaned.length <= BRANCH_COMPONENT_CAP) {
|
|
50
|
+
slug = cleaned.replace(/[.-]+$/, ''); // no trailing dot/dash
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// Truncation drops characters → reserve room for a collision-resistant
|
|
54
|
+
// suffix derived from the full cleaned slug (trp#950). The digest is hex, so
|
|
55
|
+
// it can never re-introduce a trailing dot/dash or a `.lock` suffix.
|
|
56
|
+
const suffix = crypto.createHash('sha1').update(cleaned).digest('hex').slice(0, 8);
|
|
57
|
+
const head = cleaned.slice(0, BRANCH_COMPONENT_CAP - suffix.length - 1).replace(/[.-]+$/, '');
|
|
58
|
+
slug = `${head}-${suffix}`;
|
|
59
|
+
}
|
|
39
60
|
if (/\.lock$/i.test(slug))
|
|
40
61
|
slug = slug.slice(0, -'.lock'.length).replace(/[.-]+$/, '');
|
|
41
62
|
if (!slug)
|
package/dist/facts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
|
|
2
|
-
// Source: brainclaw v1.
|
|
2
|
+
// Source: brainclaw v1.15.0 on 2026-07-15T15:24:46.074Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-07-
|
|
4
|
+
"version": "1.15.0",
|
|
5
|
+
"generated_at": "2026-07-15T15:24:46.074Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 67,
|
|
8
8
|
"published_count": 66,
|
|
@@ -472,7 +472,7 @@ export const FACTS = {
|
|
|
472
472
|
},
|
|
473
473
|
"bench": {
|
|
474
474
|
"schema": "brainclaw.bench.v1",
|
|
475
|
-
"generated_at": "2026-07-
|
|
475
|
+
"generated_at": "2026-07-15T15:24:43.956Z",
|
|
476
476
|
"node_version": "v24.18.0",
|
|
477
477
|
"platform": "linux-x64",
|
|
478
478
|
"repeats": 3,
|
|
@@ -481,7 +481,7 @@ export const FACTS = {
|
|
|
481
481
|
"name": "cold_onboard",
|
|
482
482
|
"volume": "empty",
|
|
483
483
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
484
|
-
"duration_ms_median":
|
|
484
|
+
"duration_ms_median": 76,
|
|
485
485
|
"payload_chars_median": 1650,
|
|
486
486
|
"payload_tokens_est_median": 413
|
|
487
487
|
},
|
package/dist/facts.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"generated_at": "2026-07-
|
|
2
|
+
"version": "1.15.0",
|
|
3
|
+
"generated_at": "2026-07-15T15:24:46.074Z",
|
|
4
4
|
"tools": {
|
|
5
5
|
"count": 67,
|
|
6
6
|
"published_count": 66,
|
|
@@ -470,7 +470,7 @@
|
|
|
470
470
|
},
|
|
471
471
|
"bench": {
|
|
472
472
|
"schema": "brainclaw.bench.v1",
|
|
473
|
-
"generated_at": "2026-07-
|
|
473
|
+
"generated_at": "2026-07-15T15:24:43.956Z",
|
|
474
474
|
"node_version": "v24.18.0",
|
|
475
475
|
"platform": "linux-x64",
|
|
476
476
|
"repeats": 3,
|
|
@@ -479,7 +479,7 @@
|
|
|
479
479
|
"name": "cold_onboard",
|
|
480
480
|
"volume": "empty",
|
|
481
481
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
482
|
-
"duration_ms_median":
|
|
482
|
+
"duration_ms_median": 76,
|
|
483
483
|
"payload_chars_median": 1650,
|
|
484
484
|
"payload_tokens_est_median": 413
|
|
485
485
|
},
|
|
@@ -10,6 +10,16 @@ guarantees this changelog follows.
|
|
|
10
10
|
|
|
11
11
|
## Unreleased
|
|
12
12
|
|
|
13
|
+
**Changed — MCP model selection parity (pln#520/#606)**
|
|
14
|
+
- `bclaw_dispatch` and `bclaw_coordinate` gain an optional `model` string that
|
|
15
|
+
selects the spawned worker's model (e.g. `sonnet`, `gpt-5-codex`), decoupled
|
|
16
|
+
from agent identity — closing the CLI/MCP gap (the CLI `dispatch run --model`
|
|
17
|
+
already existed). Injected only for agents that declare a `model_flag`
|
|
18
|
+
(claude-code / codex / github-copilot); no-op otherwise, and consistent with
|
|
19
|
+
the dispatcher's resolveModel chain.
|
|
20
|
+
- No tool was removed or renamed; no required argument changed.
|
|
21
|
+
- Surface fingerprint bumped in the `(current)` section below.
|
|
22
|
+
|
|
13
23
|
**Added — `bclaw_move` cross-project relocation (pln#595)**
|
|
14
24
|
- New canonical-grammar verb `bclaw_move(entity, id, to_project, from_project?, force?)`:
|
|
15
25
|
relocates a brainclaw item to another project in a multi-project workspace,
|
|
@@ -122,8 +132,14 @@ will still succeed. A follow-up PR will strip the dead handler code.
|
|
|
122
132
|
changelog records the published MCP surface fingerprint. When a tool
|
|
123
133
|
name, tier, category, or input schema changes, the test fails until
|
|
124
134
|
this section is updated.
|
|
125
|
-
- MCP public surface fingerprint: `sha256:
|
|
126
|
-
(updated 2026-07-
|
|
135
|
+
- MCP public surface fingerprint: `sha256:b53eb56d4391b5a6`
|
|
136
|
+
(updated 2026-07-15 for pln#520/#606: optional `model` string added to
|
|
137
|
+
`bclaw_dispatch` and `bclaw_coordinate` input schemas — selects the spawned
|
|
138
|
+
worker's model, decoupled from agent identity (CLI/MCP parity with
|
|
139
|
+
`dispatch run --model`). Additive: no tool added, removed, or renamed; no
|
|
140
|
+
required argument changed.
|
|
141
|
+
Previous: `sha256:2b0dfbd62acd71b7`
|
|
142
|
+
updated 2026-07-04 for trp#928: explicit `coordinator_override` boolean added
|
|
127
143
|
to `bclaw_release_claim` and `bclaw_transition` input schemas — the coordinator
|
|
128
144
|
path to release/stale a non-owned claim is now opt-in and audited rather than
|
|
129
145
|
auto-derived from trust level. Additive: no tool added, removed, or renamed; no
|