paperclipai/paperclip · error

Private state directory is not a real directory: ${diagnosti

Error message

Private state directory is not a real directory: ${diagnosticsDirectory}

What it means

When a diagnostics directory is configured, the launcher verifies with lstatSync that the path is a real directory and not a symbolic link, because the directory holds private state requiring 0o700 semantics. If lstat reports a symlink or non-directory, the error is thrown directly; other errors propagate except ENOENT, which is handled by creating the directory recursively with mode 0o700.

Source

Thrown at packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts:3409

  const withRestart = (handle: RunnerProcessHandle): RunnerProcessHandle => ({
    ...handle,
    restart: (ticket) => spawnRunner({ ...options, ticket }),
  });
  if (options.processLauncher !== undefined) {
    return withRestart(
      options.processLauncher({ command, args, cwd: packageRoot, environment }),
    );
  }

  const detached = process.platform !== "win32";
  const diagnosticsDirectory = options.diagnosticsDirectory;
  let stdoutPath: string | null = null;
  let stderrPath: string | null = null;
  if (diagnosticsDirectory) {
    try {
      const metadata = lstatSync(diagnosticsDirectory);
      if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
        throw new Error(
          `Private state directory is not a real directory: ${diagnosticsDirectory}`,
        );
      }
    } catch (error) {
      if (!isNodeError(error, "ENOENT")) throw error;
      mkdirSync(diagnosticsDirectory, { recursive: true, mode: 0o700 });
    }
    if (process.platform !== "win32") chmodSync(diagnosticsDirectory, 0o700);
    verifyPrivateDirectory(diagnosticsDirectory);
    stdoutPath = resolve(diagnosticsDirectory, "runnerd.stdout.log");
    stderrPath = resolve(diagnosticsDirectory, "runnerd.stderr.log");
    // runnerd owns every durable diagnostic write so it can redact and bound
    // the complete value before a byte reaches disk. Raw process output is
    // intentionally discarded below; these files are only the runner-owned
    // restart-survivable diagnostic channel.
    atomicPrivateWrite(stdoutPath, "");
    atomicPrivateWrite(stderrPath, "");
  }

View on GitHub (pinned to 01ad858492)

Solutions

  1. Replace the symlink or file at diagnosticsDirectory with a real directory (mkdir -p, then use the resolved path).
  2. Use fs.realpathSync on the target and pass the resolved real path instead of the symlink.
  3. Remove any pre-created file at that path and let the launcher create the directory itself (it mkdirs with mode 0o700 on ENOENT).
  4. Validate the path in your own setup code before launching.

Example fix

// before
diagnosticsDirectory: "/var/lib/runner-diagnostics" // symlink to /private/var/...
// after
diagnosticsDirectory: fs.realpathSync("/var/lib/runner-diagnostics"); // real directory, no symlink
Defensive patterns

Strategy: validation

Validate before calling

import { lstatSync } from "node:fs";
function isRealDirectory(p) {
  try {
    const st = lstatSync(p);
    return !st.isSymbolicLink() && st.isDirectory();
  } catch { return false; }
}
if (diagnosticsDirectory && !isRealDirectory(diagnosticsDirectory)) {
  throw new Error(`not a real directory: ${diagnosticsDirectory}`);
}

Type guard

function isRealDirectoryPath(p) {
  try {
    const st = lstatSync(p);
    return st.isDirectory() && !st.isSymbolicLink();
  } catch { return false; }
}

Try / catch

try {
  startRunner({ diagnosticsDirectory });
} catch (err) {
  if (err.message.startsWith("Private state directory")) {
    // replace symlink/file with a real directory and retry
  } else throw err;
}

Prevention

When it happens

Trigger: Setting diagnosticsDirectory to a symlink pointing at a directory, to a regular file, or to any non-directory node. Note the throw is also caught by the surrounding try and re-thrown unless it is ENOENT, so this error always surfaces to the caller.

Common situations: Pointing the diagnostics dir at /tmp-style symlinked paths (e.g. macOS /var -> /private/var), pre-creating the path as a file, or using a symlink farm for state directories.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/d7698489830587b5. Report an issue: GitHub.