From 0ec7711bc263538081cf274f43689a786de78ce4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 17:35:55 +0100 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + docs/cli/index.md | 3 +- docs/reference/wizard.md | 2 + docs/start/wizard-cli-reference.md | 1 + docs/start/wizard.md | 1 + ...ed-runner.sanitize-session-history.test.ts | 104 ++++++++++++++++++ src/agents/pi-embedded-runner/google.ts | 48 ++++++-- src/agents/pi-embedded-runner/run/attempt.ts | 6 +- ...-embedded-subscribe.handlers.compaction.ts | 21 ++++ .../compaction-safeguard.test.ts | 56 ++++++++++ .../pi-extensions/compaction-safeguard.ts | 10 ++ src/agents/workspace.test.ts | 18 +++ src/agents/workspace.ts | 27 ++++- src/cli/program/register.onboard.test.ts | 11 ++ src/cli/program/register.onboard.ts | 8 +- src/commands/configure.wizard.ts | 28 +++++ src/commands/onboard-types.ts | 1 + src/commands/onboard.test.ts | 63 +++++++++++ src/commands/onboard.ts | 13 ++- src/plugins/wired-hooks-compaction.test.ts | 68 ++++++++++++ 20 files changed, 472 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6289cf28ee0..109d886fbfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Models/MiniMax auth header defaults: set `authHeader: true` for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (`minimax`, `minimax-portal`) provider templates so first requests no longer fail with MiniMax `401 authentication_error` due to missing `Authorization` header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303) - Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602) - BlueBubbles/SSRF: auto-allowlist the configured `serverUrl` hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting. +- Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change `openclaw onboard --reset` default scope to `config+creds+sessions` (workspace deletion now requires `--reset-scope full`). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492. - Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting. - Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. diff --git a/docs/cli/index.md b/docs/cli/index.md index bf7218146ac..bb09b062210 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -328,7 +328,8 @@ Interactive wizard to set up gateway, workspace, and skills. Options: - `--workspace ` -- `--reset` (reset config + credentials + sessions + workspace before wizard) +- `--reset` (reset config + credentials + sessions before wizard) +- `--reset-scope ` (default `config+creds+sessions`; use `full` to also remove workspace) - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 6cc8a83b927..4f85e7e866d 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -20,6 +20,8 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - If `~/.openclaw/openclaw.json` exists, choose **Keep / Modify / Reset**. - Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`). + - CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` + to also remove workspace. - If the config is invalid or contains legacy keys, the wizard stops and asks you to run `openclaw doctor` before continuing. - Reset uses `trash` (never `rm`) and offers scopes: diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 1790020c852..5019956a05c 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -33,6 +33,7 @@ It does not install or modify anything on the remote host. - If `~/.openclaw/openclaw.json` exists, choose Keep, Modify, or Reset. - Re-running the wizard does not wipe anything unless you explicitly choose Reset (or pass `--reset`). + - CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` to also remove workspace. - If config is invalid or contains legacy keys, the wizard stops and asks you to run `openclaw doctor` before continuing. - Reset uses `trash` and offers scopes: - Config only diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 6cdb2e8fa95..ecf059c3b89 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -77,6 +77,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`). +CLI `--reset` defaults to config, credentials, and sessions; use `--reset-scope full` to include workspace. If the config is invalid or contains legacy keys, the wizard asks you to run `openclaw doctor` first. diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 6e401b92e0a..20ea0905d91 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -268,6 +268,110 @@ describe("sanitizeSessionHistory", () => { expect(assistants[1]?.usage).toBeDefined(); }); + it("drops stale usage when compaction summary appears before kept assistant messages", async () => { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + + const compactionTs = Date.parse("2026-02-26T12:00:00.000Z"); + const messages = [ + { + role: "compactionSummary", + summary: "compressed", + tokensBefore: 191_919, + timestamp: new Date(compactionTs).toISOString(), + }, + { + role: "assistant", + content: [{ type: "text", text: "kept pre-compaction answer" }], + stopReason: "stop", + timestamp: compactionTs - 1_000, + usage: { + input: 191_919, + output: 2_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 193_919, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + ] as unknown as AgentMessage[]; + + const result = await sanitizeSessionHistory({ + messages, + modelApi: "openai-responses", + provider: "openai", + sessionManager: mockSessionManager, + sessionId: TEST_SESSION_ID, + }); + + const assistant = result.find((message) => message.role === "assistant") as + | (AgentMessage & { usage?: unknown }) + | undefined; + expect(assistant?.usage).toBeUndefined(); + }); + + it("keeps fresh usage after compaction timestamp in summary-first ordering", async () => { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + + const compactionTs = Date.parse("2026-02-26T12:00:00.000Z"); + const messages = [ + { + role: "compactionSummary", + summary: "compressed", + tokensBefore: 123_000, + timestamp: new Date(compactionTs).toISOString(), + }, + { + role: "assistant", + content: [{ type: "text", text: "kept pre-compaction answer" }], + stopReason: "stop", + timestamp: compactionTs - 2_000, + usage: { + input: 120_000, + output: 3_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 123_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + { role: "user", content: "new question", timestamp: compactionTs + 1_000 }, + { + role: "assistant", + content: [{ type: "text", text: "fresh answer" }], + stopReason: "stop", + timestamp: compactionTs + 2_000, + usage: { + input: 1_000, + output: 250, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_250, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + ] as unknown as AgentMessage[]; + + const result = await sanitizeSessionHistory({ + messages, + modelApi: "openai-responses", + provider: "openai", + sessionManager: mockSessionManager, + sessionId: TEST_SESSION_ID, + }); + + const assistants = result.filter((message) => message.role === "assistant") as Array< + AgentMessage & { usage?: unknown; content?: unknown } + >; + const keptAssistant = assistants.find((message) => + JSON.stringify(message.content).includes("kept pre-compaction answer"), + ); + const freshAssistant = assistants.find((message) => + JSON.stringify(message.content).includes("fresh answer"), + ); + expect(keptAssistant?.usage).toBeUndefined(); + expect(freshAssistant?.usage).toBeDefined(); + }); + it("keeps reasoning-only assistant messages for openai-responses", async () => { setNonGoogleModelApi(); diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index 42970ea4ef6..5e8f546cd09 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -133,27 +133,59 @@ function annotateInterSessionUserMessages(messages: AgentMessage[]): AgentMessag return touched ? out : messages; } -function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]): AgentMessage[] { - let latestCompactionSummaryIndex = -1; - for (let i = 0; i < messages.length; i += 1) { - if (messages[i]?.role === "compactionSummary") { - latestCompactionSummaryIndex = i; +function parseMessageTimestamp(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) { + return parsed; } } - if (latestCompactionSummaryIndex <= 0) { + return null; +} + +function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]): AgentMessage[] { + let latestCompactionSummaryIndex = -1; + let latestCompactionTimestamp: number | null = null; + for (let i = 0; i < messages.length; i += 1) { + const entry = messages[i]; + if (entry?.role !== "compactionSummary") { + continue; + } + latestCompactionSummaryIndex = i; + latestCompactionTimestamp = parseMessageTimestamp( + (entry as { timestamp?: unknown }).timestamp ?? null, + ); + } + if (latestCompactionSummaryIndex === -1) { return messages; } const out = [...messages]; let touched = false; - for (let i = 0; i < latestCompactionSummaryIndex; i += 1) { - const candidate = out[i] as (AgentMessage & { usage?: unknown }) | undefined; + for (let i = 0; i < out.length; i += 1) { + const candidate = out[i] as + | (AgentMessage & { usage?: unknown; timestamp?: unknown }) + | undefined; if (!candidate || candidate.role !== "assistant") { continue; } if (!candidate.usage || typeof candidate.usage !== "object") { continue; } + + const messageTimestamp = parseMessageTimestamp(candidate.timestamp); + const staleByTimestamp = + latestCompactionTimestamp !== null && + messageTimestamp !== null && + messageTimestamp <= latestCompactionTimestamp; + const staleByLegacyOrdering = i < latestCompactionSummaryIndex; + if (!staleByTimestamp && !staleByLegacyOrdering) { + continue; + } + const candidateRecord = candidate as unknown as Record; const { usage: _droppedUsage, ...rest } = candidateRecord; out[i] = rest as unknown as AgentMessage; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index a0f4519a4f1..82f1df852fa 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1162,13 +1162,15 @@ export async function runEmbeddedAttempt( } } + const compactionOccurredThisAttempt = getCompactionCount() > 0; + // Append cache-TTL timestamp AFTER prompt + compaction retry completes. // Previously this was before the prompt, which caused a custom entry to be // inserted between compaction and the next prompt — breaking the // prepareCompaction() guard that checks the last entry type, leading to // double-compaction. See: https://github.com/openclaw/openclaw/issues/9282 // Skip when timed out during compaction — session state may be inconsistent. - if (!timedOutDuringCompaction) { + if (!timedOutDuringCompaction && !compactionOccurredThisAttempt) { const shouldTrackCacheTtl = params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" && isCacheTtlEligibleProvider(params.provider, params.modelId); @@ -1200,7 +1202,7 @@ export async function runEmbeddedAttempt( messagesSnapshot = snapshotSelection.messagesSnapshot; sessionIdUsed = snapshotSelection.sessionIdUsed; - if (promptError && promptErrorSource === "prompt") { + if (promptError && promptErrorSource === "prompt" && !compactionOccurredThisAttempt) { try { sessionManager.appendCustomEntry("openclaw:prompt-error", { timestamp: Date.now(), diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts index a8072bf2e1a..8ae5d1ef465 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts @@ -52,6 +52,7 @@ export function handleAutoCompactionEnd( ctx.log.debug(`embedded run compaction retry: runId=${ctx.params.runId}`); } else { ctx.maybeResolveCompactionWait(); + clearStaleAssistantUsageOnSessionMessages(ctx); } emitAgentEvent({ runId: ctx.params.runId, @@ -81,3 +82,23 @@ export function handleAutoCompactionEnd( } } } + +function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedPiSubscribeContext): void { + const messages = ctx.params.session.messages; + if (!Array.isArray(messages)) { + return; + } + for (const message of messages) { + if (!message || typeof message !== "object") { + continue; + } + const candidate = message as { role?: unknown; usage?: unknown }; + if (candidate.role !== "assistant") { + continue; + } + if (!("usage" in candidate)) { + continue; + } + delete (candidate as { usage?: unknown }).usage; + } +} diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 1c75139df97..60d3858c5d0 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -428,3 +428,59 @@ describe("compaction-safeguard extension model fallback", () => { expect(getApiKeyMock).not.toHaveBeenCalled(); }); }); + +describe("compaction-safeguard double-compaction guard", () => { + it("cancels compaction when there are no real messages to summarize", async () => { + const sessionManager = stubSessionManager(); + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model }); + + const compactionHandler = createCompactionHandler(); + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-1", + tokensBefore: 1500, + fileOps: { read: [], edited: [], written: [] }, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + + const getApiKeyMock = vi.fn().mockResolvedValue("sk-test"); + const mockContext = createCompactionContext({ + sessionManager, + getApiKeyMock, + }); + + const result = (await compactionHandler(mockEvent, mockContext)) as { + cancel?: boolean; + }; + expect(result).toEqual({ cancel: true }); + expect(getApiKeyMock).not.toHaveBeenCalled(); + }); + + it("continues when messages include real conversation content", async () => { + const sessionManager = stubSessionManager(); + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model }); + + const compactionHandler = createCompactionHandler(); + const mockEvent = createCompactionEvent({ + messageText: "real message", + tokensBefore: 1500, + }); + const getApiKeyMock = vi.fn().mockResolvedValue(null); + const mockContext = createCompactionContext({ + sessionManager, + getApiKeyMock, + }); + + const result = (await compactionHandler(mockEvent, mockContext)) as { + cancel?: boolean; + }; + expect(result).toEqual({ cancel: true }); + expect(getApiKeyMock).toHaveBeenCalled(); + }); +}); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index b7c15d50397..fbcf82b2003 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -130,6 +130,10 @@ function formatToolFailuresSection(failures: ToolFailure[]): string { return `\n\n## Tool Failures\n${lines.join("\n")}`; } +function isRealConversationMessage(message: AgentMessage): boolean { + return message.role === "user" || message.role === "assistant" || message.role === "toolResult"; +} + function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[]; @@ -191,6 +195,12 @@ async function readWorkspaceContextForSummary(): Promise { export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { const { preparation, customInstructions, signal } = event; + if (!preparation.messagesToSummarize.some(isRealConversationMessage)) { + log.warn( + "Compaction safeguard: cancelling compaction with no real conversation messages to summarize.", + ); + return { cancel: true }; + } const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps); const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles); const toolFailures = collectToolFailures([ diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 3f080077fa9..3586c6c8e3d 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -103,6 +103,24 @@ describe("ensureAgentWorkspace", () => { expect(state.bootstrapSeededAt).toBeUndefined(); expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); }); + + it("treats memory-backed workspaces as existing even when template files are missing", async () => { + const tempDir = await makeTempWorkspace("openclaw-workspace-"); + await fs.mkdir(path.join(tempDir, "memory"), { recursive: true }); + await fs.writeFile(path.join(tempDir, "memory", "2026-02-25.md"), "# Daily log\nSome notes"); + await fs.writeFile(path.join(tempDir, "MEMORY.md"), "# Long-term memory\nImportant stuff"); + + await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); + + await expect(fs.access(path.join(tempDir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined(); + await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({ + code: "ENOENT", + }); + const state = await readOnboardingState(tempDir); + expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + const memoryContent = await fs.readFile(path.join(tempDir, "MEMORY.md"), "utf-8"); + expect(memoryContent).toBe("# Long-term memory\nImportant stuff"); + }); }); describe("loadWorkspaceBootstrapFiles", () => { diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 89b788f1e02..d4db743581b 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -349,7 +349,13 @@ export async function ensureAgentWorkspace(params?: { const statePath = resolveWorkspaceStatePath(dir); const isBrandNewWorkspace = await (async () => { - const paths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath]; + const templatePaths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath]; + const userContentPaths = [ + path.join(dir, "memory"), + path.join(dir, DEFAULT_MEMORY_FILENAME), + path.join(dir, ".git"), + ]; + const paths = [...templatePaths, ...userContentPaths]; const existing = await Promise.all( paths.map(async (p) => { try { @@ -394,14 +400,27 @@ export async function ensureAgentWorkspace(params?: { } if (!state.bootstrapSeededAt && !state.onboardingCompletedAt && !bootstrapExists) { - // Legacy migration path: if USER/IDENTITY diverged from templates, treat onboarding as complete - // and avoid recreating BOOTSTRAP for already-onboarded workspaces. + // Legacy migration path: if USER/IDENTITY diverged from templates, or if user-content + // indicators exist, treat onboarding as complete and avoid recreating BOOTSTRAP for + // already-onboarded workspaces. const [identityContent, userContent] = await Promise.all([ fs.readFile(identityPath, "utf-8"), fs.readFile(userPath, "utf-8"), ]); + const hasUserContent = await (async () => { + const indicators = [path.join(dir, "memory"), path.join(dir, DEFAULT_MEMORY_FILENAME)]; + for (const indicator of indicators) { + try { + await fs.access(indicator); + return true; + } catch { + // continue + } + } + return false; + })(); const legacyOnboardingCompleted = - identityContent !== identityTemplate || userContent !== userTemplate; + identityContent !== identityTemplate || userContent !== userTemplate || hasUserContent; if (legacyOnboardingCompleted) { markState({ onboardingCompletedAt: nowIso() }); } else { diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index 89d6e2433c2..2c923bb70ab 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -108,6 +108,17 @@ describe("registerOnboardCommand", () => { ); }); + it("forwards --reset-scope to onboard command options", async () => { + await runCli(["onboard", "--reset", "--reset-scope", "full"]); + expect(onboardCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + reset: true, + resetScope: "full", + }), + runtime, + ); + }); + it("parses --mistral-api-key and forwards mistralApiKey", async () => { await runCli(["onboard", "--mistral-api-key", "sk-mistral-test"]); expect(onboardCommandMock).toHaveBeenCalledWith( diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 4c8193ce900..b039b2e83ca 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -7,6 +7,7 @@ import type { GatewayAuthChoice, GatewayBind, NodeManagerChoice, + ResetScope, SecretInputMode, TailscaleMode, } from "../../commands/onboard-types.js"; @@ -55,7 +56,11 @@ export function registerOnboardCommand(program: Command) { `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/onboard", "docs.openclaw.ai/cli/onboard")}\n`, ) .option("--workspace ", "Agent workspace directory (default: ~/.openclaw/workspace)") - .option("--reset", "Reset config + credentials + sessions + workspace before running wizard") + .option( + "--reset", + "Reset config + credentials + sessions before running wizard (workspace only with --reset-scope full)", + ) + .option("--reset-scope ", "Reset scope: config|config+creds+sessions|full") .option("--non-interactive", "Run without prompts", false) .option( "--accept-risk", @@ -178,6 +183,7 @@ export function registerOnboardCommand(program: Command) { tailscale: opts.tailscale as TailscaleMode | undefined, tailscaleResetOnExit: Boolean(opts.tailscaleResetOnExit), reset: Boolean(opts.reset), + resetScope: opts.resetScope as ResetScope | undefined, installDaemon, daemonRuntime: opts.daemonRuntime as GatewayDaemonRuntime | undefined, skipChannels: Boolean(opts.skipChannels), diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index e96983461ba..5639b5e6d07 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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: { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 95b480ce433..fee12d392bb 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -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; diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index c1150c73d0f..9e7dde1ed4c 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -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(); + }); }); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index c2affc60d78..1901d70e08f 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -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(["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") { diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index 05e63a2b2f9..f58d0d6803e 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -122,4 +122,72 @@ describe("compaction hook wiring", () => { expect(hookMocks.runner.runAfterCompaction).not.toHaveBeenCalled(); }); + + it("clears stale assistant usage after final compaction", () => { + const messages = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: "response one", + usage: { totalTokens: 180_000, input: 100, output: 50 }, + }, + { + role: "assistant", + content: "response two", + usage: { totalTokens: 181_000, input: 120, output: 60 }, + }, + ]; + + const ctx = { + params: { runId: "r4", session: { messages } }, + state: { compactionInFlight: true }, + log: { debug: vi.fn(), warn: vi.fn() }, + maybeResolveCompactionWait: vi.fn(), + getCompactionCount: () => 1, + incrementCompactionCount: vi.fn(), + }; + + handleAutoCompactionEnd( + ctx as never, + { + type: "auto_compaction_end", + willRetry: false, + } as never, + ); + + const assistantOne = messages[1] as { usage?: unknown }; + const assistantTwo = messages[2] as { usage?: unknown }; + expect(assistantOne.usage).toBeUndefined(); + expect(assistantTwo.usage).toBeUndefined(); + }); + + it("does not clear assistant usage while compaction is retrying", () => { + const messages = [ + { + role: "assistant", + content: "response", + usage: { totalTokens: 184_297, input: 130_000, output: 2_000 }, + }, + ]; + + const ctx = { + params: { runId: "r5", session: { messages } }, + state: { compactionInFlight: true }, + log: { debug: vi.fn(), warn: vi.fn() }, + noteCompactionRetry: vi.fn(), + resetForCompactionRetry: vi.fn(), + getCompactionCount: () => 0, + }; + + handleAutoCompactionEnd( + ctx as never, + { + type: "auto_compaction_end", + willRetry: true, + } as never, + ); + + const assistant = messages[0] as { usage?: unknown }; + expect(assistant.usage).toEqual({ totalTokens: 184_297, input: 130_000, output: 2_000 }); + }); });