Skip to content

Bug: fs.mkdir(..., { recursive: true }) fails in Docker when intermediate parents exist but are owned by root #312

Description

@MNCloudwerksTechnology

Bug: fs.mkdir(..., { recursive: true }) fails in Docker when intermediate parents exist but are owned by root

Description

When running inside a Docker container as a non-root user, any call to fs.mkdir(path, { recursive: true }) that
needs to traverse existing directories owned by root will fail with EACCES: permission denied.

This is because Node.js recursive: true will attempt to mkdir or lstat every segment of the path. If a
parent already exists but the process lacks write permission on it (e.g. / owned by root, or /var owned by root),
the operation throws.

Why this is a Docker-specific problem

Outside Docker, the container runs as root and has full access to the entire filesystem. Inside Docker with a
non-root user, intermediate path components like /run/opencode-manager/config may have /run or /run/opencode-manager
owned by root. The recursive creation of the full path will fail even though the application only needs to create
/run/opencode-manager/config.

Affected code

Every { recursive: true } mkdir call in the codebase is potentially affected. Notable locations:

File Line Purpose
backend/src/db/schema.ts 11 Create SQLite DB parent dir
backend/src/index.ts 249-251 Create data dirs
backend/src/services/file-operations.ts 32 Create parent dir for file writes
backend/src/services/skills.ts 138, 144 Skill installation targets
backend/src/services/repo.ts 539, 617 Repo operation dirs
backend/src/utils/ssh-key-manager.ts 14, 172 SSH key/config dirs
backend/src/utils/discovery-cache.ts 20 Discovery cache dir
backend/src/utils/atomic-json.ts 23 Atomic JSON write parent dir
backend/src/ipc/sshHostKeyHandler.ts 43 Known hosts config dir
backend/src/routes/mcp-oauth-proxy.ts 35 OAuth auth state dir
backend/src/routes/tts.ts 32 TTS cache dir

Proposed fix

Add a helper that creates the shortest possible prefix of the path that doesn't already exist, stopping when it
hits a directory the process can't write to but already exists. Example approach:

async function mkdirSafe(dirPath: string, options: MkdirSyncOptions = {}) {
  const parts = dirPath.split('/').filter(Boolean)
  let current = ''
  for (const part of parts) {
    current += `/${part}`
    try {
      await fs.mkdir(current, { ...options, recursive: true })
    } catch (err: any) {
      if (err.code === 'EEXIST') continue
      // If the directory already exists but we got EACCES, skip it
      // This handles the case where parent was created by root
      const stat = await fs.stat(current).catch(() => null)
      if (stat?.isDirectory()) continue
      throw err
    }
  }
}

Alternatively, a simpler approach: wrap every { recursive: true } call in a try/catch that silently ignores
EACCES if the directory already exists (EEXIST is already handled natively, but EACCES on existing dirs is not).

try {
  await fs.mkdir(path.dirname(fullPath), { recursive: true })
} catch (err: any) {
  if (err.code === 'EACCES') {
    // Check if the directory actually exists (may have been created by root or another process)
    try {
      await fs.access(path.dirname(fullPath))
    } catch {
      throw err  // Re-throw only if dir truly doesn't exist
    }
  } else {
    throw err
  }
}

Environment

  • OS: Linux (running inside Docker container)
  • Container: Non-root user (e.g., opencode user, UID 1000)
  • Node.js: All supported versions
  • Docker: Any configuration where the container's runtime user is non-root

Expected behavior

The application should create all necessary directories regardless of whether intermediate path components are owned
by root, as long as those directories already exist.

Actual behavior

EACCES: permission denied, mkdir '/<intermediate-root-owned-path>'

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions