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,8 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import { readAuditLog } from './audit.js';
|
|
4
3
|
import { listCandidates } from './candidates.js';
|
|
5
|
-
import {
|
|
4
|
+
import { entityRecordPaths } from './io.js';
|
|
6
5
|
import { loadVersionedJsonFile } from './migration.js';
|
|
7
6
|
import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
|
|
8
7
|
import { SessionSnapshotSchema } from './schema.js';
|
|
@@ -28,17 +27,35 @@ export function resolveContextDiffSince(options) {
|
|
|
28
27
|
// everyone's diff baseline.
|
|
29
28
|
return {};
|
|
30
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* THE LAST BY-ID SITE THAT COULD ACTUALLY FIRE, and the only one with live two-layout
|
|
32
|
+
* data in the field: this store holds 173 sessions in the legacy layout next to 1019
|
|
33
|
+
* canonical ones (dec#153-T2's dual write). `resolveEntityDir(..., 'read')` answers a
|
|
34
|
+
* DIRECTORY question with a `hasContent` heuristic, so one canonical file made every
|
|
35
|
+
* legacy record invisible — the same malformed abstraction pln#649 removed from the
|
|
36
|
+
* entity locator and the by-id loaders, still here because nothing routed sessions.
|
|
37
|
+
*
|
|
38
|
+
* The consequence was a SILENT WRONG ANSWER, which is why this one was worth fixing
|
|
39
|
+
* while the sibling sites were not: an invisible snapshot falls through to the audit-log
|
|
40
|
+
* scan, and if that misses too `resolveContextDiffSince` returns no `since`, so
|
|
41
|
+
* `buildContextDiff` returns undefined and the caller is told "no changes" over a window
|
|
42
|
+
* where there were changes. An agent cannot tell that apart from a quiet period.
|
|
43
|
+
*
|
|
44
|
+
* Uses the shared primitive rather than a fourth hand-rolled pair of paths (io.ts).
|
|
45
|
+
*/
|
|
31
46
|
function loadSessionSnapshot(sessionId, cwd) {
|
|
32
|
-
const snapshotPath
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
for (const snapshotPath of entityRecordPaths('sessions', sessionId, cwd ?? process.cwd())) {
|
|
48
|
+
if (!fs.existsSync(snapshotPath))
|
|
49
|
+
continue;
|
|
50
|
+
try {
|
|
51
|
+
return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// An unparseable record in one layout must not mask a good one in the other.
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
41
57
|
}
|
|
58
|
+
return undefined;
|
|
42
59
|
}
|
|
43
60
|
export function buildContextDiff(options = {}) {
|
|
44
61
|
const resolved = resolveContextDiffSince(options);
|
|
@@ -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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#649 step 2 (dec#153) — locate the store that OWNS an entity, by id.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM THIS SOLVES. dec#153 made the entity the routing authority: for any
|
|
5
|
+
* work unit, `owner_project` is fixed at creation and every read/mutation must
|
|
6
|
+
* reach THAT store, regardless of pid, cwd, session or the shared global pointer.
|
|
7
|
+
* Step 1 persisted the owner *inside* the record — but to read that field you must
|
|
8
|
+
* first find the file, which is the very thing ambient resolution gets wrong. This
|
|
9
|
+
* module breaks that circle: it finds the record by probing a BOUNDED candidate
|
|
10
|
+
* set, so a worker holding only an `assignment_id` can be routed correctly without
|
|
11
|
+
* anything ambient being trusted.
|
|
12
|
+
*
|
|
13
|
+
* WHY A PROBE AND NOT AN INDEX. The plan allowed an index; the code does not need
|
|
14
|
+
* one, and an index would be strictly worse here:
|
|
15
|
+
* - the candidate set is already enumerable and small (the store itself, the
|
|
16
|
+
* workspace's nested children, declared cross-project links) — one `existsSync`
|
|
17
|
+
* each, and the current store is probed FIRST so the overwhelmingly common
|
|
18
|
+
* single-project case costs exactly one stat;
|
|
19
|
+
* - an index is a second source of truth that can go stale, be half-written, or
|
|
20
|
+
* disagree with the filesystem — and the whole point of dec#153 is to stop
|
|
21
|
+
* trusting a derived answer over the record itself;
|
|
22
|
+
* - `resolveEntityDir` is store-LOCAL (io.ts:79-107 — it joins memoryDir(cwd)
|
|
23
|
+
* and falls back only to a legacy path inside the SAME store), so each probe
|
|
24
|
+
* is properly isolated and cannot leak a parent store's hit.
|
|
25
|
+
* If enumeration ever becomes expensive (hundreds of projects), a cache belongs in
|
|
26
|
+
* FRONT of this function, not instead of it.
|
|
27
|
+
*
|
|
28
|
+
* AMBIGUITY IS A RESULT, NOT AN ERROR. Two stores holding the same id is exactly
|
|
29
|
+
* the divergence T3 must refuse on (step 4), so it is reported as `ambiguous` with
|
|
30
|
+
* every match rather than silently resolved by first-wins. Callers decide; this
|
|
31
|
+
* module never picks a winner it cannot justify.
|
|
32
|
+
*
|
|
33
|
+
* @module
|
|
34
|
+
*/
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { loadConfig } from './config.js';
|
|
38
|
+
import { resolveCrossProjectLinks } from './cross-project.js';
|
|
39
|
+
import { entityRecordPaths, MEMORY_DIR } from './io.js';
|
|
40
|
+
import { resolvePrimaryStore, resolveWorkspaceRoot } from './store-resolution.js';
|
|
41
|
+
import { scanNestedBrainclawProjectsDetailed, summarizeWorkspaceProjects } from './workspace-projects.js';
|
|
42
|
+
/**
|
|
43
|
+
* Depth ceiling for nested-store scans.
|
|
44
|
+
*
|
|
45
|
+
* Review P1-2 offered two remedies for the false `not_found` at depth 7: raise the
|
|
46
|
+
* ceiling, or report incompleteness. I tried raising it to 12 and MEASURED the
|
|
47
|
+
* result — a test whose resolved workspace root was a large shared directory took
|
|
48
|
+
* 130 SECONDS in enumeration alone. Trading a false `not_found` for a pathological
|
|
49
|
+
* walk on a routing path is the worse defect, and it validated this review's own
|
|
50
|
+
* warning about hidden enumeration cost. So the ceiling stays at the underlying
|
|
51
|
+
* default and the enumeration REPORTS when it stopped early (`incomplete`), which
|
|
52
|
+
* lets a caller distinguish "not there" from "I could not look that far".
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_SCAN_DEPTH = 6;
|
|
55
|
+
/** Directory key in ENTITY_DIR_MAP for each mapped entity kind. */
|
|
56
|
+
function subdirFor(entity) {
|
|
57
|
+
return entity === 'assignment' ? 'assignments'
|
|
58
|
+
: entity === 'claim' ? 'claims'
|
|
59
|
+
: entity === 'agent_run' ? 'runs'
|
|
60
|
+
: 'plans';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* EVERY file path a record with this id could occupy in one store.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately NOT `resolveEntityDir(..., 'read')` any more (review P1-1, which the
|
|
66
|
+
* reviewer reproduced): that helper picks the canonical directory as soon as it
|
|
67
|
+
* contains ANY file (`hasContent`), which answers "where do records generally
|
|
68
|
+
* live" — not "where is THIS record". A store mid-migration holding
|
|
69
|
+
* `coordination/assignments/asgn_new.json` plus a legacy `assignments/asgn_old.json`
|
|
70
|
+
* made `asgn_old` unroutable: the canonical dir had content, so the legacy path was
|
|
71
|
+
* never looked at. Choosing a directory is the wrong primitive for a per-file
|
|
72
|
+
* question, so both layouts are probed by FILE and the caller sees one hit.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* The identifier shape `JsonStore` enforces before building a record path
|
|
76
|
+
* (json-store.ts:78). Duplicated HERE rather than trusted from the caller (review
|
|
77
|
+
* P2-6): this module joins an id into a filesystem path, so an id containing `..`
|
|
78
|
+
* or a separator would escape the store it is supposed to be probing. Callers on
|
|
79
|
+
* the MCP surface validate too and return a clean input_error — this is the
|
|
80
|
+
* backstop that makes the escape impossible rather than merely unlikely.
|
|
81
|
+
*/
|
|
82
|
+
export function isLocatableId(id) {
|
|
83
|
+
return /^[A-Za-z0-9_-]+$/.test(id);
|
|
84
|
+
}
|
|
85
|
+
function recordPaths(entity, id, storeCwd) {
|
|
86
|
+
if (entity === 'loop') {
|
|
87
|
+
// Loops are not in ENTITY_DIR_MAP — their threads live under loops/threads.
|
|
88
|
+
return [path.join(storeCwd, MEMORY_DIR, 'loops', 'threads', `${id}.json`)];
|
|
89
|
+
}
|
|
90
|
+
// Shared primitive (io.ts): this module had a hand-made copy, and so did two
|
|
91
|
+
// loaders. One export, four consumers — a fourth review would otherwise have
|
|
92
|
+
// found the same defect one layer further down.
|
|
93
|
+
return entityRecordPaths(subdirFor(entity), id, storeCwd);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One candidate, all its possible layouts. A store that is mid-write,
|
|
97
|
+
* permission-denied or otherwise unreadable counts as a miss rather than an
|
|
98
|
+
* exception: routing must not depend on the health of a project the caller has
|
|
99
|
+
* nothing to do with.
|
|
100
|
+
*/
|
|
101
|
+
function recordExists(entity, id, storeCwd) {
|
|
102
|
+
for (const candidate of recordPaths(entity, id, storeCwd)) {
|
|
103
|
+
try {
|
|
104
|
+
if (fs.existsSync(candidate))
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch { /* unreadable — treat as a miss and keep looking */ }
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Canonical identity of a store path, for deduplication (review P2-3, reproduced).
|
|
113
|
+
* `path.resolve` is LEXICAL: a Windows directory junction — or a symlink, or a
|
|
114
|
+
* case-different spelling — declared as a cross-project link survived as a SECOND
|
|
115
|
+
* candidate pointing at the same physical store, and the same record was then found
|
|
116
|
+
* twice and reported `ambiguous`. That is a false positive that blocks a healthy
|
|
117
|
+
* mutation, i.e. the exact opposite of what the ambiguity contract is for.
|
|
118
|
+
*
|
|
119
|
+
* Falls back to the lexical form when the path cannot be realpath'd (missing or
|
|
120
|
+
* unreadable): a candidate we cannot canonicalise must still be probed, not dropped.
|
|
121
|
+
*/
|
|
122
|
+
function canonicalKey(abs) {
|
|
123
|
+
try {
|
|
124
|
+
const real = fs.realpathSync.native(abs);
|
|
125
|
+
// Windows paths are case-insensitive; lower-casing the KEY only (never the
|
|
126
|
+
// stored path) makes `C:\Repo` and `c:\repo` one candidate without changing
|
|
127
|
+
// what is reported back to the caller.
|
|
128
|
+
return process.platform === 'win32' ? real.toLowerCase() : real;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return process.platform === 'win32' ? abs.toLowerCase() : abs;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function describe(storeCwd) {
|
|
135
|
+
try {
|
|
136
|
+
const config = loadConfig(storeCwd);
|
|
137
|
+
return { cwd: storeCwd, project_id: config.project_id, project_name: config.project_name };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// A store we can enumerate but not read config for is still a valid location.
|
|
141
|
+
return { cwd: storeCwd };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Candidate stores, in probe order: the caller's own store first (so the common
|
|
146
|
+
* single-project case stops after one stat), then the workspace's children, then
|
|
147
|
+
* declared cross-project links. Deduplicated by resolved absolute path.
|
|
148
|
+
*
|
|
149
|
+
* Enumeration mirrors the loop project-resolution gate (loops/project-resolution.ts)
|
|
150
|
+
* on purpose — two different candidate sets for "which projects exist here" is how
|
|
151
|
+
* routing surfaces drift apart.
|
|
152
|
+
*/
|
|
153
|
+
export function enumerateCandidateStores(cwd, options = {}) {
|
|
154
|
+
return enumerateCandidates(cwd, options).stores;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Per-process memo of the CANDIDATE LIST — never of the probes.
|
|
158
|
+
*
|
|
159
|
+
* This module's own header said "a cache belongs in FRONT of this function" and
|
|
160
|
+
* nobody wrote it, which a Fable audit ranked as the second live risk of the whole
|
|
161
|
+
* routing change: `enumerateCandidates` re-walks the workspace tree on EVERY routed
|
|
162
|
+
* mutation, and a worker emits repeated PROGRESS calls for the length of a run.
|
|
163
|
+
* Measured by a reviewer on Windows: p50 3.36 ms vs 0.23 ms for a direct load (~15x
|
|
164
|
+
* per call) — and a depth-12 experiment of mine spent 130 SECONDS in enumeration
|
|
165
|
+
* alone, which is the shape of the tail.
|
|
166
|
+
*
|
|
167
|
+
* WHAT IS AND IS NOT CACHED, deliberately. Only "which stores exist here" is memoised
|
|
168
|
+
* — a filesystem-topology answer that changes when someone creates a project. The
|
|
169
|
+
* PROBES (does this store hold this id?) are never cached: that is the state a
|
|
170
|
+
* mutation is about to change, and a stale answer there would route a write to the
|
|
171
|
+
* wrong store, which is the entire class dec#153 exists to kill.
|
|
172
|
+
*
|
|
173
|
+
* ASSUMED TRADE-OFF: for up to TTL milliseconds after a store is created, it is not a
|
|
174
|
+
* candidate. Consequences are bounded and safe in both directions — a `not_found`
|
|
175
|
+
* carries `enumeration_incomplete`, and an ambiguity that appears late is caught on
|
|
176
|
+
* the next call. The TTL is deliberately short: long enough to collapse a burst of
|
|
177
|
+
* calls from one operation, far too short to matter to a human.
|
|
178
|
+
*/
|
|
179
|
+
const ENUMERATION_MEMO_TTL_MS = 2_000;
|
|
180
|
+
const enumerationMemo = new Map();
|
|
181
|
+
/**
|
|
182
|
+
* Drop the memo. Called by tests, and by `runInit` — because a store that has just come
|
|
183
|
+
* into existence is exactly what a cached candidate list cannot know about.
|
|
184
|
+
*
|
|
185
|
+
* This comment used to name that second caller before it existed (Fable audit: intent
|
|
186
|
+
* described as code). Now it does.
|
|
187
|
+
*/
|
|
188
|
+
export function clearEnumerationMemo() {
|
|
189
|
+
enumerationMemo.clear();
|
|
190
|
+
}
|
|
191
|
+
export function enumerateCandidates(cwd, options = {}) {
|
|
192
|
+
const maxDepth = options.maxDepth ?? DEFAULT_SCAN_DEPTH;
|
|
193
|
+
// Keyed on the canonical cwd AND the depth, so two callers asking different
|
|
194
|
+
// questions cannot read each other's answer.
|
|
195
|
+
const memoKey = `${canonicalKey(path.resolve(cwd))}::${maxDepth}`;
|
|
196
|
+
if (!options.noMemo) {
|
|
197
|
+
const hit = enumerationMemo.get(memoKey);
|
|
198
|
+
if (hit && Date.now() - hit.at < ENUMERATION_MEMO_TTL_MS) {
|
|
199
|
+
// Copy the array so a caller mutating its result cannot corrupt the memo.
|
|
200
|
+
return { stores: [...hit.value.stores], incomplete: hit.value.incomplete };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const computed = enumerateCandidatesUncached(cwd, maxDepth);
|
|
204
|
+
if (!options.noMemo)
|
|
205
|
+
enumerationMemo.set(memoKey, { at: Date.now(), value: computed });
|
|
206
|
+
return { stores: [...computed.stores], incomplete: computed.incomplete };
|
|
207
|
+
}
|
|
208
|
+
function enumerateCandidatesUncached(cwd, maxDepth) {
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const ordered = [];
|
|
211
|
+
let incomplete = false;
|
|
212
|
+
const add = (candidate) => {
|
|
213
|
+
if (!candidate)
|
|
214
|
+
return;
|
|
215
|
+
const abs = path.resolve(candidate);
|
|
216
|
+
// Only real stores are candidates — an unreadable path costs nothing later.
|
|
217
|
+
if (!fs.existsSync(path.join(abs, MEMORY_DIR, 'config.yaml')))
|
|
218
|
+
return;
|
|
219
|
+
// Dedup on the CANONICAL path so a junction/symlink/case alias of a store
|
|
220
|
+
// already in the list cannot become a second candidate (review P2-3).
|
|
221
|
+
const key = canonicalKey(abs);
|
|
222
|
+
if (seen.has(key))
|
|
223
|
+
return;
|
|
224
|
+
seen.add(key);
|
|
225
|
+
ordered.push(abs);
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* ONE nested scan per root, with truncation reported BY THE WALK.
|
|
229
|
+
*
|
|
230
|
+
* An earlier version inferred it from the deepest RESULT — a store found at the
|
|
231
|
+
* ceiling depth meant deeper ones might exist. Review P1-1 reproduced why that is
|
|
232
|
+
* wrong: with a store at `root/d1/…/d7` and nothing in `d1…d6`, the walk cut the
|
|
233
|
+
* branch without returning anything near the ceiling, so the heuristic reported
|
|
234
|
+
* completeness while the target sat just below the cut — and the handler emitted a
|
|
235
|
+
* CONFIDENT `not_found`. Truncation is a property of the traversal, not of what it
|
|
236
|
+
* happened to find, so the scanner now says it (still one scan, no extra I/O).
|
|
237
|
+
*/
|
|
238
|
+
const addNested = (root) => {
|
|
239
|
+
const scan = scanNestedBrainclawProjectsDetailed(root, maxDepth);
|
|
240
|
+
for (const project of scan.projects)
|
|
241
|
+
add(project.path);
|
|
242
|
+
if (scan.truncated)
|
|
243
|
+
incomplete = true;
|
|
244
|
+
};
|
|
245
|
+
const here = path.resolve(cwd);
|
|
246
|
+
add(here);
|
|
247
|
+
let config;
|
|
248
|
+
try {
|
|
249
|
+
config = loadConfig(here);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Uninitialised store: `here` was already rejected by `add`, and there is
|
|
253
|
+
// nothing to enumerate from. Returning what we have keeps callers simple.
|
|
254
|
+
return { stores: ordered, incomplete };
|
|
255
|
+
}
|
|
256
|
+
if (config.project_mode === 'multi-project') {
|
|
257
|
+
for (const project of summarizeWorkspaceProjects(here, config).discovered_projects) {
|
|
258
|
+
// `config`-sourced entries are declared namespaces without a path of their own.
|
|
259
|
+
if (project.source !== 'config')
|
|
260
|
+
add(project.path);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// A workspace root can host nested project stores; so can the workspace root
|
|
264
|
+
// ABOVE us, which is how a child project reaches its siblings.
|
|
265
|
+
const wsRoot = resolveWorkspaceRoot(here);
|
|
266
|
+
if (wsRoot) {
|
|
267
|
+
add(wsRoot);
|
|
268
|
+
addNested(wsRoot);
|
|
269
|
+
}
|
|
270
|
+
else if (resolvePrimaryStore(here)?.role === 'workspace') {
|
|
271
|
+
addNested(here);
|
|
272
|
+
}
|
|
273
|
+
// A cross-project link can point at a WORKSPACE, whose own children are then
|
|
274
|
+
// reachable by the linked side (review P1-2, reproduced: A → linked B → B/apps/C
|
|
275
|
+
// returned candidates [A, B] and not_found for a record living in C). Adding the
|
|
276
|
+
// link root alone was an arbitrary stop one level short of what the product can
|
|
277
|
+
// reach, so nested stores under each link are enumerated too.
|
|
278
|
+
for (const link of resolveCrossProjectLinks(here)) {
|
|
279
|
+
add(link.absolutePath);
|
|
280
|
+
addNested(link.absolutePath);
|
|
281
|
+
}
|
|
282
|
+
return { stores: ordered, incomplete };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Find the store holding `id`. Never throws for a missing store or unreadable
|
|
286
|
+
* config — a locator that fails loudly on an unrelated broken sibling would make
|
|
287
|
+
* routing depend on the health of projects the caller has nothing to do with.
|
|
288
|
+
*/
|
|
289
|
+
/**
|
|
290
|
+
* The owner project id shared by EVERY match, or undefined when the records do not
|
|
291
|
+
* agree on one — which includes all the ways they can fail to answer:
|
|
292
|
+
*
|
|
293
|
+
* - a record with no `project_id` (legacy, created before step 1 persisted it):
|
|
294
|
+
* step 1 decided a missing owner must NOT trigger new behaviour, so one absent
|
|
295
|
+
* field makes the whole set undecided rather than letting the others vote;
|
|
296
|
+
* - records naming DIFFERENT owners — a genuine divergence, the case the hard
|
|
297
|
+
* refusal exists for;
|
|
298
|
+
* - a record that cannot be read or parsed. NEVER throws: this module refuses to
|
|
299
|
+
* make routing depend on the health of a project the caller has nothing to do
|
|
300
|
+
* with, and an unreadable record must degrade to `ambiguous`, not to a crash.
|
|
301
|
+
*/
|
|
302
|
+
function sharedRecordOwner(entity, id, matches) {
|
|
303
|
+
let owner;
|
|
304
|
+
for (const match of matches) {
|
|
305
|
+
let recordOwner;
|
|
306
|
+
for (const candidate of recordPaths(entity, id, match.cwd)) {
|
|
307
|
+
if (!fs.existsSync(candidate))
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
const raw = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
|
|
311
|
+
const value = raw?.project_id;
|
|
312
|
+
if (typeof value === 'string' && value.trim())
|
|
313
|
+
recordOwner = value;
|
|
314
|
+
}
|
|
315
|
+
catch { /* unreadable → undecided, handled below */ }
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (!recordOwner)
|
|
319
|
+
return undefined;
|
|
320
|
+
if (owner === undefined)
|
|
321
|
+
owner = recordOwner;
|
|
322
|
+
else if (owner !== recordOwner)
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
return owner;
|
|
326
|
+
}
|
|
327
|
+
export function locateEntity(entity, id, cwd, options = {}) {
|
|
328
|
+
// An unusable id never touches the filesystem: no path is built, nothing is
|
|
329
|
+
// probed. Reported as not_found rather than thrown so an MCP handler can turn it
|
|
330
|
+
// into a clean input_error without a crash, while an internal caller that forgot
|
|
331
|
+
// to validate still cannot escape a store.
|
|
332
|
+
if (!isLocatableId(id)) {
|
|
333
|
+
return { status: 'not_found', matches: [], probed: [], enumeration_incomplete: false };
|
|
334
|
+
}
|
|
335
|
+
const enumeration = options.candidates
|
|
336
|
+
? { stores: options.candidates, incomplete: false }
|
|
337
|
+
: enumerateCandidates(cwd, { maxDepth: options.maxDepth });
|
|
338
|
+
const matches = [];
|
|
339
|
+
const probed = [];
|
|
340
|
+
// Alias-aware dedup lives HERE, not only in enumeration: a caller-supplied
|
|
341
|
+
// candidate list can contain two spellings of one physical store just as easily
|
|
342
|
+
// (found while pinning review P2-3 — the enumeration-only dedup left this path
|
|
343
|
+
// open). Probing an alias twice would report a single record as `ambiguous`.
|
|
344
|
+
const probedKeys = new Set();
|
|
345
|
+
for (const candidate of enumeration.stores) {
|
|
346
|
+
const key = canonicalKey(path.resolve(candidate));
|
|
347
|
+
if (probedKeys.has(key))
|
|
348
|
+
continue;
|
|
349
|
+
probedKeys.add(key);
|
|
350
|
+
probed.push(candidate);
|
|
351
|
+
if (recordExists(entity, id, candidate))
|
|
352
|
+
matches.push(describe(candidate));
|
|
353
|
+
}
|
|
354
|
+
const incomplete = enumeration.incomplete;
|
|
355
|
+
if (matches.length === 1) {
|
|
356
|
+
return { status: 'found', location: matches[0], matches, probed, enumeration_incomplete: incomplete };
|
|
357
|
+
}
|
|
358
|
+
if (matches.length > 1) {
|
|
359
|
+
// ── dec#155's robustness guard: ≥2 matches carrying the SAME owner route to the
|
|
360
|
+
// owner instead of refusing. Written from that sentence, not from this code.
|
|
361
|
+
//
|
|
362
|
+
// This does NOT weaken the ambiguity contract, and the distinction is the whole
|
|
363
|
+
// point of the module header: "never picks a winner it cannot justify". Here the
|
|
364
|
+
// RECORD names its owner, so the winner is justified BY THE DATA — it is not a
|
|
365
|
+
// first-wins tie-break. Every path where the data does not justify a winner
|
|
366
|
+
// stays `ambiguous`, which is what the two entity-routed surfaces refuse on.
|
|
367
|
+
//
|
|
368
|
+
// It uses the field step 1 persisted (`project_id` on assignment / agent_run /
|
|
369
|
+
// claim) and that this function has ignored ever since, and it exists to avoid
|
|
370
|
+
// REFUSING a healthy mutation when an alias or a mirror surfaces one record
|
|
371
|
+
// twice — a false refusal is the exact opposite of what the contract is for.
|
|
372
|
+
//
|
|
373
|
+
// Paid only on the ambiguous path: one read per match, in a branch that is
|
|
374
|
+
// already the rare case. The single-store hit still costs what it cost.
|
|
375
|
+
//
|
|
376
|
+
// ONE COUNTEREXAMPLE WAS RAISED AND IS EMPTY TODAY — recorded because it will not
|
|
377
|
+
// stay empty by itself. `bclaw_move` deliberately leaves a recoverable duplicate
|
|
378
|
+
// if it crashes between writing the target and removing the source, and it copies
|
|
379
|
+
// the record VERBATIM (relocate.ts) — so the residue would be two copies naming
|
|
380
|
+
// the SAME owner, which this guard would route instead of surfacing, freezing the
|
|
381
|
+
// repair signal. It cannot happen now: `plan` is the only entity both relocatable
|
|
382
|
+
// and locatable, and plan records carry NO `project_id` (checked against real
|
|
383
|
+
// records on disk, not just the schema), so `sharedRecordOwner` returns undefined
|
|
384
|
+
// and the residue stays `ambiguous`. THE DAY PLANS GAIN AN OWNER, relocate must
|
|
385
|
+
// rewrite `project_id` on the moved copy first — then the residue carries
|
|
386
|
+
// divergent owners and stays loudly ambiguous, which is the better companion fix
|
|
387
|
+
// anyway. (Fable audit; the counterexample was sound reasoning about a field the
|
|
388
|
+
// entity does not have.)
|
|
389
|
+
const owner = sharedRecordOwner(entity, id, matches);
|
|
390
|
+
if (owner) {
|
|
391
|
+
const owned = matches.filter((m) => m.project_id === owner);
|
|
392
|
+
// Exactly one match must BE the owner store. Zero means the owner is not among
|
|
393
|
+
// the stores holding the record (so routing there would name a store that does
|
|
394
|
+
// not have it); more than one means two stores claim the same project id, which
|
|
395
|
+
// is itself a divergence and not something to resolve here.
|
|
396
|
+
if (owned.length === 1) {
|
|
397
|
+
return { status: 'found', location: owned[0], matches, probed, enumeration_incomplete: incomplete };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { status: 'ambiguous', matches, probed, enumeration_incomplete: incomplete };
|
|
401
|
+
}
|
|
402
|
+
return { status: 'not_found', matches, probed, enumeration_incomplete: incomplete };
|
|
403
|
+
}
|
|
404
|
+
//# sourceMappingURL=entity-locator.js.map
|
|
@@ -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
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sérialisation canonique — fédération v2 (pln#651 étape 5, RFC §3.1).
|
|
3
|
+
*
|
|
4
|
+
* ── POURQUOI « CANONIQUE » ET PAS SIMPLEMENT `JSON.stringify` ─────────────────
|
|
5
|
+
* Ces octets sont hachés, chiffrés et signés par UN programme, puis vérifiés par UN
|
|
6
|
+
* AUTRE. Une différence d'un seul octet — un espace, un ordre de clés, un `1e3` au lieu
|
|
7
|
+
* de `1000` — fait échouer la vérification sans qu'aucun message ne dise laquelle des
|
|
8
|
+
* deux implémentations a tort.
|
|
9
|
+
*
|
|
10
|
+
* `JSON.stringify` sur un OBJET n'est déterministe que si l'ordre d'insertion l'est. Il
|
|
11
|
+
* ne l'est pas quand l'objet vient d'un `JSON.parse`, d'un spread ou d'un tri différent.
|
|
12
|
+
* D'où un sérialiseur explicite qui trie par point de code, comme l'exige le RFC.
|
|
13
|
+
*
|
|
14
|
+
* Le RFC dit aussi : « Core et Cloud partagent les vecteurs de test ; ils ne
|
|
15
|
+
* réimplémentent pas chacun une quasi-canonicalisation. » Les vecteurs vivent dans les
|
|
16
|
+
* tests des deux dépôts, sur les mêmes chaînes littérales.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
/**
|
|
20
|
+
* Trie par POINT DE CODE et non par `localeCompare`.
|
|
21
|
+
*
|
|
22
|
+
* `Array.prototype.sort()` sans comparateur trie déjà par unité de code UTF-16, ce qui
|
|
23
|
+
* diffère du point de code pour les caractères hors du plan multilingue de base. Un
|
|
24
|
+
* emoji dans un nom de clé suffirait à faire diverger deux implémentations qui croient
|
|
25
|
+
* toutes deux « trier les clés ». On compare donc explicitement les points de code.
|
|
26
|
+
*/
|
|
27
|
+
function compareCodePoints(a, b) {
|
|
28
|
+
const ai = Array.from(a);
|
|
29
|
+
const bi = Array.from(b);
|
|
30
|
+
const n = Math.min(ai.length, bi.length);
|
|
31
|
+
for (let i = 0; i < n; i++) {
|
|
32
|
+
const ca = ai[i].codePointAt(0);
|
|
33
|
+
const cb = bi[i].codePointAt(0);
|
|
34
|
+
if (ca !== cb)
|
|
35
|
+
return ca - cb;
|
|
36
|
+
}
|
|
37
|
+
return ai.length - bi.length;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* JSON canonique : clés triées, aucune espace, chaînes NFC, entiers finis sans notation
|
|
41
|
+
* exponentielle.
|
|
42
|
+
*
|
|
43
|
+
* REFUSE plutôt que d'inventer une représentation pour ce que JSON ne porte pas
|
|
44
|
+
* fidèlement : `undefined`, `NaN`, `Infinity`, fonctions, symboles, `BigInt`. Les
|
|
45
|
+
* sérialiser en `null` — ce que fait `JSON.stringify` pour certains — produirait deux
|
|
46
|
+
* objets différents avec les mêmes octets, donc une signature valide pour un contenu
|
|
47
|
+
* qu'on n'a pas signé.
|
|
48
|
+
*/
|
|
49
|
+
export function canonicalJson(value) {
|
|
50
|
+
if (value === null)
|
|
51
|
+
return 'null';
|
|
52
|
+
switch (typeof value) {
|
|
53
|
+
case 'boolean':
|
|
54
|
+
return value ? 'true' : 'false';
|
|
55
|
+
case 'number':
|
|
56
|
+
if (!Number.isFinite(value)) {
|
|
57
|
+
throw new Error(`Canonicalisation impossible : nombre non fini (${String(value)}).`);
|
|
58
|
+
}
|
|
59
|
+
// `String(1e21)` rend "1e+21". Le RFC interdit la notation exponentielle, et un
|
|
60
|
+
// vérificateur qui lirait "1e+21" produirait d'autres octets que celui qui écrit
|
|
61
|
+
// "1000000000000000000000". Refuser est plus sûr qu'une conversion approximative.
|
|
62
|
+
if (Number.isInteger(value) && Math.abs(value) >= 1e21) {
|
|
63
|
+
throw new Error(`Canonicalisation impossible : entier hors de la plage sérialisable sans exposant (${value}).`);
|
|
64
|
+
}
|
|
65
|
+
return JSON.stringify(value);
|
|
66
|
+
case 'string':
|
|
67
|
+
// NFC : « é » composé et « e + accent » combinés sont visuellement identiques et
|
|
68
|
+
// produisent des octets différents. Sans normalisation, un titre saisi sur macOS
|
|
69
|
+
// (NFD par défaut) et le même titre saisi sur Windows ne se vérifieraient pas.
|
|
70
|
+
return JSON.stringify(value.normalize('NFC'));
|
|
71
|
+
case 'object': {
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
return `[${value.map((v) => canonicalJson(v)).join(',')}]`;
|
|
74
|
+
}
|
|
75
|
+
const obj = value;
|
|
76
|
+
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(compareCodePoints);
|
|
77
|
+
return `{${keys.map((k) => `${JSON.stringify(k.normalize('NFC'))}:${canonicalJson(obj[k])}`).join(',')}}`;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
throw new Error(`Canonicalisation impossible : type '${typeof value}' non sérialisable en JSON.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** base64url sans padding — la seule forme admise par le RFC pour les champs binaires. */
|
|
84
|
+
export function b64url(bytes) {
|
|
85
|
+
return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
86
|
+
}
|
|
87
|
+
export function b64urlDecode(s) {
|
|
88
|
+
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
89
|
+
return new Uint8Array(Buffer.from(padded + '='.repeat((4 - (padded.length % 4)) % 4), 'base64'));
|
|
90
|
+
}
|
|
91
|
+
/** SHA-256 des octets canoniques d'une valeur, rendu en base64url (RFC §3.2). */
|
|
92
|
+
export function canonicalSha256(value) {
|
|
93
|
+
return b64url(new Uint8Array(crypto.createHash('sha256').update(canonicalJson(value), 'utf-8').digest()));
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=federation-canonical.js.map
|