(projectDir)
| 302 | } |
| 303 | |
| 304 | function _fsWorkspaceId(projectDir) { |
| 305 | // Whole body is wrapped: the documented contract is "returns null on ANY |
| 306 | // read/write error" so the session-start/-end hooks degrade gracefully |
| 307 | // rather than crash. throwIfNoEntry:false only suppresses ENOENT; EACCES/EIO |
| 308 | // and friends still throw, so a bare lstat/mkdir here must not escape |
| 309 | // (Bugbot PR #557 round-2 — an unguarded lstat could crash the hook). |
| 310 | try { |
| 311 | const dir = path.join(_fsWorkspaceRoot(projectDir), '.evolver'); |
| 312 | const file = path.join(dir, 'workspace-id'); |
| 313 | // Read first, with symlink guards. |
| 314 | const existing = _readWsIdGuarded(dir, file); |
| 315 | if (existing) return existing; |
| 316 | // If the file exists but the guards rejected it (symlink / bad format), |
| 317 | // refuse rather than create over it. |
| 318 | if (fs.lstatSync(file, { throwIfNoEntry: false })) return null; |
| 319 | // Missing — create atomically. Refuse a symlinked .evolver dir (O_NOFOLLOW |
| 320 | // only guards the final component, not intermediate dirs). |
| 321 | const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false }); |
| 322 | if (dirStat && dirStat.isSymbolicLink()) return null; |
| 323 | fs.mkdirSync(dir, { recursive: true }); |
| 324 | const payload = require('crypto').randomBytes(16).toString('hex'); |
| 325 | const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | |
| 326 | (fs.constants.O_NOFOLLOW || 0); |
| 327 | let fd; |
| 328 | try { |
| 329 | fd = fs.openSync(file, flags, 0o600); |
| 330 | } catch (e) { |
| 331 | // Lost a race — re-read WITH the same symlink guards (paths.js does the |
| 332 | // same). A bare readFileSync here would follow a symlink swapped in |
| 333 | // after our dir lstat (Bugbot PR #557). |
| 334 | if (e && e.code === 'EEXIST') return _readWsIdGuarded(dir, file); |
| 335 | return null; // ELOOP/EMLINK from O_NOFOLLOW hitting a symlink — refuse. |
| 336 | } |
| 337 | try { fs.writeSync(fd, payload + '\n', 0, 'utf8'); } finally { fs.closeSync(fd); } |
| 338 | try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ } |
| 339 | return payload; |
| 340 | } catch { return null; } |
| 341 | } |
| 342 | |
| 343 | // Resolve the current workspace id — the forge-resistant tag the session-end |
| 344 | // writer stamps on every memory-graph entry (`workspace_id`). This is the |
no test coverage detected