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
- Replace the symlink or file at diagnosticsDirectory with a real directory (mkdir -p, then use the resolved path).
- Use fs.realpathSync on the target and pass the resolved real path instead of the symlink.
- Remove any pre-created file at that path and let the launcher create the directory itself (it mkdirs with mode 0o700 on ENOENT).
- 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
- Never place symlinks on private-state paths; resolve with realpathSync first.
- Let the launcher create the directory (it mkdirs 0o700) instead of pre-creating odd nodes.
- Check the path with lstat (not stat) so symlinks are caught.
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
- A trusted viewer build is required for public chat reports
- Materialized OpenCode executable has unsafe permissions
- Trusted viewer must not use symlinks
- Unexpected trusted viewer asset
- Refusing public report symlink ${absolute}
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10).
Data as JSON: /api/errors/d7698489830587b5.
Report an issue: GitHub.