Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
44caf41
fix(uninstall): exit nonzero when the gateway registration cannot be …
udsy19 Aug 20, 2026
ad8588a
merge: synchronize main into uninstall failure fix
apurvvkumaria Aug 21, 2026
578192e
fix(uninstall): preserve state on registration failure
apurvvkumaria Aug 21, 2026
fa76588
merge: synchronize main into uninstall failure fix
apurvvkumaria Aug 21, 2026
53a51b3
test(uninstall): cover registration failure paths
apurvvkumaria Aug 21, 2026
cee0668
test(uninstall): isolate provider no-op wording
apurvvkumaria Aug 21, 2026
21e1070
merge: synchronize current main into uninstall failure fix
apurvvkumaria Aug 21, 2026
ac95e65
test(uninstall): constrain gateway removal mock
apurvvkumaria Aug 21, 2026
0c50b09
merge: sync current main into uninstall exit fix
apurvvkumaria Aug 21, 2026
ef75481
merge: synchronize current main into uninstall exit fix
apurvvkumaria Aug 21, 2026
4c0c9b7
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 22, 2026
d0b576b
test(uninstall): split user-data cases
cv Aug 22, 2026
09fee88
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
prekshivyas Aug 23, 2026
4ca1dc7
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
prekshivyas Aug 23, 2026
4a8ee2c
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
prekshivyas Aug 23, 2026
5799085
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
prekshivyas Aug 23, 2026
2f50234
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
prekshivyas Aug 23, 2026
5ec315d
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
prekshivyas Aug 23, 2026
9c28ee8
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
prekshivyas Aug 23, 2026
9cb692d
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
apurvvkumaria Aug 24, 2026
92f2c7e
test(uninstall): isolate legacy failure fixture
apurvvkumaria Aug 24, 2026
6086b14
fix(uninstall): match explicit gateway absence
apurvvkumaria Aug 24, 2026
947a335
merge: update PR branch with main
apurvvkumaria Aug 24, 2026
b87fce1
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 24, 2026
b5a1c73
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 24, 2026
9f77384
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 24, 2026
d71d8eb
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 24, 2026
4290af2
fix(uninstall): stage selective cleanup safely
apurvvkumaria Aug 24, 2026
9869d83
fix(uninstall): explain staged state recovery
apurvvkumaria Aug 24, 2026
7bb5bbe
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 24, 2026
9239c1d
fix(uninstall): delete verified staged state
cv Aug 24, 2026
2b614a5
test(uninstall): isolate staged cleanup race
apurvvkumaria Aug 24, 2026
f346e5c
fix(uninstall): detect abandoned staged state
apurvvkumaria Aug 24, 2026
554fb65
fix(uninstall): reject stale cleanup staging
apurvvkumaria Aug 24, 2026
981deeb
fix(uninstall): preflight staged cleanup
apurvvkumaria Aug 24, 2026
af617f4
fix(uninstall): distinguish staged recovery paths
apurvvkumaria Aug 24, 2026
c27dbcf
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 24, 2026
0ee0302
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 24, 2026
a6569dc
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 25, 2026
d2ded93
merge: refresh from main
apurvvkumaria Aug 25, 2026
71189ef
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 27, 2026
0c5c9a8
Merge branch 'main' into fix/uninstall-gateway-registration-exit-code
cv Aug 28, 2026
d055965
fix(uninstall): detect incomplete cleanup staging
cv Aug 28, 2026
a69e8a7
fix(uninstall): clarify staged cleanup recovery
cv Aug 28, 2026
b22ac11
ci: retrigger PR review advisor
cv Aug 28, 2026
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: 86 additions & 9 deletions src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ describe("uninstall gateway-port segregation (#3053)", () => {

it("does not use legacy gateway destroy when external registration removal is unsupported (#6576)", () => {
const calls: Array<{ args: string[]; command: string }> = [];
const logs: string[] = [];
const rmSync = vi.fn();
const warnings: string[] = [];
const responses = new Map<string, RunResult>([
["openshell gateway list -o json", ok(JSON.stringify([{ name: "nemoclaw" }]))],
[
Expand Down Expand Up @@ -241,7 +244,9 @@ describe("uninstall gateway-port segregation (#3053)", () => {
},
requiredCapabilities: [],
}),
rmSync: vi.fn(),
error: (line) => warnings.push(line),
log: (line) => logs.push(line),
rmSync,
run: (command, args) => {
calls.push({ args, command });
return responses.get([command, ...args].join(" ")) ?? ok();
Expand All @@ -250,12 +255,18 @@ describe("uninstall gateway-port segregation (#3053)", () => {
},
);

expect(result.exitCode).toBe(0);
expect(result.exitCode).toBe(1);
const openshellCalls = calls
.filter(({ command }) => command === "openshell")
.map(({ args }) => args);
expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]);
expect(openshellCalls).not.toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]);
expect(warnings).toContain(
"Could not remove local registration for externally supervised gateway 'nemoclaw'. NemoClaw will not use the legacy gateway destroy command for an externally supervised gateway.",
);
expect(rmSync).not.toHaveBeenCalled();
expect(logs).not.toContain("[3/6] NemoClaw CLI");
expect(logs).not.toContain("Claws retracted. Until next time.");
});

it("fails before uninstall effects when gateway authority revalidation fails (#6576)", () => {
Expand Down Expand Up @@ -317,37 +328,103 @@ describe("uninstall gateway-port segregation (#3053)", () => {
expect(openshellCalls).toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]);
});

it("does not hide a current gateway remove failure behind the legacy verb", () => {
it.each(["permission denied", "gateway service endpoint not found"])(
"does not hide a current gateway remove failure reported as %s behind the legacy verb",
(diagnostic) => {
const calls: Array<{ args: string[]; command: string }> = [];
const logs: string[] = [];
const rmSync = vi.fn();
const warnings: string[] = [];
const responses = new Map<string, RunResult>([
["openshell gateway list -o json", ok(JSON.stringify([{ name: "nemoclaw" }]))],
["openshell gateway remove nemoclaw", { status: 1, stdout: "", stderr: diagnostic }],
]);
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
commandExists: (command) => command !== "docker" && command !== "pgrep",
env: { HOME: STATIC_TEST_HOME, TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv,
error: (line) => warnings.push(line),
existsSync: () => false,
isTty: false,
log: (line) => logs.push(line),
rmSync,
run: (command, args) => {
calls.push({ args, command });
return responses.get([command, ...args].join(" ")) ?? ok();
},
runDocker: () => ok(""),
},
);

expect(result.exitCode).toBe(1);
const openshellCalls = calls
.filter(({ command }) => command === "openshell")
.map(({ args }) => args);
expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]);
expect(openshellCalls.some((args) => args[1] === "destroy")).toBe(false);
expect(warnings).toContain(
"Could not remove gateway registration 'nemoclaw': openshell gateway remove failed (exit 1).",
);
expect(warnings).not.toContain("Gateway 'nemoclaw' already removed or unreachable");
expect(rmSync).not.toHaveBeenCalled();
expect(logs).not.toContain("[3/6] NemoClaw CLI");
expect(logs).not.toContain("Claws retracted. Until next time.");
},
);

it("fails closed when the legacy gateway destroy command fails", () => {
const calls: Array<{ args: string[]; command: string }> = [];
const logs: string[] = [];
const rmSync = vi.fn();
const warnings: string[] = [];
const responses = new Map<string, RunResult>([
["openshell gateway list -o json", ok(JSON.stringify([{ name: "nemoclaw" }]))],
["openshell gateway remove nemoclaw", { status: 1, stdout: "", stderr: "permission denied" }],
[
"openshell gateway remove nemoclaw",
{ status: 2, stdout: "", stderr: "unrecognized subcommand 'remove'" },
],
[
"openshell gateway destroy -g nemoclaw",
{
status: 1,
stdout: "",
stderr: "connection refused; OPENAI_API_KEY=must-not-be-logged",
},
],
]);
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
commandExists: (command) => command !== "docker" && command !== "pgrep",
env: { HOME: STATIC_TEST_HOME, TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv,
error: (line) => warnings.push(line),
existsSync: () => false,
isTty: false,
rmSync: vi.fn(),
log: (line) => logs.push(line),
rmSync,
run: (command, args) => {
calls.push({ args, command });
return responses.get([command, ...args].join(" ")) ?? ok();
},
runDocker: () => ok(""),
error: (line) => warnings.push(line),
},
);

expect(result.exitCode).toBe(0);
expect(result.exitCode).toBe(1);
const openshellCalls = calls
.filter(({ command }) => command === "openshell")
.map(({ args }) => args);
expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]);
expect(openshellCalls.some((args) => args[1] === "destroy")).toBe(false);
expect(warnings.join("\n")).toContain("Gateway 'nemoclaw' already removed or unreachable");
expect(openshellCalls).toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]);
expect(warnings).toContain(
"Could not remove gateway registration 'nemoclaw': openshell gateway destroy failed (connection refused; exit 1).",
);
expect(warnings.join("\n")).not.toContain("must-not-be-logged");
expect(warnings).not.toContain("Gateway 'nemoclaw' already removed or unreachable");
expect(rmSync).not.toHaveBeenCalled();
expect(logs).not.toContain("[3/6] NemoClaw CLI");
expect(logs).not.toContain("Claws retracted. Until next time.");
});

it("preserves the gateways/ subtree so uninstalling one environment leaves the others", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,18 @@ describe("uninstall on a host that owns no portable lifecycle resource", () => {
const result = uninstall(host);

expect(result.exitCode).toBe(0);
expect(host.rmSync.mock.calls.map(([target]) => String(target))).toContain(host.stateDir);
const removedPaths = host.rmSync.mock.calls.map(([target]) => String(target));
const stagedCleanupPrefix = path.join(
path.dirname(host.stateDir),
`.${path.basename(host.stateDir)}-cleanup-`,
);
expect(
removedPaths.some(
(target) => target.startsWith(stagedCleanupPrefix) && path.basename(target) === "content",
),
).toBe(true);
expect(removedPaths).not.toContain(host.stateDir);
expect(fs.existsSync(host.stateDir)).toBe(false);
});

it("refuses an unknown portable uninstall artifact in the configuration directory (#9581)", () => {
Expand Down
117 changes: 5 additions & 112 deletions src/lib/actions/uninstall/run-plan-preserved-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,50 +46,6 @@ function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRunDeps)
});
}

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

function setupStateDir(): { tmpHome: string; stateDir: string } {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-registry-"));
const stateDir = path.join(tmpHome, ".nemoclaw");
fs.mkdirSync(path.join(stateDir, "rebuild-backups"), { recursive: true });
fs.mkdirSync(path.join(stateDir, "backups"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "sandboxes.json"),
JSON.stringify({
defaultSandbox: "preserved-box",
sandboxes: { "preserved-box": { name: "preserved-box" } },
}),
);
return { tmpHome, stateDir };
}

function preserveCaseDeps(
tmpHome: string,
logs: string[],
warnings: string[],
opts: { envOverrides?: Record<string, string> } = {},
): UninstallRunDeps {
return {
commandExists: (command) => command === "openshell",
env: {
HOME: tmpHome,
NEMOCLAW_NON_INTERACTIVE: "",
NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "",
...(opts.envOverrides ?? {}),
} as NodeJS.ProcessEnv,
error: (line) => warnings.push(line),
existsSync: (target: string) => target.startsWith(tmpHome) && fs.existsSync(target),
isTty: false,
log: (line) => logs.push(line),
run: vi.fn(okWithKnownGatewayList),
runDocker: () => ok(""),
};
}

describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#6520)", () => {
it("uses the 'already removed' wording for provider and sandbox delete no-ops", () => {
// Same defect family as the gateway wording fix (#3456 sub-bug 4): when
Expand All @@ -111,6 +67,11 @@ describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#65
run: (command, args) =>
command === "openshell" && args[0] === "gateway" && args[1] === "list"
? ok(JSON.stringify([{ name: "nemoclaw" }]))
: command === "openshell" &&
args[0] === "gateway" &&
args[1] === "remove" &&
args[2] === "nemoclaw"
? ok()
: command === "openshell"
? notFound()
: args[0] === "-c"
Expand All @@ -127,72 +88,4 @@ describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#65
expect(combined).not.toContain("Deleted provider 'nvidia-nim' skipped");
expect(combined).not.toContain("Deleted all OpenShell sandboxes skipped");
});

it("warns that preserved sandboxes.json cannot be auto-recovered after uninstall removes its dependencies", () => {
// Uninstall keeps sandboxes.json but removes the gateway, provider
// registrations, and Docker image its recorded sandboxes depend on. Say
// so at the moment the preserve choice is made, with a remediation path,
// instead of letting a later reinstall report false success.
const { tmpHome } = setupStateDir();
try {
const logs: string[] = [];
const warnings: string[] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
preserveCaseDeps(tmpHome, logs, warnings),
);

expect(result.exitCode).toBe(0);
const joined = warnings.join("\n");
expect(joined).toContain("sandboxes.json");
expect(joined).toContain("cannot be recovered automatically");
expect(joined).toContain("--destroy-user-data");
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});

it("warns on the interactive keep path when the purge prompt is declined", () => {
const { tmpHome } = setupStateDir();
try {
const logs: string[] = [];
const warnings: string[] = [];
// First reply confirms the uninstall itself; the empty second reply
// declines the purge prompt, keeping user data.
const replies = ["yes", ""];
const result = runUninstallPlan(
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
{
...preserveCaseDeps(tmpHome, logs, warnings),
isTty: true,
readLine: () => replies.shift() ?? null,
},
);

expect(result.exitCode).toBe(0);
expect(logs).toContain("Keeping user data.");
expect(warnings.join("\n")).toContain("cannot be recovered automatically");
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});

it("does not warn about unrecoverable sandboxes when user data is purged", () => {
const { tmpHome } = setupStateDir();
try {
const logs: string[] = [];
const warnings: string[] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
preserveCaseDeps(tmpHome, logs, warnings, {
envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" },
}),
);

expect(result.exitCode).toBe(0);
expect(warnings.join("\n")).not.toContain("cannot be recovered automatically");
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
});
Loading
Loading