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>'
Bug:
fs.mkdir(..., { recursive: true })fails in Docker when intermediate parents exist but are owned by rootDescription
When running inside a Docker container as a non-root user, any call to
fs.mkdir(path, { recursive: true })thatneeds to traverse existing directories owned by root will fail with
EACCES: permission denied.This is because Node.js
recursive: truewill attempt tomkdirorlstatevery segment of the path. If aparent already exists but the process lacks write permission on it (e.g.
/owned by root, or/varowned 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/configmay have/runor/run/opencode-managerowned 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:backend/src/db/schema.tsbackend/src/index.tsbackend/src/services/file-operations.tsbackend/src/services/skills.tsbackend/src/services/repo.tsbackend/src/utils/ssh-key-manager.tsbackend/src/utils/discovery-cache.tsbackend/src/utils/atomic-json.tsbackend/src/ipc/sshHostKeyHandler.tsbackend/src/routes/mcp-oauth-proxy.tsbackend/src/routes/tts.tsProposed 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:
Alternatively, a simpler approach: wrap every
{ recursive: true }call in a try/catch that silently ignoresEACCESif the directory already exists (EEXISTis already handled natively, butEACCESon existing dirs is not).Environment
opencodeuser, UID 1000)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>'