* fix(hooks): resolve workspace_id via FS fallback when evolver package is absent
Found by real-Cursor end-to-end testing: on plugin-only installs (no
@evomap/evolver package on the machine), resolveWorkspaceId could not reach
paths.getWorkspaceId() and returned null. Every session-end entry was then
stamped workspace_id=null, so the forge-resistant workspace scoping silently
degraded to plain cwd-tag matching — isolation still held (cwd was fixed to
the project dir in #554/#555), but the secret-backed tag the docs promise was
never created.
Add a self-contained FS-only fallback in _runtimePaths.js that reads — and
lazily, atomically creates — the per-workspace secret at
<workspaceRoot>/.evolver/workspace-id. It mirrors src/gep/paths.js exactly:
the workspace root (OPENCLAW_WORKSPACE, else the git repo root at/above the
project dir, else the project dir), the 16-byte hex format, 0600 mode,
O_EXCL|O_NOFOLLOW atomic create, and symlink rejection on both the dir and
file. Because the path and format match, a workspace seeded by the fallback
is read back identically by paths.getWorkspaceId() once the package is
installed — verified: both resolve the same id from the same file.
resolveWorkspaceId now takes an optional projectDir so reader and writer
resolve the secret against the same root; both call sites pass the
resolveProjectDir() value they already compute.
Test: test/resolveWorkspaceId.test.js covers env override, stable hex
generation + persistence + 0600, cross-project isolation, git-repo-root
resolution, OPENCLAW_WORKSPACE override, and symlinked-.evolver refusal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hooks): close two paths.js-parity gaps in the workspace-id FS fallback
Bugbot (PR #557 round-1) found two ways the FS fallback diverged from
src/gep/paths.js, breaking either the read-back-identically guarantee or
its symlink hardening:
1. (Low, security) The EEXIST race re-read used a bare readFileSync with no
symlink guards, unlike paths.js which re-reads via _readWorkspaceIdFromFs.
An attacker swapping .evolver for a symlink between the dir lstat and
openSync could then have the re-read follow it. Factor the guarded read
into _readWsIdGuarded() and use it for BOTH the initial read and the
EEXIST re-read; also refuse a pre-existing non-regular/symlinked id file
instead of creating over it.
2. paths.js getWorkspaceRoot() returns repoRoot/workspace when that subdir
exists; _fsWorkspaceRoot omitted that step, so projects with a workspace/
dir wrote the secret to repoRoot/.evolver while the package reads
repoRoot/workspace/.evolver -- they would never find each other. Mirror
the workspace/ step.
Verified: a project with a workspace/ subdir now has the fallback and
paths.getWorkspaceId() resolve the same id from the same file.
Tests: +2 (workspace/ subdir parity; symlinked id-file refusal) -> 8 total.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hooks): guard the whole FS workspace-id resolver against fs throws
Bugbot (PR #557 round-2) found that the bare lstatSync I added in round-1
(to refuse overwriting a guard-rejected id file) sat outside any try-catch.
throwIfNoEntry:false only suppresses ENOENT; EACCES/EIO still throw. Since
evolver-session-start.js main() calls resolveWorkspaceId without a try-catch,
such an error would crash the hook instead of degrading to null -- violating
the documented "returns null on any read/write error" contract.
Wrap the entire _fsWorkspaceId body in a single try-catch so any unexpected
fs throw returns null. The hook then falls back to legacy cwd-tag matching
rather than dying.
Test: regression provoking EACCES via a chmod 000 .evolver dir, asserting
resolveWorkspaceId returns null and does not throw (skipped as root, which
ignores file permissions). 9 tests total.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: autogame-17 <autogame-17@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>