brainclaw 1.26.0 → 1.26.2

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.
@@ -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);