From 62ccf026ca8fc2972b7bc00c15ee89a65d1fb263 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 18:57:18 -0700 Subject: [PATCH] fix(config): match the config directory through symlinks when diagnosing permissions (#127734) The permission diagnosis added in #127703 compared Node's reported errno path against the configured directory as raw strings. Node reports the canonical path, so a config directory reached through a symlink never matched and the operator fell back to the raw EACCES the change existed to replace. macOS /var -> /private/var makes this ordinary, not exotic. Resolve the directory only when the raw comparison fails, so successful config writes gain no syscall. The narrow path check stays: an unrelated permission error from the caller's own mutation must keep propagating. --- src/config/mutate.test.ts | 24 ++++++++++++++++++++++++ src/config/mutate.ts | 19 +++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts index b232b7d475e4..80f9f8462a54 100644 --- a/src/config/mutate.test.ts +++ b/src/config/mutate.test.ts @@ -514,6 +514,30 @@ describe("config mutate helpers", () => { }, ); + it.runIf(process.platform !== "win32")( + "diagnoses config lock failures through a symlinked config directory", + async () => { + const root = await suiteRootTracker.make("lock-permission-symlink"); + const realConfigDir = path.join(root, "real"); + const configuredDir = path.join(root, "configured"); + await fs.mkdir(realConfigDir); + await fs.symlink(realConfigDir, configuredDir); + const configPath = path.join(configuredDir, "openclaw.json"); + const lockPath = path.join(realConfigDir, "openclaw.json.lock"); + const failure = Object.assign(new Error(`EACCES: permission denied, open '${lockPath}'`), { + code: "EACCES", + path: lockPath, + }); + fileLockMocks.withFileLock.mockRejectedValueOnce(failure); + const snapshot = createSnapshot({ hash: "hash-1", path: configPath, sourceConfig: {} }); + + await expect(replaceConfigFile({ snapshot, nextConfig: {} })).rejects.toMatchObject({ + message: `OpenClaw cannot write to the config directory ${configuredDir}. Fix its ownership or permissions, then try again. Underlying error: ${failure.message}`, + cause: failure, + }); + }, + ); + it("preserves a permission failure raised outside the config directory", async () => { const configDir = await suiteRootTracker.make("lock-unrelated-permission"); const configPath = path.join(configDir, "openclaw.json"); diff --git a/src/config/mutate.ts b/src/config/mutate.ts index a6564c9e6648..d3ad09309161 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -225,10 +225,10 @@ async function withConfigMutationLock( async () => await withFileLock(configPath, CONFIG_MUTATION_LOCK_OPTIONS, fn), ), ) - .catch((error: unknown) => { + .catch(async (error: unknown) => { // Only relabel a permission failure on the config directory itself. The caller's mutation // runs inside this scope, so an unrelated EACCES from its own work must not be misdiagnosed. - if (!isPermissionErrorInDirectory(error, configDir)) { + if (!(await isPermissionErrorInDirectory(error, configDir))) { throw error; } throw new Error( @@ -238,7 +238,7 @@ async function withConfigMutationLock( }); } -function isPermissionErrorInDirectory(error: unknown, directory: string): boolean { +async function isPermissionErrorInDirectory(error: unknown, directory: string): Promise { if ( !isErrno(error) || (error.code !== "EACCES" && error.code !== "EPERM" && error.code !== "EROFS") @@ -246,7 +246,18 @@ function isPermissionErrorInDirectory(error: unknown, directory: string): boolea return false; } const failedPath = error.path; - return typeof failedPath === "string" && path.dirname(path.resolve(failedPath)) === directory; + if (typeof failedPath !== "string") { + return false; + } + const failedDir = path.dirname(path.resolve(failedPath)); + if (failedDir === directory) { + return true; + } + // Node reports the canonical path, so a config directory reached through a symlink (a macOS + // /var -> /private/var home, for one) never matches the raw string. Resolve only on mismatch to + // keep the successful write path free of an extra syscall. + const canonicalDirectory = await fs.realpath(directory).catch(() => undefined); + return canonicalDirectory !== undefined && failedDir === canonicalDirectory; } function markActiveConfigMutationPath(configPath: string): void {