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

  1. Remove the symlink or replace it with a real directory: rm the path and mkdir -p it
  2. Point the control directory configuration at a genuine, private directory (e.g. ~/.ssh/controlmaster)
  3. Check `ls -ld <dir>` to see whether it is a symlink and where it points
  4. 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

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


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/3a1165a061e6b5a2. Report an issue: GitHub.