Skip to content

Commit 63a2b70

Browse files
committed
fix(e2e): verify destroy against registry file, not nemoclaw list
Revert the list-is-read-only change — the recovery behavior from #1187 is intentional and valuable after reboots. The E2E destroy check used `nemoclaw list` which triggers gateway recovery, potentially restarting a destroyed gateway and re-importing stale sandbox entries. Check the registry file directly instead. The list-recovery-after-destroy interaction is a real bug but belongs in a separate PR. Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
1 parent 86d638b commit 63a2b70

4 files changed

Lines changed: 346 additions & 23 deletions

File tree

bin/nemoclaw.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -850,9 +850,8 @@ function showStatus() {
850850
}
851851

852852
async function listSandboxes() {
853-
// list is read-only — just show what the registry has, no recovery or
854-
// gateway restarts. Use `nemoclaw <name> connect` to trigger recovery.
855-
const { sandboxes, defaultSandbox } = registry.listSandboxes();
853+
const recovery = await recoverRegistryEntries();
854+
const { sandboxes, defaultSandbox } = recovery;
856855
if (sandboxes.length === 0) {
857856
console.log("");
858857
const session = onboardSession.loadSession();
@@ -876,6 +875,16 @@ async function listSandboxes() {
876875
);
877876

878877
console.log("");
878+
if (recovery.recoveredFromSession) {
879+
console.log(" Recovered sandbox inventory from the last onboard session.");
880+
console.log("");
881+
}
882+
if (recovery.recoveredFromGateway > 0) {
883+
console.log(
884+
` Recovered ${recovery.recoveredFromGateway} sandbox entr${recovery.recoveredFromGateway === 1 ? "y" : "ies"} from the live OpenShell gateway.`,
885+
);
886+
console.log("");
887+
}
879888
console.log(" Sandboxes:");
880889
for (const sb of sandboxes) {
881890
const def = sb.name === defaultSandbox ? " *" : "";

test/cli.test.js

Lines changed: 322 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -610,11 +610,12 @@ describe("CLI dispatch", () => {
610610
expect(saved.sandboxes.alpha).toBeUndefined();
611611
});
612612

613-
it("list is read-only and does not recover from session or gateway", () => {
614-
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-no-recover-"));
613+
it("recovers a missing registry entry from the last onboard session during list", () => {
614+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-session-recover-"));
615+
const localBin = path.join(home, "bin");
615616
const nemoclawDir = path.join(home, ".nemoclaw");
617+
fs.mkdirSync(localBin, { recursive: true });
616618
fs.mkdirSync(nemoclawDir, { recursive: true });
617-
// Registry has only gamma — session references alpha, but list should NOT import it.
618619
fs.writeFileSync(
619620
path.join(nemoclawDir, "sandboxes.json"),
620621
JSON.stringify({
@@ -633,26 +634,333 @@ describe("CLI dispatch", () => {
633634
);
634635
fs.writeFileSync(
635636
path.join(nemoclawDir, "onboard-session.json"),
637+
JSON.stringify(
638+
{
639+
version: 1,
640+
sessionId: "session-1",
641+
resumable: true,
642+
status: "complete",
643+
mode: "interactive",
644+
startedAt: "2026-03-31T00:00:00.000Z",
645+
updatedAt: "2026-03-31T00:00:00.000Z",
646+
lastStepStarted: "policies",
647+
lastCompletedStep: "policies",
648+
failure: null,
649+
sandboxName: "alpha",
650+
provider: "nvidia-prod",
651+
model: "nvidia/nemotron-3-super-120b-a12b",
652+
endpointUrl: null,
653+
credentialEnv: null,
654+
preferredInferenceApi: null,
655+
nimContainer: null,
656+
policyPresets: ["pypi"],
657+
metadata: { gatewayName: "nemoclaw" },
658+
steps: {
659+
preflight: { status: "complete", startedAt: null, completedAt: null, error: null },
660+
gateway: { status: "complete", startedAt: null, completedAt: null, error: null },
661+
sandbox: { status: "complete", startedAt: null, completedAt: null, error: null },
662+
provider_selection: {
663+
status: "complete",
664+
startedAt: null,
665+
completedAt: null,
666+
error: null,
667+
},
668+
inference: { status: "complete", startedAt: null, completedAt: null, error: null },
669+
openclaw: { status: "complete", startedAt: null, completedAt: null, error: null },
670+
policies: { status: "complete", startedAt: null, completedAt: null, error: null },
671+
},
672+
},
673+
null,
674+
2,
675+
),
676+
{ mode: 0o600 },
677+
);
678+
fs.writeFileSync(
679+
path.join(localBin, "openshell"),
680+
[
681+
"#!/usr/bin/env bash",
682+
'if [ "$1" = "status" ]; then',
683+
" echo 'Server Status'",
684+
" echo",
685+
" echo ' Gateway: nemoclaw'",
686+
" echo ' Status: Connected'",
687+
" exit 0",
688+
"fi",
689+
'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then',
690+
" echo 'Gateway Info'",
691+
" echo",
692+
" echo ' Gateway: nemoclaw'",
693+
" exit 0",
694+
"fi",
695+
'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then',
696+
" echo 'No sandboxes found.'",
697+
" exit 0",
698+
"fi",
699+
'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then',
700+
" exit 0",
701+
"fi",
702+
'if [ "$1" = "--version" ]; then',
703+
" echo 'openshell 0.0.16'",
704+
" exit 0",
705+
"fi",
706+
"exit 0",
707+
].join("\n"),
708+
{ mode: 0o755 },
709+
);
710+
711+
const r = runWithEnv("list", {
712+
HOME: home,
713+
PATH: `${localBin}:${process.env.PATH || ""}`,
714+
});
715+
716+
expect(r.code).toBe(0);
717+
expect(
718+
r.out.includes("Recovered sandbox inventory from the last onboard session."),
719+
).toBeTruthy();
720+
expect(r.out.includes("alpha")).toBeTruthy();
721+
expect(r.out.includes("gamma")).toBeTruthy();
722+
const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8"));
723+
expect(saved.sandboxes.alpha).toBeTruthy();
724+
expect(saved.sandboxes.alpha.policies).toEqual(["pypi"]);
725+
expect(saved.sandboxes.gamma).toBeTruthy();
726+
expect(saved.defaultSandbox).toBe("gamma");
727+
});
728+
729+
it("imports additional live sandboxes into the registry during list recovery", () => {
730+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-live-recover-"));
731+
const localBin = path.join(home, "bin");
732+
const nemoclawDir = path.join(home, ".nemoclaw");
733+
fs.mkdirSync(localBin, { recursive: true });
734+
fs.mkdirSync(nemoclawDir, { recursive: true });
735+
fs.writeFileSync(
736+
path.join(nemoclawDir, "sandboxes.json"),
636737
JSON.stringify({
637-
version: 1,
638-
sessionId: "session-1",
639-
resumable: true,
640-
status: "complete",
641-
sandboxName: "alpha",
738+
sandboxes: {
739+
gamma: {
740+
name: "gamma",
741+
model: "existing-model",
742+
provider: "existing-provider",
743+
gpuEnabled: false,
744+
policies: ["npm"],
745+
},
746+
},
747+
defaultSandbox: "gamma",
642748
}),
643749
{ mode: 0o600 },
644750
);
751+
fs.writeFileSync(
752+
path.join(nemoclawDir, "onboard-session.json"),
753+
JSON.stringify(
754+
{
755+
version: 1,
756+
sessionId: "session-1",
757+
resumable: true,
758+
status: "complete",
759+
mode: "interactive",
760+
startedAt: "2026-03-31T00:00:00.000Z",
761+
updatedAt: "2026-03-31T00:00:00.000Z",
762+
lastStepStarted: "policies",
763+
lastCompletedStep: "policies",
764+
failure: null,
765+
sandboxName: "alpha",
766+
provider: "nvidia-prod",
767+
model: "nvidia/nemotron-3-super-120b-a12b",
768+
endpointUrl: null,
769+
credentialEnv: null,
770+
preferredInferenceApi: null,
771+
nimContainer: null,
772+
policyPresets: ["pypi"],
773+
metadata: { gatewayName: "nemoclaw" },
774+
steps: {
775+
preflight: { status: "complete", startedAt: null, completedAt: null, error: null },
776+
gateway: { status: "complete", startedAt: null, completedAt: null, error: null },
777+
sandbox: { status: "complete", startedAt: null, completedAt: null, error: null },
778+
provider_selection: {
779+
status: "complete",
780+
startedAt: null,
781+
completedAt: null,
782+
error: null,
783+
},
784+
inference: { status: "complete", startedAt: null, completedAt: null, error: null },
785+
openclaw: { status: "complete", startedAt: null, completedAt: null, error: null },
786+
policies: { status: "complete", startedAt: null, completedAt: null, error: null },
787+
},
788+
},
789+
null,
790+
2,
791+
),
792+
{ mode: 0o600 },
793+
);
794+
fs.writeFileSync(
795+
path.join(localBin, "openshell"),
796+
[
797+
"#!/usr/bin/env bash",
798+
'if [ "$1" = "status" ]; then',
799+
" echo 'Server Status'",
800+
" echo",
801+
" echo ' Gateway: nemoclaw'",
802+
" echo ' Status: Connected'",
803+
" exit 0",
804+
"fi",
805+
'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then',
806+
" echo 'Gateway Info'",
807+
" echo",
808+
" echo ' Gateway: nemoclaw'",
809+
" exit 0",
810+
"fi",
811+
'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then',
812+
" echo 'NAME PHASE'",
813+
" echo 'alpha Ready'",
814+
" echo 'beta Ready'",
815+
" exit 0",
816+
"fi",
817+
'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then',
818+
" exit 0",
819+
"fi",
820+
'if [ "$1" = "--version" ]; then',
821+
" echo 'openshell 0.0.16'",
822+
" exit 0",
823+
"fi",
824+
"exit 0",
825+
].join("\n"),
826+
{ mode: 0o755 },
827+
);
645828

646-
const r = runWithEnv("list", { HOME: home });
829+
const r = runWithEnv("list", {
830+
HOME: home,
831+
PATH: `${localBin}:${process.env.PATH || ""}`,
832+
});
647833

648834
expect(r.code).toBe(0);
649-
// Only gamma (from registry) should appear — not alpha (from session).
835+
expect(
836+
r.out.includes("Recovered sandbox inventory from the last onboard session."),
837+
).toBeTruthy();
838+
expect(
839+
r.out.includes("Recovered 1 sandbox entry from the live OpenShell gateway."),
840+
).toBeTruthy();
841+
expect(r.out.includes("alpha")).toBeTruthy();
842+
expect(r.out.includes("beta")).toBeTruthy();
650843
expect(r.out.includes("gamma")).toBeTruthy();
651-
expect(r.out.includes("alpha")).toBeFalsy();
652-
expect(r.out).not.toMatch(/Recovered/);
653-
// Registry must not have been mutated.
654844
const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8"));
655-
expect(saved.sandboxes.alpha).toBeUndefined();
845+
expect(saved.sandboxes.alpha).toBeTruthy();
846+
expect(saved.sandboxes.alpha.policies).toEqual(["pypi"]);
847+
expect(saved.sandboxes.beta).toBeTruthy();
848+
expect(saved.sandboxes.gamma).toBeTruthy();
849+
expect(saved.defaultSandbox).toBe("gamma");
850+
});
851+
852+
it("skips invalid recovered sandbox names during list recovery", () => {
853+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-invalid-recover-"));
854+
const localBin = path.join(home, "bin");
855+
const nemoclawDir = path.join(home, ".nemoclaw");
856+
fs.mkdirSync(localBin, { recursive: true });
857+
fs.mkdirSync(nemoclawDir, { recursive: true });
858+
fs.writeFileSync(
859+
path.join(nemoclawDir, "sandboxes.json"),
860+
JSON.stringify({
861+
sandboxes: {
862+
gamma: {
863+
name: "gamma",
864+
model: "existing-model",
865+
provider: "existing-provider",
866+
gpuEnabled: false,
867+
policies: ["npm"],
868+
},
869+
},
870+
defaultSandbox: "gamma",
871+
}),
872+
{ mode: 0o600 },
873+
);
874+
fs.writeFileSync(
875+
path.join(nemoclawDir, "onboard-session.json"),
876+
JSON.stringify(
877+
{
878+
version: 1,
879+
sessionId: "session-1",
880+
resumable: true,
881+
status: "complete",
882+
mode: "interactive",
883+
startedAt: "2026-03-31T00:00:00.000Z",
884+
updatedAt: "2026-03-31T00:00:00.000Z",
885+
lastStepStarted: "policies",
886+
lastCompletedStep: "policies",
887+
failure: null,
888+
sandboxName: "Alpha",
889+
provider: "nvidia-prod",
890+
model: "nvidia/nemotron-3-super-120b-a12b",
891+
endpointUrl: null,
892+
credentialEnv: null,
893+
preferredInferenceApi: null,
894+
nimContainer: null,
895+
policyPresets: ["pypi"],
896+
metadata: { gatewayName: "nemoclaw" },
897+
steps: {
898+
preflight: { status: "complete", startedAt: null, completedAt: null, error: null },
899+
gateway: { status: "complete", startedAt: null, completedAt: null, error: null },
900+
sandbox: { status: "complete", startedAt: null, completedAt: null, error: null },
901+
provider_selection: {
902+
status: "complete",
903+
startedAt: null,
904+
completedAt: null,
905+
error: null,
906+
},
907+
inference: { status: "complete", startedAt: null, completedAt: null, error: null },
908+
openclaw: { status: "complete", startedAt: null, completedAt: null, error: null },
909+
policies: { status: "complete", startedAt: null, completedAt: null, error: null },
910+
},
911+
},
912+
null,
913+
2,
914+
),
915+
{ mode: 0o600 },
916+
);
917+
fs.writeFileSync(
918+
path.join(localBin, "openshell"),
919+
[
920+
"#!/usr/bin/env bash",
921+
'if [ "$1" = "status" ]; then',
922+
" echo 'Server Status'",
923+
" echo",
924+
" echo ' Gateway: nemoclaw'",
925+
" echo ' Status: Connected'",
926+
" exit 0",
927+
"fi",
928+
'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then',
929+
" echo 'Gateway Info'",
930+
" echo",
931+
" echo ' Gateway: nemoclaw'",
932+
" exit 0",
933+
"fi",
934+
'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then',
935+
" echo 'NAME PHASE'",
936+
" echo 'alpha Ready'",
937+
" echo 'Bad_Name Ready'",
938+
" exit 0",
939+
"fi",
940+
'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then',
941+
" exit 0",
942+
"fi",
943+
'if [ "$1" = "--version" ]; then',
944+
" echo 'openshell 0.0.16'",
945+
" exit 0",
946+
"fi",
947+
"exit 0",
948+
].join("\n"),
949+
{ mode: 0o755 },
950+
);
951+
952+
const r = runWithEnv("list", {
953+
HOME: home,
954+
PATH: `${localBin}:${process.env.PATH || ""}`,
955+
});
956+
957+
expect(r.code).toBe(0);
958+
expect(r.out.includes("alpha")).toBeTruthy();
959+
expect(r.out.includes("Bad_Name")).toBeFalsy();
960+
const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8"));
961+
expect(saved.sandboxes.alpha).toBeTruthy();
962+
expect(saved.sandboxes.Bad_Name).toBeUndefined();
963+
expect(saved.sandboxes.Alpha).toBeUndefined();
656964
expect(saved.sandboxes.gamma).toBeTruthy();
657965
});
658966

0 commit comments

Comments
 (0)