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
95 changes: 95 additions & 0 deletions src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,101 @@ describe("uninstall gateway-port segregation (#3053)", () => {
}
});

it("does not treat the selected gateway's own port directory as a sibling (#7987)", () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-self-sibling-"));
try {
const stateDir = path.join(tmpHome, ".nemoclaw");
// The selected gateway runs on the default port, so its state root is the
// shared root rather than gateways/8080. A leftover directory named for
// its own port must not make it count itself as a sibling.
fs.mkdirSync(path.join(stateDir, "gateways", "8080"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "sandboxes.json"),
JSON.stringify({
defaultSandbox: "my-assistant",
sandboxes: {
"my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 },
},
}),
);
const logs: string[] = [];
const openshellCalls: string[][] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false },
{
commandExists: (command) => command === "openshell",
env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv,
existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target),
isTty: false,
log: (line) => logs.push(line),
rmSync: fs.rmSync,
run: (_command, args) => {
openshellCalls.push(args);
// Only the selected gateway is live; there is no sibling at all.
return args[0] === "gateway" && args[1] === "list"
? ok(JSON.stringify([{ name: "nemoclaw" }]))
: ok();
},
runDocker: () => ok(""),
},
);

expect(result.exitCode).toBe(0);
expect(logs.join("\n")).not.toContain("Sibling gateways remain");
expect(logs.join("\n")).not.toContain("resources owned by gateway 'nemoclaw'");
// A single-gateway host must get the full teardown, not the scoped one.
expect(openshellCalls).toContainEqual(["sandbox", "delete", "--all"]);
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});

it("still detects a live sibling alongside the selected gateway's own port directory (#7987)", () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-self-and-sibling-"));
try {
const stateDir = path.join(tmpHome, ".nemoclaw");
fs.mkdirSync(path.join(stateDir, "gateways", "8080"), { recursive: true });
fs.mkdirSync(path.join(stateDir, "gateways", "8091"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "sandboxes.json"),
JSON.stringify({
defaultSandbox: "my-assistant",
sandboxes: {
"my-assistant": { name: "my-assistant", gatewayName: "nemoclaw", gatewayPort: 8080 },
},
}),
);
const logs: string[] = [];
const openshellCalls: string[][] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, destroyUserData: true, keepOpenShell: false },
{
commandExists: (command) => command === "openshell",
env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv,
existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target),
isTty: false,
log: (line) => logs.push(line),
rmSync: fs.rmSync,
run: (_command, args) => {
openshellCalls.push(args);
return args[0] === "gateway" && args[1] === "list"
? ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-8091" }]))
: ok();
},
runDocker: () => ok(""),
},
);

expect(result.exitCode).toBe(0);
// Excluding our own port must not suppress a genuine sibling.
expect(logs.join("\n")).toContain("Sibling gateways remain");
expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]);
expect(fs.existsSync(path.join(stateDir, "gateways", "8091"))).toBe(true);
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});

it("preserves selected state when the owning gateway cannot be selected", async () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-select-fail-"));
const port = 9123;
Expand Down
12 changes: 10 additions & 2 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1415,10 +1415,18 @@ function inspectOtherGatewayEnvironments(
// 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()) return true;
// A per-port directory whose gateway OpenShell no longer knows is an
// orphan; dismiss it only when the live set positively lacks it.
const port = Number(entry.name);
if (!Number.isInteger(port) || port < 1 || port > 65535) return true;
// A directory named for the gateway being uninstalled is that gateway's
// own state, never a sibling. Path identity alone does not catch it: for
// the default port `selectedRoot` is the shared root, so
// `<shared>/gateways/<DEFAULT_GATEWAY_PORT>` never equals it and the
// selected gateway counts itself as a sibling, scoping cleanup to
// preserve resources nothing else owns (#7987). Match on port identity
// like every other sibling filter here and like `listGatewayStateRoots`.
if (port === GATEWAY_PORT) return false;
// A per-port directory whose gateway OpenShell no longer knows is an
// orphan; dismiss it only when the live set positively lacks it.
const live = liveGatewayNames();
if (live === null) return true;
return live.has(resolveGatewayName(port));
Expand Down
Loading