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
159 changes: 159 additions & 0 deletions src/lib/actions/uninstall/run-plan-gateway-scan-entries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, it, vi } from "vitest";

import {
type RunResult,
runUninstallPlan as runUninstallPlanBase,
type UninstallRunDeps,
type UninstallRunOptions,
} from "./run-plan";

function ok(stdout = ""): RunResult {
return { status: 0, stdout, stderr: "" };
}

function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRunDeps) {
return runUninstallPlanBase(options, {
resolveGatewayTeardownAuthority: ({ gatewayName, gatewayPort }) => ({
gatewayName,
gatewayPort,
mode: "nemoclaw-managed",
source: gatewayPort === 8080 ? "packaged-service" : "standalone",
endpoint: null,
stateDir: null,
supervisor: null,
requiredCapabilities: [],
}),
...deps,
});
}

function okWithKnownGatewayList(command: string, args: readonly string[]): RunResult {
return command === "openshell" && args[0] === "gateway" && args[1] === "list"
? ok(JSON.stringify([{ name: "nemoclaw" }]))
: ok();
}

const SCOPED_RETENTION_LOG =
"Sibling gateways remain; kept the shared NemoClaw CLI and shell shims.";

function managedWrapper(binName: string): string {
return [
"#!/usr/bin/env bash",
'export PATH="/tmp/node-bin:$PATH"',
`exec "/tmp/prefix/bin/${binName}" "$@"`,
"",
].join("\n");
}

/**
* Builds a home whose gateways directory holds only the named entries, plus a
* managed wrapper shim for every CLI alias. An entry ending in "/" is created
* as a directory. Returns the shim paths so a test can assert on removal.
*/
function makeHome(prefix: string, entries: readonly string[]): { home: string; shims: string[] } {
const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const gatewaysDir = path.join(home, ".nemoclaw", "gateways");
fs.mkdirSync(gatewaysDir, { recursive: true });
for (const entry of entries) {
const target = path.join(gatewaysDir, entry.replace(/\/$/, ""));
entry.endsWith("/") ? fs.mkdirSync(target) : fs.writeFileSync(target, "");
}
const userBin = path.join(home, ".local", "bin");
fs.mkdirSync(userBin, { recursive: true });
const shims = ["nemoclaw", "nemohermes", "nemo-deepagents"].map((binName) => {
const shimPath = path.join(userBin, binName);
fs.writeFileSync(shimPath, managedWrapper(binName), { mode: 0o755 });
return shimPath;
});
return { home, shims };
}

/**
* Runs the plan and reports which shims are still on disk afterwards. Removal
* goes through the real filesystem, restricted to the temporary home, so the
* assertions read the outcome rather than the calls a test double recorded.
*/
function uninstall(home: string, shims: readonly string[]) {
const logs: string[] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: false },
{
commandExists: (command) => command !== "docker" && command !== "lsof" && command !== "pgrep",
env: { HOME: home } as NodeJS.ProcessEnv,
existsSync: (target) => shims.includes(target) && fs.existsSync(target),
isTty: false,
log: (line) => logs.push(line),
rmSync: vi.fn((target: fs.PathLike, options?: fs.RmOptions) => {
String(target).startsWith(home) ? fs.rmSync(target, options) : undefined;
}),
run: vi.fn(okWithKnownGatewayList),
runDocker: () => ok(""),
},
);
return { result, logs, survivors: shims.filter((shim) => fs.existsSync(shim)) };
}

describe("uninstall gateway-directory scan", () => {
it.each([
[".DS_Store"],
[".localized"],
["._sandboxes.json"],
])("removes the CLI shims when the gateways directory holds only %s (#7905)", (entry) => {
const { home, shims } = makeHome("nemoclaw-uninstall-metadata-", [entry]);

try {
const { result, logs, survivors } = uninstall(home, shims);

expect(result.exitCode).toBe(0);
expect(logs).not.toContain(SCOPED_RETENTION_LOG);
expect(survivors).toEqual([]);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});

// "._" carries no name, and a directory or symlink is a shape that may hide
// live gateway state, so each of these keeps the conservative treatment.
it.each([
["not-a-port"],
["._"],
[".DS_Store/"],
])("keeps the CLI shims when the gateways directory holds %s (#7905)", (entry) => {
const { home, shims } = makeHome("nemoclaw-uninstall-conservative-", [entry]);

try {
const { result, logs, survivors } = uninstall(home, shims);

expect(result.exitCode).toBe(0);
expect(logs).toContain(SCOPED_RETENTION_LOG);
expect(survivors).toEqual(shims);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});

it("keeps the CLI shims for a desktop-metadata symlink (#7905)", () => {
const { home, shims } = makeHome("nemoclaw-uninstall-conservative-", []);
fs.symlinkSync(
"concealed-gateway-state",
path.join(home, ".nemoclaw", "gateways", ".DS_Store"),
);

try {
const { result, logs, survivors } = uninstall(home, shims);

expect(result.exitCode).toBe(0);
expect(logs).toContain(SCOPED_RETENTION_LOG);
expect(survivors).toEqual(shims);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
15 changes: 15 additions & 0 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1651,6 +1651,16 @@ function inspectOtherGatewayEnvironments(
);
}

// Names a desktop environment writes into a directory it displays. macOS writes
// .DS_Store and .localized, and ._<name> to carry a resource fork and extended
// attributes on a filesystem that lacks them. None of them hold gateway state.
function isDesktopMetadataEntry(name: string): boolean {
// "._" alone names no file it could carry, so it stays conservative.
return (
name === ".DS_Store" || name === ".localized" || (name.startsWith("._") && name.length > 2)
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function discoverOtherGatewayEnvironments(
paths: UninstallPaths,
runtime: UninstallRuntime,
Expand Down Expand Up @@ -1721,6 +1731,11 @@ function discoverOtherGatewayEnvironments(
for (const entry of fs.readdirSync(gatewaysDir, { withFileTypes: true })) {
const candidate = path.resolve(gatewaysDir, entry.name);
if (candidate === selectedRoot) continue;
// Counting desktop metadata as unidentified scopes the whole uninstall,
// which keeps the CLI and shell shims on PATH after a run that reports
// success (#7905). Only a regular file matches, so a directory or a
// symlink with the same name still gets the conservative treatment.
if (entry.isFile() && isDesktopMetadataEntry(entry.name)) continue;
// Never follow or dismiss a symlink or non-directory: a surprising shape
// may hide live gateway state, so keep the conservative treatment.
if (entry.isSymbolicLink() || !entry.isDirectory()) {
Expand Down
Loading