santifer/career-ops · error · Error

cannot read ${rel || '.'}: ${err.message}

Error message

cannot read ${rel || '.'}: ${err.message}

What it means

Thrown inside `hashPluginTree` (plugins/_lock.mjs) when `readdirSync` fails on a directory while walking the plugin tree to compute its integrity hash. The path in the message is the relative path (`rel`) within the plugin dir, or '.' for the root. The underlying OS error (e.g. EACCES, ENOENT) is appended. This hash feeds plugins.lock, so a read failure aborts lock generation rather than silently producing a partial hash that a rug-pull could exploit.

Source

Thrown at plugins/_lock.mjs:42

function sha256(buf) {
  return 'sha256-' + createHash('sha256').update(buf).digest('hex');
}

/**
 * Hash EVERY regular file in a plugin directory tree, recursively. NOT a curated
 * subset — the entry can `import('./anything.mjs')`, so a partial hash would let
 * a rug-pull mutate an un-hashed file. Rejects symlinks (a symlinked file would
 * pass the hash while pointing elsewhere). Excludes node_modules + .git.
 *
 * @param {string} dir absolute plugin directory
 * @returns {{ files: Record<string,string>, integrity: string }}
 */
export function hashPluginTree(dir) {
  const files = {};
  const walk = (abs, rel) => {
    let entries;
    try { entries = readdirSync(abs, { withFileTypes: true }); }
    catch (err) { throw new Error(`cannot read ${rel || '.'}: ${err.message}`); }
    for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
      if (e.name === 'node_modules' || e.name === '.git') continue;
      const childAbs = path.join(abs, e.name);
      const childRel = rel ? `${rel}/${e.name}` : e.name;
      // lstat (not stat) so a symlink is detected, never followed.
      const st = lstatSync(childAbs);
      if (st.isSymbolicLink()) throw new Error(`refusing to hash symlink: ${childRel}`);
      if (st.isDirectory()) walk(childAbs, childRel);
      else if (st.isFile()) files[childRel] = sha256(readFileSync(childAbs));
      else throw new Error(`refusing to hash non-regular file: ${childRel}`);
    }
  };
  walk(dir, '');
  // Aggregate integrity = sha256 over the deterministic sorted "rel:hash" join.
  const aggregate = Object.keys(files).sort().map(k => `${k}:${files[k]}`).join('\n');
  return { files, integrity: sha256(Buffer.from(aggregate)) };
}

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Check the relative path in the message — confirm the plugin dir still exists: `ls -la plugins/<name>`.
  2. Fix permissions if EACCES: ensure the running user can read the plugin dir recursively (`chmod -R +r` or correct ownership).
  3. Re-run `node plugins.mjs install` if the dir was partially deleted, to restore a complete tree.
  4. If another process is mutating plugins concurrently, serialize plugin install/remove operations.
Defensive patterns

Strategy: validation

Validate before calling

import { accessSync, constants } from 'node:fs';
// Pre-check the plugin dir is readable before hashing.
function assertReadableDir(dir) {
  try {
    accessSync(dir, constants.R_OK);
  } catch {
    throw new Error(`Plugin dir not readable: ${dir} — fix permissions or reinstall.`);
  }
}
assertReadableDir(pluginDir);

Try / catch

try {
  const { integrity } = hashPluginTree(pluginDir);
} catch (err) {
  if (/cannot read/.test(err.message)) {
    console.error(`Lock skipped — unreadable plugin dir: ${err.message}`);
    // fail-open: skip lock for this plugin rather than aborting
  } else throw err;
}

Prevention

When it happens

Trigger: `hashPluginTree(dir)` is called on a plugin directory that has been deleted between discovery and hashing (ENOENT), has restrictive permissions (EACCES/EPERM), or sits on a filesystem that rejects readdir. Any code path that writes or verifies plugins.lock (lock generation, `node doctor.mjs --lock`, engine startup with a changed plugin) triggers the walk.

Common situations: A plugin dir was `rm -rf`'d mid-run (another process or a failed git operation); plugin files are owned by a different user and the current process lacks read permission; running on a read-only or network mount that intermittently fails readdir; a race where the lock writer runs while `node plugins.mjs remove` is deleting the dir.

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/e2a2bbb1cd467c37. Report an issue: GitHub.