fix(doctor): require confirmation for transcript archive

This commit is contained in:
Peter Steinberger
2026-04-28 08:53:52 +01:00
parent 04e774eeac
commit a8b64b7d52
6 changed files with 84 additions and 7 deletions

View File

@@ -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.

View File

@@ -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.<timestamp>` to reclaim space safely.
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` 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.

View File

@@ -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();

View File

@@ -11,11 +11,16 @@ import { guardCancel } from "./onboard-helpers.js";
export type { DoctorOptions } from "./doctor.types.js";
type DoctorConfirmParams = Parameters<typeof confirm>[0];
type DoctorRuntimeRepairConfirmParams = DoctorConfirmParams & {
requiresInteractiveConfirmation?: boolean;
};
export type DoctorPrompter = {
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmAutoFix: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmAggressiveAutoFix: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmRuntimeRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmRuntimeRepair: (params: DoctorRuntimeRepairConfirmParams) => Promise<boolean>;
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
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,
);

View File

@@ -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.<timestamp>."),
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 () => {

View File

@@ -28,7 +28,11 @@ import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
type DoctorPrompterLike = {
confirmRuntimeRepair: (params: { message: string; initialValue?: boolean }) => Promise<boolean>;
confirmRuntimeRepair: (params: {
message: string;
initialValue?: boolean;
requiresInteractiveConfirmation?: boolean;
}) => Promise<boolean>;
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.<timestamp>.`,
initialValue: false,
requiresInteractiveConfirmation: true,
});
if (archiveOrphans) {
let archived = 0;