Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lib/actions/uninstall/run-plan-docker-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,19 @@ const PS_OUTPUT = [
// leaves a randomly named container that only its image identifies.
"c-probe nemoclaw-hermes-sandbox-base-local:image-abc nostalgic_curie",
"c-openclaw ghcr.io/openclaw/openclaw:latest my-openclaw-test",
"c-registry registry.example.com/nemoclaw/tool:1 registry-tool",
"c-unrelated redis:7 cache",
].join("\n");

const IMAGES_OUTPUT = [
"i-nemoclaw ghcr.io/nvidia/nemoclaw:test",
"i-managed ghcr.io/nvidia/nemoclaw/openclaw-sandbox:latest",
// The gateway builds sandbox images under this repository, so the `openshell`
// half of the filter selects real resources and must stay covered.
"i-openshell openshell/sandbox-from:1780294581",
"i-openclaw ghcr.io/openclaw/openclaw:latest",
"i-tag python:3.12-nemoclaw",
"i-registry registry.example.com/nemoclaw/tool:1",
"i-unrelated redis:7",
].join("\n");

Expand Down Expand Up @@ -109,13 +113,16 @@ describe("uninstall Docker resource scope", () => {
const calls = runWithDockerInventory();

expect(calls).not.toContainEqual(["rm", "-f", "c-openclaw"]);
expect(calls).not.toContainEqual(["rm", "-f", "c-registry"]);
expect(calls).not.toContainEqual(["rm", "-f", "c-unrelated"]);
});

it("keeps images belonging to the separate OpenClaw project (#8496)", () => {
const calls = runWithDockerInventory();

expect(calls).not.toContainEqual(["rmi", "-f", "i-openclaw"]);
expect(calls).not.toContainEqual(["rmi", "-f", "i-tag"]);
expect(calls).not.toContainEqual(["rmi", "-f", "i-registry"]);
expect(calls).not.toContainEqual(["rmi", "-f", "i-unrelated"]);
});

Expand All @@ -136,6 +143,7 @@ describe("uninstall Docker resource scope", () => {
const calls = runWithDockerInventory();

expect(calls).toContainEqual(["rmi", "-f", "i-nemoclaw"]);
expect(calls).toContainEqual(["rmi", "-f", "i-managed"]);
});

it("still removes gateway-built OpenShell sandbox images", () => {
Expand Down
42 changes: 34 additions & 8 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1704,7 +1704,9 @@ function removeDockerContainers(runtime: UninstallRuntime, gatewayName?: string)
});
const ids = splitNonEmptyLines(result.stdout)
.filter((line) => {
const name = line.trim().split(/\s+/).at(-1) ?? "";
const fields = dockerInventoryFields(line, 3);
const image = fields[1] ?? "";
const name = fields[2] ?? "";
if (!gatewayName) {
if (MANAGED_INFERENCE_CONTAINER_NAME_PATTERN.test(name)) {
return false;
Expand All @@ -1713,12 +1715,11 @@ function removeDockerContainers(runtime: UninstallRuntime, gatewayName?: string)
// `openshell-*` (cluster and sandbox) and `nemoclaw-*` (gateway compat
// and managed inference), so that term only ever selected the separate
// OpenClaw project's containers for `docker rm -f` (#8496).
// The whole `{{.ID}} {{.Image}} {{.Names}}` line is matched rather than
// the name alone. Probe containers that run with `--rm` and no
// `--name`, such as `hermesBaseImageSupportsMcp`, take a random Docker
// name. Their NemoClaw image reference is the only way to reclaim one
// that an interrupted run orphaned.
return /openshell-cluster|openshell|nemoclaw/i.test(line);
// Probe containers that run with `--rm` and no `--name`, such as
// `hermesBaseImageSupportsMcp`, take a random Docker name. Their
// NemoClaw image reference is the only way to reclaim one that an
// interrupted run orphaned.
return isOwnedDockerContainerName(name) || isOwnedDockerImageRepository(image);
}
return (
name === `openshell-cluster-${gatewayName}` ||
Expand Down Expand Up @@ -1749,7 +1750,7 @@ function removeDockerImages(runtime: UninstallRuntime): void {
// name, so the term only ever selected the separate OpenClaw project's
// images — including any `ghcr.io/openclaw/*` pulled by an unrelated
// workload — for `docker rmi -f` (#8496).
.filter((line) => /openshell|nemoclaw/i.test(line))
.filter((line) => isOwnedDockerImageRepository(dockerInventoryFields(line, 2)[1] ?? ""))
.map((line) => line.split(/\s+/)[0]);
if (ids.length === 0) {
runtime.log(`No ${runtimeBranding(runtime).display}/OpenShell Docker images found`);
Expand All @@ -1762,6 +1763,31 @@ function removeDockerImages(runtime: UninstallRuntime): void {
}
}

function dockerInventoryFields(line: string, expectedFields: number): string[] {
return line.trim().split(/\s+/, expectedFields);
}

function dockerImageRepository(imageRef: string): string {
const withoutDigest = imageRef.split("@", 1)[0] ?? "";
const tagSeparator = withoutDigest.lastIndexOf(":");
if (tagSeparator === -1) return withoutDigest;
const slashSeparator = withoutDigest.lastIndexOf("/");
return tagSeparator > slashSeparator ? withoutDigest.slice(0, tagSeparator) : withoutDigest;
}

function isOwnedDockerContainerName(name: string): boolean {
return /^openshell-(?:cluster-)?/iu.test(name) || /^nemoclaw-/iu.test(name);
}

function isOwnedDockerImageRepository(imageRef: string): boolean {
const repository = dockerImageRepository(imageRef);
return (
/^nemoclaw-/iu.test(repository) ||
/^openshell\//iu.test(repository) ||
/^ghcr\.io\/nvidia\/nemoclaw(?:[/-]|$)/iu.test(repository)
);
}

function removeDockerVolume(name: string, runtime: UninstallRuntime): void {
if (
runtime.runDocker(["volume", "inspect", name], { env: runtime.env, stdio: "ignore" }).status !==
Expand Down
Loading