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.
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
- saveVersionedJsonFile('session_snapshot', sessionSnapshotPath(snapshot.session_id, options.cwd), SessionSnapshotSchema.parse(snapshot));
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
  }
@@ -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
- const value = env.BRAINCLAW_SESSION_ID?.trim()
17
- || env.OPENCLAW_SESSION_ID?.trim()
18
- || env.CLAUDE_SESSION_ID?.trim()
19
- || env.COPILOT_SESSION_ID?.trim();
20
- if (value && value.length > 0) {
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/ directory for the session owned by this process.
87
- // Multiple parallel agents can have the same agent name/user in one repo;
88
- // a live different PID is a different agent instance, not our session.
89
- if (fs.existsSync(dir) && currentAgent) {
90
- const files = listCurrentSessionFiles(dir);
91
- const legacyPidlessCandidates = [];
92
- for (const file of files) {
93
- try {
94
- const session = loadSessionFile(path.join(dir, file));
95
- // Strict match: agent name must match, user must match (when both are known)
96
- if (session.agent !== currentAgent)
97
- continue;
98
- const userMatch = !session.user || !currentUser || session.user === currentUser;
99
- if (!userMatch || !isSessionAlive(session, ttlMs, now))
100
- continue;
101
- if (session.pid === process.pid) {
102
- return session;
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
- if (session.pid === undefined) {
105
- legacyPidlessCandidates.push(session);
125
+ catch {
126
+ // skip invalid session files
106
127
  }
107
128
  }
108
- catch {
109
- // skip invalid session files
110
- }
111
129
  }
112
- if (legacyPidlessCandidates.length === 1) {
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
- let filepath;
137
- try {
138
- filepath = sessionFilePath(sessionId, cwd);
139
- }
140
- catch {
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
- const dir = sessionsDir(cwd);
165
- if (!fs.existsSync(dir))
166
- return [];
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 file of files) {
170
- try {
171
- const migration = loadVersionedJsonFile('current_session', path.join(dir, file));
172
- sessions.push({
173
- ...CurrentSessionStateSchema.parse(migration.document),
174
- schema_version: migration.metadata.currentVersion,
175
- });
176
- }
177
- catch {
178
- // skip invalid
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 legacy `<id>.json` can still hold a pre-split snapshot (the old
195
- // 'read'-mode write bug parked snapshots in this directory). Only overwrite
196
- // a record that PROVES it is this exact current_session entry (codex review).
197
- if (fs.existsSync(filepath) && !loadSessionById(session.session_id, cwd)) {
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
- if (sessionId) {
207
- // Remove specific session file. A filename is never enough authority to
208
- // delete a record (codex review P1): the '.snapshot' alias id throws in
209
- // sessionFilePath (caught no-op), and an existing file is only unlinked
210
- // when loadSessionById PROVES it is this exact current_session record
211
- // never a pre-split snapshot parked under a plain `<id>.json`.
212
- try {
213
- if (loadSessionById(sessionId, cwd)) {
214
- fs.unlinkSync(sessionFilePath(sessionId, cwd));
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
- catch { /* ignore */ }
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
- const filepath = sessionFilePath(session.session_id, cwd);
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
- const files = listCurrentSessionFiles(dir);
248
- for (const file of files) {
249
- const filepath = path.join(dir, file);
250
- try {
251
- // POSITIVE proof before deletion (codex review): a bare `<id>.json` in
252
- // this directory can still be a pre-split snapshot (old 'read'-mode
253
- // write bug). Only a record carrying the current_session discriminant
254
- // may be collected; anything unidentifiable is preserved, never deleted.
255
- const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
256
- if (typeof raw.last_seen_at !== 'string')
257
- continue;
258
- const migration = loadVersionedJsonFile('current_session', filepath);
259
- const session = {
260
- ...CurrentSessionStateSchema.parse(migration.document),
261
- schema_version: migration.metadata.currentVersion,
262
- };
263
- if (now - Date.parse(session.last_seen_at) > ttlMs) {
264
- fs.unlinkSync(filepath);
265
- removed++;
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 sessionFilePath(sessionId, cwd) {
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(sessionsDir(cwd), `${sessionId}.json`);
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
- return env.BRAINCLAW_SESSION_ID?.trim()
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
  }
@@ -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
- return path.join(sharedRuntimeDir(cwd, mode), agent);
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
- return path.join(hostRootDir(visibility, hostId, cwd, mode), agent);
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
- return visibility === 'shared'
68
- ? path.join(sharedAgentDir(note.agent, cwd), `${note.id}.json`)
69
- : path.join(hostAgentDir(visibility, hostId, note.agent, cwd), `${note.id}.json`);
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
- const agents = agent
145
- ? [agent]
146
- : fs.readdirSync(dir).filter((entry) => fs.statSync(path.join(dir, entry)).isDirectory());
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 a of agents) {
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.0 on 2026-08-16T20:27:49.404Z
2
+ // Source: brainclaw v1.26.1 on 2026-08-18T15:32:22.089Z
3
3
  export const FACTS = {
4
- "version": "1.26.0",
5
- "generated_at": "2026-08-16T20:27:49.404Z",
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-16T20:27:47.271Z",
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": 75,
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": 132,
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.0",
3
- "generated_at": "2026-08-16T20:27:49.404Z",
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-16T20:27:47.271Z",
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": 75,
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": 132,
495
+ "duration_ms_median": 128,
496
496
  "payload_chars_median": 2626,
497
497
  "payload_tokens_est_median": 657
498
498
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.26.0",
3
+ "version": "1.26.1",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {