diff --git a/CHANGELOG.md b/CHANGELOG.md index b31c9d8dbfe..10960c8984e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -325,6 +325,7 @@ Docs: https://docs.openclaw.ai - Codex/app-server: stabilize transcript mirror dedupe across re-mirrored turns so reordered snapshots no longer drop reasoning entries or duplicate the assistant reply. Refs #77012. (#77046) Thanks @openperf. - Agents/auth-profiles: do not record request-shape (`format`) rejections as auth-profile health failures, so a single per-session transcript-shape error (such as a prefill-strict 400 "conversation must end with a user message") no longer triggers a profile-wide cooldown that blocks every other healthy session sharing the same auth profile. Refs #77228. (#77280) Thanks @openperf. - CLI/update: stop dev-channel source updates immediately when `git fetch` fails, so tag conflicts cannot keep preflight, rebase, or build steps running against stale refs while the Gateway is still on the old runtime. (#77845) Thanks @obviyus. +- Config/recovery: chmod restored `openclaw.json` back to owner-only (`0600`) after suspicious-read backup recovery on POSIX hosts, so a previously world-readable config mode cannot persist into a freshly restored credential-bearing config. (#77488) Thanks @drobison00. ## 2026.5.3-1 diff --git a/src/config/io.observe-recovery.test.ts b/src/config/io.observe-recovery.test.ts index a7f188082b7..88e8564a932 100644 --- a/src/config/io.observe-recovery.test.ts +++ b/src/config/io.observe-recovery.test.ts @@ -275,6 +275,22 @@ describe("config observe recovery", () => { }); }); + it("hardens async backup restores to owner-only config permissions", async () => { + if (process.platform === "win32") { + return; + } + await withSuiteHome(async (home) => { + const { deps, configPath } = makeDeps(home); + await seedConfigBackup(configPath, recoverableTelegramConfig); + await writeClobberedUpdateChannel(configPath); + await fsp.chmod(configPath, 0o644); + + await recoverClobberedUpdateChannel({ deps, configPath }); + + expect((await fsp.stat(configPath)).mode & 0o777).toBe(0o600); + }); + }); + it("auto-restores after a large size drop against last-good config", async () => { await withSuiteHome(async (home) => { const { deps, configPath, auditPath } = makeDeps(home); @@ -456,6 +472,22 @@ describe("config observe recovery", () => { }); }); + it("hardens sync backup restores to owner-only config permissions", async () => { + if (process.platform === "win32") { + return; + } + await withSuiteHome(async (home) => { + const { deps, configPath } = makeDeps(home); + await seedConfigBackup(configPath, recoverableTelegramConfig); + await writeClobberedUpdateChannel(configPath); + await fsp.chmod(configPath, 0o644); + + recoverClobberedUpdateChannelSync({ deps, configPath }); + + expect((await fsp.stat(configPath)).mode & 0o777).toBe(0o600); + }); + }); + it("logs async health-state write failures", async () => { await withSuiteHome(async (home) => { const { deps, configPath, warn } = makeDeps(home); diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index a9f676139bd..db5b7edf6be 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -640,6 +640,7 @@ export async function maybeRecoverSuspiciousConfigRead(params: { let restoreError: unknown; try { await params.deps.fs.promises.copyFile(backupPath, params.configPath); + await params.deps.fs.promises.chmod?.(params.configPath, 0o600).catch(() => {}); restoredFromBackup = true; } catch (error) { restoreError = error; @@ -747,6 +748,9 @@ export function maybeRecoverSuspiciousConfigReadSync(params: { let restoreError: unknown; try { params.deps.fs.copyFileSync(backupPath, params.configPath); + try { + params.deps.fs.chmodSync?.(params.configPath, 0o600); + } catch {} restoredFromBackup = true; } catch (error) { restoreError = error;