From a8b64b7d523170ffdcabb538e601c6a871d8a7a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 08:53:52 +0100 Subject: [PATCH] fix(doctor): require confirmation for transcript archive --- CHANGELOG.md | 1 + docs/cli/doctor.md | 2 +- src/commands/doctor-prompter.test.ts | 36 +++++++++++++++++++++ src/commands/doctor-prompter.ts | 22 ++++++++++--- src/commands/doctor-state-integrity.test.ts | 23 +++++++++++++ src/commands/doctor-state-integrity.ts | 7 +++- 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b767bf124b..62b16df83e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto. - Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston. - Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev. +- Doctor/state: require an interactive confirmation before archiving orphan transcript files, so `openclaw doctor --fix` no longer silently renames recoverable session history after upgrades regenerate `sessions.json`. Fixes #73106. Thanks @scottgl9. - Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9. - Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby. - Build/runtime: preserve staged bundled-plugin runtime dependency caches across source-checkout tsdown rebuilds, so local CLI and gateway-watch rebuilds no longer recreate large plugin dependency trees before starting. Refs #73205. Thanks @SymbolStar. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index cb33e59e1a0..cb02b3d948f 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -41,7 +41,7 @@ Notes: - Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts. - Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive sessions still fully load plugins when a check needs their contribution. - `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal. -- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.` to reclaim space safely. +- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place. - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`; it can also be a path-list such as `/opt/openclaw/plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps`, where earlier roots are read-only lookup layers and the final root is the repair target. - Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy. diff --git a/src/commands/doctor-prompter.test.ts b/src/commands/doctor-prompter.test.ts index 06f35b169c7..03e2cd05e96 100644 --- a/src/commands/doctor-prompter.test.ts +++ b/src/commands/doctor-prompter.test.ts @@ -85,6 +85,42 @@ describe("createDoctorPrompter", () => { expect(confirmMock).not.toHaveBeenCalled(); }); + it("does not auto-accept runtime repairs that require interactive confirmation", async () => { + const prompter = createRepairPrompter(); + + await expect( + prompter.confirmRuntimeRepair({ + message: "Archive orphan transcripts?", + initialValue: false, + requiresInteractiveConfirmation: true, + }), + ).resolves.toBe(false); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("does not accept interactive-only runtime repairs through --yes defaults", async () => { + setNonInteractiveTerminal(); + const prompter = createDoctorPrompter({ + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + options: { + yes: true, + }, + }); + + await expect( + prompter.confirmRuntimeRepair({ + message: "Archive orphan transcripts?", + initialValue: true, + requiresInteractiveConfirmation: true, + }), + ).resolves.toBe(false); + expect(confirmMock).not.toHaveBeenCalled(); + }); + it("keeps skip-in-non-interactive prompts disabled during update-mode repairs", async () => { process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; const prompter = createRepairPrompter(); diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index 32f08f53d5b..3c44ed9d9dc 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -11,11 +11,16 @@ import { guardCancel } from "./onboard-helpers.js"; export type { DoctorOptions } from "./doctor.types.js"; +type DoctorConfirmParams = Parameters[0]; +type DoctorRuntimeRepairConfirmParams = DoctorConfirmParams & { + requiresInteractiveConfirmation?: boolean; +}; + export type DoctorPrompter = { confirm: (params: Parameters[0]) => Promise; confirmAutoFix: (params: Parameters[0]) => Promise; confirmAggressiveAutoFix: (params: Parameters[0]) => Promise; - confirmRuntimeRepair: (params: Parameters[0]) => Promise; + confirmRuntimeRepair: (params: DoctorRuntimeRepairConfirmParams) => Promise; select: (params: Parameters[0], fallback: T) => Promise; shouldRepair: boolean; shouldForce: boolean; @@ -71,19 +76,26 @@ export function createDoctorPrompter(params: { ); }, confirmRuntimeRepair: async (p) => { - if (shouldAutoApproveDoctorFix(repairMode, { blockDuringUpdate: true })) { + const { requiresInteractiveConfirmation, ...confirmParams } = p; + if ( + requiresInteractiveConfirmation !== true && + shouldAutoApproveDoctorFix(repairMode, { blockDuringUpdate: true }) + ) { return true; } + if (requiresInteractiveConfirmation === true && !repairMode.canPrompt) { + return false; + } if (repairMode.nonInteractive) { return false; } if (!repairMode.canPrompt) { - return p.initialValue ?? false; + return confirmParams.initialValue ?? false; } return guardCancel( await confirm({ - ...p, - message: stylePromptMessage(p.message), + ...confirmParams, + message: stylePromptMessage(confirmParams.message), }), params.runtime, ); diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index c562f3bec55..f64e8f85c14 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -296,12 +296,35 @@ describe("doctor state integrity oauth dir checks", () => { expect(confirmRuntimeRepair).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("This only renames them to *.deleted.."), + requiresInteractiveConfirmation: true, }), ); const files = fs.readdirSync(sessionsDir); expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(true); }); + it("does not auto-archive orphan transcripts from non-interactive repair mode", async () => { + const cfg: OpenClawConfig = {}; + setupSessionState(cfg, process.env, process.env.HOME ?? ""); + const sessionsDir = resolveSessionTranscriptsDirForAgent("main", process.env, () => tempHome); + fs.writeFileSync(path.join(sessionsDir, "orphan-session.jsonl"), '{"type":"session"}\n'); + const confirmRuntimeRepair = vi.fn( + async (params: { initialValue?: boolean; requiresInteractiveConfirmation?: boolean }) => + params.requiresInteractiveConfirmation !== true, + ); + await noteStateIntegrity(cfg, { confirmRuntimeRepair, note: noteMock }); + + expect(confirmRuntimeRepair).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: false, + requiresInteractiveConfirmation: true, + }), + ); + const files = fs.readdirSync(sessionsDir); + expect(files).toContain("orphan-session.jsonl"); + expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(false); + }); + it.skipIf(process.platform === "win32")( "does not archive referenced transcripts when the state dir path resolves through a symlink", async () => { diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index cd45d559b0c..291e7881e46 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -28,7 +28,11 @@ import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; type DoctorPrompterLike = { - confirmRuntimeRepair: (params: { message: string; initialValue?: boolean }) => Promise; + confirmRuntimeRepair: (params: { + message: string; + initialValue?: boolean; + requiresInteractiveConfirmation?: boolean; + }) => Promise; note?: typeof note; }; @@ -921,6 +925,7 @@ export async function noteStateIntegrity( const archiveOrphans = await prompter.confirmRuntimeRepair({ message: `Archive ${orphanCount} in ${displaySessionsDir}? This only renames them to *.deleted..`, initialValue: false, + requiresInteractiveConfirmation: true, }); if (archiveOrphans) { let archived = 0;