diff --git a/src/lib/nemoclaw-runtime-bridge.ts b/src/lib/nemoclaw-runtime-bridge.ts index 6f73af71abf..821632ca3c6 100644 --- a/src/lib/nemoclaw-runtime-bridge.ts +++ b/src/lib/nemoclaw-runtime-bridge.ts @@ -4,7 +4,6 @@ /* v8 ignore start -- transitional bridge until command actions are extracted from src/nemoclaw.ts. */ export interface NemoClawRuntimeBridge { - sandboxDestroy: (sandboxName: string, args?: string[]) => Promise; sandboxRebuild: (sandboxName: string, args?: string[]) => Promise; upgradeSandboxes: (args?: string[]) => Promise; } diff --git a/src/lib/sandbox-destroy-action.ts b/src/lib/sandbox-destroy-action.ts new file mode 100644 index 00000000000..869cd8c19bd --- /dev/null +++ b/src/lib/sandbox-destroy-action.ts @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* v8 ignore start -- exercised through CLI subprocess destroy/rebuild tests. */ + +import fs from "node:fs"; + +import { CLI_NAME } from "./branding"; +import { prompt as askPrompt } from "./credentials"; +import * as onboardSession from "./onboard-session"; +import type { Session } from "./onboard-session"; +import { OPENSHELL_PROBE_TIMEOUT_MS } from "./openshell-timeouts"; +import { DASHBOARD_PORT } from "./ports"; +import * as registry from "./registry"; +import { resolveOpenshell } from "./resolve-openshell"; +import { parseLiveSandboxNames } from "./runtime-recovery"; +import { + createSystemDeps as createSessionDeps, + getActiveSandboxSessions, +} from "./sandbox-session-state"; +import { stripAnsi } from "./openshell"; +import { G, R, YW } from "./terminal-style"; + +type SpawnLikeResult = { + status: number | null; + stdout?: string; + stderr?: string; +}; + +type DockerRmi = (tag: string, opts?: { ignoreError?: boolean }) => { status: number | null }; + +type RemoveSandboxImageDeps = { + getSandbox?: typeof registry.getSandbox; + dockerRmi?: DockerRmi; +}; + +type RemoveSandboxRegistryEntryDeps = { + removeImage?: (sandboxName: string) => void; + removeSandbox?: typeof registry.removeSandbox; +}; + +const NEMOCLAW_GATEWAY_NAME = "nemoclaw"; +const DASHBOARD_FORWARD_PORT = String(DASHBOARD_PORT); + +function cleanupGatewayAfterLastSandbox(): void { + const { runOpenshell } = require("./openshell-runtime") as { + runOpenshell: (args: string[], opts?: Record) => { status: number | null }; + }; + const { dockerRemoveVolumesByPrefix } = require("./docker") as { + dockerRemoveVolumesByPrefix: (prefix: string, opts?: { ignoreError?: boolean }) => void; + }; + + runOpenshell(["forward", "stop", DASHBOARD_FORWARD_PORT], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); + runOpenshell(["gateway", "destroy", "-g", NEMOCLAW_GATEWAY_NAME], { ignoreError: true }); + dockerRemoveVolumesByPrefix(`openshell-cluster-${NEMOCLAW_GATEWAY_NAME}`, { + ignoreError: true, + }); +} + +function hasNoLiveSandboxes(): boolean { + const { captureOpenshell } = require("./openshell-runtime") as { + captureOpenshell: ( + args: string[], + opts?: { ignoreError?: boolean; timeout?: number }, + ) => { status: number | null; output: string }; + }; + const liveList = captureOpenshell(["sandbox", "list"], { + ignoreError: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); + if (liveList.status !== 0) { + return false; + } + return parseLiveSandboxNames(liveList.output).size === 0; +} + +function isMissingSandboxDeleteResult(output = ""): boolean { + return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( + stripAnsi(output), + ); +} + +export function getSandboxDeleteOutcome(deleteResult: SpawnLikeResult): { + output: string; + alreadyGone: boolean; +} { + const output = `${deleteResult.stdout || ""}${deleteResult.stderr || ""}`.trim(); + return { + output, + alreadyGone: deleteResult.status !== 0 && isMissingSandboxDeleteResult(output), + }; +} + +function cleanupSandboxServices( + sandboxName: string, + { stopHostServices = false }: { stopHostServices?: boolean } = {}, +): void { + if (stopHostServices) { + const { stopAll } = require("./services"); + stopAll({ sandboxName }); + } + + const sb = registry.getSandbox(sandboxName); + if (sb?.provider?.includes("ollama")) { + const { unloadOllamaModels } = require("./onboard-ollama-proxy"); + unloadOllamaModels(); + } + + try { + fs.rmSync(`/tmp/nemoclaw-services-${sandboxName}`, { recursive: true, force: true }); + } catch { + // PID directory may not exist — ignore. + } + + // Delete messaging providers created during onboard. Suppress stderr so + // "! Provider not found" noise doesn't appear when messaging was never configured. + const { runOpenshell } = require("./openshell-runtime") as { + runOpenshell: (args: string[], opts?: Record) => { status: number | null }; + }; + for (const suffix of ["telegram-bridge", "discord-bridge", "slack-bridge"]) { + runOpenshell(["provider", "delete", `${sandboxName}-${suffix}`], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); + } +} + +/** + * Remove the host-side Docker image that was built for a sandbox during onboard. + * Must be called before registry.removeSandbox() since the imageTag is stored there. + */ +export function removeSandboxImage( + sandboxName: string, + deps: RemoveSandboxImageDeps = {}, +): void { + const getSandbox = deps.getSandbox ?? registry.getSandbox; + const removeImage = + deps.dockerRmi ?? (require("./docker") as { dockerRmi: DockerRmi }).dockerRmi; + const sb = getSandbox(sandboxName); + if (!sb?.imageTag) return; + const result = removeImage(sb.imageTag, { ignoreError: true }); + if (result.status === 0) { + console.log(` Removed Docker image ${sb.imageTag}`); + } else { + console.warn( + ` ${YW}⚠${R} Failed to remove Docker image ${sb.imageTag}; run '${CLI_NAME} gc' to clean up.`, + ); + } +} + +export function removeSandboxRegistryEntry( + sandboxName: string, + deps: RemoveSandboxRegistryEntryDeps = {}, +): boolean { + const removeImage = deps.removeImage ?? removeSandboxImage; + const removeSandbox = deps.removeSandbox ?? registry.removeSandbox; + removeImage(sandboxName); + return removeSandbox(sandboxName); +} + +export async function destroySandbox(sandboxName: string, args: string[] = []): Promise { + const skipConfirm = args.includes("--yes") || args.includes("--force"); + + // Active session detection — enrich the confirmation prompt if sessions are active + let activeSessionCount = 0; + const opsBin = resolveOpenshell(); + if (opsBin) { + try { + const sessionResult = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBin)); + if (sessionResult.detected) { + activeSessionCount = sessionResult.sessions.length; + } + } catch { + /* non-fatal */ + } + } + + if (!skipConfirm) { + console.log(` ${YW}Destroy sandbox '${sandboxName}'?${R}`); + if (activeSessionCount > 0) { + const plural = activeSessionCount > 1 ? "sessions" : "session"; + console.log( + ` ${YW}⚠ Active SSH ${plural} detected (${activeSessionCount} connection${activeSessionCount > 1 ? "s" : ""})${R}`, + ); + console.log( + ` Destroying will terminate ${activeSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`, + ); + } + console.log(" This will permanently delete the sandbox and all workspace files inside it."); + console.log(" This cannot be undone."); + const answer = await askPrompt(" Type 'yes' to confirm, or press Enter to cancel [y/N]: "); + if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") { + console.log(" Cancelled."); + return; + } + } + + const nim = require("./nim") as { + stopNimContainer: (sandboxName: string, opts?: { silent?: boolean }) => void; + stopNimContainerByName: (name: string) => void; + }; + const sb = registry.getSandbox(sandboxName); + if (sb && sb.nimContainer) { + console.log(` Stopping NIM for '${sandboxName}'...`); + nim.stopNimContainerByName(sb.nimContainer); + } else { + // Best-effort cleanup of convention-named NIM containers that may not + // be recorded in the registry (e.g. older sandboxes). Suppress output + // so the user doesn't see "No such container" noise when no NIM exists. + nim.stopNimContainer(sandboxName, { silent: true }); + } + + if (sb?.provider?.includes("ollama")) { + const { unloadOllamaModels, killStaleProxy } = require("./onboard-ollama-proxy"); + unloadOllamaModels(); + killStaleProxy(); + } + + console.log(` Deleting sandbox '${sandboxName}'...`); + const { runOpenshell } = require("./openshell-runtime") as { + runOpenshell: ( + args: string[], + opts?: Record, + ) => { status: number | null; stdout?: string; stderr?: string }; + }; + const deleteResult = runOpenshell(["sandbox", "delete", sandboxName], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const { output: deleteOutput, alreadyGone } = getSandboxDeleteOutcome(deleteResult); + + if (deleteResult.status !== 0 && !alreadyGone) { + if (deleteOutput) { + console.error(` ${deleteOutput}`); + } + console.error(` Failed to destroy sandbox '${sandboxName}'.`); + process.exit(deleteResult.status || 1); + } + + const shouldStopHostServices = + (deleteResult.status === 0 || alreadyGone) && + registry.listSandboxes().sandboxes.length === 1 && + !!registry.getSandbox(sandboxName); + + cleanupSandboxServices(sandboxName, { stopHostServices: shouldStopHostServices }); + const removed = removeSandboxRegistryEntry(sandboxName); + const session = onboardSession.loadSession(); + if (session && session.sandboxName === sandboxName) { + onboardSession.updateSession((s: Session) => { + s.sandboxName = null; + return s; + }); + } + if ( + (deleteResult.status === 0 || alreadyGone) && + removed && + registry.listSandboxes().sandboxes.length === 0 && + hasNoLiveSandboxes() + ) { + cleanupGatewayAfterLastSandbox(); + } + if (alreadyGone) { + console.log(` Sandbox '${sandboxName}' was already absent from the live gateway.`); + } + console.log(` ${G}✓${R} Sandbox '${sandboxName}' destroyed`); +} diff --git a/src/lib/sandbox-runtime-actions.ts b/src/lib/sandbox-runtime-actions.ts index f3d9921e6d6..c9dc85ab768 100644 --- a/src/lib/sandbox-runtime-actions.ts +++ b/src/lib/sandbox-runtime-actions.ts @@ -32,7 +32,10 @@ export function showSandboxLogs(sandboxName: string, options: SandboxLogsOptions } export async function destroySandbox(sandboxName: string, args: string[] = []): Promise { - await getNemoClawRuntimeBridge().sandboxDestroy(sandboxName, args); + const { destroySandbox: destroyExtractedSandbox } = require("./sandbox-destroy-action") as { + destroySandbox: (sandboxName: string, args?: string[]) => Promise; + }; + await destroyExtractedSandbox(sandboxName, args); } export async function rebuildSandbox(sandboxName: string, args: string[] = []): Promise { diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 05b32cb0276..2e59ac164c3 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -67,6 +67,10 @@ const { const { executeSandboxCommand, } = require("./lib/sandbox-process-recovery-action"); +const { + getSandboxDeleteOutcome, + removeSandboxRegistryEntry, +} = require("./lib/sandbox-destroy-action"); const { runRegisteredOclifCommand } = require("./lib/oclif-runner"); const { isErrnoException }: typeof import("./lib/errno") = require("./lib/errno"); const agentRuntime = require("../bin/lib/agent-runtime"); @@ -115,44 +119,7 @@ const DASHBOARD_FORWARD_PORT = String(DASHBOARD_PORT); const DEFAULT_LOGS_PROBE_TIMEOUT_MS = 5000; const LOGS_PROBE_TIMEOUT_ENV = "NEMOCLAW_LOGS_PROBE_TIMEOUT_MS"; -function cleanupGatewayAfterLastSandbox() { - runOpenshell(["forward", "stop", DASHBOARD_FORWARD_PORT], { - ignoreError: true, - stdio: ["ignore", "ignore", "ignore"], - }); - runOpenshell(["gateway", "destroy", "-g", NEMOCLAW_GATEWAY_NAME], { ignoreError: true }); - dockerRemoveVolumesByPrefix(`openshell-cluster-${NEMOCLAW_GATEWAY_NAME}`, { - ignoreError: true, - }); -} - -function hasNoLiveSandboxes() { - const liveList = captureOpenshell(["sandbox", "list"], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); - if (liveList.status !== 0) { - return false; - } - return parseLiveSandboxNames(liveList.output).size === 0; -} - -function isMissingSandboxDeleteResult(output = ""): boolean { - return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( - stripAnsi(output), - ); -} - -function getSandboxDeleteOutcome(deleteResult: SpawnLikeResult) { - const output = `${deleteResult.stdout || ""}${deleteResult.stderr || ""}`.trim(); - return { - output, - alreadyGone: deleteResult.status !== 0 && isMissingSandboxDeleteResult(output), - }; -} - exports.runtimeBridge = { - sandboxDestroy, sandboxRebuild, upgradeSandboxes, }; @@ -184,153 +151,6 @@ function printSandboxActionUsage(action: string): void { console.log(` Usage: ${CLI_NAME} ${action}`); } -function cleanupSandboxServices( - sandboxName: string, - { stopHostServices = false }: { stopHostServices?: boolean } = {}, -) { - if (stopHostServices) { - const { stopAll } = require("./lib/services"); - stopAll({ sandboxName }); - } - - const sb = registry.getSandbox(sandboxName); - if (sb?.provider?.includes("ollama")) { - const { unloadOllamaModels } = require("./lib/onboard-ollama-proxy"); - unloadOllamaModels(); - } - - try { - fs.rmSync(`/tmp/nemoclaw-services-${sandboxName}`, { recursive: true, force: true }); - } catch { - // PID directory may not exist — ignore. - } - - // Delete messaging providers created during onboard. Suppress stderr so - // "! Provider not found" noise doesn't appear when messaging was never configured. - for (const suffix of ["telegram-bridge", "discord-bridge", "slack-bridge"]) { - runOpenshell(["provider", "delete", `${sandboxName}-${suffix}`], { - ignoreError: true, - stdio: ["ignore", "ignore", "ignore"], - }); - } -} - -/** - * Remove the host-side Docker image that was built for a sandbox during onboard. - * Must be called before registry.removeSandbox() since the imageTag is stored there. - */ -function removeSandboxImage(sandboxName: string) { - const sb = registry.getSandbox(sandboxName); - if (!sb?.imageTag) return; - const result = dockerRmi(sb.imageTag, { ignoreError: true }); - if (result.status === 0) { - console.log(` Removed Docker image ${sb.imageTag}`); - } else { - console.warn( - ` ${YW}⚠${R} Failed to remove Docker image ${sb.imageTag}; run '${CLI_NAME} gc' to clean up.`, - ); - } -} - -async function sandboxDestroy(sandboxName: string, args: string[] = []): Promise { - const skipConfirm = args.includes("--yes") || args.includes("--force"); - - // Active session detection — enrich the confirmation prompt if sessions are active - let activeSessionCount = 0; - const opsBin = resolveOpenshell(); - if (opsBin) { - try { - const sessionResult = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBin)); - if (sessionResult.detected) { - activeSessionCount = sessionResult.sessions.length; - } - } catch { - /* non-fatal */ - } - } - - if (!skipConfirm) { - console.log(` ${YW}Destroy sandbox '${sandboxName}'?${R}`); - if (activeSessionCount > 0) { - const plural = activeSessionCount > 1 ? "sessions" : "session"; - console.log( - ` ${YW}⚠ Active SSH ${plural} detected (${activeSessionCount} connection${activeSessionCount > 1 ? "s" : ""})${R}`, - ); - console.log( - ` Destroying will terminate ${activeSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`, - ); - } - console.log(" This will permanently delete the sandbox and all workspace files inside it."); - console.log(" This cannot be undone."); - const answer = await askPrompt(" Type 'yes' to confirm, or press Enter to cancel [y/N]: "); - if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") { - console.log(" Cancelled."); - return; - } - } - - const sb = registry.getSandbox(sandboxName); - if (sb && sb.nimContainer) { - console.log(` Stopping NIM for '${sandboxName}'...`); - nim.stopNimContainerByName(sb.nimContainer); - } else { - // Best-effort cleanup of convention-named NIM containers that may not - // be recorded in the registry (e.g. older sandboxes). Suppress output - // so the user doesn't see "No such container" noise when no NIM exists. - nim.stopNimContainer(sandboxName, { silent: true }); - } - - if (sb?.provider?.includes("ollama")) { - const { unloadOllamaModels, killStaleProxy } = require("./lib/onboard-ollama-proxy"); - unloadOllamaModels(); - killStaleProxy(); - } - - console.log(` Deleting sandbox '${sandboxName}'...`); - const deleteResult = runOpenshell(["sandbox", "delete", sandboxName], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - const { output: deleteOutput, alreadyGone } = getSandboxDeleteOutcome(deleteResult); - - if (deleteResult.status !== 0 && !alreadyGone) { - if (deleteOutput) { - console.error(` ${deleteOutput}`); - } - console.error(` Failed to destroy sandbox '${sandboxName}'.`); - process.exit(deleteResult.status || 1); - } - - const shouldStopHostServices = - (deleteResult.status === 0 || alreadyGone) && - registry.listSandboxes().sandboxes.length === 1 && - !!registry.getSandbox(sandboxName); - - cleanupSandboxServices(sandboxName, { stopHostServices: shouldStopHostServices }); - removeSandboxImage(sandboxName); - - const removed = registry.removeSandbox(sandboxName); - const session = onboardSession.loadSession(); - if (session && session.sandboxName === sandboxName) { - onboardSession.updateSession((s: Session) => { - s.sandboxName = null; - return s; - }); - } - if ( - (deleteResult.status === 0 || alreadyGone) && - removed && - registry.listSandboxes().sandboxes.length === 0 && - hasNoLiveSandboxes() - ) { - cleanupGatewayAfterLastSandbox(); - } - if (alreadyGone) { - console.log(` Sandbox '${sandboxName}' was already absent from the live gateway.`); - } - console.log(` ${G}✓${R} Sandbox '${sandboxName}' destroyed`); -} - // ── Rebuild ────────────────────────────────────────────────────── function _rebuildLog(msg: string) { @@ -567,8 +387,7 @@ async function sandboxRebuild( bail("Failed to delete sandbox.", deleteResult.status || 1); return; } - removeSandboxImage(sandboxName); - registry.removeSandbox(sandboxName); + removeSandboxRegistryEntry(sandboxName); log( `Registry after remove: ${JSON.stringify(registry.listSandboxes().sandboxes.map((s: { name: string }) => s.name))}`, ); diff --git a/test/image-cleanup.test.ts b/test/image-cleanup.test.ts index f40f299868f..9714f9f83e6 100644 --- a/test/image-cleanup.test.ts +++ b/test/image-cleanup.test.ts @@ -8,62 +8,66 @@ import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; +import { + getSandboxDeleteOutcome, + removeSandboxImage, + removeSandboxRegistryEntry, +} from "../src/lib/sandbox-destroy-action"; import { help as renderRootHelp } from "../src/lib/root-help-action"; const ROOT = path.resolve(import.meta.dirname, ".."); describe("image cleanup: sandbox destroy removes Docker image (#2086)", () => { - const nemoclawSrc = fs.readFileSync(path.join(ROOT, "src/nemoclaw.ts"), "utf-8"); - - it("removeSandboxImage() helper exists and calls docker rmi", () => { - const match = nemoclawSrc.match(/function removeSandboxImage[\s\S]*?^}/m); - expect(match).toBeTruthy(); - if (!match) throw new Error("Expected removeSandboxImage() in src/nemoclaw.ts"); - expect(match[0]).toMatch(/dockerRmi\(|docker.*\.rmi\(/); + it("removes sandbox images before deleting the registry entry", () => { + const calls: string[] = []; + + const removed = removeSandboxRegistryEntry("alpha", { + removeImage: (sandboxName) => calls.push(`image:${sandboxName}`), + removeSandbox: (sandboxName) => { + calls.push(`registry:${sandboxName}`); + return true; + }, + }); + + expect(removed).toBe(true); + expect(calls).toEqual(["image:alpha", "registry:alpha"]); }); - it("sandboxDestroy calls removeSandboxImage before registry.removeSandbox", () => { - // Extract the sandboxDestroy function body - const destroyMatch = nemoclawSrc.match(/async function sandboxDestroy[\s\S]*?^}/m); - expect(destroyMatch).toBeTruthy(); - if (!destroyMatch) { - throw new Error("Expected sandboxDestroy() in src/nemoclaw.ts"); - } - const destroyBody = destroyMatch[0]; - - // removeSandboxImage must appear before registry.removeSandbox - const removeImageIdx = destroyBody.indexOf("removeSandboxImage("); - const removeRegistryIdx = destroyBody.indexOf("registry.removeSandbox("); - expect(removeImageIdx).toBeGreaterThan(-1); - expect(removeRegistryIdx).toBeGreaterThan(-1); - expect(removeImageIdx).toBeLessThan(removeRegistryIdx); - }); + it("removeSandboxImage calls docker rmi for recorded image tags", () => { + const removedTags: string[] = []; - it("sandboxRebuild calls removeSandboxImage before registry.removeSandbox", () => { - const rebuildMatch = nemoclawSrc.match( - /async function sandboxRebuild[\s\S]*?^\s*console\.log\(`\s*\$\{G\}.*Sandbox.*rebuilt/m, - ); - expect(rebuildMatch).toBeTruthy(); - if (!rebuildMatch) { - throw new Error("Expected sandboxRebuild() in src/nemoclaw.ts"); - } - const rebuildBody = rebuildMatch[0]; + removeSandboxImage("alpha", { + getSandbox: () => ({ name: "alpha", imageTag: "openshell/sandbox-from:123" }) as any, + dockerRmi: (tag) => { + removedTags.push(tag); + return { status: 0 } as any; + }, + }); - const removeImageIdx = rebuildBody.indexOf("removeSandboxImage("); - const removeRegistryIdx = rebuildBody.indexOf("registry.removeSandbox("); - expect(removeImageIdx).toBeGreaterThan(-1); - expect(removeRegistryIdx).toBeGreaterThan(-1); - expect(removeImageIdx).toBeLessThan(removeRegistryIdx); + expect(removedTags).toEqual(["openshell/sandbox-from:123"]); }); it("removeSandboxImage gracefully handles missing imageTag", () => { - // The function should check for imageTag before attempting removal - const fnMatch = nemoclawSrc.match(/function removeSandboxImage[\s\S]*?^}/m); - expect(fnMatch).toBeTruthy(); - if (!fnMatch) { - throw new Error("Expected removeSandboxImage() in src/nemoclaw.ts"); - } - expect(fnMatch[0]).toContain("imageTag"); + const removedTags: string[] = []; + + removeSandboxImage("alpha", { + getSandbox: () => ({ name: "alpha", imageTag: null }) as any, + dockerRmi: (tag) => { + removedTags.push(tag); + return { status: 0 } as any; + }, + }); + + expect(removedTags).toEqual([]); + }); + + it("treats missing sandbox delete results as already gone", () => { + expect( + getSandboxDeleteOutcome({ status: 1, stderr: "Error: sandbox alpha not found" }), + ).toEqual({ + output: "Error: sandbox alpha not found", + alreadyGone: true, + }); }); });