NousResearch/hermes-agent · error · Error
Unsafe SSH control dir: ${controlDir} is owned by uid ${st.u
Error message
Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}. What it means
Thrown during SSH control-master setup (POSIX only) when the control directory's owner uid does not match the current process uid (st.uid !== process.getuid()). A control dir owned by another user could let them control or read the multiplexed socket, so ownership is enforced before the master connection opens.
Source
Thrown at apps/desktop/electron/ssh-connection.ts:596
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) {
throw this._fail(error, SSH_ERROR.UNREACHABLE)
}
if (result.code !== 0) {View on GitHub (pinned to c896c09c42)
Solutions
- Fix ownership: sudo chown -R $(id -u):$(id -g) <controlDir>, or simply delete it so the app recreates it as the current user.
- Avoid running the desktop app as root/sudo.
- In containers, ensure consistent uids across restarts or point the control path at a per-uid directory.
Example fix
# before (shell) sudo chown -R $(id -u):$(id -g) ~/.cache/hermes/ssh-control # or simply rm -rf ~/.cache/hermes/ssh-control # app recreates it as current user # after: control master connects normally
Defensive patterns
Strategy: validation
Validate before calling
import fs from 'node:fs'
function isControlDirOwnedByMe(dir: string): boolean {
try {
const st = fs.lstatSync(dir)
return st.isDirectory() && st.uid === process.getuid!()
} catch {
return false
}
}
if (!isControlDirOwnedByMe(controlDir)) {
// refuse and instruct the user (or relocate the dir) before opening the master
} Try / catch
try {
await conn.open()
} catch (e) {
if (e instanceof Error && /owned by uid/.test(e.message)) {
showFixHint(`Run: sudo chown -R $(id -u):$(id -g) ${path.dirname(conn.controlPath)}`)
return
}
throw e
} Prevention
- Never run the desktop app under sudo — it leaves root-owned dirs in user paths.
- Place the control dir in a per-user runtime path (userData / XDG_RUNTIME_DIR) the app owns.
- On containerized setups, keep a stable uid across container restarts.
When it happens
Trigger: The dir was created by root (app once run with sudo), by a different local user, or by a system service — lstatSync reports a uid different from the current process uid.
Common situations: App was once launched with sudo, leaving root-owned dirs in the user's cache/runtime path; shared machines where another user pre-created the dir; containers where uid mapping differs between runs.
Related errors
- Unsafe SSH control dir: ${controlDir} is a symlink.
- Unsafe SSH control dir: ${controlDir} is not a directory.
- Refusing to start: could not pre-create audit log {audit_pat
- Refusing to write iron-proxy log {log_path}: {exc}. Remove
- iron-proxy log {log_path} has unexpected owner uid={st.st_ui
AI-assisted analysis of NousResearch/hermes-agent@c896c09c42 (2026-08-14).
Data as JSON: /api/errors/a84429ee34a6d17a.
Report an issue: GitHub.