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.
- package/README.md +28 -12
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/session-start.js +54 -3
- package/dist/core/agent-files.js +21 -21
- package/dist/core/identity.js +256 -115
- package/dist/core/io.js +165 -0
- package/dist/core/protocol-tool-policy.js +44 -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/docs/PROTOCOL.md +6 -4
- package/docs/cli.md +1 -1
- package/docs/concepts/loop-engine.md +60 -34
- package/docs/integrations/hermes.md +42 -3
- package/docs/integrations/mcp.md +17 -5
- package/docs/product/agent-first-model.md +33 -33
- package/package.json +1 -1
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);
|