Skip to content

Commit f084c27

Browse files
autogame-17claude
andcommitted
sync hooks with evolver #554/#555/#557 (workspace-id FS fallback)
Pulls in the merged Cursor-compatibility fixes from @evomap/evolver: - #554 project-dir resolution (CURSOR_PROJECT_DIR) - #555 workspace-scoped recall + no-changes breadcrumb + bounded parse - #557 FS-only workspace_id fallback for plugin-only installs (no evolver package), with paths.js parity (workspace/ subdir, symlink guards) and graceful null-on-fs-error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9c13e5e commit f084c27

3 files changed

Lines changed: 119 additions & 14 deletions

File tree

hooks/_runtimePaths.js

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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-f0-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

hooks/evolver-session-end.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ function recordToHub(outcome) {
148148

149149
function recordToLocal(graphPath, outcome) {
150150
try {
151+
// Resolve the project dir once so the cwd tag and the workspace_id secret
152+
// share a single, consistent source (both must agree with the session-start
153+
// reader's resolveProjectDir()-based scoping).
154+
const projectDir = resolveProjectDir();
151155
const entry = {
152156
timestamp: new Date().toISOString(),
153157
gene_id: outcome.geneId || 'ad_hoc',
@@ -179,8 +183,8 @@ function recordToLocal(graphPath, outcome) {
179183
// cwd-only entry (Bugbot PR #555). collect.js only uses cwd as a legacy
180184
// fallback (disabled once a workspace_id secret exists), so changing the
181185
// tag's source — still a directory path — does not affect its scoping.
182-
cwd: resolveProjectDir(),
183-
workspace_id: resolveWorkspaceId(),
186+
cwd: projectDir,
187+
workspace_id: resolveWorkspaceId(undefined, projectDir),
184188
source: 'hook:session-end',
185189
};
186190
fs.appendFileSync(graphPath, JSON.stringify(entry) + '\n', 'utf8');

hooks/evolver-session-start.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,8 @@ function main() {
153153
// workspace's outcomes out of view. When the workspace id can't be resolved,
154154
// belongsToWorkspace() falls back to "show it" — no regression vs. the old
155155
// unscoped behavior.
156-
const currentId = resolveWorkspaceId(evolverRoot);
157156
const currentDir = resolveProjectDir();
157+
const currentId = resolveWorkspaceId(evolverRoot, currentDir);
158158
const recent = readRecentWorkspaceEntries(graphPath, currentId, currentDir, 5);
159159
const filtered = filterRelevantOutcomes(recent);
160160

0 commit comments

Comments
 (0)