Merge remote-tracking branch 'origin/main' into fix/inline-slash-skills

This commit is contained in:
Jesse Merhi
2026-08-22 11:58:47 +10:00
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 {