can1357/oh-my-pi · error
SSH control directory ${dir} ${isSymlink ? "is a symlink" :
Error message
SSH control directory ${dir} ${isSymlink ? "is a symlink" : "is not a directory"} What it means
assertOwnerPrivateDir pins and validates the SSH ControlMaster directory. If the open fails with ELOOP (symlink loop) or ENOTDIR, it lstats the path to determine whether it is a symlink and throws a descriptive error — the control directory path is a symlink or not a directory, which could enable symlink attacks or break socket placement. The path is rejected regardless of race outcome after this point.
Source
Thrown at packages/coding-agent/src/ssh/connection-manager.ts:201
* Exported as a test seam.
*/
export function assertOwnerPrivateDir(dir: string): void {
const uid = process.getuid?.();
let fd: number;
try {
fd = fs.openSync(dir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
// O_NOFOLLOW rejects a symlinked final component; kernels report it as
// either ELOOP or (with O_DIRECTORY) ENOTDIR. Either way the entry is
// already refused — we only lstat here to label the failure precisely, so
// a swap after this point cannot weaken the (already-final) rejection.
if (code === "ELOOP" || code === "ENOTDIR") {
let isSymlink = false;
try {
isSymlink = fs.lstatSync(dir).isSymbolicLink();
} catch {}
throw new Error(`SSH control directory ${dir} ${isSymlink ? "is a symlink" : "is not a directory"}`);
}
throw err;
}
try {
let st = fs.fstatSync(fd);
// Normalize perms on the pinned inode only when it is ours; never fchmod a
// directory another user owns.
if ((uid === undefined || st.uid === uid) && (st.mode & 0o777) !== 0o700) {
try {
fs.fchmodSync(fd, 0o700);
st = fs.fstatSync(fd);
} catch (err) {
logger.debug("SSH control dir chmod failed", { path: dir, error: String(err) });
}
}
const reason = controlDirGuardError(
{ isSymlink: false, isDir: st.isDirectory(), uid: st.uid, mode: st.mode },
uid,View on GitHub (pinned to 9690622007)
Solutions
- Remove the symlink or replace it with a real directory: rm the path and mkdir -p it
- Point the control directory configuration at a genuine, private directory (e.g. ~/.ssh/controlmaster)
- Check `ls -ld <dir>` to see whether it is a symlink and where it points
- Ensure no startup script recreates the symlink
Example fix
# before $ ls -ld ~/.ssh/ctl lrwxr-xr-x ~/.ssh/ctl -> /tmp/ctl # after $ rm ~/.ssh/ctl && mkdir -m 700 ~/.ssh/ctl
Defensive patterns
Strategy: validation
Validate before calling
import * as fs from "node:fs";
const st = fs.lstatSync(dir);
if (st.isSymbolicLink() || !st.isDirectory()) {
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { mode: 0o700 });
} Type guard
function isRealDir(p: string): boolean { try { const s = fs.lstatSync(p); return s.isDirectory() && !s.isSymbolicLink(); } catch { return false; } } Try / catch
try { await ensureSshControlDir(dir); } catch (e) { if (e instanceof Error && /is a symlink|is not a directory/.test(e.message)) { fs.rmSync(dir, { force: true, recursive: true }); fs.mkdirSync(dir, { mode: 0o700 }); await ensureSshControlDir(dir); } else throw e; } Prevention
- Point the control dir at a real private directory (0700), not a symlinked cloud-synced path
- Check `ls -ld` before configuring a custom control directory
- Avoid setup scripts that create symlinks at the control path
- Keep the directory out of shared /tmp locations
When it happens
Trigger: SSH control master dir configured (e.g. via env or options) pointing at: a symlink, a regular file, or a symlink loop; path created by another tool as a link; corrupted tmp/userdata layout.
Common situations: Users pointing the control dir at ~/control when that's a symlink into Dropbox/iCloud, tampered or overly-permissive shared tmp directories, leftover symlink from a previous setup script.
Related errors
- The managed-skills root is a symlink; refusing to operate ou
- Managed skill "${name}" SKILL.md is a symlink; refusing to o
- Managed skill "${name}" resolves through a symlink; refusing
- Managed skill "${safe}" is a symlink; refusing to delete out
- Security output directory must not be a symbolic link
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/3a1165a061e6b5a2.
Report an issue: GitHub.