diff --git a/biome.json b/biome.json index 2be5a2ab7bc..0339890e330 100644 --- a/biome.json +++ b/biome.json @@ -86,7 +86,7 @@ "noExcessiveCognitiveComplexity": { "level": "error", "options": { - "maxAllowedComplexity": 244 + "maxAllowedComplexity": 243 } } }, diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 540efb40d1c..c4e1651c608 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -311,6 +311,35 @@ function countActiveSandboxSessionsForRebuild(sandboxName: string): number { } } +async function confirmSandboxRebuildIfNeeded( + skipConfirm: boolean, + rebuildActiveSessionCount: number, +): Promise { + if (skipConfirm) return true; + + if (rebuildActiveSessionCount > 0) { + const plural = rebuildActiveSessionCount > 1 ? "sessions" : "session"; + console.log( + ` ${YW}⚠ Active SSH ${plural} detected (${rebuildActiveSessionCount} connection${rebuildActiveSessionCount > 1 ? "s" : ""})${R}`, + ); + console.log( + ` Rebuilding will terminate ${rebuildActiveSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`, + ); + console.log(""); + } + console.log(" This will:"); + console.log(" 1. Back up workspace state"); + console.log(" 2. Destroy and recreate the sandbox with the current image"); + console.log(" 3. Restore workspace state into the new sandbox"); + console.log(""); + const answer = await askPrompt(" Proceed? [y/N]: "); + if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") { + console.log(" Cancelled."); + return false; + } + return true; +} + async function reapplyMessagingManifestAfterOpenClawDoctor( sandboxName: string, plan: SandboxMessagingPlan | null, @@ -424,30 +453,11 @@ export async function rebuildSandbox( } console.log(""); - let rebuildConfirmed = false; - if (!skipConfirm) { - if (rebuildActiveSessionCount > 0) { - const plural = rebuildActiveSessionCount > 1 ? "sessions" : "session"; - console.log( - ` ${YW}⚠ Active SSH ${plural} detected (${rebuildActiveSessionCount} connection${rebuildActiveSessionCount > 1 ? "s" : ""})${R}`, - ); - console.log( - ` Rebuilding will terminate ${rebuildActiveSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`, - ); - console.log(""); - } - console.log(" This will:"); - console.log(" 1. Back up workspace state"); - console.log(" 2. Destroy and recreate the sandbox with the current image"); - console.log(" 3. Restore workspace state into the new sandbox"); - console.log(""); - const answer = await askPrompt(" Proceed? [y/N]: "); - if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") { - console.log(" Cancelled."); - return; - } - rebuildConfirmed = true; - } + const rebuildConfirmed = await confirmSandboxRebuildIfNeeded( + skipConfirm, + rebuildActiveSessionCount, + ); + if (!rebuildConfirmed) return; // Step 0: Preflight — verify recreate preconditions BEFORE destroying // anything. The most common rebuild failure is a missing provider diff --git a/test/rebuild-credential-preflight.test.ts b/test/rebuild-credential-preflight.test.ts index 394dfadd8db..a1ae4b58581 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -351,6 +351,45 @@ function registryHasSandbox(fixture: ReturnType): boolean describe("Issue #2273: atomic rebuild", () => { describe("Layer 2: preflight credential check", () => { + it("cancels interactive rebuild before credential preflight or backup on non-affirmative input", { + timeout: 60_000, + }, () => { + const f = createFixture({ + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + providerRegistered: false, + }); + + const result = runRebuild(f, {}, { yes: false, input: "n\n" }); + const output = (result.stderr || "") + (result.stdout || ""); + + expect(result.status).toBe(0); + expect(output).toContain("Proceed? [y/N]:"); + expect(output).toContain("Cancelled."); + expect(output).not.toContain("preflight failed"); + expect(output).not.toContain("Backing up sandbox state"); + expect(registryHasSandbox(f)).toBe(true); + }); + + it("accepts trimmed case-insensitive yes input before continuing rebuild", { + timeout: 60_000, + }, () => { + const f = createFixture({ + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + savedCredential: { + key: "NVIDIA_INFERENCE_API_KEY", + value: "nvapi-test-key-for-rebuild", + }, + }); + + const result = runRebuild(f, {}, { yes: false, input: " YES \n" }); + const output = (result.stderr || "") + (result.stdout || ""); + + expect(output).toContain("Proceed? [y/N]:"); + expect(output).not.toContain("Cancelled."); + expect(output).not.toContain("preflight failed"); + expect(output).toContain("Backing up sandbox state"); + }); + it("prints active SSH session warning before interactive confirmation", { timeout: 60_000, }, () => {