NousResearch/hermes-agent · error · Error
Unsafe SSH control dir: ${controlDir} is not a directory.
Error message
Unsafe SSH control dir: ${controlDir} is not a directory. What it means
Thrown during SSH control-master setup (POSIX only) when lstat() shows the control-socket directory exists but is not a directory (a regular file, socket, device node, ...). The control socket cannot live inside a non-directory, and an unexpected file type there signals a broken or tampered state.
Source
Thrown at apps/desktop/electron/ssh-connection.ts:592
}
const controlDir = path.dirname(this.controlPath)
try {
fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 })
} catch {
void 0
}
if (process.platform !== 'win32') {
const st = fs.lstatSync(controlDir)
if (st.isSymbolicLink()) {
throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`)
}
if (!st.isDirectory()) {
throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`)
}
if (st.uid !== process.getuid!()) {
throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`)
}
if ((st.mode & 0o777) !== 0o700) {
fs.chmodSync(controlDir, 0o700)
}
}
const args = buildMasterArgs(this, this._connectTimeoutMs)
this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)
let result
try {
result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {View on GitHub (pinned to c896c09c42)
Solutions
- Remove the offending non-directory entry and recreate it as a directory with mode 0700.
- Run mkdir -p on the path manually to surface the real error the swallowed catch hid (permissions, mount point).
- Move the control path to a clean app-owned directory.
Example fix
# before (shell) ls -la ~/.cache/hermes/ssh-control # regular file # after rm ~/.cache/hermes/ssh-control && mkdir -m 700 ~/.cache/hermes/ssh-control
Defensive patterns
Strategy: validation
Validate before calling
import fs from 'node:fs'
function ensureControlDir(dir: string): void {
if (fs.existsSync(dir)) {
const st = fs.lstatSync(dir)
if (!st.isDirectory()) fs.rmSync(dir) // clear stray non-dir entry, recreate below
}
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
} Try / catch
try {
await conn.open()
} catch (e) {
if (e instanceof Error && /is not a directory/.test(e.message)) {
fs.rmSync(path.dirname(conn.controlPath))
await conn.open() // app recreates the dir with 0700 on retry
} else throw e
} Prevention
- Create the control dir with mkdirSync(recursive, 0700) at app startup, before any connection.
- Do not pre-create placeholder files at the control dir path.
- Log mkdirSync failures instead of swallowing them, so a blocked creation is visible.
When it happens
Trigger: path.dirname(controlPath) exists as a plain file — a placeholder file, log, or editor backup occupies the exact path, or the preceding mkdirSync failed (its error is swallowed by the catch) because the non-dir entry already existed.
Common situations: A file created at the exact path where the control dir should be; mkdir failing silently on EEXIST-with-wrong-type; partial cleanup from an old layout leaving a stray file.
Related errors
- Unsafe SSH control dir: ${controlDir} is a symlink.
- Unsafe SSH control dir: ${controlDir} is owned by uid ${st.u
- Unsafe SSH target: host must not start with a dash ("${host}
- Unsafe SSH target: host contains control characters.
- Unsafe SSH target: user contains control characters.
AI-assisted analysis of NousResearch/hermes-agent@c896c09c42 (2026-08-14).
Data as JSON: /api/errors/9c51413ef01f8adf.
Report an issue: GitHub.