@@ -111,6 +111,100 @@ function resolveProjectDir() {
111111 return process . cwd ( ) ;
112112}
113113
114+ // Determine the workspace ROOT for a project, mirroring src/gep/paths.js
115+ // getWorkspaceRoot() step-for-step so the FS-only fallback lands its secret at
116+ // the SAME path paths.js would (what lets an installed @evomap/evolver read the
117+ // very same id):
118+ // 1. OPENCLAW_WORKSPACE override.
119+ // 2. else the git repo root at/above projectDir, BUT if that repo root has a
120+ // `workspace/` subdirectory, paths.js returns <repoRoot>/workspace — so we
121+ // must too, or the two land on different .evolver/workspace-id files (the
122+ // "read back identically" guarantee would break for such projects).
123+ // 3. else projectDir.
124+ function _fsWorkspaceRoot ( projectDir ) {
125+ if ( process . env . OPENCLAW_WORKSPACE ) return process . env . OPENCLAW_WORKSPACE ;
126+ // Walk up from projectDir looking for a .git entry (file or dir) = repo root.
127+ let repoRoot = null ;
128+ let dir = projectDir ;
129+ while ( dir ) {
130+ if ( fs . existsSync ( path . join ( dir , '.git' ) ) ) { repoRoot = dir ; break ; }
131+ const parent = path . dirname ( dir ) ;
132+ if ( parent === dir ) break ;
133+ dir = parent ;
134+ }
135+ if ( ! repoRoot ) return projectDir ;
136+ // Mirror getWorkspaceRoot()'s workspace/ subdir step.
137+ const workspaceDir = path . join ( repoRoot , 'workspace' ) ;
138+ if ( fs . existsSync ( workspaceDir ) ) return workspaceDir ;
139+ return repoRoot ;
140+ }
141+
142+ // FS-only re-implementation of src/gep/paths.js getWorkspaceId() for the case
143+ // where the evolver package is not installed (plugin-only installs). It reads
144+ // — and lazily, atomically creates — the per-workspace secret at
145+ // <workspaceRoot>/.evolver/workspace-id. The format (16-byte hex), the path,
146+ // the 0600 mode, the O_EXCL|O_NOFOLLOW atomic create, and the symlink
147+ // rejection all match paths.js exactly, so a workspace seeded by this fallback
148+ // is transparently picked up by paths.getWorkspaceId() once the package is
149+ // present, and vice-versa. Returns null on any read/write error (caller then
150+ // falls back to legacy cwd-tag matching — no regression).
151+ // Read <dir>/workspace-id with the same symlink guards paths.js'
152+ // _readWorkspaceIdFromFs uses: reject a symlinked .evolver dir, reject a
153+ // symlinked / non-regular id file, and require hex format. Returns the id, or
154+ // null on any error / missing file. Used for BOTH the initial read and the
155+ // EEXIST race re-read so a symlink swapped in between our lstat and openSync
156+ // can never be followed (Bugbot PR #557).
157+ function _readWsIdGuarded ( dir , file ) {
158+ try {
159+ const dirStat = fs . lstatSync ( dir , { throwIfNoEntry : false } ) ;
160+ if ( dirStat && dirStat . isSymbolicLink ( ) ) return null ;
161+ const fileStat = fs . lstatSync ( file , { throwIfNoEntry : false } ) ;
162+ if ( ! fileStat ) return null ;
163+ if ( fileStat . isSymbolicLink ( ) || ! fileStat . isFile ( ) ) return null ;
164+ const raw = fs . readFileSync ( file , 'utf8' ) . trim ( ) ;
165+ return raw && / ^ [ a - f 0 - 9 ] { 32 , } $ / i. test ( raw ) ? raw : null ;
166+ } catch { return null ; }
167+ }
168+
169+ function _fsWorkspaceId ( projectDir ) {
170+ // Whole body is wrapped: the documented contract is "returns null on ANY
171+ // read/write error" so the session-start/-end hooks degrade gracefully
172+ // rather than crash. throwIfNoEntry:false only suppresses ENOENT; EACCES/EIO
173+ // and friends still throw, so a bare lstat/mkdir here must not escape
174+ // (Bugbot PR #557 round-2 — an unguarded lstat could crash the hook).
175+ try {
176+ const dir = path . join ( _fsWorkspaceRoot ( projectDir ) , '.evolver' ) ;
177+ const file = path . join ( dir , 'workspace-id' ) ;
178+ // Read first, with symlink guards.
179+ const existing = _readWsIdGuarded ( dir , file ) ;
180+ if ( existing ) return existing ;
181+ // If the file exists but the guards rejected it (symlink / bad format),
182+ // refuse rather than create over it.
183+ if ( fs . lstatSync ( file , { throwIfNoEntry : false } ) ) return null ;
184+ // Missing — create atomically. Refuse a symlinked .evolver dir (O_NOFOLLOW
185+ // only guards the final component, not intermediate dirs).
186+ const dirStat = fs . lstatSync ( dir , { throwIfNoEntry : false } ) ;
187+ if ( dirStat && dirStat . isSymbolicLink ( ) ) return null ;
188+ fs . mkdirSync ( dir , { recursive : true } ) ;
189+ const payload = require ( 'crypto' ) . randomBytes ( 16 ) . toString ( 'hex' ) ;
190+ const flags = fs . constants . O_WRONLY | fs . constants . O_CREAT | fs . constants . O_EXCL |
191+ ( fs . constants . O_NOFOLLOW || 0 ) ;
192+ let fd ;
193+ try {
194+ fd = fs . openSync ( file , flags , 0o600 ) ;
195+ } catch ( e ) {
196+ // Lost a race — re-read WITH the same symlink guards (paths.js does the
197+ // same). A bare readFileSync here would follow a symlink swapped in
198+ // after our dir lstat (Bugbot PR #557).
199+ if ( e && e . code === 'EEXIST' ) return _readWsIdGuarded ( dir , file ) ;
200+ return null ; // ELOOP/EMLINK from O_NOFOLLOW hitting a symlink — refuse.
201+ }
202+ try { fs . writeSync ( fd , payload + '\n' , 0 , 'utf8' ) ; } finally { fs . closeSync ( fd ) ; }
203+ try { fs . chmodSync ( file , 0o600 ) ; } catch { /* best-effort */ }
204+ return payload ;
205+ } catch { return null ; }
206+ }
207+
114208// Resolve the current workspace id — the forge-resistant tag the session-end
115209// writer stamps on every memory-graph entry (`workspace_id`). This is the
116210// SINGLE source of that resolution: the session-end writer stamps it and the
@@ -120,19 +214,26 @@ function resolveProjectDir() {
120214// match the reader's filter and workspace scoping would silently break.
121215// Resolution order:
122216// 1. EVOLVER_WORKSPACE_ID env override
123- // 2. paths.getWorkspaceId() loaded from the resolved evolver root
124- // Returns null when neither is available (e.g. evolver package not installed),
125- // in which case callers must NOT filter — falling back to "show everything"
126- // preserves prior behavior rather than hiding all memory on a resolution miss.
127- function resolveWorkspaceId ( evolverRoot ) {
217+ // 2. paths.getWorkspaceId() loaded from the resolved evolver root (this is
218+ // the richer path — it can additionally back the secret with the OS
219+ // keychain when @napi -rs/keyring is installed).
220+ // 3. FS-only fallback for plugin-only installs where the evolver package is
221+ // not reachable. Without this, plugin users got workspace_id=null and the
222+ // forge-resistant scoping silently degraded to cwd-tag matching (found
223+ // via real-Cursor end-to-end testing). The fallback writes the same
224+ // secret file paths.js uses, so installing the package later is seamless.
225+ // Still returns null if even the FS write fails — callers must then NOT filter
226+ // (show everything), preserving prior behavior rather than hiding all memory.
227+ function resolveWorkspaceId ( evolverRoot , projectDir ) {
128228 if ( process . env . EVOLVER_WORKSPACE_ID ) return String ( process . env . EVOLVER_WORKSPACE_ID ) ;
129229 const root = evolverRoot || findEvolverRoot ( ) ;
130- if ( ! root ) return null ;
131- try {
132- const paths = require ( path . join ( root , 'src' , 'gep' , 'paths.js' ) ) ;
133- if ( typeof paths . getWorkspaceId === 'function' ) return paths . getWorkspaceId ( ) ;
134- } catch { /* paths.js unreachable — return null */ }
135- return null ;
230+ if ( root ) {
231+ try {
232+ const paths = require ( path . join ( root , 'src' , 'gep' , 'paths.js' ) ) ;
233+ if ( typeof paths . getWorkspaceId === 'function' ) return paths . getWorkspaceId ( ) ;
234+ } catch { /* paths.js unreachable — fall through to FS-only */ }
235+ }
236+ return _fsWorkspaceId ( projectDir || resolveProjectDir ( ) ) ;
136237}
137238
138239// Returns a path to the evolution memory graph, or a fallback location that
0 commit comments