brainclaw 1.26.0 → 1.26.1
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/commands/session-start.js +54 -3
- package/dist/core/identity.js +256 -115
- package/dist/core/io.js +165 -0
- package/dist/core/runtime.js +76 -11
- package/dist/core/store-resolution.js +5 -21
- package/dist/facts.js +6 -6
- package/dist/facts.json +5 -5
- package/package.json +1 -1
|
Binary file
|
|
@@ -2,9 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { execSync } from 'node:child_process';
|
|
5
|
-
import { isSessionSnapshotRecordFilename, memoryExists, resolveEntityDir, sessionSnapshotRecordPaths } from '../core/io.js';
|
|
5
|
+
import { assertSafeSessionId, isSessionSnapshotRecordFilename, memoryExists, resolveEntityDir, sessionSnapshotRecordPaths } from '../core/io.js';
|
|
6
6
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from '../core/migration.js';
|
|
7
|
-
import { buildOperationalIdentity, loadAllSessions, saveCurrentSession } from '../core/identity.js';
|
|
7
|
+
import { buildOperationalIdentity, describeIgnoredSessionIdEnv, loadAllSessions, saveCurrentSession } from '../core/identity.js';
|
|
8
8
|
import { requireMinimumTrustLevel, resolveCurrentModel, resolveOrAutoRegisterAgentIdentity } from '../core/agent-registry.js';
|
|
9
9
|
import { buildContext, renderContextPromptTemplate } from '../core/context.js';
|
|
10
10
|
import { writeContextMarker } from '../core/freshness.js';
|
|
@@ -35,8 +35,39 @@ function sessionSnapshotWriteDir(cwd) {
|
|
|
35
35
|
return resolveEntityDir('sessions', cwd ?? process.cwd(), 'write');
|
|
36
36
|
}
|
|
37
37
|
function sessionSnapshotPath(sessionId, cwd) {
|
|
38
|
+
// pln#672 review P1 — THE SECOND WRITER. This builder was unguarded while
|
|
39
|
+
// sessionFilePathIn / sessionSnapshotRecordPaths were hardened, so an
|
|
40
|
+
// env-controlled traversal still escaped the store HERE, before the
|
|
41
|
+
// current_session write refused it (reproduced by the reviewer:
|
|
42
|
+
// `ESCAPED.snapshot.json` landed outside, and the later throw did not undo
|
|
43
|
+
// the escaped write). A guard on some builders is not a guard.
|
|
44
|
+
assertSafeSessionId(sessionId);
|
|
38
45
|
return path.join(sessionSnapshotWriteDir(cwd), `${sessionId}.snapshot.json`);
|
|
39
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* pln#672 review P2 — the snapshot write needs the same positive proof the
|
|
49
|
+
* current_session write already has. On a case-insensitive filesystem
|
|
50
|
+
* `CaseSnapshot` and `casesnapshot` name the SAME file, so a second session
|
|
51
|
+
* silently overwrote the first one's snapshot. Refuse to replace a record
|
|
52
|
+
* that names a different session; identical-id rewrites (heartbeat, restart)
|
|
53
|
+
* stay allowed.
|
|
54
|
+
*/
|
|
55
|
+
function assertSnapshotSlotFree(filepath, sessionId) {
|
|
56
|
+
if (!fs.existsSync(filepath))
|
|
57
|
+
return;
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
|
60
|
+
if (typeof raw.session_id === 'string' && raw.session_id !== sessionId) {
|
|
61
|
+
throw new Error(`Refusing to overwrite the session_snapshot of '${raw.session_id}' at '${filepath}' with '${sessionId}' — the two ids resolve to the same filename on this filesystem`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// A deliberate refusal propagates; an unreadable record does not block a
|
|
66
|
+
// fresh write (it is not proof that another session owns the slot).
|
|
67
|
+
if (err instanceof Error && err.message.startsWith('Refusing to overwrite'))
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
40
71
|
/**
|
|
41
72
|
* pln#670 — lazy migration of pre-split snapshot records: rename `<id>.json` to
|
|
42
73
|
* `<id>.snapshot.json` in the CANONICAL sessions directory only. The legacy
|
|
@@ -145,6 +176,11 @@ export async function runSessionStart(options = {}) {
|
|
|
145
176
|
if (snapshot.stale_surfaces) {
|
|
146
177
|
console.warn(`⚠ ${snapshot.stale_surfaces.message}`);
|
|
147
178
|
}
|
|
179
|
+
// pln#672 — a dropped env session id must reach the human too: the whole
|
|
180
|
+
// point of the warning is that the session identity is NOT the one asked for.
|
|
181
|
+
if (snapshot.invalid_session_id_ignored) {
|
|
182
|
+
console.warn(`⚠ ${snapshot.invalid_session_id_ignored.message}`);
|
|
183
|
+
}
|
|
148
184
|
// Fifth instance of the computed-then-dropped class, caught by the new seam
|
|
149
185
|
// guard on its first run: built since the shared-checkout detection landed,
|
|
150
186
|
// read by nothing. Two agents editing one checkout is precisely what a human
|
|
@@ -202,7 +238,9 @@ export async function startSession(options = {}) {
|
|
|
202
238
|
const dir = sessionSnapshotWriteDir(options.cwd);
|
|
203
239
|
if (!fs.existsSync(dir))
|
|
204
240
|
fs.mkdirSync(dir, { recursive: true });
|
|
205
|
-
|
|
241
|
+
const snapshotPath = sessionSnapshotPath(snapshot.session_id, options.cwd);
|
|
242
|
+
assertSnapshotSlotFree(snapshotPath, snapshot.session_id);
|
|
243
|
+
saveVersionedJsonFile('session_snapshot', snapshotPath, SessionSnapshotSchema.parse(snapshot));
|
|
206
244
|
// Resolve git branch and worktree for session tracking
|
|
207
245
|
let currentBranch;
|
|
208
246
|
let currentWorktreePath;
|
|
@@ -371,6 +409,18 @@ export async function startSession(options = {}) {
|
|
|
371
409
|
staleSurfaces = staleSurfaceWarning(freshness, currentVersion);
|
|
372
410
|
}
|
|
373
411
|
catch { /* non-fatal */ }
|
|
412
|
+
// pln#672 — report a DROPPED env session id. Never echo the raw value: it is
|
|
413
|
+
// attacker-influenced and would land straight in logs; the variable name and
|
|
414
|
+
// its length are enough to diagnose.
|
|
415
|
+
let invalidSessionIdIgnored;
|
|
416
|
+
const droppedSessionEnv = describeIgnoredSessionIdEnv();
|
|
417
|
+
if (droppedSessionEnv) {
|
|
418
|
+
invalidSessionIdIgnored = {
|
|
419
|
+
code: 'invalid_session_id_ignored',
|
|
420
|
+
message: `${droppedSessionEnv.variable} carries a session id that cannot be a record filename (${droppedSessionEnv.length} chars) — it was ignored and this session runs under '${snapshot.session_id}'. Unset or fix the variable to resume the intended session.`,
|
|
421
|
+
data: { variable: droppedSessionEnv.variable, length: droppedSessionEnv.length, effective_session_id: snapshot.session_id },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
374
424
|
// Materialize incoming federation signals from linked projects (Phase 0 — local)
|
|
375
425
|
if (maintenanceMode === 'full') {
|
|
376
426
|
try {
|
|
@@ -406,6 +456,7 @@ export async function startSession(options = {}) {
|
|
|
406
456
|
...(staleClaimsReleased ? { stale_claims_released: staleClaimsReleased } : {}),
|
|
407
457
|
...(memoryPressure ? { memory_pressure: memoryPressure } : {}),
|
|
408
458
|
...(staleSurfaces ? { stale_surfaces: toWarningDetail(staleSurfaces) } : {}),
|
|
459
|
+
...(invalidSessionIdIgnored ? { invalid_session_id_ignored: toWarningDetail(invalidSessionIdIgnored) } : {}),
|
|
409
460
|
...(autoRegistered ? { auto_registered: true } : {}),
|
|
410
461
|
};
|
|
411
462
|
}
|
package/dist/core/identity.js
CHANGED
|
@@ -6,18 +6,19 @@ import { detectAiAgent } from './ai-agent-detection.js';
|
|
|
6
6
|
import { requireRegisteredAgentIdentity } from './agent-registry.js';
|
|
7
7
|
import { loadConfig } from './config.js';
|
|
8
8
|
import { resolveCurrentHostId } from './host.js';
|
|
9
|
-
import { isSessionSnapshotRecordFilename, memoryDir } from './io.js';
|
|
9
|
+
import { assertSafeSessionId, findSessionAnchorRoot, isSafeSessionId, isSessionSnapshotRecordFilename, memoryDir } from './io.js';
|
|
10
10
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
|
|
11
11
|
import { CurrentSessionStateSchema } from './schema.js';
|
|
12
12
|
const SESSIONS_DIR = 'sessions';
|
|
13
13
|
const LEGACY_SESSION_FILE = '.current-session';
|
|
14
14
|
// --- Public API ---
|
|
15
15
|
export function resolveCurrentSessionId(env = process.env, cwd, options = {}) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// pln#672 review P1: ONE validated resolution for the env session id. This
|
|
17
|
+
// used to re-read the variables raw, so an unsafe value bypassed the
|
|
18
|
+
// boundary here and reached startSession's snapshot write — the traversal
|
|
19
|
+
// stayed exploitable through a second writer (reproduced by the reviewer).
|
|
20
|
+
const value = resolveExplicitSessionId(env);
|
|
21
|
+
if (value) {
|
|
21
22
|
return value;
|
|
22
23
|
}
|
|
23
24
|
const agentName = options.agentName?.trim();
|
|
@@ -73,7 +74,6 @@ export function resolveEventSessionId(event) {
|
|
|
73
74
|
* Checks sessions/ directory first, falls back to legacy .current-session.
|
|
74
75
|
*/
|
|
75
76
|
export function loadCurrentSession(cwd) {
|
|
76
|
-
const dir = sessionsDir(cwd);
|
|
77
77
|
const currentUser = resolveCurrentUser();
|
|
78
78
|
const currentAgent = resolveCurrentAgentName();
|
|
79
79
|
const explicitSessionId = resolveExplicitSessionId();
|
|
@@ -83,34 +83,52 @@ export function loadCurrentSession(cwd) {
|
|
|
83
83
|
const explicit = loadSessionById(explicitSessionId, cwd);
|
|
84
84
|
return explicit && isSessionAlive(explicit, ttlMs, now) ? explicit : undefined;
|
|
85
85
|
}
|
|
86
|
-
// 1. Look in sessions
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
86
|
+
// 1. Look in the sessions read-chain (workspace anchor first, then the
|
|
87
|
+
// pre-anchor legacy location — pln#648) for the session owned by this
|
|
88
|
+
// process. Multiple parallel agents can have the same agent name/user in
|
|
89
|
+
// one repo; a live different PID is a different agent instance, not our
|
|
90
|
+
// session. Pidless legacy candidates are deduped by id: during relocation a
|
|
91
|
+
// record can transiently exist in both locations, and a duplicate must not
|
|
92
|
+
// inflate the candidate count.
|
|
93
|
+
if (currentAgent) {
|
|
94
|
+
const currentHostId = resolveCurrentHostId();
|
|
95
|
+
const legacyPidlessCandidates = new Map();
|
|
96
|
+
for (const dir of sessionsDirs(cwd)) {
|
|
97
|
+
if (!fs.existsSync(dir))
|
|
98
|
+
continue;
|
|
99
|
+
for (const file of listCurrentSessionFiles(dir)) {
|
|
100
|
+
try {
|
|
101
|
+
const session = loadSessionFile(path.join(dir, file));
|
|
102
|
+
// Strict match: agent name must match, user must match (when both are known)
|
|
103
|
+
if (session.agent !== currentAgent)
|
|
104
|
+
continue;
|
|
105
|
+
const userMatch = !session.user || !currentUser || session.user === currentUser;
|
|
106
|
+
if (!userMatch || !isSessionAlive(session, ttlMs, now))
|
|
107
|
+
continue;
|
|
108
|
+
if (session.pid === process.pid) {
|
|
109
|
+
return session;
|
|
110
|
+
}
|
|
111
|
+
// Legacy pidless adoption, HOST-GUARDED (pln#648 anchoring follow-up):
|
|
112
|
+
// anchoring parks every session of the workspace at one directory, so
|
|
113
|
+
// the historical weak adoption — agent name + user only — would now
|
|
114
|
+
// see records it never saw before, including another instance's
|
|
115
|
+
// stale intent (the exact hijack the P1-1/P1-2 review pins forbid on
|
|
116
|
+
// the resolver's added probes). A pidless record is only adoptable
|
|
117
|
+
// when it was written by THIS host; foreign-instance records need
|
|
118
|
+
// strong identity (named id or pid) everywhere.
|
|
119
|
+
if (session.pid === undefined
|
|
120
|
+
&& (!session.host_id || session.host_id === currentHostId)
|
|
121
|
+
&& !legacyPidlessCandidates.has(session.session_id)) {
|
|
122
|
+
legacyPidlessCandidates.set(session.session_id, session);
|
|
123
|
+
}
|
|
103
124
|
}
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
catch {
|
|
126
|
+
// skip invalid session files
|
|
106
127
|
}
|
|
107
128
|
}
|
|
108
|
-
catch {
|
|
109
|
-
// skip invalid session files
|
|
110
|
-
}
|
|
111
129
|
}
|
|
112
|
-
if (legacyPidlessCandidates.
|
|
113
|
-
return legacyPidlessCandidates[0];
|
|
130
|
+
if (legacyPidlessCandidates.size === 1) {
|
|
131
|
+
return [...legacyPidlessCandidates.values()][0];
|
|
114
132
|
}
|
|
115
133
|
}
|
|
116
134
|
// 2. Legacy fallback: .current-session
|
|
@@ -133,49 +151,61 @@ export function loadCurrentSession(cwd) {
|
|
|
133
151
|
* Load a specific session by ID.
|
|
134
152
|
*/
|
|
135
153
|
export function loadSessionById(sessionId, cwd) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// Reserved '.snapshot' alias id — such a current_session record can never exist.
|
|
142
|
-
return undefined;
|
|
143
|
-
}
|
|
144
|
-
if (!fs.existsSync(filepath))
|
|
145
|
-
return undefined;
|
|
146
|
-
try {
|
|
147
|
-
const migration = loadVersionedJsonFile('current_session', filepath);
|
|
148
|
-
const session = {
|
|
149
|
-
...CurrentSessionStateSchema.parse(migration.document),
|
|
150
|
-
schema_version: migration.metadata.currentVersion,
|
|
151
|
-
};
|
|
152
|
-
// The filename is only an index, never an identity (codex review): do not
|
|
153
|
-
// adopt a record whose payload names a different session.
|
|
154
|
-
return session.session_id === sessionId ? session : undefined;
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
154
|
+
// A READ answers "no such record" rather than throwing: an unsafe id
|
|
155
|
+
// (traversal — pln#672) or the reserved '.snapshot' alias (pln#670) simply
|
|
156
|
+
// cannot name a current_session record. The throwing guard stays in
|
|
157
|
+
// sessionFilePathIn for the write paths.
|
|
158
|
+
if (!isSafeSessionId(sessionId) || !isCurrentSessionFilename(sessionId))
|
|
157
159
|
return undefined;
|
|
160
|
+
// pln#648 read-chain: anchor first, then the pre-anchor legacy location. A
|
|
161
|
+
// bad record in one location must not mask a good one in the other.
|
|
162
|
+
for (const dir of sessionsDirs(cwd)) {
|
|
163
|
+
const filepath = sessionFilePathIn(dir, sessionId);
|
|
164
|
+
if (!fs.existsSync(filepath))
|
|
165
|
+
continue;
|
|
166
|
+
try {
|
|
167
|
+
const migration = loadVersionedJsonFile('current_session', filepath);
|
|
168
|
+
const session = {
|
|
169
|
+
...CurrentSessionStateSchema.parse(migration.document),
|
|
170
|
+
schema_version: migration.metadata.currentVersion,
|
|
171
|
+
};
|
|
172
|
+
// The filename is only an index, never an identity (codex review): do not
|
|
173
|
+
// adopt a record whose payload names a different session.
|
|
174
|
+
if (session.session_id === sessionId)
|
|
175
|
+
return session;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// fall through to the next location
|
|
179
|
+
}
|
|
158
180
|
}
|
|
181
|
+
return undefined;
|
|
159
182
|
}
|
|
160
183
|
/**
|
|
161
184
|
* Load ALL sessions (active + stale) from the sessions/ directory.
|
|
162
185
|
*/
|
|
163
186
|
export function loadAllSessions(cwd) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const files = listCurrentSessionFiles(dir);
|
|
187
|
+
// pln#648 read-chain: anchored records win over a transient pre-anchor copy
|
|
188
|
+
// of the same session (dedup by id, anchor scanned first).
|
|
189
|
+
const seen = new Set();
|
|
168
190
|
const sessions = [];
|
|
169
|
-
for (const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
191
|
+
for (const dir of sessionsDirs(cwd)) {
|
|
192
|
+
if (!fs.existsSync(dir))
|
|
193
|
+
continue;
|
|
194
|
+
for (const file of listCurrentSessionFiles(dir)) {
|
|
195
|
+
try {
|
|
196
|
+
const migration = loadVersionedJsonFile('current_session', path.join(dir, file));
|
|
197
|
+
const session = {
|
|
198
|
+
...CurrentSessionStateSchema.parse(migration.document),
|
|
199
|
+
schema_version: migration.metadata.currentVersion,
|
|
200
|
+
};
|
|
201
|
+
if (seen.has(session.session_id))
|
|
202
|
+
continue;
|
|
203
|
+
seen.add(session.session_id);
|
|
204
|
+
sessions.push(session);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// skip invalid
|
|
208
|
+
}
|
|
179
209
|
}
|
|
180
210
|
}
|
|
181
211
|
return sessions.sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
|
|
@@ -191,40 +221,62 @@ export function saveCurrentSession(session, cwd) {
|
|
|
191
221
|
// sessionFilePath throws on a '.snapshot' alias id — a write must never
|
|
192
222
|
// construct a snapshot filename (codex review P1).
|
|
193
223
|
const filepath = sessionFilePath(session.session_id, cwd);
|
|
194
|
-
// A plain
|
|
195
|
-
// 'read'-mode write bug parked snapshots in
|
|
196
|
-
//
|
|
197
|
-
|
|
224
|
+
// A plain `<id>.json` can still hold a pre-split snapshot (the old
|
|
225
|
+
// 'read'-mode write bug parked snapshots in session directories). Only
|
|
226
|
+
// overwrite THIS path when its record proves to be this exact
|
|
227
|
+
// current_session entry (codex review, made path-local by pln#648: the
|
|
228
|
+
// proof must be about the file being replaced, not about any location).
|
|
229
|
+
if (fs.existsSync(filepath) && !isProvenCurrentSessionAt(filepath, session.session_id)) {
|
|
198
230
|
throw new Error(`Refusing to overwrite non-current_session record at '${filepath}'`);
|
|
199
231
|
}
|
|
200
232
|
saveVersionedJsonFile('current_session', filepath, CurrentSessionStateSchema.parse(session));
|
|
233
|
+
// pln#648 relocation: a pre-anchor copy of the SAME session under the
|
|
234
|
+
// effective cwd would linger until TTL decay — remove it once the anchored
|
|
235
|
+
// write has landed, on positive proof only. Best effort: the read-chain and
|
|
236
|
+
// the GC cover any leftover. legacySessionsDir normalizes exactly like the
|
|
237
|
+
// anchor (codex review P1): a relative cwd must never make the SAME
|
|
238
|
+
// directory compare unequal — the unlink below would delete the record
|
|
239
|
+
// this function just wrote.
|
|
240
|
+
const legacyDir = legacySessionsDir(cwd);
|
|
241
|
+
if (legacyDir !== dir) {
|
|
242
|
+
try {
|
|
243
|
+
const legacyPath = sessionFilePathIn(legacyDir, session.session_id);
|
|
244
|
+
if (fs.existsSync(legacyPath) && isProvenCurrentSessionAt(legacyPath, session.session_id)) {
|
|
245
|
+
fs.unlinkSync(legacyPath);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch { /* best effort */ }
|
|
249
|
+
}
|
|
201
250
|
}
|
|
202
251
|
/**
|
|
203
252
|
* Clear a session. If sessionId is provided, only clear that specific session.
|
|
204
253
|
*/
|
|
205
254
|
export function clearCurrentSession(cwd, sessionId) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
255
|
+
// A filename is never enough authority to delete a record (codex review P1):
|
|
256
|
+
// the '.snapshot' alias id throws in sessionFilePathIn (caught → no-op), and
|
|
257
|
+
// a file is only unlinked when it PROVES to be this exact current_session
|
|
258
|
+
// record — never a pre-split snapshot parked under a plain `<id>.json`.
|
|
259
|
+
// pln#648: the record can live at the anchor OR at the pre-anchor legacy
|
|
260
|
+
// location — clear wherever it proves.
|
|
261
|
+
const unlinkProven = (id) => {
|
|
262
|
+
for (const dir of sessionsDirs(cwd)) {
|
|
263
|
+
try {
|
|
264
|
+
const filepath = sessionFilePathIn(dir, id);
|
|
265
|
+
if (fs.existsSync(filepath) && isProvenCurrentSessionAt(filepath, id)) {
|
|
266
|
+
fs.unlinkSync(filepath);
|
|
267
|
+
}
|
|
215
268
|
}
|
|
269
|
+
catch { /* ignore */ }
|
|
216
270
|
}
|
|
217
|
-
|
|
271
|
+
};
|
|
272
|
+
if (sessionId) {
|
|
273
|
+
unlinkProven(sessionId);
|
|
218
274
|
return;
|
|
219
275
|
}
|
|
220
276
|
// Clear the session for the current agent+user
|
|
221
277
|
const session = loadCurrentSession(cwd);
|
|
222
278
|
if (session) {
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
fs.unlinkSync(filepath);
|
|
226
|
-
}
|
|
227
|
-
catch { /* ignore */ }
|
|
279
|
+
unlinkProven(session.session_id);
|
|
228
280
|
}
|
|
229
281
|
// Also clean legacy file
|
|
230
282
|
const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
|
|
@@ -238,43 +290,95 @@ export function clearCurrentSession(cwd, sessionId) {
|
|
|
238
290
|
* Returns the number of sessions removed.
|
|
239
291
|
*/
|
|
240
292
|
export function gcStaleSessions(cwd, ttlOverride) {
|
|
241
|
-
const dir = sessionsDir(cwd);
|
|
242
|
-
if (!fs.existsSync(dir))
|
|
243
|
-
return 0;
|
|
244
293
|
const ttlMs = parseDurationToMs(ttlOverride ?? loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
|
|
245
294
|
const now = Date.now();
|
|
246
295
|
let removed = 0;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
296
|
+
// pln#648: sweep the whole read-chain — pre-anchor legacy records are
|
|
297
|
+
// exactly what this GC must decay.
|
|
298
|
+
for (const dir of sessionsDirs(cwd)) {
|
|
299
|
+
if (!fs.existsSync(dir))
|
|
300
|
+
continue;
|
|
301
|
+
for (const file of listCurrentSessionFiles(dir)) {
|
|
302
|
+
const filepath = path.join(dir, file);
|
|
303
|
+
try {
|
|
304
|
+
// POSITIVE proof before deletion (codex review): a bare `<id>.json` in
|
|
305
|
+
// this directory can still be a pre-split snapshot (old 'read'-mode
|
|
306
|
+
// write bug). Only a record carrying the current_session discriminant
|
|
307
|
+
// may be collected; anything unidentifiable is preserved, never deleted.
|
|
308
|
+
const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
|
309
|
+
if (typeof raw.last_seen_at !== 'string')
|
|
310
|
+
continue;
|
|
311
|
+
const migration = loadVersionedJsonFile('current_session', filepath);
|
|
312
|
+
const session = {
|
|
313
|
+
...CurrentSessionStateSchema.parse(migration.document),
|
|
314
|
+
schema_version: migration.metadata.currentVersion,
|
|
315
|
+
};
|
|
316
|
+
if (now - Date.parse(session.last_seen_at) > ttlMs) {
|
|
317
|
+
fs.unlinkSync(filepath);
|
|
318
|
+
removed++;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// An unidentifiable record is not proven stale current_session state —
|
|
323
|
+
// preserving it beats risking the deletion of a legacy snapshot.
|
|
266
324
|
}
|
|
267
|
-
}
|
|
268
|
-
catch {
|
|
269
|
-
// An unidentifiable record is not proven stale current_session state —
|
|
270
|
-
// preserving it beats risking the deletion of a legacy snapshot.
|
|
271
325
|
}
|
|
272
326
|
}
|
|
273
327
|
return removed;
|
|
274
328
|
}
|
|
275
329
|
// --- Internal helpers ---
|
|
330
|
+
/**
|
|
331
|
+
* pln#648 (a) — the session record must live at a STABLE, workspace-unique
|
|
332
|
+
* location. Anchored on the effective cwd, the record landed under the store
|
|
333
|
+
* of the project being LEFT at session-start, and every switch moved the
|
|
334
|
+
* truth out of the resolver's reach (the reproduced P0: status said api,
|
|
335
|
+
* writes went to web). The anchor is the outermost .brainclaw/ above cwd —
|
|
336
|
+
* a pure filesystem answer, independent of active-project state, so every
|
|
337
|
+
* probe of the same workspace derives the SAME directory.
|
|
338
|
+
*/
|
|
339
|
+
function sessionAnchorCwd(cwd) {
|
|
340
|
+
// path.resolve BEFORE anchoring (codex review P1): the anchor and the legacy
|
|
341
|
+
// location below are compared for equality — a relative cwd ('.') must not
|
|
342
|
+
// make the SAME directory look like two, or the relocation would unlink the
|
|
343
|
+
// record it just wrote. Role-aware walk: the nearest declared workspace wins,
|
|
344
|
+
// so sibling workspaces under a parent store stay isolated (review P1 #2).
|
|
345
|
+
const base = path.resolve(cwd ?? process.cwd());
|
|
346
|
+
return findSessionAnchorRoot(base) ?? base;
|
|
347
|
+
}
|
|
348
|
+
/** The write + primary read location for current_session records. */
|
|
276
349
|
function sessionsDir(cwd) {
|
|
277
|
-
return path.join(memoryDir(cwd), SESSIONS_DIR);
|
|
350
|
+
return path.join(memoryDir(sessionAnchorCwd(cwd)), SESSIONS_DIR);
|
|
351
|
+
}
|
|
352
|
+
/** The pre-anchor legacy location, NORMALIZED the same way as the anchor. */
|
|
353
|
+
function legacySessionsDir(cwd) {
|
|
354
|
+
return path.join(memoryDir(path.resolve(cwd ?? process.cwd())), SESSIONS_DIR);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Read-chain (pln#648 migration): anchor first, then the pre-anchor location
|
|
358
|
+
* under the effective cwd where existing records still live. Sessions expire
|
|
359
|
+
* within the implicit TTL (4h), so the legacy probe decays naturally — no
|
|
360
|
+
* rewrite migration; saveCurrentSession relocates its own record on the next
|
|
361
|
+
* heartbeat and the GC sweeps both. Deduped when both resolve to the same
|
|
362
|
+
* directory (single-project stores — the common case).
|
|
363
|
+
*/
|
|
364
|
+
function sessionsDirs(cwd) {
|
|
365
|
+
const anchored = sessionsDir(cwd);
|
|
366
|
+
const legacy = legacySessionsDir(cwd);
|
|
367
|
+
return anchored === legacy ? [anchored] : [anchored, legacy];
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Positive proof that the file at `filepath` is THE current_session record for
|
|
371
|
+
* `sessionId` (pln#670 discipline: a filename is never authority to delete or
|
|
372
|
+
* overwrite — a plain `<id>.json` can be a pre-split snapshot).
|
|
373
|
+
*/
|
|
374
|
+
function isProvenCurrentSessionAt(filepath, sessionId) {
|
|
375
|
+
try {
|
|
376
|
+
const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
|
377
|
+
return typeof raw.last_seen_at === 'string' && raw.session_id === sessionId;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
278
382
|
}
|
|
279
383
|
/**
|
|
280
384
|
* pln#670 — current_session scanners must be type-strict: session_snapshot
|
|
@@ -294,7 +398,11 @@ function listCurrentSessionFiles(dir) {
|
|
|
294
398
|
function isCurrentSessionFilename(sessionId) {
|
|
295
399
|
return !isSessionSnapshotRecordFilename(`${sessionId}.json`);
|
|
296
400
|
}
|
|
297
|
-
function
|
|
401
|
+
function sessionFilePathIn(dir, sessionId) {
|
|
402
|
+
// pln#672 — PATH SAFETY first: the id is env-controlled and becomes a
|
|
403
|
+
// filename, so a traversal ('../../evil') must never build a path. This is
|
|
404
|
+
// the choke point for save / load / clear alike.
|
|
405
|
+
assertSafeSessionId(sessionId);
|
|
298
406
|
// pln#670 review fix (codex P1): a session id ending in ".snapshot" would
|
|
299
407
|
// produce `<base>.snapshot.json` — the snapshot filename of session <base>
|
|
300
408
|
// in a shared directory. Refuse the alias instead of silently colliding
|
|
@@ -302,7 +410,10 @@ function sessionFilePath(sessionId, cwd) {
|
|
|
302
410
|
if (!isCurrentSessionFilename(sessionId)) {
|
|
303
411
|
throw new Error(`session id '${sessionId}' is reserved for session_snapshot records — the '.snapshot' suffix would collide across record types`);
|
|
304
412
|
}
|
|
305
|
-
return path.join(
|
|
413
|
+
return path.join(dir, `${sessionId}.json`);
|
|
414
|
+
}
|
|
415
|
+
function sessionFilePath(sessionId, cwd) {
|
|
416
|
+
return sessionFilePathIn(sessionsDir(cwd), sessionId);
|
|
306
417
|
}
|
|
307
418
|
function resolveCurrentUser() {
|
|
308
419
|
return process.env.USER || process.env.USERNAME || os.userInfo().username || undefined;
|
|
@@ -321,11 +432,41 @@ function resolveCurrentAgentName() {
|
|
|
321
432
|
* resolution from a store the agent never named.
|
|
322
433
|
*/
|
|
323
434
|
export function resolveExplicitSessionId(env = process.env) {
|
|
324
|
-
|
|
435
|
+
const named = env.BRAINCLAW_SESSION_ID?.trim()
|
|
325
436
|
|| env.OPENCLAW_SESSION_ID?.trim()
|
|
326
437
|
|| env.CLAUDE_SESSION_ID?.trim()
|
|
327
438
|
|| env.COPILOT_SESSION_ID?.trim()
|
|
328
439
|
|| undefined;
|
|
440
|
+
// pln#672 — this value becomes a FILENAME (`<id>.json`). An id that cannot
|
|
441
|
+
// safely do so is IGNORED, not honoured: falling back to the implicit
|
|
442
|
+
// session is safe, while using it walks out of the store (reproduced on
|
|
443
|
+
// disk with '../../../ESCAPED'). Refusing to run at all would be worse —
|
|
444
|
+
// a stale exported variable would break every command in the shell — so
|
|
445
|
+
// the boundary drops the value here and the filename builders below refuse
|
|
446
|
+
// loudly for any other caller. The drop is NOT silent: session
|
|
447
|
+
// establishment surfaces it (see describeIgnoredSessionIdEnv).
|
|
448
|
+
return named && isSafeSessionId(named) ? named : undefined;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Name the env variable whose session id was DROPPED as unsafe, if any
|
|
452
|
+
* (pln#672 review — the availability-first fallback must not silently change
|
|
453
|
+
* the agent's identity: continuing under an implicit session while the caller
|
|
454
|
+
* believes it resumed the supplied one is exactly the kind of silent
|
|
455
|
+
* divergence this project refuses).
|
|
456
|
+
*
|
|
457
|
+
* Returns the VARIABLE NAME and a non-sensitive reason only — never the raw
|
|
458
|
+
* value, which is attacker-influenced and would land in logs.
|
|
459
|
+
*/
|
|
460
|
+
export function describeIgnoredSessionIdEnv(env = process.env) {
|
|
461
|
+
for (const variable of ['BRAINCLAW_SESSION_ID', 'OPENCLAW_SESSION_ID', 'CLAUDE_SESSION_ID', 'COPILOT_SESSION_ID']) {
|
|
462
|
+
const raw = env[variable]?.trim();
|
|
463
|
+
if (!raw)
|
|
464
|
+
continue;
|
|
465
|
+
// The first variable that carries a value decides — same precedence as
|
|
466
|
+
// resolveExplicitSessionId, so the report names the one actually used.
|
|
467
|
+
return isSafeSessionId(raw) ? undefined : { variable, length: raw.length };
|
|
468
|
+
}
|
|
469
|
+
return undefined;
|
|
329
470
|
}
|
|
330
471
|
function loadSessionFile(filepath) {
|
|
331
472
|
const migration = loadVersionedJsonFile('current_session', filepath);
|
package/dist/core/io.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { withLock, cleanStaleLocks } from './lock.js';
|
|
4
6
|
export { mutate } from './mutation-pipeline.js';
|
|
@@ -121,6 +123,90 @@ export function entityRecordDirs(subdir, cwd = process.cwd(), preferredDirName)
|
|
|
121
123
|
export function entityRecordPaths(subdir, id, cwd, preferredDirName) {
|
|
122
124
|
return entityRecordDirs(subdir, cwd ?? process.cwd(), preferredDirName).map((d) => path.join(d, `${id}.json`));
|
|
123
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The id grammar every session record filename is built from (pln#672).
|
|
128
|
+
*
|
|
129
|
+
* A session id arrives from the ENVIRONMENT (BRAINCLAW_SESSION_ID and the
|
|
130
|
+
* per-agent variants read by resolveExplicitSessionId) and is interpolated
|
|
131
|
+
* straight into a filename: `<id>.json` / `<id>.snapshot.json`. Unvalidated,
|
|
132
|
+
* `../../../ESCAPED` walks out of the store — reproduced on disk on
|
|
133
|
+
* 2026-08-18: saveCurrentSession wrote outside the store root, and the same
|
|
134
|
+
* path feeds loadSessionById (read) and clearCurrentSession (unlink).
|
|
135
|
+
*
|
|
136
|
+
* Allowed: a leading alphanumeric, then alphanumerics, `.`, `_`, `-`, up to
|
|
137
|
+
* 128 chars — covers brainclaw's own `sess_<hex>` and the UUID-shaped ids
|
|
138
|
+
* some agents export. Refused by construction: path separators, `..`, drive
|
|
139
|
+
* letters and absolute paths, empty ids, and any id starting with a dot.
|
|
140
|
+
* The `.snapshot` suffix stays separately refused for current_session ids
|
|
141
|
+
* (pln#670) — that is a type-collision rule, not a path-safety one.
|
|
142
|
+
*/
|
|
143
|
+
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
144
|
+
/**
|
|
145
|
+
* Win32 device namespace (pln#672 review P2, reproduced on a Windows host):
|
|
146
|
+
* `CON`, `NUL`, `COM1`… are not directory entries — `CON.json` opens the
|
|
147
|
+
* console device, `stat` reports a file, and the sessions directory stays
|
|
148
|
+
* empty. A record "written" there is silently lost. The reservation applies
|
|
149
|
+
* to the basename BEFORE the first dot, case-insensitively, so `con.json`
|
|
150
|
+
* and `Con.anything` are covered too. Refused on every platform: the grammar
|
|
151
|
+
* is shared, and an id must mean the same thing on all of them.
|
|
152
|
+
*/
|
|
153
|
+
const WIN32_RESERVED_BASENAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
154
|
+
export function isSafeSessionId(sessionId) {
|
|
155
|
+
if (!SAFE_SESSION_ID_RE.test(sessionId))
|
|
156
|
+
return false;
|
|
157
|
+
return !WIN32_RESERVED_BASENAME_RE.test(sessionId.split('.')[0]);
|
|
158
|
+
}
|
|
159
|
+
/** Throwing variant for the filename builders — a traversal must be loud, never silent. */
|
|
160
|
+
export function assertSafeSessionId(sessionId) {
|
|
161
|
+
if (!isSafeSessionId(sessionId)) {
|
|
162
|
+
throw new Error(`session id '${sessionId}' is not a valid record identifier: only [A-Za-z0-9._-] (starting with an alphanumeric, max 128 chars) may become a session filename`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Normalize an agent name into ONE filesystem path segment (pln#673).
|
|
167
|
+
*
|
|
168
|
+
* The agent name arrives from the environment (BRAINCLAW_AGENT_NAME, read by
|
|
169
|
+
* resolveCurrentAgentName) and became a DIRECTORY name unvalidated: proved on
|
|
170
|
+
* disk on 2026-08-18 that `'../../../../outside/PWNED'` made saveRuntimeNote
|
|
171
|
+
* create the directory and write the note ENTIRELY OUTSIDE the store.
|
|
172
|
+
*
|
|
173
|
+
* This is deliberately the SAME normalization the inbox has always used
|
|
174
|
+
* (`agentInboxDir`, messaging.ts) rather than a new convention: lower-case,
|
|
175
|
+
* then every character outside [a-z0-9_-] becomes `_`. Separators and dots
|
|
176
|
+
* cannot survive it, so no traversal can. When that replacement (or a length
|
|
177
|
+
* cap) would collapse distinct raw names, a stable hash suffix keeps their
|
|
178
|
+
* runtime directories separate. It remains IDENTITY for every normal agent
|
|
179
|
+
* name brainclaw produces (claude-code, codex, github-copilot, …) — verified
|
|
180
|
+
* against the real store — so existing normal directories do not move.
|
|
181
|
+
* Readers still probe a contained raw legacy name as a fallback (see
|
|
182
|
+
* runtime.ts) so a non-canonical legacy directory never becomes invisible.
|
|
183
|
+
*
|
|
184
|
+
* Alias resolution is deliberately NOT applied here: mapping `copilot` to
|
|
185
|
+
* `github-copilot` would relocate notes, which is a product decision, not a
|
|
186
|
+
* path-safety one.
|
|
187
|
+
*/
|
|
188
|
+
const AGENT_SEGMENT_UNSAFE_RE = /[^a-z0-9_-]/g;
|
|
189
|
+
const WIN32_RESERVED_BASENAME_FOR_SEGMENT_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
190
|
+
// 128 leaves ample room for the record id and works below the 255-byte
|
|
191
|
+
// component limit even when an agent name contains non-BMP characters (which
|
|
192
|
+
// normalize to ASCII underscores). The hash makes truncation collision-safe.
|
|
193
|
+
const MAX_AGENT_PATH_SEGMENT_LENGTH = 128;
|
|
194
|
+
export function sanitizeAgentPathSegment(agent) {
|
|
195
|
+
const source = agent.trim().toLowerCase();
|
|
196
|
+
let normalized = source.replace(AGENT_SEGMENT_UNSAFE_RE, '_');
|
|
197
|
+
if (normalized.length === 0)
|
|
198
|
+
return 'unknown-agent';
|
|
199
|
+
// A Win32 device name is not a usable directory either (mkdir CON fails).
|
|
200
|
+
if (WIN32_RESERVED_BASENAME_FOR_SEGMENT_RE.test(normalized))
|
|
201
|
+
normalized = `${normalized}_`;
|
|
202
|
+
// A replacement would otherwise merge distinct identities (`a.b` and `a_b`)
|
|
203
|
+
// into one runtime directory. Preserve safe canonical names exactly, but add
|
|
204
|
+
// a deterministic discriminator to every lossy or length-capped segment.
|
|
205
|
+
if (source === normalized && normalized.length <= MAX_AGENT_PATH_SEGMENT_LENGTH)
|
|
206
|
+
return normalized;
|
|
207
|
+
const suffix = crypto.createHash('sha256').update(source).digest('hex').slice(0, 16);
|
|
208
|
+
return `${normalized.slice(0, MAX_AGENT_PATH_SEGMENT_LENGTH - suffix.length - 1)}_${suffix}`;
|
|
209
|
+
}
|
|
124
210
|
export const SESSION_SNAPSHOT_FILENAME_SUFFIX = '.snapshot.json';
|
|
125
211
|
/**
|
|
126
212
|
* Filesystem type discriminator for session snapshots (codex review, pln#670).
|
|
@@ -142,6 +228,9 @@ export function isSessionSnapshotRecordFilename(filename) {
|
|
|
142
228
|
* before the split; readers must schema-validate every candidate.
|
|
143
229
|
*/
|
|
144
230
|
export function sessionSnapshotRecordPaths(sessionId, cwd, preferredDirName) {
|
|
231
|
+
// pln#672 — the id becomes a filename here too: refuse a traversal loudly
|
|
232
|
+
// rather than build a path that escapes the store.
|
|
233
|
+
assertSafeSessionId(sessionId);
|
|
145
234
|
const dirs = entityRecordDirs('sessions', cwd ?? process.cwd(), preferredDirName);
|
|
146
235
|
return [
|
|
147
236
|
...dirs.map((d) => path.join(d, `${sessionId}.snapshot.json`)),
|
|
@@ -151,6 +240,82 @@ export function sessionSnapshotRecordPaths(sessionId, cwd, preferredDirName) {
|
|
|
151
240
|
export function memoryDir(cwd = process.cwd(), preferredDirName) {
|
|
152
241
|
return path.join(cwd, preferredDirName ?? MEMORY_DIR);
|
|
153
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Walk UP from a directory and return the outermost .brainclaw/ root found.
|
|
245
|
+
* Bypasses resolveEffectiveCwd / active project entirely — the answer depends
|
|
246
|
+
* only on the filesystem, which is what makes it safe for identity-level state
|
|
247
|
+
* that must NOT follow the active project (pln#648: a session record anchored
|
|
248
|
+
* on the effective cwd moved with every switch, out of the resolver's reach).
|
|
249
|
+
*
|
|
250
|
+
* Lives HERE, in a leaf module: identity.ts needs it, and store-resolution.ts
|
|
251
|
+
* imports identity.ts — the import cycle that blocked pln#648's first attempt.
|
|
252
|
+
* store-resolution re-exports it for its existing callers.
|
|
253
|
+
*
|
|
254
|
+
* Stops at the filesystem root, at $HOME (a user-level store is never a
|
|
255
|
+
* workspace root), and never climbs ABOVE BRAINCLAW_STORE_BOUNDARY when set —
|
|
256
|
+
* the containment contract tests and agent shells rely on (a leaked parent
|
|
257
|
+
* store must not widen the walk into the host machine).
|
|
258
|
+
*/
|
|
259
|
+
export function findOutermostBrainclawRoot(startDir) {
|
|
260
|
+
let dir = path.resolve(startDir);
|
|
261
|
+
const root = path.parse(dir).root;
|
|
262
|
+
const home = os.homedir();
|
|
263
|
+
const boundaryRaw = process.env.BRAINCLAW_STORE_BOUNDARY?.trim();
|
|
264
|
+
const boundary = boundaryRaw ? path.resolve(boundaryRaw) : undefined;
|
|
265
|
+
let outermost;
|
|
266
|
+
while (dir !== root && dir !== home) {
|
|
267
|
+
if (fs.existsSync(path.join(dir, MEMORY_DIR, 'config.yaml'))) {
|
|
268
|
+
outermost = dir;
|
|
269
|
+
}
|
|
270
|
+
if (boundary && dir === boundary)
|
|
271
|
+
break;
|
|
272
|
+
const parent = path.dirname(dir);
|
|
273
|
+
if (parent === dir)
|
|
274
|
+
break;
|
|
275
|
+
dir = parent;
|
|
276
|
+
}
|
|
277
|
+
return outermost;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The workspace anchor for identity-level state (pln#648 review P1): walking
|
|
281
|
+
* UP, the NEAREST store declaring `store_type: workspace` wins; only when no
|
|
282
|
+
* workspace is declared does the outermost store answer. Without the role
|
|
283
|
+
* check, two sibling declared workspaces under a common parent store would
|
|
284
|
+
* anchor to that parent and see each other's sessions — breaking exactly the
|
|
285
|
+
* isolation `resolveWorkspaceRoot` (chain-based, role-aware) guarantees.
|
|
286
|
+
* The role is read from the raw YAML — the same convention the store-chain
|
|
287
|
+
* walk uses (`store_type` is not part of the typed Config surface) — so this
|
|
288
|
+
* stays a leaf-module fs answer with no config.ts dependency.
|
|
289
|
+
* Same stops as the outermost walk: filesystem root, $HOME, and never above
|
|
290
|
+
* BRAINCLAW_STORE_BOUNDARY.
|
|
291
|
+
*/
|
|
292
|
+
export function findSessionAnchorRoot(startDir) {
|
|
293
|
+
let dir = path.resolve(startDir);
|
|
294
|
+
const root = path.parse(dir).root;
|
|
295
|
+
const home = os.homedir();
|
|
296
|
+
const boundaryRaw = process.env.BRAINCLAW_STORE_BOUNDARY?.trim();
|
|
297
|
+
const boundary = boundaryRaw ? path.resolve(boundaryRaw) : undefined;
|
|
298
|
+
let outermost;
|
|
299
|
+
while (dir !== root && dir !== home) {
|
|
300
|
+
const configPath = path.join(dir, MEMORY_DIR, 'config.yaml');
|
|
301
|
+
if (fs.existsSync(configPath)) {
|
|
302
|
+
outermost = dir;
|
|
303
|
+
try {
|
|
304
|
+
if (/^store_type:\s*workspace\b/m.test(fs.readFileSync(configPath, 'utf-8'))) {
|
|
305
|
+
return dir;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch { /* unreadable config — treat as a plain store */ }
|
|
309
|
+
}
|
|
310
|
+
if (boundary && dir === boundary)
|
|
311
|
+
break;
|
|
312
|
+
const parent = path.dirname(dir);
|
|
313
|
+
if (parent === dir)
|
|
314
|
+
break;
|
|
315
|
+
dir = parent;
|
|
316
|
+
}
|
|
317
|
+
return outermost;
|
|
318
|
+
}
|
|
154
319
|
export function memoryPath(filename, cwd, preferredDirName) {
|
|
155
320
|
return path.join(memoryDir(cwd, preferredDirName), filename);
|
|
156
321
|
}
|
package/dist/core/runtime.js
CHANGED
|
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { resolveCurrentHostId, sanitizeHostId } from './host.js';
|
|
5
|
-
import { resolveEntityDir } from './io.js';
|
|
5
|
+
import { resolveEntityDir, sanitizeAgentPathSegment } from './io.js';
|
|
6
6
|
import { mutate } from './mutation-pipeline.js';
|
|
7
7
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
|
|
8
8
|
import { RuntimeNoteSchema } from './schema.js';
|
|
@@ -19,14 +19,60 @@ function privateRuntimeDir(cwd, mode = 'read') {
|
|
|
19
19
|
return resolveEntityDir('runtime-private', cwd ?? process.cwd(), mode);
|
|
20
20
|
}
|
|
21
21
|
function sharedAgentDir(agent, cwd, mode = 'read') {
|
|
22
|
-
|
|
22
|
+
// pln#673 — the agent name is env-controlled and becomes a path segment:
|
|
23
|
+
// normalize it so a traversal cannot be expressed at all.
|
|
24
|
+
return path.join(sharedRuntimeDir(cwd, mode), sanitizeAgentPathSegment(agent));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Return a pre-normalization directory only when it is provably one direct
|
|
28
|
+
* child of `baseDir`. Compatibility reads must not reintroduce the traversal
|
|
29
|
+
* the normalized write path closes: the agent name is still env-controlled.
|
|
30
|
+
*
|
|
31
|
+
* Dots inside a segment are retained for existing names such as
|
|
32
|
+
* `Legacy.Agent`; separators, Win32 aliases (including trailing dots/spaces),
|
|
33
|
+
* and platform-invalid components are not legacy data we can safely probe.
|
|
34
|
+
*/
|
|
35
|
+
const UNSAFE_LEGACY_AGENT_SEGMENT_RE = /[<>:"/\\|?*\u0000-\u001F]/;
|
|
36
|
+
const WIN32_RESERVED_LEGACY_AGENT_BASENAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
37
|
+
function legacyAgentDir(baseDir, agent) {
|
|
38
|
+
if (agent.length === 0
|
|
39
|
+
|| agent !== agent.trim()
|
|
40
|
+
|| agent.endsWith('.')
|
|
41
|
+
|| UNSAFE_LEGACY_AGENT_SEGMENT_RE.test(agent)
|
|
42
|
+
|| WIN32_RESERVED_LEGACY_AGENT_BASENAME_RE.test(agent.split('.')[0]))
|
|
43
|
+
return undefined;
|
|
44
|
+
const base = path.resolve(baseDir);
|
|
45
|
+
const candidate = path.resolve(path.join(base, agent));
|
|
46
|
+
return path.dirname(candidate) === base ? candidate : undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Both directories an agent's notes can occupy, canonical first (pln#673).
|
|
50
|
+
* Writes always use the normalized segment; reads also probe the RAW name so
|
|
51
|
+
* notes written before the normalization stay visible — the dual-read pattern
|
|
52
|
+
* pln#648/pln#670 already use for relocated records. Deduped when the name is
|
|
53
|
+
* already canonical, which is the case for every agent brainclaw produces.
|
|
54
|
+
*/
|
|
55
|
+
function agentDirCandidates(baseDir, agent) {
|
|
56
|
+
const canonical = path.join(baseDir, sanitizeAgentPathSegment(agent));
|
|
57
|
+
const raw = legacyAgentDir(baseDir, agent);
|
|
58
|
+
return raw && canonical !== raw ? [canonical, raw] : [canonical];
|
|
23
59
|
}
|
|
24
60
|
function hostRootDir(visibility, hostId, cwd, mode = 'read') {
|
|
25
61
|
const baseDir = visibility === 'machine' ? machineRuntimeDir(cwd, mode) : privateRuntimeDir(cwd, mode);
|
|
26
62
|
return path.join(baseDir, sanitizeHostId(hostId));
|
|
27
63
|
}
|
|
28
64
|
function hostAgentDir(visibility, hostId, agent, cwd, mode = 'read') {
|
|
29
|
-
|
|
65
|
+
// pln#673 — same normalization as the shared tree; the host segment was
|
|
66
|
+
// already sanitized (sanitizeHostId), the agent segment was not.
|
|
67
|
+
return path.join(hostRootDir(visibility, hostId, cwd, mode), sanitizeAgentPathSegment(agent));
|
|
68
|
+
}
|
|
69
|
+
/** A contained raw path that can be retired after an update reaches its canonical location. */
|
|
70
|
+
function legacyRuntimeNotePath(note, visibility, hostId, cwd) {
|
|
71
|
+
const base = visibility === 'shared'
|
|
72
|
+
? sharedRuntimeDir(cwd, 'write')
|
|
73
|
+
: hostRootDir(visibility, hostId, cwd, 'write');
|
|
74
|
+
const legacyDir = legacyAgentDir(base, note.agent);
|
|
75
|
+
return legacyDir ? path.join(legacyDir, `${note.id}.json`) : undefined;
|
|
30
76
|
}
|
|
31
77
|
export function ensureRuntimeDir(agent, cwd, visibility = 'shared', hostId) {
|
|
32
78
|
const dir = visibility === 'shared'
|
|
@@ -57,6 +103,13 @@ export function saveRuntimeNote(note, cwd) {
|
|
|
57
103
|
registryFaultPoint('after_registry_journal');
|
|
58
104
|
}
|
|
59
105
|
saveVersionedJsonFile('runtime_note', filepath, parsed);
|
|
106
|
+
// An update to a pre-normalization record must not leave two physical
|
|
107
|
+
// copies with the same id. Retire only the verified-contained raw path,
|
|
108
|
+
// and only after the canonical write succeeds.
|
|
109
|
+
const legacyPath = legacyRuntimeNotePath(note, visibility, hostId, cwd);
|
|
110
|
+
if (legacyPath && legacyPath !== filepath && fs.existsSync(legacyPath)) {
|
|
111
|
+
fs.unlinkSync(legacyPath);
|
|
112
|
+
}
|
|
60
113
|
appendEvent({ action: 'create', item_type: 'runtime_note', item_id: note.id, agent: note.agent, agent_id: note.agent_id }, cwd);
|
|
61
114
|
commitMemoryChange(`runtime note: ${note.note_type ?? 'note'} (${note.agent})`, cwd);
|
|
62
115
|
});
|
|
@@ -64,9 +117,16 @@ export function saveRuntimeNote(note, cwd) {
|
|
|
64
117
|
export function runtimeNotePath(note, cwd) {
|
|
65
118
|
const visibility = note.visibility ?? 'shared';
|
|
66
119
|
const hostId = sanitizeHostId(note.host_id ?? resolveCurrentHostId());
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
120
|
+
// pln#673 — the canonical (normalized) location, plus the RAW-name fallback
|
|
121
|
+
// for notes written before the normalization: this function answers "where is
|
|
122
|
+
// THIS note", and a record must not become invisible (nor undeletable)
|
|
123
|
+
// because its directory predates the fix. Canonical first; the raw candidate
|
|
124
|
+
// only wins when it actually holds the file.
|
|
125
|
+
const base = visibility === 'shared'
|
|
126
|
+
? sharedRuntimeDir(cwd)
|
|
127
|
+
: hostRootDir(visibility, hostId, cwd);
|
|
128
|
+
const candidates = agentDirCandidates(base, note.agent).map((dir) => path.join(dir, `${note.id}.json`));
|
|
129
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
|
|
70
130
|
}
|
|
71
131
|
/**
|
|
72
132
|
* Park one runtime note's raw record under `.brainclaw/gc-backups/` — the same
|
|
@@ -141,12 +201,17 @@ export function listSharedJournaledRuntimeNotes(cwd) {
|
|
|
141
201
|
function readAgentNotes(dir, agent) {
|
|
142
202
|
if (!fs.existsSync(dir))
|
|
143
203
|
return [];
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
204
|
+
// pln#673 — a filtered read probes BOTH the normalized directory and the raw
|
|
205
|
+
// name (a pre-normalization directory must stay readable); an unfiltered read
|
|
206
|
+
// enumerates whatever is on disk, which covers both by construction. The
|
|
207
|
+
// candidates are absolute, so the join below must not prepend `dir` again.
|
|
208
|
+
const agentDirectories = agent
|
|
209
|
+
? agentDirCandidates(dir, agent)
|
|
210
|
+
: fs.readdirSync(dir)
|
|
211
|
+
.filter((entry) => fs.statSync(path.join(dir, entry)).isDirectory())
|
|
212
|
+
.map((entry) => path.join(dir, entry));
|
|
147
213
|
const notes = [];
|
|
148
|
-
for (const
|
|
149
|
-
const agentDirectory = path.join(dir, a);
|
|
214
|
+
for (const agentDirectory of agentDirectories) {
|
|
150
215
|
if (!fs.existsSync(agentDirectory))
|
|
151
216
|
continue;
|
|
152
217
|
const files = fs.readdirSync(agentDirectory).filter((file) => file.endsWith('.json'));
|
|
@@ -4,7 +4,11 @@ import path from 'node:path';
|
|
|
4
4
|
import { loadActiveProject } from './active-project.js';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
6
|
import { loadCurrentSession, loadSessionById, resolveExplicitSessionId } from './identity.js';
|
|
7
|
-
import { MEMORY_DIR } from './io.js';
|
|
7
|
+
import { findOutermostBrainclawRoot, MEMORY_DIR } from './io.js';
|
|
8
|
+
// pln#648 — the walk moved to io.ts (leaf) so identity.ts can anchor session
|
|
9
|
+
// records on it without importing this module (which imports identity.ts).
|
|
10
|
+
// Re-exported here to keep the existing API surface.
|
|
11
|
+
export { findOutermostBrainclawRoot } from './io.js';
|
|
8
12
|
import { summarizeWorkspaceProjects } from './workspace-projects.js';
|
|
9
13
|
/**
|
|
10
14
|
* Walk up the filesystem from `cwd`, collecting every `.brainclaw/` directory
|
|
@@ -423,26 +427,6 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
|
|
|
423
427
|
}
|
|
424
428
|
return undefined;
|
|
425
429
|
}
|
|
426
|
-
/**
|
|
427
|
-
* Walk UP from a directory and return the outermost .brainclaw/ root found.
|
|
428
|
-
* This bypasses resolveEffectiveCwd / active project to find the true workspace root.
|
|
429
|
-
*/
|
|
430
|
-
export function findOutermostBrainclawRoot(startDir) {
|
|
431
|
-
let dir = path.resolve(startDir);
|
|
432
|
-
const root = path.parse(dir).root;
|
|
433
|
-
const home = os.homedir();
|
|
434
|
-
let outermost;
|
|
435
|
-
while (dir !== root && dir !== home) {
|
|
436
|
-
if (fs.existsSync(path.join(dir, MEMORY_DIR, 'config.yaml'))) {
|
|
437
|
-
outermost = dir;
|
|
438
|
-
}
|
|
439
|
-
const parent = path.dirname(dir);
|
|
440
|
-
if (parent === dir)
|
|
441
|
-
break;
|
|
442
|
-
dir = parent;
|
|
443
|
-
}
|
|
444
|
-
return outermost;
|
|
445
|
-
}
|
|
446
430
|
/**
|
|
447
431
|
* Resolve the most specific child store that should answer a context request.
|
|
448
432
|
*
|
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.26.
|
|
2
|
+
// Source: brainclaw v1.26.1 on 2026-08-18T15:32:22.089Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.26.
|
|
5
|
-
"generated_at": "2026-08-
|
|
4
|
+
"version": "1.26.1",
|
|
5
|
+
"generated_at": "2026-08-18T15:32:22.089Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 70,
|
|
8
8
|
"published_count": 68,
|
|
@@ -477,7 +477,7 @@ export const FACTS = {
|
|
|
477
477
|
},
|
|
478
478
|
"bench": {
|
|
479
479
|
"schema": "brainclaw.bench.v1",
|
|
480
|
-
"generated_at": "2026-08-
|
|
480
|
+
"generated_at": "2026-08-18T15:32:19.941Z",
|
|
481
481
|
"node_version": "v24.19.0",
|
|
482
482
|
"platform": "linux-x64",
|
|
483
483
|
"repeats": 3,
|
|
@@ -486,7 +486,7 @@ export const FACTS = {
|
|
|
486
486
|
"name": "cold_onboard",
|
|
487
487
|
"volume": "empty",
|
|
488
488
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
489
|
-
"duration_ms_median":
|
|
489
|
+
"duration_ms_median": 86,
|
|
490
490
|
"payload_chars_median": 1640,
|
|
491
491
|
"payload_tokens_est_median": 410
|
|
492
492
|
},
|
|
@@ -494,7 +494,7 @@ export const FACTS = {
|
|
|
494
494
|
"name": "warm_work",
|
|
495
495
|
"volume": "medium",
|
|
496
496
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
497
|
-
"duration_ms_median":
|
|
497
|
+
"duration_ms_median": 128,
|
|
498
498
|
"payload_chars_median": 2626,
|
|
499
499
|
"payload_tokens_est_median": 657
|
|
500
500
|
},
|
package/dist/facts.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.26.
|
|
3
|
-
"generated_at": "2026-08-
|
|
2
|
+
"version": "1.26.1",
|
|
3
|
+
"generated_at": "2026-08-18T15:32:22.089Z",
|
|
4
4
|
"tools": {
|
|
5
5
|
"count": 70,
|
|
6
6
|
"published_count": 68,
|
|
@@ -475,7 +475,7 @@
|
|
|
475
475
|
},
|
|
476
476
|
"bench": {
|
|
477
477
|
"schema": "brainclaw.bench.v1",
|
|
478
|
-
"generated_at": "2026-08-
|
|
478
|
+
"generated_at": "2026-08-18T15:32:19.941Z",
|
|
479
479
|
"node_version": "v24.19.0",
|
|
480
480
|
"platform": "linux-x64",
|
|
481
481
|
"repeats": 3,
|
|
@@ -484,7 +484,7 @@
|
|
|
484
484
|
"name": "cold_onboard",
|
|
485
485
|
"volume": "empty",
|
|
486
486
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
487
|
-
"duration_ms_median":
|
|
487
|
+
"duration_ms_median": 86,
|
|
488
488
|
"payload_chars_median": 1640,
|
|
489
489
|
"payload_tokens_est_median": 410
|
|
490
490
|
},
|
|
@@ -492,7 +492,7 @@
|
|
|
492
492
|
"name": "warm_work",
|
|
493
493
|
"volume": "medium",
|
|
494
494
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
495
|
-
"duration_ms_median":
|
|
495
|
+
"duration_ms_median": 128,
|
|
496
496
|
"payload_chars_median": 2626,
|
|
497
497
|
"payload_tokens_est_median": 657
|
|
498
498
|
},
|