fix(agents): harden compaction and reset safety

Co-authored-by: jaden-clovervnd <91520439+jaden-clovervnd@users.noreply.github.com>
Co-authored-by: Sid <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: Marcus Widing <245375637+widingmarcus-cyber@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-26 17:35:55 +01:00
parent 273973d374
commit 0ec7711bc2
20 changed files with 472 additions and 18 deletions

View File

@@ -1,3 +1,5 @@
import fsPromises from "node:fs/promises";
import nodePath from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js";
@@ -332,6 +334,32 @@ export async function runConfigureWizard(
runtime,
);
workspaceDir = resolveUserPath(String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE);
if (!snapshot.exists) {
const indicators = ["MEMORY.md", "memory", ".git"].map((name) =>
nodePath.join(workspaceDir, name),
);
const hasExistingContent = (
await Promise.all(
indicators.map(async (candidate) => {
try {
await fsPromises.access(candidate);
return true;
} catch {
return false;
}
}),
)
).some(Boolean);
if (hasExistingContent) {
note(
[
`Existing workspace detected at ${workspaceDir}`,
"Existing files are preserved. Missing templates may be created, never overwritten.",
].join("\n"),
"Existing workspace",
);
}
}
nextConfig = {
...nextConfig,
agents: {

View File

@@ -98,6 +98,7 @@ export type OnboardOptions = {
/** Required for non-interactive onboarding; skips the interactive risk prompt when true. */
acceptRisk?: boolean;
reset?: boolean;
resetScope?: ResetScope;
authChoice?: AuthChoice;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProvider?: string;

View File

@@ -4,6 +4,8 @@ import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
runInteractiveOnboarding: vi.fn(async () => {}),
runNonInteractiveOnboarding: vi.fn(async () => {}),
readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })),
handleReset: vi.fn(async () => {}),
}));
vi.mock("./onboard-interactive.js", () => ({
@@ -14,6 +16,15 @@ vi.mock("./onboard-non-interactive.js", () => ({
runNonInteractiveOnboarding: mocks.runNonInteractiveOnboarding,
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
vi.mock("./onboard-helpers.js", () => ({
DEFAULT_WORKSPACE: "~/.openclaw/workspace",
handleReset: mocks.handleReset,
}));
const { onboardCommand } = await import("./onboard.js");
function makeRuntime(): RuntimeEnv {
@@ -27,6 +38,7 @@ function makeRuntime(): RuntimeEnv {
describe("onboardCommand", () => {
afterEach(() => {
vi.clearAllMocks();
mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: false, config: {} });
});
it("fails fast for invalid secret-input-mode before onboarding starts", async () => {
@@ -46,4 +58,55 @@ describe("onboardCommand", () => {
expect(mocks.runInteractiveOnboarding).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveOnboarding).not.toHaveBeenCalled();
});
it("defaults --reset to config+creds+sessions scope", async () => {
const runtime = makeRuntime();
await onboardCommand(
{
reset: true,
},
runtime,
);
expect(mocks.handleReset).toHaveBeenCalledWith(
"config+creds+sessions",
expect.any(String),
runtime,
);
});
it("accepts explicit --reset-scope full", async () => {
const runtime = makeRuntime();
await onboardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(mocks.handleReset).toHaveBeenCalledWith("full", expect.any(String), runtime);
});
it("fails fast for invalid --reset-scope", async () => {
const runtime = makeRuntime();
await onboardCommand(
{
reset: true,
resetScope: "invalid" as never,
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
'Invalid --reset-scope. Use "config", "config+creds+sessions", or "full".',
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runInteractiveOnboarding).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveOnboarding).not.toHaveBeenCalled();
});
});

View File

@@ -8,7 +8,9 @@ import { isDeprecatedAuthChoice, normalizeLegacyOnboardAuthChoice } from "./auth
import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js";
import { runInteractiveOnboarding } from "./onboard-interactive.js";
import { runNonInteractiveOnboarding } from "./onboard-non-interactive.js";
import type { OnboardOptions } from "./onboard-types.js";
import type { OnboardOptions, ResetScope } from "./onboard-types.js";
const VALID_RESET_SCOPES = new Set<ResetScope>(["config", "config+creds+sessions", "full"]);
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
assertSupportedRuntime(runtime);
@@ -45,6 +47,12 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
return;
}
if (normalizedOpts.resetScope && !VALID_RESET_SCOPES.has(normalizedOpts.resetScope)) {
runtime.error('Invalid --reset-scope. Use "config", "config+creds+sessions", or "full".');
runtime.exit(1);
return;
}
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
runtime.error(
[
@@ -62,7 +70,8 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
const baseConfig = snapshot.valid ? snapshot.config : {};
const workspaceDefault =
normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE;
await handleReset("full", resolveUserPath(workspaceDefault), runtime);
const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions";
await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime);
}
if (process.platform === "win32") {