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.
This commit is contained in:
Peter Steinberger
2026-08-21 18:57:18 -07:00
committed by GitHub
parent 557a5c131b
commit 62ccf026ca
2 changed files with 39 additions and 4 deletions
+24
View File
@@ -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");
+15 -4
View File
@@ -225,10 +225,10 @@ async function withConfigMutationLock<T>(
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<T>(
});
}
function isPermissionErrorInDirectory(error: unknown, directory: string): boolean {
async function isPermissionErrorInDirectory(error: unknown, directory: string): Promise<boolean> {
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 {