From 7645547cca0512a43637e179964a2091501b2389 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:16:51 -0700 Subject: [PATCH 01/57] refactor(cli): add typed OpenShell sandbox observer Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ci/source-architecture-budget.json | 6 +- src/lib/actions/maintenance.test.ts | 138 ++++---- src/lib/actions/maintenance.ts | 11 +- .../agent/passthrough-ollama-recovery.test.ts | 8 +- .../agent/passthrough-shields-warning.test.ts | 12 +- .../actions/sandbox/agent/passthrough.test.ts | 47 ++- src/lib/actions/sandbox/agent/passthrough.ts | 3 +- src/lib/actions/sandbox/connect.ts | 137 ++++--- src/lib/actions/sandbox/doctor-flow.test.ts | 2 +- src/lib/actions/sandbox/doctor.ts | 53 +-- .../sandbox/gateway-state-drift.test.ts | 60 ++-- .../gateway-state-owning-gateway.test.ts | 10 +- src/lib/actions/sandbox/gateway-state.ts | 177 ++++----- .../actions/sandbox/rebuild-flow-helpers.ts | 33 +- .../sandbox/rebuild-gateway-drift.test.ts | 241 +++++++------ .../sandbox/rebuild-resume-snapshot.test.ts | 7 +- src/lib/actions/sandbox/start-wait.test.ts | 81 ++++- src/lib/actions/sandbox/start.test.ts | 106 +++--- src/lib/actions/sandbox/start.ts | 42 +-- .../sandbox/status-snapshot-recovery.test.ts | 49 +-- src/lib/actions/sandbox/status-snapshot.ts | 21 +- src/lib/actions/sandbox/status.ts | 3 +- .../upgrade-sandboxes-preflight.test.ts | 21 +- .../upgrade-sandboxes-recovery.test.ts | 42 +-- src/lib/actions/upgrade-sandboxes.ts | 25 +- .../openshell/sandbox-observer-cli.test.ts | 282 +++++++++++++++ .../openshell/sandbox-observer-cli.ts | 335 ++++++++++++++++++ .../adapters/openshell/sandbox-observer.ts | 100 ++++++ src/lib/openshell-sandbox-list.test.ts | 80 +++-- src/lib/openshell-sandbox-list.ts | 85 +++-- src/lib/registry-recovery-action.test.ts | 36 +- src/lib/registry-recovery-action.ts | 23 +- .../registry-recovery-seeded-paths.test.ts | 56 ++- test/helpers/rebuild-flow-dcode-harness.ts | 8 +- test/helpers/rebuild-flow-generic-harness.ts | 9 +- test/helpers/rebuild-flow-harness.ts | 33 ++ 36 files changed, 1666 insertions(+), 716 deletions(-) create mode 100644 src/lib/adapters/openshell/sandbox-observer-cli.test.ts create mode 100644 src/lib/adapters/openshell/sandbox-observer-cli.ts create mode 100644 src/lib/adapters/openshell/sandbox-observer.ts diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index c9b0c1938cb..0a0240c09b7 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -6,7 +6,7 @@ "src/lib/actions/sandbox/mcp-bridge-contracts.ts": 26, "src/lib/actions/sandbox/process-recovery.ts": 27, "src/lib/adapters/docker/index.ts": 43, - "src/lib/adapters/openshell/client.ts": 23, + "src/lib/adapters/openshell/client.ts": 20, "src/lib/adapters/openshell/resolve.ts": 27, "src/lib/adapters/openshell/runtime.ts": 54, "src/lib/adapters/openshell/timeouts.ts": 38, @@ -39,8 +39,8 @@ "src/lib/actions/inference-set.ts": 32, "src/lib/actions/sandbox/connect.ts": 42, "src/lib/actions/sandbox/destroy.ts": 29, - "src/lib/actions/sandbox/doctor.ts": 29, - "src/lib/actions/sandbox/status-snapshot.ts": 20, + "src/lib/actions/sandbox/doctor.ts": 28, + "src/lib/actions/sandbox/status-snapshot.ts": 19, "src/lib/actions/sandbox/policy-channel.ts": 30, "src/lib/actions/sandbox/process-recovery.ts": 21, "src/lib/actions/sandbox/rebuild-pipeline.ts": 29, diff --git a/src/lib/actions/maintenance.test.ts b/src/lib/actions/maintenance.test.ts index e1f9b4b647d..89b84bc035c 100644 --- a/src/lib/actions/maintenance.test.ts +++ b/src/lib/actions/maintenance.test.ts @@ -8,8 +8,6 @@ const mocks = vi.hoisted(() => ({ getSandbox: vi.fn(), backupSandboxState: vi.fn(), captureSandboxListWithGatewayPreflightOrExit: vi.fn(), - parseReadySandboxNames: vi.fn(), - parseLiveSandboxNames: vi.fn(), dockerListImagesFormat: vi.fn().mockReturnValue(""), dockerRmi: vi.fn(), prompt: vi.fn(), @@ -25,6 +23,19 @@ const mocks = vi.hoisted(() => ({ withPortableHostFence: vi.fn(), })); +let readySandboxNames = new Set(); +let liveSandboxNames = new Set(); + +function sandboxInventory() { + return { + sandboxes: [...new Set([...readySandboxNames, ...liveSandboxNames])].map((name) => ({ + name, + phase: null, + readiness: readySandboxNames.has(name) ? ("ready" as const) : ("not_ready" as const), + })), + }; +} + async function runSandboxMutationAction( _sandboxName: string, action: () => unknown, @@ -57,10 +68,6 @@ vi.mock("./sandbox/snapshot/backup-authority", () => ({ vi.mock("../openshell-sandbox-list", () => ({ captureSandboxListWithGatewayPreflightOrExit: mocks.captureSandboxListWithGatewayPreflightOrExit, })); -vi.mock("../runtime-recovery", () => ({ - parseReadySandboxNames: mocks.parseReadySandboxNames, - parseLiveSandboxNames: mocks.parseLiveSandboxNames, -})); // GATEWAY_PORT is baked from NEMOCLAW_GATEWAY_PORT at module load. Pin it so // the #6520 orphan-classification tests (which run the real gateway-binding // resolvers against literal ports) don't invert on a shell that exports a @@ -108,15 +115,14 @@ describe("backupAll", () => { vi.clearAllMocks(); mocks.backupStartedSandboxState.mockReset(); delete process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS; - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "sb-good\nsb-bad\n", - }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-good", "sb-bad"])); + readySandboxNames = new Set(["sb-good", "sb-bad"]); + liveSandboxNames = new Set(); + mocks.captureSandboxListWithGatewayPreflightOrExit.mockImplementation(async () => + sandboxInventory(), + ); // Defaults keep every pre-#6520 case on its original path: no sandbox is // gateway-observed (so orphan classification is decided by the absence // gate alone) and no container is ever definitively absent. - mocks.parseLiveSandboxNames.mockReturnValue(new Set()); mocks.isSandboxContainerDefinitivelyAbsent.mockReturnValue(false); mocks.startStoppedSandboxContainerForBackup.mockReturnValue(null); mocks.returnSandboxContainerToStopped.mockReturnValue(true); @@ -208,7 +214,7 @@ describe("backupAll", () => { ], defaultSandbox: "alpha", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha", "beta"])); + readySandboxNames = new Set(["alpha", "beta"]); mocks.backupSandboxState.mockImplementation((name: string) => ({ success: true, backedUpDirs: ["workspace"], @@ -238,7 +244,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-good" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-good"])); + readySandboxNames = new Set(["sb-good"]); mocks.backupSandboxState.mockReturnValue({ success: true, backedUpDirs: ["workspace"], @@ -271,7 +277,7 @@ describe("backupAll", () => { sandboxes: [{ name: "alpha" }, { name: "beta" }], defaultSandbox: "alpha", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha", "beta"])); + readySandboxNames = new Set(["alpha", "beta"]); mocks.withSandboxMutationLock .mockRejectedValueOnce(new Error("Timed out waiting for the sandbox mutation lock")) .mockImplementation(runSandboxMutationAction); @@ -308,7 +314,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-stopped" }], defaultSandbox: "sb-stopped", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); mocks.startStoppedSandboxContainerForBackup.mockReturnValue({ containerName: "openshell-sb-stopped-abc", }); @@ -349,7 +355,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-bad" }, { name: "sb-good" }, { name: "sb-stopped" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad", "sb-good"])); + readySandboxNames = new Set(["sb-bad", "sb-good"]); mocks.backupSandboxState.mockImplementation((name: string) => name === "sb-bad" ? { @@ -393,7 +399,7 @@ describe("backupAll", () => { sandboxes: [{ name: "alpha" }, { name: "beta" }], defaultSandbox: "alpha", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha", "beta"])); + readySandboxNames = new Set(["alpha", "beta"]); const events: string[] = []; mocks.withSandboxMutationLock.mockImplementation( async (name: string, action: () => unknown) => { @@ -464,7 +470,7 @@ describe("backupAll", () => { sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha"])); + readySandboxNames = new Set(["alpha"]); mocks.openBackupShieldsWindow.mockReturnValue({ relocked: false, wasLocked: true }); mocks.backupSandboxState.mockReturnValue({ success: false, @@ -494,7 +500,7 @@ describe("backupAll", () => { sandboxes: [{ name: "alpha" }, { name: "beta" }], defaultSandbox: "alpha", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha", "beta"])); + readySandboxNames = new Set(["alpha", "beta"]); mocks.openBackupShieldsWindow.mockImplementation((name: string) => name === "alpha" ? null : { relocked: false, wasLocked: false }, ); @@ -527,7 +533,7 @@ describe("backupAll", () => { sandboxes: [{ name: "alpha" }, { name: "beta" }], defaultSandbox: "alpha", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha", "beta"])); + readySandboxNames = new Set(["alpha", "beta"]); mocks.openBackupShieldsWindow.mockReturnValue({ relocked: false, wasLocked: true }); mocks.backupSandboxState.mockReturnValue({ success: true, @@ -554,7 +560,7 @@ describe("backupAll", () => { sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha"])); + readySandboxNames = new Set(["alpha"]); mocks.openBackupShieldsWindow.mockReturnValue({ relocked: false, wasLocked: true }); const backupError = new Error("EACCES: permission denied, open '/var/backups/state'"); mocks.backupSandboxState.mockImplementation(() => { @@ -591,7 +597,7 @@ describe("backupAll", () => { sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha"])); + readySandboxNames = new Set(["alpha"]); mocks.openBackupShieldsWindow.mockReturnValue({ relocked: false, wasLocked: true }); const orphanMessage = "Agent 'alpha' not found: /agents/alpha/manifest.yaml"; mocks.backupSandboxState.mockImplementation(() => { @@ -621,7 +627,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-good" }, { name: "sb-stopped" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-good"])); + readySandboxNames = new Set(["sb-good"]); mocks.backupSandboxState.mockReturnValue({ success: true, backedUpDirs: ["workspace"], @@ -654,7 +660,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-good" }, { name: "sb-stopped" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-good"])); + readySandboxNames = new Set(["sb-good"]); mocks.backupSandboxState.mockReturnValue({ success: true, backedUpDirs: ["workspace"], @@ -701,7 +707,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-stopped" }], defaultSandbox: "sb-stopped", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); const events: string[] = []; let lockActive = false; mocks.withSandboxMutationLock.mockImplementation( @@ -772,7 +778,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-stopped" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); mocks.startStoppedSandboxContainerForBackup.mockReturnValue({ containerName: "openshell-sb-stopped-abc", }); @@ -805,7 +811,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-stopped" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); mocks.startStoppedSandboxContainerForBackup.mockReturnValue({ containerName: "openshell-sb-stopped-abc", }); @@ -835,7 +841,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-stopped" }], defaultSandbox: "sb-stopped", }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); let lockActive = false; mocks.withSandboxMutationLock.mockImplementation( async (_name: string, action: () => unknown) => { @@ -890,7 +896,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-stopped" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); mocks.startStoppedSandboxContainerForBackup.mockReturnValue({ containerName: "openshell-sb-stopped-abc", }); @@ -912,7 +918,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-stopped" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); mocks.startStoppedSandboxContainerForBackup.mockReturnValue(null); process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -963,11 +969,8 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-bad" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad"])); - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "sb-bad\n", - }); + readySandboxNames = new Set(["sb-bad"]); + mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue(sandboxInventory()); mocks.backupSandboxState.mockImplementation(() => { throw new Error("Agent 'orphan' not found: /agents/orphan/manifest.yaml"); @@ -990,7 +993,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-orphan" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-orphan"])); + readySandboxNames = new Set(["sb-orphan"]); mocks.backupSandboxState.mockImplementation(() => { throw new Error("Agent 'orphan' not found: /agents/orphan/manifest.yaml"); }); @@ -1015,11 +1018,8 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-bad" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad"])); - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "sb-bad\n", - }); + readySandboxNames = new Set(["sb-bad"]); + mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue(sandboxInventory()); mocks.backupSandboxState.mockImplementation(() => { throw new Error("EACCES: permission denied, open '/var/backups/state'"); @@ -1040,11 +1040,8 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-bad" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad"])); - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "sb-bad\n", - }); + readySandboxNames = new Set(["sb-bad"]); + mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue(sandboxInventory()); mocks.backupSandboxState.mockImplementation(() => { throw new Error("Agent 'phantom' not found"); @@ -1064,11 +1061,8 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-bad" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad"])); - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "sb-bad\n", - }); + readySandboxNames = new Set(["sb-bad"]); + mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue(sandboxInventory()); mocks.backupSandboxState.mockImplementation(() => { throw new Error("Agent 'phantom' not found: /agents/phantom/binary"); @@ -1125,7 +1119,7 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-bad" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad"])); + readySandboxNames = new Set(["sb-bad"]); mocks.backupSandboxState.mockReturnValue({ success: false, unreachable: true, @@ -1157,11 +1151,8 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-bad" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad"])); - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "sb-bad\n", - }); + readySandboxNames = new Set(["sb-bad"]); + mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue(sandboxInventory()); mocks.backupSandboxState.mockImplementation(() => ({ success: false, unreachable: true, @@ -1203,8 +1194,8 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-good" }, { name: "sb-stranded" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-good"])); - mocks.parseLiveSandboxNames.mockReturnValue(new Set(["sb-good"])); + readySandboxNames = new Set(["sb-good"]); + liveSandboxNames = new Set(["sb-good"]); mocks.isSandboxContainerDefinitivelyAbsent.mockImplementation( (name: string) => name === "sb-stranded", ); @@ -1255,8 +1246,8 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-other", gatewayPort: 9999 }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); - mocks.parseLiveSandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); + liveSandboxNames = new Set(); mocks.isSandboxContainerDefinitivelyAbsent.mockReturnValue(true); process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -1285,8 +1276,8 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-reconnecting" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); - mocks.parseLiveSandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); + liveSandboxNames = new Set(); mocks.isSandboxContainerDefinitivelyAbsent.mockReturnValue(false); process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -1312,16 +1303,12 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-flapping" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); mocks.captureSandboxListWithGatewayPreflightOrExit - .mockResolvedValueOnce({ status: 0, output: "" }) + .mockResolvedValueOnce({ sandboxes: [] }) .mockResolvedValueOnce({ - status: 0, - output: "sb-flapping openshell 2026-07-21 10:00:00 Ready\n", + sandboxes: [{ name: "sb-flapping", phase: null, readiness: "ready" }], }); - mocks.parseLiveSandboxNames.mockImplementation((output: string) => - output.includes("sb-flapping") ? new Set(["sb-flapping"]) : new Set(), - ); mocks.isSandboxContainerDefinitivelyAbsent.mockReturnValue(true); process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -1345,12 +1332,9 @@ describe("backupAll", () => { sandboxes: [{ name: "sb-flapping" }], defaultSandbox: null, }); - mocks.parseReadySandboxNames.mockReturnValue(new Set()); - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "", - }); - mocks.parseLiveSandboxNames.mockReturnValue(new Set()); + readySandboxNames = new Set(); + mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue(sandboxInventory()); + liveSandboxNames = new Set(); mocks.isSandboxContainerDefinitivelyAbsent.mockReturnValueOnce(true).mockReturnValueOnce(false); process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = "1"; const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/src/lib/actions/maintenance.ts b/src/lib/actions/maintenance.ts index e13da54b113..14751692712 100644 --- a/src/lib/actions/maintenance.ts +++ b/src/lib/actions/maintenance.ts @@ -21,7 +21,6 @@ import { import { SANDBOX_IMAGE_REPOS } from "../domain/sandbox/image-tag"; import { resolveGatewayName, resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { captureSandboxListWithGatewayPreflightOrExit } from "../openshell-sandbox-list"; -import { parseLiveSandboxNames, parseReadySandboxNames } from "../runtime-recovery"; import { withSandboxMutationLock } from "../state/mcp-lifecycle-lock"; import * as registry from "../state/registry"; import * as sandboxState from "../state/sandbox"; @@ -303,7 +302,11 @@ async function backupAllWithoutPortableAuthority(): Promise { }, { gatewayName: selectedGatewayName }, ); - const readyNames = parseReadySandboxNames(liveList.output || ""); + const readyNames = new Set( + liveList.sandboxes + .filter((sandbox) => sandbox.readiness === "ready") + .map((sandbox) => sandbox.name), + ); // Source-of-truth review (#6520): // // - Invalid state: a sandbox the selected gateway does not observe, whose @@ -332,7 +335,7 @@ async function backupAllWithoutPortableAuthority(): Promise { // candidate the gateway observes again reverts to a genuine strict skip. const orphanNames = new Set( classifyOrphanedRegistrySandboxes(sandboxes, { - observedNames: parseLiveSandboxNames(liveList.output || ""), + observedNames: new Set(liveList.sandboxes.map((sandbox) => sandbox.name)), reconnectedNames: new Set(), selectedGatewayName, resolveGatewayBinding: resolveSandboxGatewayName, @@ -448,7 +451,7 @@ async function backupAllWithoutPortableAuthority(): Promise { }, { gatewayName: selectedGatewayName }, ); - const observedOnRecheck = parseLiveSandboxNames(confirmation.output || ""); + const observedOnRecheck = new Set(confirmation.sandboxes.map((sandbox) => sandbox.name)); confirmedStranded = strandedOrphans.filter( (name) => !observedOnRecheck.has(name) && isSandboxContainerDefinitivelyAbsent(name), ); diff --git a/src/lib/actions/sandbox/agent/passthrough-ollama-recovery.test.ts b/src/lib/actions/sandbox/agent/passthrough-ollama-recovery.test.ts index 7594cd04110..d8516b67e5f 100644 --- a/src/lib/actions/sandbox/agent/passthrough-ollama-recovery.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough-ollama-recovery.test.ts @@ -147,9 +147,11 @@ function makePassthroughDeps( getSandbox: ((name) => ({ name, agent: "openclaw", ...route })) as NonNullable< AgentPassthroughDeps["getSandbox"] >, - ensureLive: (async () => ({ state: "present", output: "Phase: Ready" })) as NonNullable< - AgentPassthroughDeps["ensureLive"] - >, + ensureLive: (async () => ({ + state: "present", + phase: "Ready", + output: "Phase: Ready", + })) as NonNullable, execNonJson: ((): never => { events.push("dispatch"); throw new Error("__exit:0"); diff --git a/src/lib/actions/sandbox/agent/passthrough-shields-warning.test.ts b/src/lib/actions/sandbox/agent/passthrough-shields-warning.test.ts index 7b62d05544e..8a1cd55dbaa 100644 --- a/src/lib/actions/sandbox/agent/passthrough-shields-warning.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough-shields-warning.test.ts @@ -10,7 +10,7 @@ import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit"; const execMock = vi.hoisted(() => vi.fn(async () => {})); const ensureLiveMock = vi.hoisted(() => - vi.fn(async () => ({ state: "present", output: "Phase: Ready" }) as { output?: string }), + vi.fn(async () => ({ state: "present", phase: "Ready", output: "Phase: Ready" })), ); const getSandboxMock = vi.hoisted(() => vi.fn(() => ({ agent: "openclaw" }))); const listAgentsMock = vi.hoisted(() => vi.fn(() => ["langchain-deepagents-code", "openclaw"])); @@ -210,12 +210,10 @@ describe("runAgentPassthrough shields-relock warning", () => { it("does not consult OpenClaw relock history for terminal-runtime passthroughs (#5922)", async () => { getSandboxMock.mockReturnValueOnce({ agent: "langchain-deepagents-code" }); - const getRecentShieldsAutoRestore = vi.fn( - (): ShieldsAutoRestoreReadResult => ({ - kind: "event", - event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 }, - }), - ); + const getRecentShieldsAutoRestore = vi.fn((): ShieldsAutoRestoreReadResult => ({ + kind: "event", + event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 }, + })); const { writes, proc } = makeProcMock(); await runAgentPassthrough( diff --git a/src/lib/actions/sandbox/agent/passthrough.test.ts b/src/lib/actions/sandbox/agent/passthrough.test.ts index 9ba1369f180..ddb49b96d15 100644 --- a/src/lib/actions/sandbox/agent/passthrough.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough.test.ts @@ -5,7 +5,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const execMock = vi.hoisted(() => vi.fn(async () => {})); const ensureLiveMock = vi.hoisted(() => - vi.fn(async () => ({ state: "present", output: "Phase: Ready" }) as { output?: string }), + vi.fn( + async () => + ({ state: "present", phase: "Ready", output: "Phase: Ready" }) as { + state: string; + phase: string | null; + output: string; + }, + ), ); const getSandboxMock = vi.hoisted(() => vi.fn( @@ -164,10 +171,7 @@ describe("runAgentPassthrough", () => { headless_command: "python3 /app/run_with_harness.py", }, }); - await runAgentPassthrough( - "alpha", - { extraArgs: ["start", "--task-id", "demo"] }, - ); + await runAgentPassthrough("alpha", { extraArgs: ["start", "--task-id", "demo"] }); expect(execMock).toHaveBeenCalledWith( "alpha", ["python3", "/app/run_with_harness.py", "start", "--task-id", "demo"], @@ -599,7 +603,11 @@ describe("runAgentPassthrough", () => { }); it("prints recovery hints with exit 1 before selector rejection for the literal stopped-sandbox repro `agent -m ping` (#5655)", async () => { - ensureLiveMock.mockResolvedValueOnce({ output: "Phase: Error" }); + ensureLiveMock.mockResolvedValueOnce({ + state: "present", + phase: "Error", + output: "Phase: Error", + }); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { writes, exit, proc } = makeProcMock(); await expect( @@ -652,7 +660,11 @@ describe("runAgentPassthrough", () => { }); it("rejects with exit 1 + recovery hints when sandbox phase is non-Ready", async () => { - ensureLiveMock.mockResolvedValueOnce({ output: "Phase: Error" }); + ensureLiveMock.mockResolvedValueOnce({ + state: "present", + phase: "Error", + output: "Phase: Error", + }); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { writes, exit, proc } = makeProcMock(); await expect( @@ -673,8 +685,12 @@ describe("runAgentPassthrough", () => { expect(all).toMatch(/onboard --resume/); }); - it("fails closed with exit 2 when ensureLive returns output without a parseable Phase line, never invoking exec", async () => { - ensureLiveMock.mockResolvedValueOnce({ output: "Name: alpha\n(no phase line here)\n" }); + it("fails closed with exit 2 when ensureLive returns no observed phase, never invoking exec", async () => { + ensureLiveMock.mockResolvedValueOnce({ + state: "present", + phase: null, + output: "Name: alpha\n(no phase line here)\n", + }); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { writes, exit, proc } = makeProcMock(); await expect( @@ -781,10 +797,15 @@ describe("runAgentNonJsonPassthrough", () => { const { proc } = makeNonJsonProcMock(); const runDispatchMock = makeDispatchMock("PONG\n", "", 0); await expect( - runAgentNonJsonPassthrough("my-sb", ["openclaw", "agent", "--agent", "main", "-m", "ping"], proc, { - getOpenshellBinary: stubBinary, - runDispatch: runDispatchMock, - }), + runAgentNonJsonPassthrough( + "my-sb", + ["openclaw", "agent", "--agent", "main", "-m", "ping"], + proc, + { + getOpenshellBinary: stubBinary, + runDispatch: runDispatchMock, + }, + ), ).rejects.toThrow("__exit:0"); expect(buildOpenshellExecArgsMock.mock.calls[0]?.[2]?.timeoutSeconds).toBeUndefined(); }); diff --git a/src/lib/actions/sandbox/agent/passthrough.ts b/src/lib/actions/sandbox/agent/passthrough.ts index 8fdbd7574e3..318ccea1a34 100644 --- a/src/lib/actions/sandbox/agent/passthrough.ts +++ b/src/lib/actions/sandbox/agent/passthrough.ts @@ -116,7 +116,6 @@ import { CLI_NAME } from "../../../cli/branding"; import { isStdinTty } from "../../../core/stdin"; import { resolveSandboxHermesApiPort } from "../../../onboard/hermes-api-port"; import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit"; -import { parseSandboxPhase } from "../../../state/gateway"; import * as registry from "../../../state/registry"; import { buildOpenshellExecArgs, @@ -541,7 +540,7 @@ export async function runAgentPassthrough( if (!command) return; const ensureLive = deps.ensureLive ?? ensureLiveSandboxOrExit; const state = await ensureLive(sandboxName, { allowNonReadyPhase: true }); - const phase = parseSandboxPhase(state?.output ?? ""); + const phase = state?.phase ?? null; if (!phase) { rejectUnparseablePhase(sandboxName, proc); } diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 22363996a7d..cb173a760d7 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -2,6 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { + createCliOpenShellSandboxObserver, + namedOpenShellGateway, + type OpenShellSandboxError, + type OpenShellSandboxObservation, + type OpenShellSandboxObserver, +} from "../../adapters/openshell/sandbox-observer-cli"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { captureOpenshell, @@ -43,13 +50,6 @@ import { isWsl } from "../../platform"; import { ROOT } from "../../runner"; import * as sandboxVersion from "../../sandbox/version"; import { redact, redactFull } from "../../security/redact"; -import { - isSandboxReady, - isTerminalSandboxPhase, - parseSandboxPhase, - parseSandboxStatus, - TERMINAL_SANDBOX_PHASES, -} from "../../state/gateway"; import type { SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { @@ -85,6 +85,8 @@ import { printGatewayLifecycleHint, qualifyPortableAgentLifecycleAuthority, recoverPortableDemoSandboxLifecycleForConnect, + isTerminalSandboxPhase, + TERMINAL_SANDBOX_PHASES, requireHermesPortableActiveLifecycleAuthority, startStoppedSandboxContainerForProbeRecovery, withConnectSandboxLifecycleLock, @@ -137,11 +139,6 @@ type SpawnLikeResult = { signal?: NodeJS.Signals | null; }; -type SandboxListProbe = { - status: number | null; - output: string; -}; - export type SandboxInferenceRouteProbe = { healthy: boolean; broken: boolean; @@ -570,8 +567,41 @@ function failConnectReadinessGatewayUnavailable(sandboxName: string, detailOutpu process.exit(1); } -function outputShowsGatewayUnavailable(output = ""): boolean { - return GATEWAY_UNAVAILABLE_RE.test(output); +function failConnectReadinessObservation( + sandboxName: string, + error: OpenShellSandboxError, +): never { + if (error.kind === "transport") { + failConnectReadinessGatewayUnavailable(sandboxName, error.message); + } + + console.error(""); + switch (error.kind) { + case "authentication": + console.error( + ` OpenShell could not authenticate while checking sandbox '${sandboxName}' readiness.`, + ); + console.error(" Restore authentication for the sandbox's recorded gateway, then retry."); + break; + case "schema": + console.error( + ` The OpenShell CLI and gateway schemas do not match; cannot verify sandbox '${sandboxName}' readiness.`, + ); + console.error(" Use matching supported OpenShell CLI and gateway versions, then retry."); + break; + case "timeout": + console.error(` The OpenShell readiness request for sandbox '${sandboxName}' timed out.`); + console.error(" Check gateway health and retry after it responds."); + break; + case "command": + console.error(` The OpenShell readiness request for sandbox '${sandboxName}' failed.`); + console.error( + ` Run \`${CLI_NAME} ${sandboxName} status\` to inspect the failure before retrying.`, + ); + break; + } + console.error(` ${error.message}`); + process.exit(1); } // Fail fast with Docker-outage guidance instead of polling to the readiness @@ -1187,10 +1217,7 @@ function exitWithConnectSpawnResult(sandboxName: string, result: SpawnLikeResult type WaitForSandboxReadyOptions = { allowInitialErrorAfterStart?: boolean; allowDockerRuntimeInspection?: boolean; - captureSandboxList?: ( - args: string[], - options: { readonly ignoreError: true; readonly timeout: number }, - ) => ReturnType; + observer?: OpenShellSandboxObserver; defaultTimeoutSec?: number; retryCommand?: string; successLogs?: readonly string[]; @@ -1212,17 +1239,20 @@ const START_INITIAL_ERROR_GRACE_POLLS = 10; export const SANDBOX_REPAIR_READY_TIMEOUT_SEC = 300; /** Wait for a sandbox to become ready, exiting with recovery guidance on terminal failure. */ -export function waitForSandboxReadyOrExit( +export async function waitForSandboxReadyOrExit( sandboxName: string, { allowInitialErrorAfterStart = false, allowDockerRuntimeInspection = true, - captureSandboxList = captureOpenshell, + observer = createCliOpenShellSandboxObserver({ + capture: captureOpenshell, + defaultTimeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, + }), defaultTimeoutSec = 120, retryCommand = "connect", successLogs = [], }: WaitForSandboxReadyOptions = {}, -): void { +): Promise { const rawTimeout = process.env.NEMOCLAW_CONNECT_TIMEOUT; let timeout = defaultTimeoutSec; if (rawTimeout !== undefined) { @@ -1241,27 +1271,25 @@ export function waitForSandboxReadyOrExit( const gatewayName = getSandboxTargetGatewayName(sandboxName); const elapsedSec = () => Math.floor((Date.now() - startedAt) / 1000); const remainingMs = () => Math.max(1, deadline - Date.now()); - const runSandboxList = (): SandboxListProbe => { + const observeSandbox = async (): Promise => { // Gateway selection is process-global and another CLI can change it while // this command waits. Pin each poll to the registry-recorded owner so a // same-named sandbox on a sibling gateway cannot satisfy readiness. - const result = captureSandboxList(["sandbox", "list", "-g", gatewayName], { - ignoreError: true, - timeout: remainingMs(), + const result = await observer.listSandboxes({ + target: namedOpenShellGateway(gatewayName), + timeoutMs: remainingMs(), }); - return { status: result.status, output: result.output }; + if (!result.ok) { + failConnectReadinessObservation(sandboxName, result.error); + } + return result.value.sandboxes.find((sandbox) => sandbox.name === sandboxName) ?? null; }; - const listProbe = runSandboxList(); - const listCommandFailed = listProbe.status !== 0; - if (listCommandFailed && outputShowsGatewayUnavailable(listProbe.output)) { - failConnectReadinessGatewayUnavailable(sandboxName, listProbe.output); - } - const list = listProbe.output; - if (isSandboxReady(list, sandboxName)) return; + const initial = await observeSandbox(); + if (initial?.readiness === "ready") return; - const status = parseSandboxStatus(list, sandboxName); - if (!listCommandFailed && status && /^unknown$/i.test(status)) { + const status = initial?.phase ?? null; + if (status && /^unknown$/i.test(status)) { failIfGatewayBlocksConnectReadiness(sandboxName); } let remainingInitialErrorGracePolls = @@ -1283,21 +1311,18 @@ export function waitForSandboxReadyOrExit( while (Date.now() < deadline) { const sleepFor = Math.min(interval, remainingMs() / 1000); if (sleepFor <= 0) break; - spawnSync("sleep", [String(sleepFor)]); - const pollProbe = runSandboxList(); - const pollCommandFailed = pollProbe.status !== 0; - if (pollCommandFailed && outputShowsGatewayUnavailable(pollProbe.output)) { - failConnectReadinessGatewayUnavailable(sandboxName, pollProbe.output); + if (process.env.VITEST !== "true" && process.env.NEMOCLAW_TEST_NO_SLEEP !== "1") { + await new Promise((resolve) => setTimeout(resolve, sleepFor * 1000)); } - const poll = pollProbe.output; + const poll = await observeSandbox(); const elapsed = elapsedSec(); - if (isSandboxReady(poll, sandboxName)) { + if (poll?.readiness === "ready") { ready = true; break; } - const parsedCur = parseSandboxStatus(poll, sandboxName); + const parsedCur = poll?.phase ?? null; const cur = parsedCur || "unknown"; - if (!pollCommandFailed && parsedCur && /^unknown$/i.test(parsedCur)) { + if (parsedCur && /^unknown$/i.test(parsedCur)) { failIfGatewayBlocksConnectReadiness(sandboxName); } if (cur !== "unknown") everSeen = true; @@ -1486,7 +1511,7 @@ async function runConnectEntryPreflight( gatewayRecovery: probeOnly ? "observe" : "recover", }), ); - const livePhase = parseSandboxPhase(live.output || ""); + const livePhase = live.phase ?? null; if ( livePhase && livePhase !== "Ready" && @@ -1611,11 +1636,14 @@ export async function prepareInteractiveSession(sandboxName: string): Promise<{ } // Ensure Ollama auth proxy is running (recovers from host reboots) if (!hermesPortable) ensureOllamaAuthProxy(); - waitForSandboxReadyOrExit(sandboxName, { + await waitForSandboxReadyOrExit(sandboxName, { allowDockerRuntimeInspection: !hermesPortable, - captureSandboxList: hermesPortable - ? (args, captureOptions) => - captureHermesPortableOpenShell(sandboxName, args, captureOptions) + observer: hermesPortable + ? createCliOpenShellSandboxObserver({ + capture: (args, captureOptions) => + captureHermesPortableOpenShell(sandboxName, args, captureOptions), + defaultTimeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, + }) : undefined, successLogs: [" Sandbox is ready. Connecting..."], }); @@ -1785,12 +1813,15 @@ async function prepareConnectSandboxWithinLifecycleFence( startStoppedSandboxContainerForProbeRecovery(sandboxName), ); } - probeTiming!.measure("gateway", () => + await probeTiming!.measureAsync("gateway", () => waitForSandboxReadyOrExit(sandboxName, { allowDockerRuntimeInspection: !hermesPortable, - captureSandboxList: hermesPortable - ? (args, captureOptions) => - captureHermesPortableOpenShell(sandboxName, args, captureOptions) + observer: hermesPortable + ? createCliOpenShellSandboxObserver({ + capture: (args, captureOptions) => + captureHermesPortableOpenShell(sandboxName, args, captureOptions), + defaultTimeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, + }) : undefined, defaultTimeoutSec: SANDBOX_REPAIR_READY_TIMEOUT_SEC, retryCommand: "connect --probe-only", diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index c3d299f7090..c7f1147a987 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -951,7 +951,7 @@ describe("runSandboxDoctor flow", () => { gatewayName: "nemoclaw-19080", }); expect(harness.captureOpenShellSpy).toHaveBeenCalledWith( - ["sandbox", "list"], + ["sandbox", "list", "-g", "nemoclaw-19080"], expect.any(Object), ); expect(harness.probeSandboxInferenceGatewayHealthSpy).toHaveBeenCalledWith("alpha"); diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 83ccf86f424..ed50ad543ee 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -3,7 +3,11 @@ import fs from "node:fs"; import path from "node:path"; -import { stripAnsi } from "../../adapters/openshell/client"; +import { + createCliOpenShellSandboxObserver, + namedOpenShellGateway, + stripOpenShellCliAnsi, +} from "../../adapters/openshell/sandbox-observer-cli"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { captureOpenshell } from "../../adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; @@ -31,7 +35,6 @@ import { type BaselineExclusionRuntimeStatus, } from "../../policy/baseline-exclusion"; import { ROOT } from "../../runner"; -import { parseLiveSandboxNames } from "../../runtime-recovery"; import * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; import type { SandboxEntry } from "../../state/registry"; @@ -59,8 +62,6 @@ import { import { cloudflaredDoctorCheck, dockerInspectGateway, - findSandboxListLine, - inferSandboxReadyFromLine, inspectSandboxDoctorPortableAuthority, ollamaDoctorCheck, oneLine, @@ -242,7 +243,7 @@ async function probeOpenShellGateway( connected: boolean; }> { const lifecycle = await gatewayLifecycle(gatewayName, recoverGateway); - const cleanStatus = stripAnsi(lifecycle?.status || ""); + const cleanStatus = stripOpenShellCliAnsi(lifecycle?.status || ""); const connected = lifecycle?.state === "healthy_named"; return { connected, @@ -262,11 +263,11 @@ function liveSandboxDetail( sandboxName: string, present: boolean, ready: boolean | null, - line: string | null, + phase: string | null, ): string { if (!present) return `${sandboxName} not present in live OpenShell sandbox list`; if (ready) return `${sandboxName} present (Ready)`; - return `${sandboxName} present${line ? ` (${oneLine(line)})` : ""}`; + return `${sandboxName} present${phase ? ` (${oneLine(phase)})` : ""}`; } function liveSandboxHint( @@ -281,15 +282,19 @@ function liveSandboxHint( return `run \`${CLI_NAME} ${sandboxName} status\` or \`${CLI_NAME} ${sandboxName} logs --follow\``; } -function liveSandboxCheck(sandboxName: string): SandboxProbe { - const list = captureOpenshell(["sandbox", "list"], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, +async function liveSandboxCheck(sandboxName: string, gatewayName: string): Promise { + const list = await createCliOpenShellSandboxObserver({ + capture: captureOpenshell, + defaultTimeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, + }).listSandboxes({ + target: namedOpenShellGateway(gatewayName), + timeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, }); - const liveNames = parseLiveSandboxNames(list.output || ""); - const present = list.status === 0 && liveNames.has(sandboxName); - const line = findSandboxListLine(list.output || "", sandboxName); - const ready = inferSandboxReadyFromLine(line); + const observed = list.ok + ? (list.value.sandboxes.find((sandbox) => sandbox.name === sandboxName) ?? null) + : null; + const present = observed !== null; + const ready = observed ? observed.readiness === "ready" : null; const reachable = present && ready === true; return { reachable, @@ -298,19 +303,22 @@ function liveSandboxCheck(sandboxName: string): SandboxProbe { group: "Sandbox", label: "Live sandbox", status: reachable ? "ok" : "fail", - detail: liveSandboxDetail(sandboxName, present, ready, line), + detail: liveSandboxDetail(sandboxName, present, ready, observed?.phase ?? null), hint: liveSandboxHint(sandboxName, present, ready), }, ], }; } -function collectSandboxReadinessChecks( +async function collectSandboxReadinessChecks( sandboxName: string, + gatewayName: string | null, openshellBin: ReturnType, openshellConnected: boolean, -): SandboxProbe { - if (openshellBin && openshellConnected) return liveSandboxCheck(sandboxName); +): Promise { + if (gatewayName && openshellBin && openshellConnected) { + return liveSandboxCheck(sandboxName, gatewayName); + } if (!openshellBin) return { checks: [], reachable: false }; return { reachable: false, @@ -563,7 +571,12 @@ async function collectDoctorChecks( }, ], }; - const sandbox = collectSandboxReadinessChecks(sandboxName, host.openshellBin, gateway.connected); + const sandbox = await collectSandboxReadinessChecks( + sandboxName, + gatewayName, + host.openshellBin, + gateway.connected, + ); const route = resolveInferenceRoute(sb, host.openshellBin, gateway.connected); return [ ...host.checks, diff --git a/src/lib/actions/sandbox/gateway-state-drift.test.ts b/src/lib/actions/sandbox/gateway-state-drift.test.ts index feab9617159..0a4b8105016 100644 --- a/src/lib/actions/sandbox/gateway-state-drift.test.ts +++ b/src/lib/actions/sandbox/gateway-state-drift.test.ts @@ -218,36 +218,38 @@ describe("sandbox gateway state drift guard", () => { }, expected: "gateway exists in metadata, but its API is refusing connections after restart", }, - ])("preserves registry state when the named gateway reports $lifecycle.state", async ({ - lifecycle, - expected, - }) => { - detectPreflightIssueSpy.mockReturnValue(null); - getSandboxSpy.mockReturnValue({ - name: "alpha", - gatewayName: "nemoclaw", - gatewayPort: 8080, - }); - captureOpenshellSpy.mockReturnValue({ - status: 1, - output: 'Error: status: NotFound, message: "sandbox not found"', - }); - getNamedGatewayLifecycleStateSpy.mockReturnValue(lifecycle); - - await expect(gatewayState.ensureLiveSandboxOrExit("alpha")).rejects.toThrow("process.exit(1)"); - - expect(errorSpy.mock.calls.flat().join("\n")).toContain(expected); - expect(removeSandboxSpy).not.toHaveBeenCalled(); - }); + ])( + "preserves registry state when the named gateway reports $lifecycle.state", + async ({ lifecycle, expected }) => { + detectPreflightIssueSpy.mockReturnValue(null); + getSandboxSpy.mockReturnValue({ + name: "alpha", + gatewayName: "nemoclaw", + gatewayPort: 8080, + }); + captureOpenshellSpy.mockReturnValue({ + status: 1, + output: 'Error: status: NotFound, message: "sandbox not found"', + }); + getNamedGatewayLifecycleStateSpy.mockReturnValue(lifecycle); + + await expect(gatewayState.ensureLiveSandboxOrExit("alpha")).rejects.toThrow( + "process.exit(1)", + ); + + expect(errorSpy.mock.calls.flat().join("\n")).toContain(expected); + expect(removeSandboxSpy).not.toHaveBeenCalled(); + }, + ); - it("propagates schema mismatch after selecting the named gateway", () => { + it("propagates schema mismatch after selecting the named gateway", async () => { getNamedGatewayLifecycleStateSpy.mockReturnValue({ state: "connected_other", activeGateway: "openshell", status: "Gateway: openshell\nStatus: Connected", }); - const lookup = gatewayState.reconcileMissingAgainstNamedGateway("alpha", { + const lookup = await gatewayState.reconcileMissingAgainstNamedGateway("alpha", { state: "missing", output: "NotFound", }); @@ -293,7 +295,7 @@ describe("sandbox gateway state drift guard", () => { expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledWith({ gatewayName: "nemoclaw-8090" }); }); - it("classifies the `sandbox has no spec` gRPC reply as a missing sandbox so the named-gateway reconciler can retry on the owning gateway", () => { + it("classifies the `sandbox has no spec` gRPC reply as a missing sandbox so the named-gateway reconciler can retry on the owning gateway", async () => { detectPreflightIssueSpy.mockReturnValue(null); captureOpenshellSpy.mockReturnValue({ status: 1, @@ -301,10 +303,10 @@ describe("sandbox gateway state drift guard", () => { 'status: Internal, message: "sandbox has no spec", details: [], metadata: MetadataMap {}', }); - const lookup = gatewayState.getSandboxGatewayState("alpha"); + const lookup = await gatewayState.getSandboxGatewayState("alpha"); expect(lookup.state).toBe("missing"); - expect(lookup.output).toContain("sandbox has no spec"); + expect(lookup.output).not.toContain("sandbox has no spec"); }); it("classifies the same gRPC reply as `missing` on the async status-probe path so the live `nemoclaw status` lookup goes through the named-gateway reconciler too", async () => { @@ -318,10 +320,10 @@ describe("sandbox gateway state drift guard", () => { const lookup = await gatewayState.getSandboxGatewayStateForStatus("alpha"); expect(lookup.state).toBe("missing"); - expect(lookup.output).toContain("sandbox has no spec"); + expect(lookup.output).not.toContain("sandbox has no spec"); }); - it("selects the sandbox's owning gateway and retries when the active gateway is a sibling that has no spec for it", () => { + it("selects the sandbox's owning gateway and retries when the active gateway is a sibling that has no spec for it", async () => { detectPreflightIssueSpy.mockReturnValue(null); getSandboxSpy.mockReturnValue({ name: "instance-a", @@ -338,7 +340,7 @@ describe("sandbox gateway state drift guard", () => { output: "Sandbox:\n Name: instance-a\n Phase: Ready", }); - const retry = gatewayState.reconcileMissingAgainstNamedGateway("instance-a", { + const retry = await gatewayState.reconcileMissingAgainstNamedGateway("instance-a", { state: "missing", output: 'status: Internal, message: "sandbox has no spec"', }); diff --git a/src/lib/actions/sandbox/gateway-state-owning-gateway.test.ts b/src/lib/actions/sandbox/gateway-state-owning-gateway.test.ts index fec4dc749b1..bbbbd423e89 100644 --- a/src/lib/actions/sandbox/gateway-state-owning-gateway.test.ts +++ b/src/lib/actions/sandbox/gateway-state-owning-gateway.test.ts @@ -32,7 +32,7 @@ describe("getReconciledSandboxGatewayState owning-gateway guard", () => { vi.restoreAllMocks(); }); - it("pins both the sandbox and policy RPCs to the recorded owner", () => { + it("pins both the sandbox and policy RPCs to the recorded owner", async () => { vi.spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue").mockReturnValue(null); vi.spyOn(gatewayDrift, "detectOpenShellStateRpcResultIssue").mockReturnValue(null); const capture = vi @@ -40,7 +40,7 @@ describe("getReconciledSandboxGatewayState owning-gateway guard", () => { .mockReturnValueOnce({ status: 0, output: "Policy:\nPhase: Ready" } as never) .mockReturnValueOnce({ status: 0, output: "version: 1" } as never); - const result = getSandboxGatewayState("beta", "nemoclaw-8091"); + const result = await getSandboxGatewayState("beta", "nemoclaw-8091"); expect(result.state).toBe("present"); expect(capture).toHaveBeenNthCalledWith( @@ -55,7 +55,7 @@ describe("getReconciledSandboxGatewayState owning-gateway guard", () => { ); }); - it("classifies the owner-scoped Internal no-spec response as missing", () => { + it("classifies the owner-scoped Internal no-spec response as missing", async () => { vi.spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue").mockReturnValue(null); vi.spyOn(gatewayDrift, "detectOpenShellStateRpcResultIssue").mockReturnValue(null); const capture = vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ @@ -63,7 +63,7 @@ describe("getReconciledSandboxGatewayState owning-gateway guard", () => { output: 'status: Internal, message: "sandbox has no spec"', } as never); - expect(getSandboxGatewayState("beta", "nemoclaw-8091")).toMatchObject({ + await expect(getSandboxGatewayState("beta", "nemoclaw-8091")).resolves.toMatchObject({ state: "missing", }); expect(capture).toHaveBeenCalledWith( @@ -101,7 +101,7 @@ describe("getReconciledSandboxGatewayState owning-gateway guard", () => { const syncCapture = vi.spyOn(openshellRuntime, "captureOpenshell"); const asyncCapture = vi.spyOn(openshellRuntime, "captureOpenshellForStatus"); - expect(getSandboxGatewayState("beta", "nemoclaw-8091")).toMatchObject({ + await expect(getSandboxGatewayState("beta", "nemoclaw-8091")).resolves.toMatchObject({ state: "gateway_endpoint_override", output: expect.stringContaining("OPENSHELL_GATEWAY_ENDPOINT is set"), }); diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index d10dfa1e679..256d2d88828 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -12,7 +12,8 @@ import { } from "../../gateway-runtime-action"; import { gatewayStartGuidance } from "../../gateway-start-guidance"; import { assertNoOpenShellGatewayEndpointOverride } from "../../openshell-gateway-endpoint-guard"; -import { isTerminalSandboxPhase, parseSandboxPhase } from "../../state/gateway"; +import { isTerminalSandboxPhase, TERMINAL_SANDBOX_PHASES } from "../../state/gateway"; +export { isTerminalSandboxPhase, TERMINAL_SANDBOX_PHASES }; import { withMcpLifecycleLock, withMcpLifecycleLockSync, @@ -29,10 +30,16 @@ const { pruneKnownHostsEntries } = require("../../onboard/known-hosts") as { }; import { dockerStart } from "../../adapters/docker/container"; -import { stripAnsi } from "../../adapters/openshell/client"; +import { + createCliOpenShellSandboxLookup, + namedOpenShellGateway, + selectedOpenShellGateway, + stripOpenShellCliAnsi, + type CliOpenShellSandboxLookup, + type OpenShellSandboxError, +} from "../../adapters/openshell/sandbox-observer-cli"; import { detectOpenShellStateRpcPreflightIssue, - detectOpenShellStateRpcResultIssue, formatOpenShellStateRpcIssue, type OpenShellStateRpcIssue, } from "../../adapters/openshell/gateway-drift"; @@ -71,6 +78,7 @@ import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-f export type SandboxGatewayState = { state: string; output: string; + phase?: string | null; activeGateway?: string | null; recoveredGateway?: boolean; recoveryVia?: string | null; @@ -189,13 +197,6 @@ function gatewayEndpointOverrideState(): SandboxGatewayState | null { } } -/** Canonical OpenShell response classifier for an absent sandbox record. */ -export function isMissingSandboxGatewayOutput(output = ""): boolean { - return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox has no spec/i.test( - stripAnsi(String(output)), - ); -} - function formatGatewaySchemaMismatchOutput( issue: OpenShellStateRpcIssue, action: string, @@ -206,12 +207,12 @@ function formatGatewaySchemaMismatchOutput( export function mergeLivePolicyIntoSandboxOutput(output: string, livePolicyOutput: string): string { const rawLines = String(output).split("\n"); - const cleanLines = stripAnsi(String(output)).split("\n"); + const cleanLines = stripOpenShellCliAnsi(String(output)).split("\n"); const policyLineIdx = cleanLines.findIndex((line: string) => line.trim() === "Policy:"); if (policyLineIdx === -1) return output; const before = rawLines.slice(0, policyLineIdx + 1).join("\n"); - const cleanLivePolicy = stripAnsi(String(livePolicyOutput)); + const cleanLivePolicy = stripOpenShellCliAnsi(String(livePolicyOutput)); const delimIdx = cleanLivePolicy.search(/^---\s*$/m); const metadataPart = delimIdx !== -1 ? cleanLivePolicy.slice(0, delimIdx) : ""; const yamlPart = @@ -238,10 +239,39 @@ export function mergeLivePolicyIntoSandboxOutput(output: string, livePolicyOutpu } /** Query sandbox presence and return its output with the live enforced policy. */ -export function getSandboxGatewayState( +function sandboxObservationTarget(gatewayName?: string) { + return gatewayName ? namedOpenShellGateway(gatewayName) : selectedOpenShellGateway(); +} + +function schemaMismatchState(action: string): SandboxGatewayState { + return { + state: "gateway_schema_mismatch", + output: formatOpenShellStateRpcIssue( + { kind: "protobuf_mismatch", drift: null, output: "" }, + { action }, + ).join("\n"), + }; +} + +function sandboxObservationErrorState( + error: OpenShellSandboxError, + action: string, +): SandboxGatewayState { + if (error.kind === "schema") return schemaMismatchState(action); + if (error.kind === "authentication" || error.kind === "transport" || error.kind === "timeout") { + return { state: "gateway_error", output: error.message }; + } + return { state: "unknown_error", output: error.message }; +} + +export async function getSandboxGatewayState( sandboxName: string, gatewayName?: string, -): SandboxGatewayState { + lookupSandbox: CliOpenShellSandboxLookup = createCliOpenShellSandboxLookup({ + capture: captureOpenshell, + defaultTimeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, + }), +): Promise { const endpointOverride = gatewayEndpointOverrideState(); if (endpointOverride) return endpointOverride; const preflightIssue = detectOpenShellStateRpcPreflightIssue({ gatewayName }); @@ -254,20 +284,22 @@ export function getSandboxGatewayState( ), }; } - const result = captureOpenshell(gatewayScopedArgs(["sandbox", "get", sandboxName], gatewayName), { - timeout: OPENSHELL_PROBE_TIMEOUT_MS, + const observed = await lookupSandbox({ + sandboxName, + target: sandboxObservationTarget(gatewayName), + timeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, }); - let output = result.output; - const resultIssue = detectOpenShellStateRpcResultIssue(result, { gatewayName }); - if (resultIssue) { - return { - state: "gateway_schema_mismatch", - output: formatOpenShellStateRpcIssue(resultIssue, { - action: `verifying sandbox '${sandboxName}' against OpenShell`, - }).join("\n"), - }; - } - if (result.status === 0) { + const lookup = observed.result; + const action = `verifying sandbox '${sandboxName}' against OpenShell`; + if (!lookup.ok) return sandboxObservationErrorState(lookup.error, action); + if (lookup.value.state === "missing") { + return { state: "missing", output: "OpenShell did not find the sandbox." }; + } + // Preserve the current CLI-formatted status display without putting it in + // the transport-neutral observation contract. Presence and phase decisions + // do not parse this text. + let output = observed.displayOutput; + if (lookup.value.state === "present") { const livePolicy = captureOpenshell( gatewayScopedArgs(["policy", "get", "--full", sandboxName], gatewayName), { @@ -278,29 +310,18 @@ export function getSandboxGatewayState( if (livePolicy.status === 0 && livePolicy.output.trim()) { output = mergeLivePolicyIntoSandboxOutput(output, livePolicy.output); } - return { state: "present", output }; + return { state: "present", output, phase: lookup.value.sandbox.phase }; } - // `sandbox has no spec` is the gRPC reply when the queried gateway does not - // know about this sandbox. On an unscoped lookup that can be an ambient - // sibling; an owner-scoped lookup means the sandbox is genuinely absent - // from its recorded gateway. Both remain `missing`, and reconciliation uses - // the presence of the explicit owner pin to distinguish those cases. - if (isMissingSandboxGatewayOutput(output)) { - return { state: "missing", output }; - } - if ( - /transport error|Connection refused|handshake verification failed|Missing gateway auth token|device identity required/i.test( - output, - ) - ) { - return { state: "gateway_error", output }; - } - return { state: "unknown_error", output }; + return { state: "unknown_error", output: "OpenShell returned an unknown sandbox state." }; } export async function getSandboxGatewayStateForStatus( sandboxName: string, gatewayName?: string, + lookupSandbox: CliOpenShellSandboxLookup = createCliOpenShellSandboxLookup({ + capture: captureOpenshellForStatus, + defaultTimeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, + }), ): Promise { const timeoutMs = getStatusProbeTimeoutMs(); const endpointOverride = gatewayEndpointOverrideState(); @@ -316,30 +337,28 @@ export async function getSandboxGatewayStateForStatus( ), }; } - const result = await captureOpenshellForStatus( - gatewayScopedArgs(["sandbox", "get", sandboxName], gatewayName), - { - timeout: timeoutMs, - }, - ); - let output = result.output; - const resultIssue = detectOpenShellStateRpcResultIssue(result, { gatewayName, timeoutMs }); - if (resultIssue) { - return { - state: "gateway_schema_mismatch", - output: formatOpenShellStateRpcIssue(resultIssue, { - action: `checking status for sandbox '${sandboxName}'`, - command: `${CLI_NAME} ${sandboxName} status`, - }).join("\n"), - }; - } - if (isCommandTimeout(result)) { + const observed = await lookupSandbox({ + sandboxName, + target: sandboxObservationTarget(gatewayName), + timeoutMs, + }); + const lookup = observed.result; + const action = `checking status for sandbox '${sandboxName}'`; + if (!lookup.ok && lookup.error.kind === "timeout") { return { state: "status_probe_timeout", output: ` Live sandbox status probe timed out after ${Math.ceil(timeoutMs / 1000)}s. Local registry data is shown above.`, }; } - if (result.status === 0) { + if (!lookup.ok) return sandboxObservationErrorState(lookup.error, action); + if (lookup.value.state === "missing") { + return { state: "missing", output: "OpenShell did not find the sandbox." }; + } + // Preserve the current CLI-formatted status display without putting it in + // the transport-neutral observation contract. Presence and phase decisions + // do not parse this text. + let output = observed.displayOutput; + if (lookup.value.state === "present") { const livePolicy = await captureOpenshellForStatus( gatewayScopedArgs(["policy", "get", "--full", sandboxName], gatewayName), { @@ -350,19 +369,9 @@ export async function getSandboxGatewayStateForStatus( if (!isCommandTimeout(livePolicy) && livePolicy.status === 0 && livePolicy.output.trim()) { output = mergeLivePolicyIntoSandboxOutput(output, livePolicy.output); } - return { state: "present", output }; + return { state: "present", output, phase: lookup.value.sandbox.phase }; } - if (isMissingSandboxGatewayOutput(output)) { - return { state: "missing", output }; - } - if ( - /transport error|Connection refused|handshake verification failed|Missing gateway auth token|device identity required/i.test( - output, - ) - ) { - return { state: "gateway_error", output }; - } - return { state: "unknown_error", output }; + return { state: "unknown_error", output: "OpenShell returned an unknown sandbox state." }; } /** @@ -375,11 +384,11 @@ export async function getSandboxGatewayStateForStatus( * already came from the recorded owner, so ambient selection is ignored and * only the existing Docker-side recovery path is considered. */ -export function reconcileMissingAgainstNamedGateway( +export async function reconcileMissingAgainstNamedGateway( sandboxName: string, missingLookup: SandboxGatewayState, pinnedGatewayName?: string, -): SandboxGatewayState { +): Promise { const targetGatewayName = pinnedGatewayName ?? getSandboxTargetGatewayName(sandboxName); if (pinnedGatewayName) { // The owner-scoped RPC reached this exact gateway and reported NotFound. @@ -392,7 +401,7 @@ export function reconcileMissingAgainstNamedGateway( ignoreError: true, timeout: OPENSHELL_OPERATION_TIMEOUT_MS, }); - const retry = getSandboxGatewayState(sandboxName, targetGatewayName); + const retry = await getSandboxGatewayState(sandboxName, targetGatewayName); if (retry.state === "present") { return { ...retry, recoveredGateway: true, recoveryVia: "select" }; } @@ -449,11 +458,11 @@ export function reconcileMissingAgainstNamedGateway( * `missing` lookup unchanged so the caller's existing non-destructive * guidance fires. */ -function tryRecoverDockerDriverSandbox( +async function tryRecoverDockerDriverSandbox( sandboxName: string, missingLookup: SandboxGatewayState, gatewayName?: string, -): SandboxGatewayState { +): Promise { let recovery: DockerDriverRecoveryResult; try { recovery = recoverDockerDriverSandbox(sandboxName); @@ -465,7 +474,7 @@ function tryRecoverDockerDriverSandbox( } // Recovery succeeded against Docker; re-query OpenShell so the // returned state reflects what the gateway sees post-restart. - const retried = getSandboxGatewayState(sandboxName, gatewayName); + const retried = await getSandboxGatewayState(sandboxName, gatewayName); return { ...retried, recoveredSandbox: true, @@ -504,7 +513,7 @@ export function printGatewayLifecycleHint( sandboxName = "", writer: (message: string) => void = console.error, ): void { - const cleanOutput = stripAnsi(output); + const cleanOutput = stripOpenShellCliAnsi(output); const targetGatewayName = getSandboxTargetGatewayName(sandboxName); // The gateway-side gRPC reply `sandbox has no spec` is returned when the // active OpenShell gateway does not know about the sandbox — which on a @@ -639,7 +648,7 @@ export async function getReconciledSandboxGatewayState( return { ...retried, recoveredGateway: true, recoveryVia: recovery.via || null }; } const latestLifecycle = getNamedGatewayLifecycleState(recoveryGatewayName); - const latestStatus = stripAnsi(latestLifecycle.status || ""); + const latestStatus = stripOpenShellCliAnsi(latestLifecycle.status || ""); if (/No gateway configured/i.test(latestStatus)) { return { state: "gateway_missing_after_restart", @@ -721,7 +730,7 @@ export async function ensureLiveSandboxOrExit( selectOwningGateway, }); if (lookup.state === "present") { - const phase = parseSandboxPhase(lookup.output || ""); + const phase = lookup.phase ?? null; if (!allowNonReadyPhase && phase && phase !== "Ready" && phase !== "Running") { // Don't steer toward rebuild when the host Docker daemon is down: the // sandbox is fine and recreating it cannot succeed until Docker is back diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.ts index c3217515e7c..4aabd35a4d3 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -2,10 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { dockerRmi } from "../../adapters/docker/image"; -import { - detectOpenShellStateRpcResultIssue, - printOpenShellStateRpcIssue, -} from "../../adapters/openshell/gateway-drift"; +import { printOpenShellStateRpcIssue } from "../../adapters/openshell/gateway-drift"; import { loadAgent } from "../../agent/defs"; import { bindLocalAgentBaseImageHandoffToResolution, @@ -33,7 +30,6 @@ import { captureSandboxListWithGatewayRecovery, printSandboxListFailureWithRecoveryContext, } from "../../openshell-sandbox-list"; -import { parseLiveSandboxNames } from "../../runtime-recovery"; import { parseContentAddressedSandboxBaseImageId, type SandboxBaseImageResolutionMetadata, @@ -164,28 +160,25 @@ export async function resolveRebuildLiveState( const liveRecovery = await captureSandboxListWithGatewayRecovery({ gatewayName: recordedGateway, }); - const isLive = liveRecovery.result; - log( - `openshell sandbox list exit=${isLive.status}, output=${(isLive.output || "").substring(0, 200)}`, - ); - const liveListIssue = detectOpenShellStateRpcResultIssue(isLive, { - gatewayName: recordedGateway, - }); - if (liveListIssue) { - printOpenShellStateRpcIssue(liveListIssue, { - action: `rebuilding sandbox '${sandboxName}'`, - command: `${CLI_NAME} ${sandboxName} rebuild`, - }); + const observed = liveRecovery.result; + if (!observed.ok && observed.error.kind === "schema") { + printOpenShellStateRpcIssue( + { kind: "protobuf_mismatch", drift: null, output: "" }, + { + action: `rebuilding sandbox '${sandboxName}'`, + command: `${CLI_NAME} ${sandboxName} rebuild`, + }, + ); bail("OpenShell gateway schema mismatch."); return null; } - if (isLive.status !== 0) { + if (!observed.ok) { printSandboxListFailureWithRecoveryContext(liveRecovery); - bail("Failed to query running sandboxes from OpenShell.", isLive.status || 1); + bail("Failed to query running sandboxes from OpenShell.", 1); return null; } - const liveNames = parseLiveSandboxNames(isLive.output || ""); + const liveNames = new Set(observed.value.sandboxes.map((sandbox) => sandbox.name)); log(`Live sandboxes: ${Array.from(liveNames).join(", ") || "(none)"}`); if (liveNames.has(sandboxName)) return { staleRecovery: false, staleRegistrySnapshot: null }; diff --git a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts index f291319fea2..9c1764f95c2 100644 --- a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts @@ -167,45 +167,50 @@ describe("rebuild gateway drift preflight", () => { recordedPort: 9000, activeGateway: "nemoclaw", }, - ])("recovers $recordedGateway as stale even while $activeGateway is ambiently active, since the sandbox RPC is gateway-pinned (#4497)", async ({ - recordedGateway, - recordedPort, - activeGateway, - }) => { - const entry = makeSandboxEntry(recordedGateway, recordedPort); - const registrySnapshot = { sandboxes: { alpha: entry } }; - vi.mocked(registry.getSandbox).mockReturnValue(entry as never); - vi.mocked(registryPersistence.load).mockReturnValue(registrySnapshot as never); - captureOpenshellSpy - .mockReturnValueOnce({ status: 0, output: "" }) - .mockReturnValueOnce({ status: 1, output: "Error: × Not Found: sandbox not found" }); - getNamedGatewayLifecycleStateSpy.mockReturnValue({ - state: "connected_other", - activeGateway, - status: `Gateway: ${activeGateway}\nStatus: Connected`, - } as never); - const behaviorLog = vi.fn(); - - const result = await resolveRebuildLiveState("alpha", entry, behaviorLog, bail); - - expect(result).toEqual({ staleRecovery: true, staleRegistrySnapshot: registrySnapshot }); - expect(result?.staleRegistrySnapshot).not.toBe(registrySnapshot); - expect(getNamedGatewayLifecycleStateSpy).not.toHaveBeenCalled(); - expect(runOpenshellSpy).toHaveBeenCalledWith( - ["gateway", "select", recordedGateway], - expect.objectContaining({ ignoreError: true }), - ); - expect(captureOpenshellSpy).toHaveBeenNthCalledWith(1, ["sandbox", "list"]); - expect(captureOpenshellSpy).toHaveBeenNthCalledWith( - 2, - ["sandbox", "get", "-g", recordedGateway, "alpha"], - expect.anything(), - ); - expect(recoverDockerDriverSandboxSpy).toHaveBeenCalledWith("alpha"); - expect(registryPersistence.load).toHaveBeenCalledOnce(); - expect(logSpy.mock.calls.flat().join("\n")).toContain("absent from the live OpenShell gateway"); - expect(behaviorLog.mock.calls.flat().join("\n")).toContain("Stale-sandbox recovery"); - }); + ])( + "recovers $recordedGateway as stale even while $activeGateway is ambiently active, since the sandbox RPC is gateway-pinned (#4497)", + async ({ recordedGateway, recordedPort, activeGateway }) => { + const entry = makeSandboxEntry(recordedGateway, recordedPort); + const registrySnapshot = { sandboxes: { alpha: entry } }; + vi.mocked(registry.getSandbox).mockReturnValue(entry as never); + vi.mocked(registryPersistence.load).mockReturnValue(registrySnapshot as never); + captureOpenshellSpy + .mockReturnValueOnce({ status: 0, output: "" }) + .mockReturnValueOnce({ status: 1, output: "Error: × Not Found: sandbox not found" }); + getNamedGatewayLifecycleStateSpy.mockReturnValue({ + state: "connected_other", + activeGateway, + status: `Gateway: ${activeGateway}\nStatus: Connected`, + } as never); + const behaviorLog = vi.fn(); + + const result = await resolveRebuildLiveState("alpha", entry, behaviorLog, bail); + + expect(result).toEqual({ staleRecovery: true, staleRegistrySnapshot: registrySnapshot }); + expect(result?.staleRegistrySnapshot).not.toBe(registrySnapshot); + expect(getNamedGatewayLifecycleStateSpy).not.toHaveBeenCalled(); + expect(runOpenshellSpy).toHaveBeenCalledWith( + ["gateway", "select", recordedGateway], + expect.objectContaining({ ignoreError: true }), + ); + expect(captureOpenshellSpy).toHaveBeenNthCalledWith( + 1, + ["sandbox", "list", "-g", recordedGateway], + expect.objectContaining({ ignoreError: true }), + ); + expect(captureOpenshellSpy).toHaveBeenNthCalledWith( + 2, + ["sandbox", "get", "-g", recordedGateway, "alpha"], + expect.anything(), + ); + expect(recoverDockerDriverSandboxSpy).toHaveBeenCalledWith("alpha"); + expect(registryPersistence.load).toHaveBeenCalledOnce(); + expect(logSpy.mock.calls.flat().join("\n")).toContain( + "absent from the live OpenShell gateway", + ); + expect(behaviorLog.mock.calls.flat().join("\n")).toContain("Stale-sandbox recovery"); + }, + ); it("removes one exactly labeled Docker orphan before a registry-only rebuild (#8720)", async () => { const entry = { ...makeSandboxEntry(), openshellDriver: "docker" }; @@ -279,82 +284,91 @@ describe("rebuild gateway drift preflight", () => { removeResult: { status: 0 }, removeCalls: 1, }, - ])("fails closed before registry recovery on Docker orphan $failure (#8720)", async ({ - queryResults, - removeResult, - removeCalls, - }) => { - const entry = { ...makeSandboxEntry(), openshellDriver: "docker" }; - vi.mocked(registry.getSandbox).mockReturnValue(entry as never); - captureOpenshellSpy - .mockReturnValueOnce({ status: 0, output: "" }) - .mockReturnValueOnce({ status: 1, output: "Error: sandbox not found" }); - queryDockerContainersSpy.mockReturnValueOnce(queryResults[0] as never); - queryDockerContainersSpy.mockReturnValueOnce((queryResults[1] ?? queryResults[0]) as never); - forceRemoveDockerContainerSpy.mockReturnValue(removeResult); - - await expect(resolveRebuildLiveState("alpha", entry, vi.fn(), bail)).rejects.toThrow( - "Stale-recovery Docker orphan cleanup failed", - ); - - expect(forceRemoveDockerContainerSpy).toHaveBeenCalledTimes(removeCalls); - expect(registryPersistence.load).not.toHaveBeenCalled(); - }); + ])( + "fails closed before registry recovery on Docker orphan $failure (#8720)", + async ({ queryResults, removeResult, removeCalls }) => { + const entry = { ...makeSandboxEntry(), openshellDriver: "docker" }; + vi.mocked(registry.getSandbox).mockReturnValue(entry as never); + captureOpenshellSpy + .mockReturnValueOnce({ status: 0, output: "" }) + .mockReturnValueOnce({ status: 1, output: "Error: sandbox not found" }); + queryDockerContainersSpy.mockReturnValueOnce(queryResults[0] as never); + queryDockerContainersSpy.mockReturnValueOnce((queryResults[1] ?? queryResults[0]) as never); + forceRemoveDockerContainerSpy.mockReturnValue(removeResult); + + await expect(resolveRebuildLiveState("alpha", entry, vi.fn(), bail)).rejects.toThrow( + "Stale-recovery Docker orphan cleanup failed", + ); + + expect(forceRemoveDockerContainerSpy).toHaveBeenCalledTimes(removeCalls); + expect(registryPersistence.load).not.toHaveBeenCalled(); + }, + ); it.each([ { gatewayName: "nemoclaw", gatewayPort: 8080 }, { gatewayName: "nemoclaw-12345", gatewayPort: 12345 }, - ])("recovers $gatewayName and returns stale state after confirming the sandbox is absent (#4497)", async ({ - gatewayName, - gatewayPort, - }) => { - const entry = makeSandboxEntry(gatewayName, gatewayPort); - const registrySnapshot = { sandboxes: { alpha: entry } }; - vi.mocked(registry.getSandbox).mockReturnValue(entry as never); - vi.mocked(registryPersistence.load).mockReturnValue(registrySnapshot as never); - captureOpenshellSpy - .mockReturnValueOnce({ - status: 1, - output: "client error (Connect): Connection refused", - }) - .mockReturnValueOnce({ status: 0, output: "beta Ready" }) - .mockReturnValueOnce({ status: 1, output: "Error: × Not Found: sandbox not found" }); - getNamedGatewayLifecycleStateSpy.mockReturnValue({ - state: "healthy_named", - activeGateway: gatewayName, - status: `Gateway: ${gatewayName}\nStatus: Connected`, - } as never); - const behaviorLog = vi.fn(); - - const result = await resolveRebuildLiveState("alpha", entry, behaviorLog, bail); - - expect(result).toEqual({ - staleRecovery: true, - staleRegistrySnapshot: registrySnapshot, - }); - expect(result?.staleRegistrySnapshot).not.toBe(registrySnapshot); - expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledTimes(2); - expect(recoverNamedGatewayRuntimeSpy).toHaveBeenNthCalledWith(1, { - gatewayName, - recoverableStates: recoveryStates, - }); - expect(recoverNamedGatewayRuntimeSpy).toHaveBeenNthCalledWith(2, { - gatewayName, - recoverableStates: recoveryStates, - }); - expect(captureOpenshellSpy).toHaveBeenNthCalledWith(1, ["sandbox", "list"]); - expect(captureOpenshellSpy).toHaveBeenNthCalledWith(2, ["sandbox", "list"]); - expect(captureOpenshellSpy).toHaveBeenNthCalledWith( - 3, - ["sandbox", "get", "-g", gatewayName, "alpha"], - expect.anything(), - ); - expect(getNamedGatewayLifecycleStateSpy).not.toHaveBeenCalled(); - expect(recoverDockerDriverSandboxSpy).toHaveBeenCalledWith("alpha"); - expect(registryPersistence.load).toHaveBeenCalledOnce(); - expect(logSpy.mock.calls.flat().join("\n")).toContain("absent from the live OpenShell gateway"); - expect(behaviorLog.mock.calls.flat().join("\n")).toContain("Stale-sandbox recovery"); - }); + ])( + "recovers $gatewayName and returns stale state after confirming the sandbox is absent (#4497)", + async ({ gatewayName, gatewayPort }) => { + const entry = makeSandboxEntry(gatewayName, gatewayPort); + const registrySnapshot = { sandboxes: { alpha: entry } }; + vi.mocked(registry.getSandbox).mockReturnValue(entry as never); + vi.mocked(registryPersistence.load).mockReturnValue(registrySnapshot as never); + captureOpenshellSpy + .mockReturnValueOnce({ + status: 1, + output: "client error (Connect): Connection refused", + }) + .mockReturnValueOnce({ status: 0, output: "beta Ready" }) + .mockReturnValueOnce({ status: 1, output: "Error: × Not Found: sandbox not found" }); + getNamedGatewayLifecycleStateSpy.mockReturnValue({ + state: "healthy_named", + activeGateway: gatewayName, + status: `Gateway: ${gatewayName}\nStatus: Connected`, + } as never); + const behaviorLog = vi.fn(); + + const result = await resolveRebuildLiveState("alpha", entry, behaviorLog, bail); + + expect(result).toEqual({ + staleRecovery: true, + staleRegistrySnapshot: registrySnapshot, + }); + expect(result?.staleRegistrySnapshot).not.toBe(registrySnapshot); + expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledTimes(2); + expect(recoverNamedGatewayRuntimeSpy).toHaveBeenNthCalledWith(1, { + gatewayName, + recoverableStates: recoveryStates, + }); + expect(recoverNamedGatewayRuntimeSpy).toHaveBeenNthCalledWith(2, { + gatewayName, + recoverableStates: recoveryStates, + }); + expect(captureOpenshellSpy).toHaveBeenNthCalledWith( + 1, + ["sandbox", "list", "-g", gatewayName], + expect.objectContaining({ ignoreError: true }), + ); + expect(captureOpenshellSpy).toHaveBeenNthCalledWith( + 2, + ["sandbox", "list", "-g", gatewayName], + expect.objectContaining({ ignoreError: true }), + ); + expect(captureOpenshellSpy).toHaveBeenNthCalledWith( + 3, + ["sandbox", "get", "-g", gatewayName, "alpha"], + expect.anything(), + ); + expect(getNamedGatewayLifecycleStateSpy).not.toHaveBeenCalled(); + expect(recoverDockerDriverSandboxSpy).toHaveBeenCalledWith("alpha"); + expect(registryPersistence.load).toHaveBeenCalledOnce(); + expect(logSpy.mock.calls.flat().join("\n")).toContain( + "absent from the live OpenShell gateway", + ); + expect(behaviorLog.mock.calls.flat().join("\n")).toContain("Stale-sandbox recovery"); + }, + ); it("fails without a sandbox-list retry after a generic query error", async () => { const entry = makeSandboxEntry(); @@ -373,7 +387,10 @@ describe("rebuild gateway drift preflight", () => { recoverableStates: recoveryStates, }); expect(captureOpenshellSpy).toHaveBeenCalledOnce(); - expect(captureOpenshellSpy).toHaveBeenCalledWith(["sandbox", "list"]); + expect(captureOpenshellSpy).toHaveBeenCalledWith( + ["sandbox", "list", "-g", "nemoclaw"], + expect.objectContaining({ ignoreError: true }), + ); expect(getNamedGatewayLifecycleStateSpy).not.toHaveBeenCalled(); expect(recoverDockerDriverSandboxSpy).not.toHaveBeenCalled(); expect(registryPersistence.load).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts index 0709b09b695..de649c76882 100644 --- a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts @@ -121,7 +121,12 @@ describe("rebuild resume snapshot repair", () => { attempted: false, }), vi.spyOn(sandboxList, "captureSandboxListWithGatewayRecovery").mockResolvedValue({ - result: { status: 0, output: "alpha Ready" }, + result: { + ok: true, + value: { + sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }], + }, + }, recoveryAttempted: false, recoverySucceeded: false, }), diff --git a/src/lib/actions/sandbox/start-wait.test.ts b/src/lib/actions/sandbox/start-wait.test.ts index 9f1f7bc1703..0342de59582 100644 --- a/src/lib/actions/sandbox/start-wait.test.ts +++ b/src/lib/actions/sandbox/start-wait.test.ts @@ -1,67 +1,118 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createConnectHarness } from "../../../../test/support/connect-flow-test-harness"; describe("sandbox start readiness", () => { - it("waits through the stopped sandbox Error phase after start (#9753)", () => { + it("waits through the stopped sandbox Error phase after start (#9753)", async () => { const harness = createConnectHarness({ listOutputs: ["alpha Error", "alpha Provisioning", "alpha Ready"], }); - expect(() => + await expect( harness.waitForSandboxReadyOrExit("alpha", { allowInitialErrorAfterStart: true }), - ).not.toThrow(); + ).resolves.toBeUndefined(); expect(harness.captureOpenshellSpy).toHaveBeenCalledTimes(3); }); - it("keeps Error terminal outside the post-start grace period (#9753)", () => { + it("keeps Error terminal outside the post-start grace period (#9753)", async () => { const harness = createConnectHarness({ listOutputs: ["alpha Error"] }); - expect(() => harness.waitForSandboxReadyOrExit("alpha")).toThrow( + await expect(harness.waitForSandboxReadyOrExit("alpha")).rejects.toThrow( 'process.exit unexpectedly called with "1"', ); expect(harness.captureOpenshellSpy).toHaveBeenCalledTimes(1); }); - it("ends the post-start Error grace after the phase advances (#9753)", () => { + it("ends the post-start Error grace after the phase advances (#9753)", async () => { const harness = createConnectHarness({ listOutputs: ["alpha Error", "alpha Provisioning", "alpha Error"], }); - expect(() => + await expect( harness.waitForSandboxReadyOrExit("alpha", { allowInitialErrorAfterStart: true }), - ).toThrow('process.exit unexpectedly called with "1"'); + ).rejects.toThrow('process.exit unexpectedly called with "1"'); expect(harness.captureOpenshellSpy).toHaveBeenCalledTimes(3); }); - it("fails after the stopped sandbox Error phase remains terminal (#9753)", () => { + it("fails after the stopped sandbox Error phase remains terminal (#9753)", async () => { const harness = createConnectHarness({ listOutputs: Array.from({ length: 11 }, () => "alpha Error"), }); - expect(() => + await expect( harness.waitForSandboxReadyOrExit("alpha", { allowInitialErrorAfterStart: true }), - ).toThrow('process.exit unexpectedly called with "1"'); + ).rejects.toThrow('process.exit unexpectedly called with "1"'); expect(harness.captureOpenshellSpy).toHaveBeenCalledTimes(11); }); it.each(["Failed", "CrashLoopBackOff"])( "fails immediately when start reports the terminal %s phase (#9753)", - (phase) => { + async (phase) => { const harness = createConnectHarness({ listOutputs: [`alpha ${phase}`] }); - expect(() => + await expect( harness.waitForSandboxReadyOrExit("alpha", { allowInitialErrorAfterStart: true }), - ).toThrow('process.exit unexpectedly called with "1"'); + ).rejects.toThrow('process.exit unexpectedly called with "1"'); expect(harness.captureOpenshellSpy).toHaveBeenCalledTimes(1); }, ); + + it.each([ + { + error: { + kind: "authentication", + message: "OpenShell could not authenticate the sandbox observation.", + }, + guidance: "could not authenticate", + }, + { + error: { + kind: "schema", + message: "The OpenShell CLI and gateway sandbox schemas do not match.", + }, + guidance: "schemas do not match", + }, + { + error: { kind: "timeout", message: "OpenShell sandbox observation timed out." }, + guidance: "readiness request for sandbox 'alpha' timed out", + }, + { + error: { + kind: "command", + reason: "failed", + message: "The OpenShell sandbox observation failed.", + }, + guidance: "readiness request for sandbox 'alpha' failed", + }, + { + error: { + kind: "transport", + message: "OpenShell could not reach the selected gateway.", + }, + guidance: "gateway is not running or unreachable", + }, + ] as const)("prints accurate $error.kind readiness failure guidance (#9803)", async (testCase) => { + const harness = createConnectHarness(); + const observer = { + listSandboxes: vi.fn().mockResolvedValue({ ok: false, error: testCase.error }), + } as never; + + await expect(harness.waitForSandboxReadyOrExit("alpha", { observer })).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ); + + const output = harness.errorSpy.mock.calls.flat().join("\n"); + expect(output).toContain(testCase.guidance); + expect(output.includes("gateway is not running or unreachable")).toBe( + testCase.error.kind === "transport", + ); + }); }); diff --git a/src/lib/actions/sandbox/start.test.ts b/src/lib/actions/sandbox/start.test.ts index 57e87d2ab8b..1fb50099514 100644 --- a/src/lib/actions/sandbox/start.test.ts +++ b/src/lib/actions/sandbox/start.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; +import type { OpenShellSandboxObserver } from "../../adapters/openshell/sandbox-observer"; import { createDockerRuntimeProviderBundle, createKubernetesRuntimeProviderBundle, @@ -115,12 +116,12 @@ function harness(overrides: Partial = {}) { } describe("startSandbox", () => { - it("restores sealed access before recovering sandbox processes (#8112)", () => { + it("restores sealed access before recovering sandbox processes (#8112)", async () => { const restoreAccess = vi.fn(); const recovery = SUCCESSFUL_RECOVERY; const restoreProcesses = vi.fn(() => recovery); - const result = restoreStoppedSandboxStartupState("my-sandbox", { + const result = await restoreStoppedSandboxStartupState("my-sandbox", { agent: "openclaw", restoreLockedStartupAccess: restoreAccess, waitForSandboxReady: vi.fn(), @@ -135,11 +136,11 @@ describe("startSandbox", () => { expect(result).toBe(recovery); }); - it("keeps Hermes sealed state untouched while recovering sandbox processes (#8112)", () => { + it("keeps Hermes sealed state untouched while recovering sandbox processes (#8112)", async () => { const restoreAccess = vi.fn(); const restoreProcesses = vi.fn(() => SUCCESSFUL_RECOVERY); - restoreStoppedSandboxStartupState("my-sandbox", { + await restoreStoppedSandboxStartupState("my-sandbox", { agent: "hermes", restoreLockedStartupAccess: restoreAccess, waitForSandboxReady: vi.fn(), @@ -150,12 +151,12 @@ describe("startSandbox", () => { expect(restoreProcesses).toHaveBeenCalledWith("my-sandbox"); }); - it("waits for OpenShell readiness after restoring sealed access and before recovering sandbox processes (#8978)", () => { + it("waits for OpenShell readiness after restoring sealed access and before recovering sandbox processes (#8978)", async () => { const restoreAccess = vi.fn(); const waitForSandboxReady = vi.fn(); const restoreProcesses = vi.fn(() => SUCCESSFUL_RECOVERY); - restoreStoppedSandboxStartupState("my-sandbox", { + await restoreStoppedSandboxStartupState("my-sandbox", { agent: "openclaw", restoreLockedStartupAccess: restoreAccess, waitForSandboxReady, @@ -171,11 +172,11 @@ describe("startSandbox", () => { ); }); - it("waits for OpenShell readiness before recovering Hermes sandbox processes (#8978)", () => { + it("waits for OpenShell readiness before recovering Hermes sandbox processes (#8978)", async () => { const waitForSandboxReady = vi.fn(); const restoreProcesses = vi.fn(() => SUCCESSFUL_RECOVERY); - restoreStoppedSandboxStartupState("my-sandbox", { + await restoreStoppedSandboxStartupState("my-sandbox", { agent: "hermes", restoreLockedStartupAccess: vi.fn(), waitForSandboxReady, @@ -222,16 +223,32 @@ describe("startSandbox", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-start-readiness-")); vi.stubEnv("HOME", home); const listOutputs = ["my-sandbox Error", "my-sandbox Provisioning", "my-sandbox Ready"]; - const captureSandboxList = vi.fn(() => ({ - status: 0, - output: listOutputs.shift() ?? "my-sandbox Ready", - stdout: "", - stderr: "", - })); + const listSandboxes = vi.fn(async () => { + const output = listOutputs.shift() ?? "my-sandbox Ready"; + const phase = output.split(/\s+/u)[1] ?? null; + return { + ok: true, + value: { + sandboxes: [ + { + name: "my-sandbox", + phase, + readiness: + phase === "Ready" ? "ready" : phase === "Error" ? "terminal" : "not_ready", + }, + ], + }, + }; + }); + const observer: OpenShellSandboxObserver = { + listSandboxes, + lookupSandbox: vi.fn(), + waitForSandboxReady: vi.fn(), + }; const restoreProcesses = vi.fn(() => SUCCESSFUL_RECOVERY); const h = harness({ allowDockerRuntimeInspection: false, - captureSandboxList, + observer, environment: { ...process.env, HOME: home }, restoreLockedStartupAccess: vi.fn(), restoreProcessState: restoreProcesses, @@ -242,10 +259,10 @@ describe("startSandbox", () => { const result = await startSandbox("my-sandbox", h.deps); expect(result.exitCode).toBe(0); - expect(captureSandboxList).toHaveBeenCalledTimes(3); + expect(listSandboxes).toHaveBeenCalledTimes(3); expect(restoreProcesses).toHaveBeenCalledWith("my-sandbox"); expect(h.verifyGateway).toHaveBeenCalledWith("my-sandbox"); - expect(captureSandboxList.mock.invocationCallOrder[2]).toBeLessThan( + expect(listSandboxes.mock.invocationCallOrder[2]).toBeLessThan( restoreProcesses.mock.invocationCallOrder[0], ); expect(restoreProcesses.mock.invocationCallOrder[0]).toBeLessThan( @@ -443,17 +460,20 @@ describe("startSandbox", () => { }, /did not become ready in OpenShell/iu, ], - ] as const)("propagates an actionable %s %s failure (#8662)", async (agent, _layer, recovery, expected) => { - const h = harness(); - h.getSandbox.mockReturnValue(sandbox({ agent })); - h.restoreStartupState.mockReturnValue(recovery); + ] as const)( + "propagates an actionable %s %s failure (#8662)", + async (agent, _layer, recovery, expected) => { + const h = harness(); + h.getSandbox.mockReturnValue(sandbox({ agent })); + h.restoreStartupState.mockReturnValue(recovery); - const failure = await startSandbox("my-sandbox", h.deps).catch((error) => String(error)); - expect(failure).toMatch(expected); - expect(failure).toMatch(/nemoclaw my-sandbox recover/iu); - expect(failure).not.toContain(REDACTED_TOKEN); - expect(h.verifyGateway).not.toHaveBeenCalled(); - }); + const failure = await startSandbox("my-sandbox", h.deps).catch((error) => String(error)); + expect(failure).toMatch(expected); + expect(failure).toMatch(/nemoclaw my-sandbox recover/iu); + expect(failure).not.toContain(REDACTED_TOKEN); + expect(h.verifyGateway).not.toHaveBeenCalled(); + }, + ); it("does not claim preservation when startup recovery reports a failed rollback (#9364)", async () => { const h = harness(); @@ -676,24 +696,24 @@ describe("startSandbox", () => { expect(h.verifyGateway).not.toHaveBeenCalled(); }); - it.each([ - "unknown-runtime", - "mxc-not-installed", - ])("fails closed for unregistered provider %s without lifecycle side effects", async (providerId) => { - const h = harness(); - h.getSandbox.mockReturnValue(sandbox({ openshellDriver: providerId })); + it.each(["unknown-runtime", "mxc-not-installed"])( + "fails closed for unregistered provider %s without lifecycle side effects", + async (providerId) => { + const h = harness(); + h.getSandbox.mockReturnValue(sandbox({ openshellDriver: providerId })); - const result = await startSandbox("my-sandbox", h.deps); + const result = await startSandbox("my-sandbox", h.deps); - expect(result.exitCode).toBe(1); - expect(result.message).toContain(providerId); - expect(result.message).toContain("has no registered lifecycle provider"); - expect(h.findLabeledSandboxContainers).not.toHaveBeenCalled(); - expect(h.dockerUnpause).not.toHaveBeenCalled(); - expect(h.recoverDockerDriverSandbox).not.toHaveBeenCalled(); - expect(h.restoreStartupState).not.toHaveBeenCalled(); - expect(h.verifyGateway).not.toHaveBeenCalled(); - }); + expect(result.exitCode).toBe(1); + expect(result.message).toContain(providerId); + expect(result.message).toContain("has no registered lifecycle provider"); + expect(h.findLabeledSandboxContainers).not.toHaveBeenCalled(); + expect(h.dockerUnpause).not.toHaveBeenCalled(); + expect(h.recoverDockerDriverSandbox).not.toHaveBeenCalled(); + expect(h.restoreStartupState).not.toHaveBeenCalled(); + expect(h.verifyGateway).not.toHaveBeenCalled(); + }, + ); it.each([ ["null driver", sandbox({ openshellDriver: null })], diff --git a/src/lib/actions/sandbox/start.ts b/src/lib/actions/sandbox/start.ts index 0ece1124902..17d250a8665 100644 --- a/src/lib/actions/sandbox/start.ts +++ b/src/lib/actions/sandbox/start.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { OpenShellSandboxObserver } from "../../adapters/openshell/sandbox-observer-cli"; import { cliName } from "../../onboard/branding"; -import type { captureOpenshell } from "../../adapters/openshell/runtime"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES, type RuntimeProviderBundleRegistry, @@ -42,20 +42,17 @@ function restoreLockedStartupAccess(sandboxName: string): void { } /** Wait for a just-started sandbox while tolerating its bounded transient Error phase. */ -function waitForSandboxReady( +async function waitForSandboxReady( sandboxName: string, - captureSandboxList?: ( - args: string[], - options: { readonly ignoreError: true; readonly timeout: number }, - ) => ReturnType, + observer?: OpenShellSandboxObserver, allowDockerRuntimeInspection = true, -): void { +): Promise { const { waitForSandboxReadyOrExit, SANDBOX_REPAIR_READY_TIMEOUT_SEC } = require("./connect") as typeof import("./connect"); - waitForSandboxReadyOrExit(sandboxName, { + await waitForSandboxReadyOrExit(sandboxName, { allowInitialErrorAfterStart: true, allowDockerRuntimeInspection, - ...(captureSandboxList ? { captureSandboxList } : {}), + ...(observer ? { observer } : {}), defaultTimeoutSec: SANDBOX_REPAIR_READY_TIMEOUT_SEC, retryCommand: "start", }); @@ -64,33 +61,32 @@ function waitForSandboxReady( export interface SandboxStartupStateDeps { agent?: SandboxEntry["agent"]; restoreLockedStartupAccess?: (sandboxName: string) => void; - waitForSandboxReady?: (sandboxName: string) => void; + waitForSandboxReady?: (sandboxName: string) => void | Promise; restoreProcessState?: (sandboxName: string) => SandboxStartupRecoveryResult; } -export function restoreStoppedSandboxStartupState( +export async function restoreStoppedSandboxStartupState( sandboxName: string, deps: SandboxStartupStateDeps = {}, -): SandboxStartupRecoveryResult { +): Promise { if ((deps.agent ?? "openclaw") === "openclaw") { (deps.restoreLockedStartupAccess ?? restoreLockedStartupAccess)(sandboxName); } - (deps.waitForSandboxReady ?? waitForSandboxReady)(sandboxName); + await (deps.waitForSandboxReady ?? waitForSandboxReady)(sandboxName); return (deps.restoreProcessState ?? restoreProcessState)(sandboxName); } export interface SandboxStartDeps { allowDockerRuntimeInspection?: boolean; - captureSandboxList?: ( - args: string[], - options: { readonly ignoreError: true; readonly timeout: number }, - ) => ReturnType; + observer?: OpenShellSandboxObserver; environment?: NodeJS.ProcessEnv; getSandbox?: typeof registry.getSandbox; restoreLockedStartupAccess?: (sandboxName: string) => void; restoreProcessState?: (sandboxName: string) => SandboxStartupRecoveryResult; runtimeProviders?: RuntimeProviderBundleRegistry; - restoreStartupState?: (sandboxName: string) => SandboxStartupRecoveryResult; + restoreStartupState?: ( + sandboxName: string, + ) => SandboxStartupRecoveryResult | Promise; waitForManagedGatewaySupervisor?: (sandboxName: string) => boolean; verifyGateway?: (sandboxName: string) => Promise; probeInferenceInvocation?: typeof probeSandboxInferenceInvocation; @@ -221,15 +217,11 @@ async function startSandboxWithinLifecycleFence( restoreLockedStartupAccess: deps.restoreLockedStartupAccess, restoreProcessState: deps.restoreProcessState, waitForSandboxReady: (readyName) => - waitForSandboxReady( - readyName, - deps.captureSandboxList, - deps.allowDockerRuntimeInspection, - ), + waitForSandboxReady(readyName, deps.observer, deps.allowDockerRuntimeInspection), })); let recovery: SandboxStartupRecoveryResult; try { - recovery = restoreStartupState(name); + recovery = await restoreStartupState(name); } catch (error) { throw startupRecoveryError(name, error); } @@ -247,7 +239,7 @@ async function startSandboxWithinLifecycleFence( } if (supervisorReady) { try { - recovery = restoreStartupState(name); + recovery = await restoreStartupState(name); } catch (error) { throw startupRecoveryError(name, error); } diff --git a/src/lib/actions/sandbox/status-snapshot-recovery.test.ts b/src/lib/actions/sandbox/status-snapshot-recovery.test.ts index 48a1f768c66..b8ec24c683d 100644 --- a/src/lib/actions/sandbox/status-snapshot-recovery.test.ts +++ b/src/lib/actions/sandbox/status-snapshot-recovery.test.ts @@ -55,6 +55,7 @@ const healthyRoute: SandboxInferenceRouteHealth = { function recoveredLookup() { return Promise.resolve({ state: "present", + phase: "Ready", output: "Phase: Ready", recoveredSandbox: true, recoverySandboxVia: "started-stopped-original", @@ -90,6 +91,7 @@ describe("collectSandboxStatusSnapshot Docker recovery", () => { reconcile: () => Promise.resolve({ state: "present" as const, + phase: "Ready", output: "Phase: Ready", }), }; @@ -111,6 +113,7 @@ describe("collectSandboxStatusSnapshot Docker recovery", () => { reconcile: () => Promise.resolve({ state: "present" as const, + phase: "Ready", output: "Phase: Ready", }), }; @@ -124,29 +127,30 @@ describe("collectSandboxStatusSnapshot Docker recovery", () => { expect(deps.probeSandboxInferenceGatewayHealthImpl).not.toHaveBeenCalled(); }); - it.each([ - "Provisioning", - "Failed", - ])("keeps the existing %s phase diagnosis ahead of markerless recovery (#7824)", async (phase) => { - const deps = { - ...snapshotDeps({ - checked: true, - wasRunning: false, - recovered: false, - forwardRecovered: false, - }), - reconcile: () => - Promise.resolve({ - state: "present" as const, - output: `Phase: ${phase}`, + it.each(["Provisioning", "Failed"])( + "keeps the existing %s phase diagnosis ahead of markerless recovery (#7824)", + async (phase) => { + const deps = { + ...snapshotDeps({ + checked: true, + wasRunning: false, + recovered: false, + forwardRecovered: false, }), - }; - - const snapshot = await collectSandboxStatusSnapshot("alpha", { deps }); - - expect(deps.recoverSandboxProcesses).not.toHaveBeenCalled(); - expect(snapshot.lookup.state).toBe("present"); - }); + reconcile: () => + Promise.resolve({ + state: "present" as const, + phase, + output: `Phase: ${phase}`, + }), + }; + + const snapshot = await collectSandboxStatusSnapshot("alpha", { deps }); + + expect(deps.recoverSandboxProcesses).not.toHaveBeenCalled(); + expect(snapshot.lookup.state).toBe("present"); + }, + ); it("keeps a host preflight failure ahead of markerless recovery (#7824)", async () => { const deps = { @@ -159,6 +163,7 @@ describe("collectSandboxStatusSnapshot Docker recovery", () => { reconcile: () => Promise.resolve({ state: "present" as const, + phase: "Ready", output: "Phase: Ready", }), }; diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index ccdfa74fcdb..5d89ac63bbf 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -32,7 +32,6 @@ import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { getBaselineExclusionRuntimeStatus } from "../../policy"; import type { BaselineExclusionRuntimeStatus } from "../../policy/baseline-exclusion"; import { redact } from "../../security/redact"; -import { parseSandboxPhase } from "../../state/gateway"; import * as registry from "../../state/registry"; import { buildGatewayInferenceGetArgs, @@ -269,7 +268,7 @@ export function resolveSandboxStatusAgent(agentName = "openclaw"): SandboxStatus type ReconcileSandboxGatewayState = (sandboxName: string) => Promise; type ProbeTerminalRuntimeHealth = (sandboxName: string) => TerminalRuntimeOomProbeResult; type RecoverSandboxProcesses = - typeof import("./status/process-recovery")["checkAndRecoverSandboxProcesses"]; + (typeof import("./status/process-recovery"))["checkAndRecoverSandboxProcesses"]; type SandboxProcessRecoveryResult = ReturnType; type SandboxProcessRecoveryFailure = { @@ -435,7 +434,7 @@ export async function collectSandboxStatusSnapshot( lookup.state === "present" && sb?.openshellDriver === "docker" && (sb.agent ?? "openclaw") === "openclaw" && - parseSandboxPhase(lookup.output || "") === "Ready" && + lookup.phase === "Ready" && !opts.preflight?.failure; let recoveredManagedGateway = false; if ( @@ -535,13 +534,13 @@ export async function collectSandboxStatusSnapshot( recorded: routeDriftPlan.recorded, canConnect: Boolean( sb && - gatewayName && - canSandboxGatewayRouteRealign( - sandboxName, - sb, - gatewayName, - (opts.deps?.listSandboxes ?? registry.listSandboxes)().sandboxes, - ), + gatewayName && + canSandboxGatewayRouteRealign( + sandboxName, + sb, + gatewayName, + (opts.deps?.listSandboxes ?? registry.listSandboxes)().sandboxes, + ), ), } : null; @@ -717,7 +716,7 @@ async function buildSandboxStatusReport( terminalRuntimeHealth, } = snapshot; const dockerRuntime = lookup.state === "present" ? getSandboxDockerRuntime(sandboxName) : null; - const phase = lookup.state === "present" ? parseSandboxPhase(lookup.output || "") : null; + const phase = lookup.state === "present" ? (lookup.phase ?? null) : null; const effectivePreflight = withoutTerminalPhasePreflight( snapshot.postRecoveryPreflight ?? preflight, phase, diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index f33b4ef81ca..0d64b62f974 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -5,7 +5,6 @@ import { printOpenShellStateRpcIssue } from "../../adapters/openshell/gateway-dr import { CLI_NAME } from "../../cli/branding"; import { deferSandboxLifecycleExit, isSandboxLifecycleDeferredExit } from "../../core/process-exit"; import { inspectManagedLlamaCppStatus } from "../../inference/llama-cpp/managed-status"; -import { parseSandboxPhase } from "../../state/gateway"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition"; import * as registry from "../../state/registry"; import { getSandboxDockerRuntime } from "./docker-health"; @@ -196,7 +195,7 @@ async function showLegacySandboxStatus(sandboxName: string): Promise { // Resolve the docker-driver container once: reused for the paused-container // recovery hint (#4495) and the Docker health line below (#3975). const dockerRuntime = lookup.state === "present" ? getSandboxDockerRuntime(sandboxName) : null; - const phase = lookup.state === "present" ? parseSandboxPhase(lookup.output || "") : null; + const phase = lookup.state === "present" ? (lookup.phase ?? null) : null; const effectivePreflight = withoutTerminalPhasePreflight( snapshot.postRecoveryPreflight ?? preflight, phase, diff --git a/src/lib/actions/upgrade-sandboxes-preflight.test.ts b/src/lib/actions/upgrade-sandboxes-preflight.test.ts index 70f701599c7..379c5620413 100644 --- a/src/lib/actions/upgrade-sandboxes-preflight.test.ts +++ b/src/lib/actions/upgrade-sandboxes-preflight.test.ts @@ -13,8 +13,6 @@ const mocks = vi.hoisted(() => ({ getLatestBackup: vi.fn(), getVersion: vi.fn(), listSandboxes: vi.fn(), - parseLiveSandboxEntries: vi.fn(), - parseReadySandboxNames: vi.fn(), prompt: vi.fn(), shouldSkipUpgradeConfirmation: vi.fn(), splitRebuildableSandboxes: vi.fn(), @@ -36,10 +34,6 @@ vi.mock("../openshell-sandbox-list", () => ({ captureNamedGatewaySandboxListReadOnly: mocks.captureNamedGatewaySandboxListReadOnly, captureSandboxListWithGatewayPreflightOrExit: mocks.captureSandboxListWithGatewayPreflightOrExit, })); -vi.mock("../runtime-recovery", () => ({ - parseLiveSandboxEntries: mocks.parseLiveSandboxEntries, - parseReadySandboxNames: mocks.parseReadySandboxNames, -})); vi.mock("../sandbox/version", () => ({ checkAgentVersion: mocks.checkAgentVersion })); vi.mock("../state/registry", () => ({ isPublishedSandboxRegistration: (entry: { pendingRouteReservation?: true }) => @@ -56,20 +50,15 @@ describe("upgrade-sandboxes gateway preflight adapter (#6237)", () => { vi.stubEnv("NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE", ""); vi.spyOn(upgradeSandboxesDependencies, "getGatewayPort").mockReturnValue(8080); vi.spyOn(upgradeSandboxesDependencies, "rebuildSandbox").mockResolvedValue(undefined); - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "alpha Ready", - }); - mocks.captureNamedGatewaySandboxListReadOnly.mockReturnValue({ - status: 0, - output: "alpha Ready", - }); + const inventory = { + sandboxes: [{ name: "alpha", phase: null, readiness: "ready" as const }], + }; + mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue(inventory); + mocks.captureNamedGatewaySandboxListReadOnly.mockResolvedValue(inventory); mocks.getVersion.mockReturnValue("0.0.74"); mocks.listSandboxes.mockReturnValue({ sandboxes: [{ name: "alpha", provider: "nvidia-prod", model: "nemotron" }], }); - mocks.parseLiveSandboxEntries.mockReturnValue([{ name: "alpha", phase: "Ready" }]); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["alpha"])); mocks.classifyUpgradeableSandboxes.mockReturnValue({ stale: [], unknown: [] }); mocks.shouldSkipUpgradeConfirmation.mockReturnValue(true); mocks.splitRebuildableSandboxes.mockReturnValue({ rebuildable: [], stopped: [] }); diff --git a/src/lib/actions/upgrade-sandboxes-recovery.test.ts b/src/lib/actions/upgrade-sandboxes-recovery.test.ts index bbc057eaa9f..f1c40d6d7d9 100644 --- a/src/lib/actions/upgrade-sandboxes-recovery.test.ts +++ b/src/lib/actions/upgrade-sandboxes-recovery.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseCliOpenShellSandboxInventory } from "../adapters/openshell/sandbox-observer-cli"; import * as coreVersion from "../core/version"; import * as sandboxList from "../openshell-sandbox-list"; import * as sandboxVersion from "../sandbox/version"; @@ -40,6 +41,10 @@ function makeManifest(sandboxName: string, agentType: ManifestAgentType = "openc }; } +function sandboxInventory(output: string) { + return parseCliOpenShellSandboxInventory(output); +} + function createRecoveryHarness( names: string[], options: { @@ -91,19 +96,17 @@ function createRecoveryHarness( vi.spyOn(coreVersion, "getVersion").mockReturnValue("0.0.71"); const liveListSpy = vi .spyOn(sandboxList, "captureSandboxListWithGatewayPreflightOrExit") - .mockResolvedValue({ - status: 0, - output: options.liveOutput ?? names.map((name) => `${name} Error`).join("\n"), - }); + .mockResolvedValue( + sandboxInventory(options.liveOutput ?? names.map((name) => `${name} Error`).join("\n")), + ); // #7279: check mode observes gateways through the read-only helper instead of // the recovering preflight; keep both stubbed so a check-mode run never hits // the real openshell adapter. const readOnlyListSpy = vi .spyOn(sandboxList, "captureNamedGatewaySandboxListReadOnly") - .mockReturnValue({ - status: 0, - output: options.liveOutput ?? names.map((name) => `${name} Error`).join("\n"), - }); + .mockResolvedValue( + sandboxInventory(options.liveOutput ?? names.map((name) => `${name} Error`).join("\n")), + ); vi.spyOn(registry, "listSandboxes").mockReturnValue({ defaultSandbox: null, sandboxes: names.map((name) => ({ @@ -121,8 +124,7 @@ function createRecoveryHarness( .mockImplementation((...args: unknown[]) => { const name = String(args[0]); return { - sandboxVersion: - options.staleNames?.includes(name) === true ? "2026.5.26" : "2026.5.27", + sandboxVersion: options.staleNames?.includes(name) === true ? "2026.5.26" : "2026.5.27", expectedVersion: "2026.5.27", isStale: options.staleNames?.includes(name) === true, verificationFailed: false, @@ -642,8 +644,8 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { staleNames: ["reconnecting-box"], }); harness.liveListSpy - .mockResolvedValueOnce({ status: 0, output: "other-box Ready" }) - .mockResolvedValueOnce({ status: 0, output: "reconnecting-box Ready" }); + .mockResolvedValueOnce(sandboxInventory("other-box Ready")) + .mockResolvedValueOnce(sandboxInventory("reconnecting-box Ready")); await expect(harness.upgradeSandboxes({ auto: true })).resolves.toBeUndefined(); @@ -705,8 +707,8 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { liveOutput: "other-box Ready", }); harness.liveListSpy - .mockResolvedValueOnce({ status: 0, output: "other-box Ready" }) - .mockResolvedValueOnce({ status: 0, output: "still-other-box Ready" }); + .mockResolvedValueOnce(sandboxInventory("other-box Ready")) + .mockResolvedValueOnce(sandboxInventory("still-other-box Ready")); await expect(harness.upgradeSandboxes({ auto: true })).resolves.toBeUndefined(); @@ -729,8 +731,8 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { const harness = createRecoveryHarness(["healthy-box"], { gatewayPort: 12345 }); harness.liveListSpy.mockImplementation(async (...args: unknown[]) => (args[1] as { gatewayName?: string } | undefined)?.gatewayName === targetGatewayName - ? { status: 0, output: "healthy-box Ready" } - : { status: 0, output: "default-other-box Ready" }, + ? sandboxInventory("healthy-box Ready") + : sandboxInventory("default-other-box Ready"), ); await expect(harness.upgradeSandboxes({ auto: true })).resolves.toBeUndefined(); @@ -796,8 +798,8 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { staleNames: ["reconnecting-box"], }); harness.liveListSpy - .mockResolvedValueOnce({ status: 0, output: "other-box Ready" }) - .mockResolvedValueOnce({ status: 0, output: "reconnecting-box Ready" }); + .mockResolvedValueOnce(sandboxInventory("other-box Ready")) + .mockResolvedValueOnce(sandboxInventory("reconnecting-box Ready")); await expect(harness.upgradeSandboxes({ auto: true })).resolves.toBeUndefined(); @@ -816,8 +818,8 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { liveOutput: "other-box Ready", }); harness.liveListSpy - .mockResolvedValueOnce({ status: 0, output: "other-box Ready" }) - .mockResolvedValueOnce({ status: 0, output: `orphaned-box ${phase}` }); + .mockResolvedValueOnce(sandboxInventory("other-box Ready")) + .mockResolvedValueOnce(sandboxInventory(`orphaned-box ${phase}`)); await expect(harness.upgradeSandboxes({ auto: true })).resolves.toBeUndefined(); diff --git a/src/lib/actions/upgrade-sandboxes.ts b/src/lib/actions/upgrade-sandboxes.ts index 3ccccd02dd0..3d3be1eeeaa 100644 --- a/src/lib/actions/upgrade-sandboxes.ts +++ b/src/lib/actions/upgrade-sandboxes.ts @@ -26,7 +26,6 @@ import { captureNamedGatewaySandboxListReadOnly, captureSandboxListWithGatewayPreflightOrExit, } from "../openshell-sandbox-list"; -import { parseLiveSandboxEntries, parseReadySandboxNames } from "../runtime-recovery"; import * as sandboxVersion from "../sandbox/version"; import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import * as registry from "../state/registry"; @@ -205,11 +204,15 @@ async function confirmAbsentRecoveryCandidates( }; // #7279: a read-only check must never recover/select the gateway. const confirmation = checkOnly - ? captureNamedGatewaySandboxListReadOnly(context, selectedGatewayName) + ? await captureNamedGatewaySandboxListReadOnly(context, selectedGatewayName) : await captureSandboxListWithGatewayPreflightOrExit(context, { gatewayName: selectedGatewayName, }); - const confirmedLiveNames = parseReadySandboxNames(confirmation.output || ""); + const confirmedLiveNames = new Set( + confirmation.sandboxes + .filter((sandbox) => sandbox.readiness === "ready") + .map((sandbox) => sandbox.name), + ); return absentCandidates.filter((sandbox) => !confirmedLiveNames.has(sandbox.name)); } @@ -298,21 +301,23 @@ export async function upgradeSandboxes( command: `${CLI_NAME} upgrade-sandboxes`, }; const liveResult = checkOnly - ? captureNamedGatewaySandboxListReadOnly(liveListContext, selectedGatewayName) + ? await captureNamedGatewaySandboxListReadOnly(liveListContext, selectedGatewayName) : await captureSandboxListWithGatewayPreflightOrExit(liveListContext, { gatewayName: selectedGatewayName, }); - const liveNames = parseReadySandboxNames(liveResult.output || ""); + const liveNames = new Set( + liveResult.sandboxes + .filter((sandbox) => sandbox.readiness === "ready") + .map((sandbox) => sandbox.name), + ); // Sandboxes the selected gateway observes in a non-Ready phase. Absence from // the selected gateway and stale Ready/Running rows are handled by // isPreparedRecoveryCandidate, which recovers them only when they resolve to // the selected gateway. const nonReadyLiveNames = new Set( - parseLiveSandboxEntries(liveResult.output || "") - .filter( - (entry) => entry.phase !== null && entry.phase !== "Ready" && entry.phase !== "Running", - ) - .map((entry) => entry.name), + liveResult.sandboxes + .filter((sandbox) => sandbox.phase !== null && sandbox.readiness !== "ready") + .map((sandbox) => sandbox.name), ); // Classify sandboxes as stale, unknown, or current. Pass the running NemoClaw diff --git a/src/lib/adapters/openshell/sandbox-observer-cli.test.ts b/src/lib/adapters/openshell/sandbox-observer-cli.test.ts new file mode 100644 index 00000000000..03adda617d2 --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-observer-cli.test.ts @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + createCliOpenShellSandboxLookup, + createCliOpenShellSandboxObserver as createObserver, + type CapturedSandboxCommandResult, + type CliOpenShellSandboxObserverDeps, + parseCliOpenShellSandboxInventory, +} from "./sandbox-observer-cli"; +import { namedOpenShellGateway, selectedOpenShellGateway } from "./sandbox-observer"; + +function createCliOpenShellSandboxObserver( + deps: Omit, +) { + return createObserver({ ...deps, defaultTimeoutMs: 15_000 }); +} + +function captured( + status: number | null, + stdout = "", + stderr = "", + error?: Error, +): CapturedSandboxCommandResult { + return { + status, + output: `${stdout}${stderr}`.trim(), + stdout, + stderr, + ...(error ? { error } : {}), + }; +} + +describe("CLI OpenShell sandbox observer", () => { + it("targets a named gateway and returns typed list observations (#9803)", async () => { + const capture = vi.fn(() => + captured( + 0, + [ + "NAME CREATED PHASE", + "alpha 2m Ready", + "beta 1m Provisioning", + "gamma 30s CrashLoopBackOff", + ].join("\n"), + ), + ); + const observer = createCliOpenShellSandboxObserver({ capture }); + + const result = await observer.listSandboxes({ + target: namedOpenShellGateway("nemoclaw-18080"), + timeoutMs: 4_321, + }); + + expect(capture).toHaveBeenCalledWith(["sandbox", "list", "-g", "nemoclaw-18080"], { + ignoreError: true, + includeStderr: true, + includeStreams: true, + timeout: 4_321, + }); + expect(result).toEqual({ + ok: true, + value: { + sandboxes: [ + { name: "alpha", phase: "Ready", readiness: "ready" }, + { name: "beta", phase: "Provisioning", readiness: "not_ready" }, + { name: "gamma", phase: "CrashLoopBackOff", readiness: "terminal" }, + ], + }, + }); + }); + + it("contains table and ANSI compatibility inside the CLI implementation (#9803)", () => { + expect( + parseCliOpenShellSandboxInventory( + "\u001b[1mNAME\u001b[0m CREATED PHASE\n" + + "\u001b[1malpha\u001b[0m 2m \u001b[32mReady\u001b[0m\n" + + "beta Ready NotReady 1m ago\n" + + "No sandboxes found.", + ), + ).toEqual({ + sandboxes: [ + { name: "alpha", phase: "Ready", readiness: "ready" }, + { name: "beta", phase: "NotReady", readiness: "not_ready" }, + ], + }); + }); + + it("parses successful list output from stdout without treating stderr as inventory (#9803)", async () => { + const observer = createCliOpenShellSandboxObserver({ + capture: () => captured(0, "alpha Ready", "warning text"), + }); + + await expect(observer.listSandboxes({ target: selectedOpenShellGateway() })).resolves.toEqual({ + ok: true, + value: { + sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }], + }, + }); + }); + + it("looks up a sandbox without exposing process-shaped results (#9803)", async () => { + const capture = vi.fn(() => captured(0, "\u001b[1mName:\u001b[0m alpha\nPhase: Running\n")); + const observer = createCliOpenShellSandboxObserver({ capture }); + + const result = await observer.lookupSandbox({ + sandboxName: "alpha", + target: namedOpenShellGateway("nemoclaw"), + timeoutMs: 1_000, + }); + + expect(capture).toHaveBeenCalledWith(["sandbox", "get", "-g", "nemoclaw", "alpha"], { + ignoreError: true, + includeStderr: true, + includeStreams: true, + timeout: 1_000, + }); + expect(result).toEqual({ + ok: true, + value: { + state: "present", + sandbox: { name: "alpha", phase: "Running", readiness: "ready" }, + }, + }); + }); + + it("keeps formatted get output on an explicit CLI-only compatibility path (#9803)", async () => { + const lookup = createCliOpenShellSandboxLookup({ + capture: () => captured(0, "\u001b[1mName:\u001b[0m alpha\nPhase: Running\n"), + }); + + await expect( + lookup({ sandboxName: "alpha", target: selectedOpenShellGateway() }), + ).resolves.toEqual({ + result: { + ok: true, + value: { + state: "present", + sandbox: { name: "alpha", phase: "Running", readiness: "ready" }, + }, + }, + displayOutput: "Name: alpha\nPhase: Running", + }); + }); + + it("keeps a missing sandbox distinct from authentication failure (#9803)", async () => { + const capture = vi + .fn() + .mockReturnValueOnce(captured(1, "", "sandbox has no spec: NotFound")) + .mockReturnValueOnce( + captured(1, "", "Error: authentication failed: sandbox not found: bearer value"), + ); + const observer = createCliOpenShellSandboxObserver({ capture }); + const request = { sandboxName: "alpha", target: selectedOpenShellGateway() }; + + await expect(observer.lookupSandbox(request)).resolves.toEqual({ + ok: true, + value: { state: "missing" }, + }); + await expect(observer.lookupSandbox(request)).resolves.toEqual({ + ok: false, + error: { + kind: "authentication", + message: "OpenShell could not authenticate the sandbox observation.", + }, + }); + }); + + it.each([ + ["transport", undefined, captured(1, "", "client error (Connect): Connection refused")], + ["schema", undefined, captured(1, "", "protobuf decode error: invalid wire type")], + ["command", "failed", captured(7, "", "unexpected opaque failure")], + ["command", "invalid_request", captured(2, "", "unknown option")], + ] as const)( + "maps %s failures without retaining CLI diagnostics (#9803)", + async (kind, reason, value) => { + const observer = createCliOpenShellSandboxObserver({ capture: () => value }); + + const result = await observer.listSandboxes({ target: selectedOpenShellGateway() }); + + expect(result.ok).toBe(false); + const mapped = result as Extract; + expect(mapped.error.kind).toBe(kind); + expect(mapped.error).toMatchObject(reason ? { reason } : {}); + expect(mapped.error.message).not.toContain(value.stderr); + }, + ); + + it("maps a subprocess timeout without retaining its command text (#9803)", async () => { + const timeout = new Error( + "spawn openshell sandbox list token-value timed out", + ) as NodeJS.ErrnoException; + timeout.code = "ETIMEDOUT"; + const observer = createCliOpenShellSandboxObserver({ + capture: () => captured(null, "", "credential-bearing detail", timeout), + }); + + await expect( + observer.listSandboxes({ target: selectedOpenShellGateway(), timeoutMs: 25 }), + ).resolves.toEqual({ + ok: false, + error: { kind: "timeout", message: "OpenShell sandbox observation timed out." }, + }); + }); + + it("waits for stable readiness using typed observations (#9803)", async () => { + const outputs = ["alpha Provisioning", "alpha Error", "alpha Ready", "alpha Running"]; + let clock = 0; + const observer = createCliOpenShellSandboxObserver({ + capture: () => captured(0, outputs.shift() ?? ""), + now: () => clock, + sleep: (ms) => { + clock += ms; + }, + }); + + const result = await observer.waitForSandboxReady({ + sandboxName: "alpha", + target: namedOpenShellGateway("nemoclaw"), + timeoutMs: 1_000, + pollIntervalMs: 10, + stableReadyObservations: 2, + errorPhaseDebounceObservations: 2, + }); + + expect(result).toEqual({ + ok: true, + value: { + state: "ready", + sandbox: { name: "alpha", phase: "Running", readiness: "ready" }, + observations: 4, + }, + }); + }); + + it("returns a terminal phase before the readiness deadline (#9803)", async () => { + const observer = createCliOpenShellSandboxObserver({ + capture: () => captured(0, "alpha Failed"), + now: () => 0, + sleep: vi.fn(), + }); + + await expect( + observer.waitForSandboxReady({ + sandboxName: "alpha", + target: selectedOpenShellGateway(), + timeoutMs: 1_000, + }), + ).resolves.toEqual({ + ok: true, + value: { + state: "terminal", + sandbox: { name: "alpha", phase: "Failed", readiness: "terminal" }, + observations: 1, + }, + }); + }); + + it("returns missing as the last observation when readiness times out (#9803)", async () => { + let clock = 0; + const observer = createCliOpenShellSandboxObserver({ + capture: () => captured(0, "No sandboxes found."), + now: () => clock, + sleep: (ms) => { + clock += ms; + }, + }); + + await expect( + observer.waitForSandboxReady({ + sandboxName: "alpha", + target: selectedOpenShellGateway(), + timeoutMs: 20, + pollIntervalMs: 10, + }), + ).resolves.toEqual({ + ok: true, + value: { state: "timeout", lastObservation: null, observations: 2 }, + }); + }); +}); diff --git a/src/lib/adapters/openshell/sandbox-observer-cli.ts b/src/lib/adapters/openshell/sandbox-observer-cli.ts new file mode 100644 index 00000000000..a6643ad6a89 --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-observer-cli.ts @@ -0,0 +1,335 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { setTimeout as sleepMsAsync } from "node:timers/promises"; + +import { + type ListOpenShellSandboxesRequest, + type LookupOpenShellSandboxRequest, + type OpenShellGatewayTarget, + type OpenShellSandboxError, + type OpenShellSandboxInventory, + type OpenShellSandboxLookup, + type OpenShellSandboxObservation, + type OpenShellSandboxObserver, + type OpenShellSandboxReadinessWait, + type OpenShellSandboxResult, + type WaitForOpenShellSandboxReadyRequest, +} from "./sandbox-observer"; + +export { namedOpenShellGateway, selectedOpenShellGateway } from "./sandbox-observer"; +export type { + OpenShellGatewayTarget, + OpenShellSandboxError, + OpenShellSandboxInventory, + OpenShellSandboxLookup, + OpenShellSandboxObservation, + OpenShellSandboxObserver, + OpenShellSandboxReadiness, + OpenShellSandboxReadinessWait, + OpenShellSandboxResult, +} from "./sandbox-observer"; + +const ANSI_RE = /\x1b\[[0-9;]*m/gu; +const DEFAULT_SANDBOX_OBSERVATION_TIMEOUT_MS = 15_000; + +const READY_PHASES = new Set(["Ready", "Running"]); +const TERMINAL_PHASES = new Set([ + "CrashLoopBackOff", + "Error", + "Evicted", + "Failed", + "ImagePullBackOff", + "Unknown", +]); +const KNOWN_PHASES = new Set([ + ...READY_PHASES, + ...TERMINAL_PHASES, + "Creating", + "Deleting", + "NotReady", + "Pending", + "Provisioning", + "Terminating", +]); + +function isOpenShellSandboxSchemaMismatch(output: string): boolean { + return ( + /invalid wire type/iu.test(output) || /proto(?:buf)?(?: decode| schema| wire)/iu.test(output) + ); +} + +export type CapturedSandboxCommandResult = Readonly<{ + status: number | null; + output: string; + stdout?: string; + stderr?: string; + error?: Error; +}>; + +export type CaptureSandboxCommand = ( + args: string[], + options: { + ignoreError: true; + includeStderr: true; + includeStreams: true; + timeout: number; + }, +) => CapturedSandboxCommandResult | Promise; + +export type CliOpenShellSandboxObserverDeps = Readonly<{ + capture: CaptureSandboxCommand; + defaultTimeoutMs?: number; + now?: () => number; + sleep?: (ms: number) => void | Promise; +}>; + +export type CliOpenShellSandboxLookupResult = Readonly<{ + result: OpenShellSandboxResult; + displayOutput: string; +}>; + +export type CliOpenShellSandboxLookup = ( + request: LookupOpenShellSandboxRequest, +) => Promise; + +function readinessForPhase(phase: string | null): OpenShellSandboxObservation["readiness"] { + if (phase && READY_PHASES.has(phase)) return "ready"; + if (phase && TERMINAL_PHASES.has(phase)) return "terminal"; + return "not_ready"; +} + +export function stripOpenShellCliAnsi(value = ""): string { + return String(value).replace(ANSI_RE, ""); +} + +function observation(name: string, phase: string | null): OpenShellSandboxObservation { + return { name, phase, readiness: readinessForPhase(phase) }; +} + +function isNonSandboxRow(line: string, firstColumn: string): boolean { + return ( + firstColumn === "NAME" || + line === "No sandboxes found" || + line === "No sandboxes found." || + /^Error:/iu.test(line) || + isOpenShellSandboxSchemaMismatch(line) + ); +} + +export function parseCliOpenShellSandboxInventory(output: string): OpenShellSandboxInventory { + const sandboxes: OpenShellSandboxObservation[] = []; + for (const rawLine of stripOpenShellCliAnsi(output).split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line) continue; + const columns = line.split(/\s+/u); + const name = columns[0]; + if (!name || isNonSandboxRow(line, name)) continue; + const phaseColumns = columns.slice(1); + const phase = phaseColumns.includes("NotReady") + ? "NotReady" + : (phaseColumns.find((column) => KNOWN_PHASES.has(column)) ?? null); + sandboxes.push(observation(name, phase)); + } + return { sandboxes }; +} + +function parseCliOpenShellSandboxPhase(output: string): string | null { + const match = stripOpenShellCliAnsi(output).match(/^\s*Phase:\s+(\S+)/mu); + return match?.[1] ?? null; +} + +function targetArgs( + command: "get" | "list", + target: OpenShellGatewayTarget, + sandboxName?: string, +): string[] { + const args = ["sandbox", command]; + if (target.kind === "named") args.push("-g", target.gatewayName); + if (sandboxName) args.push(sandboxName); + return args; +} + +function commandOutput(result: CapturedSandboxCommandResult): string { + return `${result.stderr ?? ""}\n${result.stdout ?? result.output ?? ""}`.trim(); +} + +function successfulCommandOutput(result: CapturedSandboxCommandResult): string { + return stripOpenShellCliAnsi(result.stdout ?? result.output); +} + +function commandError(result: CapturedSandboxCommandResult): OpenShellSandboxError | null { + const output = stripOpenShellCliAnsi(commandOutput(result)); + const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code; + if (errorCode === "ETIMEDOUT") { + return { kind: "timeout", message: "OpenShell sandbox observation timed out." }; + } + if (isOpenShellSandboxSchemaMismatch(output)) { + return { + kind: "schema", + message: "The OpenShell CLI and gateway sandbox schemas do not match.", + }; + } + if ( + /\b(?:authentication failed|unauthorized|forbidden|permission denied|missing gateway auth token|device identity required|invalid token|expired token)\b/iu.test( + output, + ) + ) { + return { + kind: "authentication", + message: "OpenShell could not authenticate the sandbox observation.", + }; + } + if ( + /\b(?:connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured|handshake verification failed)\b/iu.test( + output, + ) + ) { + return { + kind: "transport", + message: "OpenShell could not reach the selected gateway.", + }; + } + if (result.status !== 0) { + return { + kind: "command", + reason: result.status === 2 ? "invalid_request" : "failed", + message: "The OpenShell sandbox observation failed.", + }; + } + return null; +} + +function isMissingSandboxOutput(output: string): boolean { + return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox has no spec/iu.test( + stripOpenShellCliAnsi(output), + ); +} + +function success(value: T): OpenShellSandboxResult { + return { ok: true, value }; +} + +function failure(error: OpenShellSandboxError): OpenShellSandboxResult { + return { ok: false, error }; +} + +/** + * CLI-only compatibility lookup for the legacy status display. Presence and + * phase decisions must use `result`; `displayOutput` remains a CLI-only + * presentation compatibility path. + */ +export function createCliOpenShellSandboxLookup( + deps: Pick, +): CliOpenShellSandboxLookup { + return async (request) => { + const result = await deps.capture(targetArgs("get", request.target, request.sandboxName), { + ignoreError: true, + includeStderr: true, + includeStreams: true, + timeout: request.timeoutMs ?? deps.defaultTimeoutMs ?? DEFAULT_SANDBOX_OBSERVATION_TIMEOUT_MS, + }); + const output = commandOutput(result); + const error = commandError(result); + if (error && error.kind !== "command") { + return { result: failure(error), displayOutput: "" }; + } + if (result.status !== 0 && isMissingSandboxOutput(output)) { + return { result: success({ state: "missing" }), displayOutput: "" }; + } + if (error) return { result: failure(error), displayOutput: "" }; + const displayOutput = successfulCommandOutput(result).trim(); + return { + result: success({ + state: "present", + sandbox: observation(request.sandboxName, parseCliOpenShellSandboxPhase(displayOutput)), + }), + displayOutput, + }; + }; +} + +export function createCliOpenShellSandboxObserver( + deps: CliOpenShellSandboxObserverDeps, +): OpenShellSandboxObserver { + const capture = deps.capture; + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? sleepMsAsync; + const cliLookup = createCliOpenShellSandboxLookup(deps); + + const listSandboxes = async ( + request: ListOpenShellSandboxesRequest, + ): Promise> => { + const result = await capture(targetArgs("list", request.target), { + ignoreError: true, + includeStderr: true, + includeStreams: true, + timeout: request.timeoutMs ?? deps.defaultTimeoutMs ?? DEFAULT_SANDBOX_OBSERVATION_TIMEOUT_MS, + }); + const error = commandError(result); + if (error) return failure(error); + return success(parseCliOpenShellSandboxInventory(successfulCommandOutput(result))); + }; + + const lookupSandbox = async ( + request: LookupOpenShellSandboxRequest, + ): Promise> => { + return (await cliLookup(request)).result; + }; + + const waitForSandboxReady = async ( + request: WaitForOpenShellSandboxReadyRequest, + ): Promise> => { + const timeoutMs = Math.max(0, request.timeoutMs); + const pollIntervalMs = Math.max(0, request.pollIntervalMs ?? 250); + const stableReadyObservations = Math.max(1, Math.round(request.stableReadyObservations ?? 1)); + const errorPhaseDebounceObservations = Math.max( + 1, + Math.round(request.errorPhaseDebounceObservations ?? 1), + ); + const deadline = now() + timeoutMs; + let observations = 0; + let consecutiveReady = 0; + let consecutiveError = 0; + let lastObservation: OpenShellSandboxObservation | null = null; + + while (now() < deadline) { + const remainingMs = Math.max(1, deadline - now()); + const listed = await listSandboxes({ + target: request.target, + timeoutMs: remainingMs, + }); + if (!listed.ok) return listed; + observations += 1; + const current = + listed.value.sandboxes.find((sandbox) => sandbox.name === request.sandboxName) ?? null; + lastObservation = current; + + if (current?.readiness === "ready") { + consecutiveReady += 1; + consecutiveError = 0; + if (consecutiveReady >= stableReadyObservations) { + return success({ state: "ready", sandbox: current, observations }); + } + } else { + consecutiveReady = 0; + if (current?.readiness === "terminal") { + consecutiveError = current.phase === "Error" ? consecutiveError + 1 : 0; + if (current.phase !== "Error" || consecutiveError >= errorPhaseDebounceObservations) { + return success({ state: "terminal", sandbox: current, observations }); + } + } else { + consecutiveError = 0; + } + } + + const remainingAfterObservationMs = deadline - now(); + if (remainingAfterObservationMs <= 0) break; + await sleep(Math.min(pollIntervalMs, remainingAfterObservationMs)); + } + + return success({ state: "timeout", lastObservation, observations }); + }; + + return { listSandboxes, lookupSandbox, waitForSandboxReady }; +} diff --git a/src/lib/adapters/openshell/sandbox-observer.ts b/src/lib/adapters/openshell/sandbox-observer.ts new file mode 100644 index 00000000000..391c382ac0e --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-observer.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type OpenShellGatewayTarget = { kind: "named"; gatewayName: string } | { kind: "selected" }; + +export type OpenShellSandboxReadiness = "ready" | "not_ready" | "terminal"; + +export type OpenShellSandboxObservation = Readonly<{ + name: string; + phase: string | null; + readiness: OpenShellSandboxReadiness; +}>; + +export type OpenShellSandboxInventory = Readonly<{ + sandboxes: readonly OpenShellSandboxObservation[]; +}>; + +export type OpenShellSandboxLookup = + | Readonly<{ state: "present"; sandbox: OpenShellSandboxObservation }> + | Readonly<{ state: "missing" }>; + +export type OpenShellSandboxErrorKind = + | "authentication" + | "command" + | "schema" + | "timeout" + | "transport"; + +export type OpenShellSandboxError = + | Readonly<{ + kind: Exclude; + message: string; + }> + | Readonly<{ + kind: "command"; + reason: "failed" | "invalid_request"; + message: string; + }>; + +export type OpenShellSandboxResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; error: OpenShellSandboxError }>; + +export type ListOpenShellSandboxesRequest = Readonly<{ + target: OpenShellGatewayTarget; + timeoutMs?: number; +}>; + +export type LookupOpenShellSandboxRequest = ListOpenShellSandboxesRequest & + Readonly<{ + sandboxName: string; + }>; + +export type WaitForOpenShellSandboxReadyRequest = LookupOpenShellSandboxRequest & + Readonly<{ + timeoutMs: number; + pollIntervalMs?: number; + stableReadyObservations?: number; + errorPhaseDebounceObservations?: number; + }>; + +export type OpenShellSandboxReadinessWait = + | Readonly<{ + state: "ready"; + sandbox: OpenShellSandboxObservation; + observations: number; + }> + | Readonly<{ + state: "terminal"; + sandbox: OpenShellSandboxObservation; + observations: number; + }> + | Readonly<{ + state: "timeout"; + lastObservation: OpenShellSandboxObservation | null; + observations: number; + }>; + +/** Transport-neutral sandbox observation capabilities used by NemoClaw. */ +export interface OpenShellSandboxObserver { + listSandboxes( + request: ListOpenShellSandboxesRequest, + ): Promise>; + + lookupSandbox( + request: LookupOpenShellSandboxRequest, + ): Promise>; + + waitForSandboxReady( + request: WaitForOpenShellSandboxReadyRequest, + ): Promise>; +} + +export function namedOpenShellGateway(gatewayName: string): OpenShellGatewayTarget { + return { kind: "named", gatewayName }; +} + +export function selectedOpenShellGateway(): OpenShellGatewayTarget { + return { kind: "selected" }; +} diff --git a/src/lib/openshell-sandbox-list.test.ts b/src/lib/openshell-sandbox-list.test.ts index d9fa697e0f8..b041c8277cc 100644 --- a/src/lib/openshell-sandbox-list.test.ts +++ b/src/lib/openshell-sandbox-list.test.ts @@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({ detectResultIssue: vi.fn(), printIssue: vi.fn(), recoverNamedGatewayRuntime: vi.fn(), - stripAnsi: vi.fn((value: string) => value), })); vi.mock("./adapters/openshell/gateway-drift", () => ({ @@ -19,9 +18,6 @@ vi.mock("./adapters/openshell/gateway-drift", () => ({ detectOpenShellStateRpcResultIssue: mocks.detectResultIssue, printOpenShellStateRpcIssue: mocks.printIssue, })); -vi.mock("./adapters/openshell/client", () => ({ - stripAnsi: mocks.stripAnsi, -})); vi.mock("./adapters/openshell/runtime", () => ({ captureOpenshell: mocks.captureOpenshell, })); @@ -94,9 +90,14 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { it("returns the successful sandbox list without gateway recovery", async () => { const result = await captureSandboxListWithGatewayPreflightOrExit(context); - expect(result).toEqual({ status: 0, output: "alpha Ready" }); + expect(result).toEqual({ + sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }], + }); expect(mocks.captureOpenshell).toHaveBeenCalledOnce(); - expect(mocks.captureOpenshell).toHaveBeenCalledWith(["sandbox", "list"]); + expect(mocks.captureOpenshell).toHaveBeenCalledWith( + ["sandbox", "list"], + expect.objectContaining({ ignoreError: true, includeStreams: true }), + ); expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); }); @@ -121,7 +122,10 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { }; expect(mocks.recoverNamedGatewayRuntime).toHaveBeenNthCalledWith(1, expectedRecoveryOptions); expect(mocks.recoverNamedGatewayRuntime).toHaveBeenNthCalledWith(2, expectedRecoveryOptions); - expect(mocks.detectResultIssue).toHaveBeenCalledWith(result, options); + expect(mocks.captureOpenshell).toHaveBeenCalledWith( + ["sandbox", "list", "-g", "nemoclaw-12345"], + expect.anything(), + ); expect(exitSpy).not.toHaveBeenCalled(); }); @@ -162,7 +166,9 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { const result = await captureSandboxListWithGatewayPreflightOrExit(context); - expect(result).toEqual({ status: 0, output: "alpha Ready" }); + expect(result).toEqual({ + sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }], + }); expect(mocks.recoverNamedGatewayRuntime).toHaveBeenCalledWith({ recoverableStates: [ "missing_named", @@ -172,8 +178,16 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { ], }); expect(mocks.captureOpenshell).toHaveBeenCalledTimes(2); - expect(mocks.captureOpenshell).toHaveBeenNthCalledWith(1, ["sandbox", "list"]); - expect(mocks.captureOpenshell).toHaveBeenNthCalledWith(2, ["sandbox", "list"]); + expect(mocks.captureOpenshell).toHaveBeenNthCalledWith( + 1, + ["sandbox", "list"], + expect.anything(), + ); + expect(mocks.captureOpenshell).toHaveBeenNthCalledWith( + 2, + ["sandbox", "list"], + expect.anything(), + ); }); it("classifies protobuf mismatch from the retry before generic failure handling", async () => { @@ -193,13 +207,16 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { expect(mocks.captureOpenshell).toHaveBeenCalledTimes(2); expect(mocks.recoverNamedGatewayRuntime).toHaveBeenCalledOnce(); - expect(mocks.printIssue).toHaveBeenCalledWith(issue, context); + expect(mocks.printIssue).toHaveBeenCalledWith( + { kind: "protobuf_mismatch", drift: null, output: "" }, + context, + ); expect(errorSpy).not.toHaveBeenCalledWith( expect.stringContaining("Failed to query running sandboxes"), ); }); - it("preserves a generic failure status from the single retry", async () => { + it("preserves invalid-request exit behavior from the single retry", async () => { mocks.captureOpenshell .mockReturnValueOnce({ status: 1, output: "client error (Connect): Connection refused" }) .mockReturnValueOnce({ status: 2, output: "unknown option: --json" }); @@ -257,7 +274,10 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { "process.exit(1)", ); - expect(mocks.printIssue).toHaveBeenCalledWith(issue, context); + expect(mocks.printIssue).toHaveBeenCalledWith( + { kind: "protobuf_mismatch", drift: null, output: "" }, + context, + ); expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); expect(errorSpy).not.toHaveBeenCalledWith( expect.stringContaining("Failed to query running sandboxes"), @@ -282,44 +302,52 @@ describe("read-only named-gateway sandbox list (#7279)", () => { vi.restoreAllMocks(); }); - it("lists the named gateway with -g and never recovers or selects", () => { - const result = captureNamedGatewaySandboxListReadOnly(context, "nemoclaw-18080"); + it("lists the named gateway with -g and never recovers or selects", async () => { + const result = await captureNamedGatewaySandboxListReadOnly(context, "nemoclaw-18080"); expect(mocks.captureOpenshell).toHaveBeenCalledWith( ["sandbox", "list", "-g", "nemoclaw-18080"], - { ignoreError: true }, + expect.objectContaining({ ignoreError: true, includeStreams: true }), ); expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); - expect(result).toEqual({ status: 0, output: "alpha Ready" }); + expect(result).toEqual({ + sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }], + }); }); - it("stays non-fatal when the recorded gateway is down", () => { + it("stays non-fatal when the recorded gateway is down", async () => { mocks.captureOpenshell.mockReturnValue({ status: 1, output: "tcp connect error: Connection refused", }); - const result = captureNamedGatewaySandboxListReadOnly(context, "nemoclaw-18080"); + const result = await captureNamedGatewaySandboxListReadOnly(context, "nemoclaw-18080"); - expect(result.status).toBe(1); + expect(result).toEqual({ sandboxes: [] }); expect(exitSpy).not.toHaveBeenCalled(); expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); }); - it("still exits on a state-RPC result drift issue", () => { - mocks.detectResultIssue.mockReturnValue(imageDriftIssue); + it("still exits on a state-RPC result drift issue", async () => { + mocks.captureOpenshell.mockReturnValue({ + status: 1, + output: "Sandbox.metadata: invalid wire type value: 6", + }); - expect(() => captureNamedGatewaySandboxListReadOnly(context, "nemoclaw-18080")).toThrow( + await expect(captureNamedGatewaySandboxListReadOnly(context, "nemoclaw-18080")).rejects.toThrow( "process.exit(1)", ); - expect(mocks.printIssue).toHaveBeenCalledWith(imageDriftIssue, context); + expect(mocks.printIssue).toHaveBeenCalledWith( + { kind: "protobuf_mismatch", drift: null, output: "" }, + context, + ); expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); }); - it("exits before listing on a preflight drift issue", () => { + it("exits before listing on a preflight drift issue", async () => { mocks.detectPreflightIssue.mockReturnValue(hostProcessDriftIssue); - expect(() => captureNamedGatewaySandboxListReadOnly(context, "nemoclaw-18080")).toThrow( + await expect(captureNamedGatewaySandboxListReadOnly(context, "nemoclaw-18080")).rejects.toThrow( "process.exit(1)", ); expect(mocks.captureOpenshell).not.toHaveBeenCalled(); diff --git a/src/lib/openshell-sandbox-list.ts b/src/lib/openshell-sandbox-list.ts index 76754b586e7..7b42844de56 100644 --- a/src/lib/openshell-sandbox-list.ts +++ b/src/lib/openshell-sandbox-list.ts @@ -1,16 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { stripAnsi } from "./adapters/openshell/client"; import { detectOpenShellStateRpcPreflightIssue, - detectOpenShellStateRpcResultIssue, printOpenShellStateRpcIssue, } from "./adapters/openshell/gateway-drift"; +import { + createCliOpenShellSandboxObserver, + namedOpenShellGateway, + selectedOpenShellGateway, + type OpenShellSandboxInventory, + type OpenShellSandboxObserver, + type OpenShellSandboxResult, +} from "./adapters/openshell/sandbox-observer-cli"; import { captureOpenshell } from "./adapters/openshell/runtime"; import { recoverNamedGatewayRuntime } from "./gateway-runtime-action"; -type SandboxListResult = ReturnType; +type SandboxListResult = OpenShellSandboxResult; export type SandboxListPreflightContext = { action: string; @@ -25,24 +31,21 @@ export type SandboxListRecoveryResult = { export type CaptureSandboxListWithGatewayRecoveryOptions = { gatewayName?: string; + observer?: OpenShellSandboxObserver; }; -export function isRecoverableSandboxListGatewayFailure( - result: SandboxListResult, - options: CaptureSandboxListWithGatewayRecoveryOptions = {}, -): boolean { - if (result.status === 0 || detectOpenShellStateRpcResultIssue(result, options)) { - return false; - } - const output = stripAnsi(String(result.output || "")); - return /Connection refused|client error \(Connect\)|tcp connect error|No active gateway|No gateway configured|Status:\s*Disconnected/i.test( - output, - ); +function isRecoverableObservedSandboxListGatewayFailure(result: SandboxListResult): boolean { + return !result.ok && result.error.kind === "transport"; } export async function captureSandboxListWithGatewayRecovery( options: CaptureSandboxListWithGatewayRecoveryOptions = {}, ): Promise { + const observer = + options.observer ?? + createCliOpenShellSandboxObserver({ + capture: captureOpenshell, + }); const recoveryOptions: Parameters[0] = { recoverableStates: ["missing_named", "named_unhealthy", "named_unreachable", "connected_other"], }; @@ -60,15 +63,24 @@ export async function captureSandboxListWithGatewayRecovery( targetRecoveryAttempted = targetRecovery.attempted === true; if (!targetRecovery.recovered) { return { - result: { status: 1, output: "" }, + result: { + ok: false, + error: { + kind: "transport", + message: "OpenShell could not reach the selected gateway.", + }, + }, recoveryAttempted: targetRecovery.attempted === true, recoverySucceeded: false, }; } } - const initial = captureOpenshell(["sandbox", "list"]); - if (!isRecoverableSandboxListGatewayFailure(initial, options)) { + const target = options.gatewayName + ? namedOpenShellGateway(options.gatewayName) + : selectedOpenShellGateway(); + const initial = await observer.listSandboxes({ target }); + if (!isRecoverableObservedSandboxListGatewayFailure(initial)) { return { result: initial, recoveryAttempted: targetRecoveryAttempted, @@ -82,7 +94,7 @@ export async function captureSandboxListWithGatewayRecovery( } return { - result: captureOpenshell(["sandbox", "list"]), + result: await observer.listSandboxes({ target }), recoveryAttempted: true, recoverySucceeded: true, }; @@ -91,24 +103,29 @@ export async function captureSandboxListWithGatewayRecovery( export async function captureSandboxListWithGatewayPreflightOrExit( context: SandboxListPreflightContext, options: CaptureSandboxListWithGatewayRecoveryOptions = {}, -): Promise { - const preflightIssue = detectOpenShellStateRpcPreflightIssue(options); +): Promise { + const preflightOptions = options.gatewayName ? { gatewayName: options.gatewayName } : {}; + const preflightIssue = detectOpenShellStateRpcPreflightIssue(preflightOptions); if (preflightIssue) { printOpenShellStateRpcIssue(preflightIssue, context); process.exit(1); } const recovery = await captureSandboxListWithGatewayRecovery(options); - const resultIssue = detectOpenShellStateRpcResultIssue(recovery.result, options); - if (resultIssue) { - printOpenShellStateRpcIssue(resultIssue, context); + if (!recovery.result.ok && recovery.result.error.kind === "schema") { + printOpenShellStateRpcIssue({ kind: "protobuf_mismatch", drift: null, output: "" }, context); process.exit(1); } - if (recovery.result.status !== 0) { + if (!recovery.result.ok) { printSandboxListFailureWithRecoveryContext(recovery); - process.exit(recovery.result.status || 1); + process.exit( + recovery.result.error.kind === "command" && + recovery.result.error.reason === "invalid_request" + ? 2 + : 1, + ); } - return recovery.result; + return recovery.result.value; } /** @@ -120,10 +137,13 @@ export async function captureSandboxListWithGatewayPreflightOrExit( * but a down or unreachable gateway is non-fatal — its empty output makes the * sandbox report as unobserved instead of triggering a gateway start. */ -export function captureNamedGatewaySandboxListReadOnly( +export async function captureNamedGatewaySandboxListReadOnly( context: SandboxListPreflightContext, gatewayName: string, -): SandboxListResult { + observer: OpenShellSandboxObserver = createCliOpenShellSandboxObserver({ + capture: captureOpenshell, + }), +): Promise { const options: CaptureSandboxListWithGatewayRecoveryOptions = { gatewayName }; const preflightIssue = detectOpenShellStateRpcPreflightIssue(options); if (preflightIssue) { @@ -131,13 +151,12 @@ export function captureNamedGatewaySandboxListReadOnly( process.exit(1); } - const result = captureOpenshell(["sandbox", "list", "-g", gatewayName], { ignoreError: true }); - const resultIssue = detectOpenShellStateRpcResultIssue(result, options); - if (resultIssue) { - printOpenShellStateRpcIssue(resultIssue, context); + const result = await observer.listSandboxes({ target: namedOpenShellGateway(gatewayName) }); + if (!result.ok && result.error.kind === "schema") { + printOpenShellStateRpcIssue({ kind: "protobuf_mismatch", drift: null, output: "" }, context); process.exit(1); } - return result; + return result.ok ? result.value : { sandboxes: [] }; } export function printSandboxListFailureWithRecoveryContext( diff --git a/src/lib/registry-recovery-action.test.ts b/src/lib/registry-recovery-action.test.ts index 6b0f344f9a7..85e3b7ea417 100644 --- a/src/lib/registry-recovery-action.test.ts +++ b/src/lib/registry-recovery-action.test.ts @@ -50,10 +50,6 @@ vi.mock("./state/onboard-session.js", () => ({ loadSession: vi.fn(), })); -vi.mock("./runtime-recovery.js", () => ({ - parseLiveSandboxEntries: vi.fn(() => [] as Array<{ name: string; phase: string | null }>), -})); - vi.mock("./runner.js", () => ({ validateName: (name: string) => { if (!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(name)) { @@ -70,7 +66,6 @@ import { recoverNamedGatewayRuntime, } from "./gateway-runtime-action.js"; import { recoverRegistryEntries } from "./registry-recovery-action.js"; -import { parseLiveSandboxEntries } from "./runtime-recovery.js"; import { loadSession } from "./state/onboard-session.js"; function resetRegistryRecoveryDependencyMocks(): void { @@ -84,8 +79,7 @@ function resetRegistryRecoveryDependencyMocks(): void { .mockReturnValue({ state: "missing_named" } as never); vi.mocked(captureOpenshell) .mockReset() - .mockReturnValue({ output: "", status: 0 } as never); - vi.mocked(parseLiveSandboxEntries).mockReset().mockReturnValue([]); + .mockReturnValue({ output: "No sandboxes found.", status: 0 } as never); } describe("recoverRegistryEntries seed-time guard (#2753)", () => { @@ -346,7 +340,10 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", // is not an authoritative agent source; the real agent is reconciled by a // follow-up `nemoclaw status`. vi.mocked(getNamedGatewayLifecycleState).mockReturnValue({ state: "healthy_named" } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "dcode-station", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "dcode-station Ready", + status: 0, + } as never); const result = await recoverRegistryEntries(); @@ -395,7 +392,7 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", }, } as never); vi.mocked(getNamedGatewayLifecycleState).mockReturnValue({ state: "healthy_named" } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "live-x", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ output: "live-x Ready", status: 0 } as never); const result = await recoverRegistryEntries(); @@ -413,7 +410,10 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", // and permanently misclassify a Deep Agents/Hermes sandbox. Recovery is // display-only: the on-disk registry must stay empty. vi.mocked(getNamedGatewayLifecycleState).mockReturnValue({ state: "healthy_named" } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "dcode-station", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "dcode-station Ready", + status: 0, + } as never); await recoverRegistryEntries(); @@ -430,7 +430,10 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", state: "connected_other", activeGateway: "nemoclaw-8092", } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "dcode-station", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "dcode-station Ready", + status: 0, + } as never); const result = await recoverRegistryEntries(); @@ -446,7 +449,6 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", output: "transport error: connection reset", status: 1, } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "transport", phase: null }]); const result = await recoverRegistryEntries(); @@ -462,7 +464,10 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", state: "connected_other", activeGateway: "some-other-project", } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "foreign-sbox", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "foreign-sbox Ready", + status: 0, + } as never); const result = await recoverRegistryEntries(); @@ -475,7 +480,10 @@ describe("recoverRegistryEntries empty-registry live gateway recovery (#5714)", // effect of listing: it inspects the lifecycle directly and never calls // the mutating recoverNamedGatewayRuntime path. vi.mocked(getNamedGatewayLifecycleState).mockReturnValue({ state: "healthy_named" } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "dcode-station", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "dcode-station Ready", + status: 0, + } as never); await recoverRegistryEntries(); diff --git a/src/lib/registry-recovery-action.ts b/src/lib/registry-recovery-action.ts index 513d531c8c0..1fce01977b2 100644 --- a/src/lib/registry-recovery-action.ts +++ b/src/lib/registry-recovery-action.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { resolveOpenshell } from "./adapters/openshell/resolve"; +import { + createCliOpenShellSandboxObserver, + namedOpenShellGateway, +} from "./adapters/openshell/sandbox-observer-cli"; import { captureOpenshell } from "./adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; import { GATEWAY_PORT } from "./core/ports"; @@ -16,7 +20,6 @@ import { import { withGatewayRouteMutationLock } from "./inference/gateway-route-mutation-lock"; import { resolveGatewayName, resolveSandboxGatewayName } from "./onboard/gateway-binding"; import { validateName } from "./runner"; -import { parseLiveSandboxEntries } from "./runtime-recovery"; import * as onboardSession from "./state/onboard-session"; import type { SandboxEntry } from "./state/registry"; import * as registry from "./state/registry"; @@ -333,18 +336,20 @@ async function recoverRegistryFromLiveGateway( // Provisioning or absent from the live gateway (#7105). `-g` targets the // named gateway without selecting it, matching the readiness poll in // `connect` and `captureNamedGatewaySandboxListReadOnly`. - const liveList = captureOpenshell(["sandbox", "list", "-g", gatewayName], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, + const liveList = await createCliOpenShellSandboxObserver({ + capture: captureOpenshell, + defaultTimeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, + }).listSandboxes({ + target: namedOpenShellGateway(gatewayName), + timeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, }); // Only trust the output of a clean `sandbox list`. On a non-zero/failed probe - // (timeout, transport error) OpenShell may print free-form text whose first - // token parseLiveSandboxEntries would otherwise mistake for a sandbox name. - if (liveList.status !== 0) { + // (timeout, transport error) the typed observer returns an error instead of + // treating command diagnostics as sandbox rows. + if (!liveList.ok) { return { recoveredFromGateway: 0, ephemeralSandboxes: [] }; } - const liveEntries = parseLiveSandboxEntries(liveList.output); - for (const { name, phase } of liveEntries) { + for (const { name, phase } of liveList.value.sandboxes) { const metadata = metadataByName.get(name) || undefined; if (readOnly) { // Unseeded recovery: surface the live sandbox for THIS `list` only and do diff --git a/src/lib/registry-recovery-seeded-paths.test.ts b/src/lib/registry-recovery-seeded-paths.test.ts index adc3cc4167d..c16ee2562dc 100644 --- a/src/lib/registry-recovery-seeded-paths.test.ts +++ b/src/lib/registry-recovery-seeded-paths.test.ts @@ -51,10 +51,6 @@ vi.mock("./state/onboard-session.js", () => ({ loadSession: vi.fn(), })); -vi.mock("./runtime-recovery.js", () => ({ - parseLiveSandboxEntries: vi.fn(), -})); - vi.mock("./runner.js", async () => { const actual = await vi.importActual("./runner.js"); return { ROOT: actual.ROOT, validateName: actual.validateName }; @@ -67,7 +63,6 @@ import { recoverNamedGatewayRuntime, } from "./gateway-runtime-action.js"; import { recoverRegistryEntries } from "./registry-recovery-action.js"; -import { parseLiveSandboxEntries } from "./runtime-recovery.js"; import { loadSession } from "./state/onboard-session.js"; const gammaEntry = (policies: string[]): SandboxEntry => ({ @@ -103,8 +98,7 @@ function resetSeededRecoveryMocks(): void { .mockReturnValue({ state: "missing_named" } as never); vi.mocked(captureOpenshell) .mockReset() - .mockReturnValue({ output: "live sandboxes", status: 0 } as never); - vi.mocked(parseLiveSandboxEntries).mockReset().mockReturnValue([]); + .mockReturnValue({ output: "No sandboxes found.", status: 0 } as never); } describe("recoverRegistryEntries seeded recovery paths", () => { @@ -114,10 +108,10 @@ describe("recoverRegistryEntries seeded recovery paths", () => { mockRegistryState.sandboxes.gamma = gammaEntry(["npm"]); mockRegistryState.defaultSandbox = "gamma"; vi.mocked(loadSession).mockReturnValue(completedSession("alpha", ["pypi"])); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([ - { name: "alpha", phase: "Ready" }, - { name: "beta", phase: "Ready" }, - ]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "alpha Ready\nbeta Ready", + status: 0, + } as never); const result = await recoverRegistryEntries(); @@ -141,7 +135,7 @@ describe("recoverRegistryEntries seeded recovery paths", () => { }; mockRegistryState.defaultSandbox = "gamma"; vi.mocked(loadSession).mockReturnValue(completedSession("alpha", [])); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "alpha", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ output: "alpha Ready", status: 0 } as never); const result = await recoverRegistryEntries(); @@ -174,7 +168,7 @@ describe("recoverRegistryEntries seeded recovery paths", () => { sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, }, } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "alpha", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ output: "alpha Ready", status: 0 } as never); await recoverRegistryEntries({ requestedSandboxName: "missing-sandbox" }); @@ -191,10 +185,10 @@ describe("recoverRegistryEntries seeded recovery paths", () => { mockRegistryState.sandboxes.gamma = gammaEntry([]); mockRegistryState.defaultSandbox = "gamma"; vi.mocked(loadSession).mockReturnValue(completedSession("Alpha", [])); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([ - { name: "alpha", phase: "Ready" }, - { name: "Bad_Name", phase: "Ready" }, - ]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "alpha Ready\nBad_Name Ready", + status: 0, + } as never); const result = await recoverRegistryEntries(); @@ -220,7 +214,10 @@ describe("recoverRegistryEntries seeded recovery paths", () => { }, } as never); vi.mocked(getNamedGatewayLifecycleState).mockReturnValue({ state: "healthy_named" } as never); - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "dcode-station", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "dcode-station Ready", + status: 0, + } as never); const result = await recoverRegistryEntries(); @@ -240,7 +237,7 @@ describe("recoverRegistryEntries seeded recovery paths", () => { }); it("persists a requested live sandbox and makes it the default", async () => { - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "alpha", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ output: "alpha Ready", status: 0 } as never); const result = await recoverRegistryEntries({ requestedSandboxName: "alpha" }); @@ -253,7 +250,7 @@ describe("recoverRegistryEntries seeded recovery paths", () => { }); it("keeps a missing requested sandbox absent while recovering other live entries", async () => { - vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "alpha", phase: "Ready" }]); + vi.mocked(captureOpenshell).mockReturnValue({ output: "alpha Ready", status: 0 } as never); const result = await recoverRegistryEntries({ requestedSandboxName: "beta" }); @@ -267,9 +264,10 @@ describe("recoverRegistryEntries seeded recovery paths", () => { it("blocks route mutation after seeded recovery persists a live row without route metadata (#6315)", async () => { mockRegistryState.sandboxes.gamma = gammaEntry([]); mockRegistryState.defaultSandbox = "gamma"; - vi.mocked(parseLiveSandboxEntries).mockReturnValue([ - { name: "recovered-live", phase: "Ready" }, - ]); + vi.mocked(captureOpenshell).mockReturnValue({ + output: "recovered-live Ready", + status: 0, + } as never); await recoverRegistryEntries({ requestedSandboxName: "missing-sandbox" }); expect(mockRegistryState.sandboxes["recovered-live"]).toMatchObject({ @@ -313,13 +311,10 @@ describe("recoverRegistryEntries seeded recovery paths", () => { vi.mocked(captureOpenshell).mockImplementation( (args: string[]) => ({ - output: args.includes("-g") ? "scoped-list" : "host-wide-list", + output: args.includes("-g") ? "No sandboxes found." : "hermes-station Ready", status: 0, }) as never, ); - vi.mocked(parseLiveSandboxEntries).mockImplementation((output?: string) => - output === "scoped-list" ? [] : [{ name: "hermes-station", phase: "Ready" }], - ); mockRegistryState.sandboxes.gamma = gammaEntry([]); mockRegistryState.defaultSandbox = "gamma"; @@ -341,15 +336,10 @@ describe("recoverRegistryEntries seeded recovery paths", () => { vi.mocked(captureOpenshell).mockImplementation( (args: string[]) => ({ - output: args.includes("-g") ? "scoped-list" : "host-wide-list", + output: args.includes("-g") ? "target-sandbox Ready" : "sibling-sandbox Ready", status: 0, }) as never, ); - vi.mocked(parseLiveSandboxEntries).mockImplementation((output?: string) => - output === "scoped-list" - ? [{ name: "target-sandbox", phase: "Ready" }] - : [{ name: "sibling-sandbox", phase: "Ready" }], - ); const result = await recoverRegistryEntries(); diff --git a/test/helpers/rebuild-flow-dcode-harness.ts b/test/helpers/rebuild-flow-dcode-harness.ts index c1b2f8fab2f..85d050797f1 100644 --- a/test/helpers/rebuild-flow-dcode-harness.ts +++ b/test/helpers/rebuild-flow-dcode-harness.ts @@ -8,6 +8,7 @@ import { agentDefs, agentOnboard, agentRuntime, + createSandboxInventoryFake, createRebuildFlowSession, destroy, dockerImage, @@ -239,7 +240,12 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): resolveGatewayAuthority, ); vi.spyOn(sandboxList, "captureSandboxListWithGatewayRecovery").mockResolvedValue({ - result: { status: 0, output: overrides.sandboxListOutput ?? "alpha Ready" }, + result: { + ok: true, + value: createSandboxInventoryFake(overrides.sandboxListOutput ?? "alpha Ready"), + }, + recoveryAttempted: false, + recoverySucceeded: false, }); vi.spyOn(resolve, "resolveOpenshell").mockReturnValue(null); vi.spyOn(dockerImage, "dockerBuild").mockReturnValue({ status: 0 }); diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index 4dc7812dac9..e9870483077 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -10,6 +10,7 @@ import { agentDefs, agentRuntime, buildContextFingerprint, + createSandboxInventoryFake, createHarnessTempDir, createRebuildFlowSession, destroy, @@ -105,9 +106,13 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): ); vi.spyOn(sandboxList, "captureSandboxListWithGatewayRecovery").mockResolvedValue({ result: { - status: 0, - output: overrides.sandboxListOutput ?? (overrides.staleRecovery ? "" : "alpha Ready"), + ok: true, + value: createSandboxInventoryFake( + overrides.sandboxListOutput ?? (overrides.staleRecovery ? "" : "alpha Ready"), + ), }, + recoveryAttempted: false, + recoverySucceeded: false, }); vi.spyOn(gatewayState, "getReconciledSandboxGatewayState").mockResolvedValue( overrides.reconciledSandboxGatewayState ?? { diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index 071594096bc..b5eb30ccc92 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -84,6 +84,39 @@ export function sourceSandboxGateway(argv: string[], verb: string): string | nul : null; } +export function createSandboxInventoryFake(output: string): { + sandboxes: Array<{ + name: string; + phase: string | null; + readiness: "ready" | "not_ready" | "terminal"; + }>; +} { + const terminalPhases = new Set([ + "CrashLoopBackOff", + "Error", + "Evicted", + "Failed", + "ImagePullBackOff", + "Unknown", + ]); + return { + sandboxes: output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [name = "", phase = null] = line.split(/\s+/u); + const readiness = + phase === "Ready" || phase === "Running" + ? "ready" + : phase && terminalPhases.has(phase) + ? "terminal" + : "not_ready"; + return { name, phase, readiness }; + }), + }; +} + const harnessTempDirs: string[] = []; export function createHarnessTempDir(prefix: string): string { From 233c318e61943a3361e34044751a9f614a5624f1 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:47:36 -0700 Subject: [PATCH 02/57] fix(cli): preserve OpenShell observation parity Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../sandbox/gateway-state-hints.test.ts | 32 +++++++++-------- src/lib/actions/sandbox/gateway-state.ts | 9 +++-- src/lib/actions/sandbox/start-wait.test.ts | 36 ++++++++++--------- .../openshell/sandbox-observer-cli.test.ts | 21 ++++++++++- .../openshell/sandbox-observer-cli.ts | 23 +++++++++--- .../adapters/openshell/sandbox-observer.ts | 9 ++++- src/lib/openshell-sandbox-list.ts | 4 +-- test/cli/connect-readiness.test.ts | 1 + test/cli/doctor-gateway-token.test.ts | 22 ++++++------ 9 files changed, 105 insertions(+), 52 deletions(-) diff --git a/src/lib/actions/sandbox/gateway-state-hints.test.ts b/src/lib/actions/sandbox/gateway-state-hints.test.ts index 3c24ff9f729..401f3a4149e 100644 --- a/src/lib/actions/sandbox/gateway-state-hints.test.ts +++ b/src/lib/actions/sandbox/gateway-state-hints.test.ts @@ -132,7 +132,8 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { .mockResolvedValueOnce({ state: "gateway_error", output: "transport error" }) .mockResolvedValueOnce({ state: "gateway_error", - output: "transport error: handshake verification failed", + output: "The selected gateway identity does not match the recorded identity.", + transportReason: "identity_mismatch", }); const lookup = await gatewayState.getReconciledSandboxGatewayState("instance-a", { getState }); @@ -317,20 +318,19 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { expectedState: "gateway_error", expectedGatewayRecoveryFailed: true, }, - ])("maps failed gateway recovery to $expectedState", async ({ - lifecycle, - expectedState, - expectedGatewayRecoveryFailed, - }) => { - getNamedGatewayLifecycleStateSpy.mockReturnValue(lifecycle); - - const lookup = await gatewayState.getReconciledSandboxGatewayState("instance-a", { - getState: async () => ({ state: "gateway_error", output: "transport error" }), - }); + ])( + "maps failed gateway recovery to $expectedState", + async ({ lifecycle, expectedState, expectedGatewayRecoveryFailed }) => { + getNamedGatewayLifecycleStateSpy.mockReturnValue(lifecycle); - expect(lookup.state).toBe(expectedState); - expect(lookup.gatewayRecoveryFailed).toBe(expectedGatewayRecoveryFailed); - }); + const lookup = await gatewayState.getReconciledSandboxGatewayState("instance-a", { + getState: async () => ({ state: "gateway_error", output: "transport error" }), + }); + + expect(lookup.state).toBe(expectedState); + expect(lookup.gatewayRecoveryFailed).toBe(expectedGatewayRecoveryFailed); + }, + ); it("prints reconnect and recreate guidance when identity drift persists", async () => { captureOpenshellSpy.mockReturnValue({ @@ -405,7 +405,9 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { ).rejects.toThrow("process.exit(1)"); const output = lines.join("\n"); - expect(output).toContain("This sandbox-scoped command will not restart the shared host gateway"); + expect(output).toContain( + "This sandbox-scoped command will not restart the shared host gateway", + ); expect(output).toContain("Start the gateway again with `nemoclaw onboard`."); expect(output).not.toContain("openshell gateway start"); expect(recoverNamedGatewayRuntimeSpy).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 256d2d88828..259132abad4 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -37,6 +37,7 @@ import { stripOpenShellCliAnsi, type CliOpenShellSandboxLookup, type OpenShellSandboxError, + type OpenShellSandboxTransportReason, } from "../../adapters/openshell/sandbox-observer-cli"; import { detectOpenShellStateRpcPreflightIssue, @@ -82,6 +83,7 @@ export type SandboxGatewayState = { activeGateway?: string | null; recoveredGateway?: boolean; recoveryVia?: string | null; + transportReason?: OpenShellSandboxTransportReason; gatewayRecoveryFailed?: boolean; /** * True when active Docker-driver sandbox recovery (#4423 part 2) @@ -258,7 +260,10 @@ function sandboxObservationErrorState( action: string, ): SandboxGatewayState { if (error.kind === "schema") return schemaMismatchState(action); - if (error.kind === "authentication" || error.kind === "transport" || error.kind === "timeout") { + if (error.kind === "transport") { + return { state: "gateway_error", output: error.message, transportReason: error.reason }; + } + if (error.kind === "authentication" || error.kind === "timeout") { return { state: "gateway_error", output: error.message }; } return { state: "unknown_error", output: error.message }; @@ -637,7 +642,7 @@ export async function getReconciledSandboxGatewayState( if (retried.state === "present" || retried.state === "missing") { return { ...retried, recoveredGateway: true, recoveryVia: recovery.via || null }; } - if (/handshake verification failed/i.test(retried.output)) { + if (retried.transportReason === "identity_mismatch") { return { state: "identity_drift", output: retried.output, diff --git a/src/lib/actions/sandbox/start-wait.test.ts b/src/lib/actions/sandbox/start-wait.test.ts index 0342de59582..9a8086dbbe9 100644 --- a/src/lib/actions/sandbox/start-wait.test.ts +++ b/src/lib/actions/sandbox/start-wait.test.ts @@ -95,24 +95,28 @@ describe("sandbox start readiness", () => { { error: { kind: "transport", + reason: "unreachable", message: "OpenShell could not reach the selected gateway.", }, guidance: "gateway is not running or unreachable", }, - ] as const)("prints accurate $error.kind readiness failure guidance (#9803)", async (testCase) => { - const harness = createConnectHarness(); - const observer = { - listSandboxes: vi.fn().mockResolvedValue({ ok: false, error: testCase.error }), - } as never; - - await expect(harness.waitForSandboxReadyOrExit("alpha", { observer })).rejects.toThrow( - 'process.exit unexpectedly called with "1"', - ); - - const output = harness.errorSpy.mock.calls.flat().join("\n"); - expect(output).toContain(testCase.guidance); - expect(output.includes("gateway is not running or unreachable")).toBe( - testCase.error.kind === "transport", - ); - }); + ] as const)( + "prints accurate $error.kind readiness failure guidance (#9803)", + async (testCase) => { + const harness = createConnectHarness(); + const observer = { + listSandboxes: vi.fn().mockResolvedValue({ ok: false, error: testCase.error }), + } as never; + + await expect(harness.waitForSandboxReadyOrExit("alpha", { observer })).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ); + + const output = harness.errorSpy.mock.calls.flat().join("\n"); + expect(output).toContain(testCase.guidance); + expect(output.includes("gateway is not running or unreachable")).toBe( + testCase.error.kind === "transport", + ); + }, + ); }); diff --git a/src/lib/adapters/openshell/sandbox-observer-cli.test.ts b/src/lib/adapters/openshell/sandbox-observer-cli.test.ts index 03adda617d2..931cfbb98ae 100644 --- a/src/lib/adapters/openshell/sandbox-observer-cli.test.ts +++ b/src/lib/adapters/openshell/sandbox-observer-cli.test.ts @@ -77,12 +77,14 @@ describe("CLI OpenShell sandbox observer", () => { "\u001b[1mNAME\u001b[0m CREATED PHASE\n" + "\u001b[1malpha\u001b[0m 2m \u001b[32mReady\u001b[0m\n" + "beta Ready NotReady 1m ago\n" + + "gamma unknown 1m ago\n" + "No sandboxes found.", ), ).toEqual({ sandboxes: [ { name: "alpha", phase: "Ready", readiness: "ready" }, { name: "beta", phase: "NotReady", readiness: "not_ready" }, + { name: "gamma", phase: "Unknown", readiness: "terminal" }, ], }); }); @@ -125,6 +127,22 @@ describe("CLI OpenShell sandbox observer", () => { }); }); + it("canonicalizes lookup phase tokens case-insensitively (#9803)", async () => { + const observer = createCliOpenShellSandboxObserver({ + capture: () => captured(0, "Name: alpha\nPhase: ready\n"), + }); + + await expect( + observer.lookupSandbox({ sandboxName: "alpha", target: selectedOpenShellGateway() }), + ).resolves.toEqual({ + ok: true, + value: { + state: "present", + sandbox: { name: "alpha", phase: "Ready", readiness: "ready" }, + }, + }); + }); + it("keeps formatted get output on an explicit CLI-only compatibility path (#9803)", async () => { const lookup = createCliOpenShellSandboxLookup({ capture: () => captured(0, "\u001b[1mName:\u001b[0m alpha\nPhase: Running\n"), @@ -168,7 +186,8 @@ describe("CLI OpenShell sandbox observer", () => { }); it.each([ - ["transport", undefined, captured(1, "", "client error (Connect): Connection refused")], + ["transport", "unreachable", captured(1, "", "client error (Connect): Connection refused")], + ["transport", "identity_mismatch", captured(1, "", "handshake verification failed")], ["schema", undefined, captured(1, "", "protobuf decode error: invalid wire type")], ["command", "failed", captured(7, "", "unexpected opaque failure")], ["command", "invalid_request", captured(2, "", "unknown option")], diff --git a/src/lib/adapters/openshell/sandbox-observer-cli.ts b/src/lib/adapters/openshell/sandbox-observer-cli.ts index a6643ad6a89..9302d323dab 100644 --- a/src/lib/adapters/openshell/sandbox-observer-cli.ts +++ b/src/lib/adapters/openshell/sandbox-observer-cli.ts @@ -28,6 +28,7 @@ export type { OpenShellSandboxReadiness, OpenShellSandboxReadinessWait, OpenShellSandboxResult, + OpenShellSandboxTransportReason, } from "./sandbox-observer"; const ANSI_RE = /\x1b\[[0-9;]*m/gu; @@ -52,6 +53,9 @@ const KNOWN_PHASES = new Set([ "Provisioning", "Terminating", ]); +const CANONICAL_PHASES = new Map( + [...KNOWN_PHASES].map((phase) => [phase.toLowerCase(), phase] as const), +); function isOpenShellSandboxSchemaMismatch(output: string): boolean { return ( @@ -125,10 +129,12 @@ export function parseCliOpenShellSandboxInventory(output: string): OpenShellSand const columns = line.split(/\s+/u); const name = columns[0]; if (!name || isNonSandboxRow(line, name)) continue; - const phaseColumns = columns.slice(1); + const phaseColumns = columns + .slice(1) + .map((column) => CANONICAL_PHASES.get(column.toLowerCase()) ?? null); const phase = phaseColumns.includes("NotReady") ? "NotReady" - : (phaseColumns.find((column) => KNOWN_PHASES.has(column)) ?? null); + : (phaseColumns.find((column) => column !== null) ?? null); sandboxes.push(observation(name, phase)); } return { sandboxes }; @@ -136,7 +142,8 @@ export function parseCliOpenShellSandboxInventory(output: string): OpenShellSand function parseCliOpenShellSandboxPhase(output: string): string | null { const match = stripOpenShellCliAnsi(output).match(/^\s*Phase:\s+(\S+)/mu); - return match?.[1] ?? null; + const phase = match?.[1] ?? null; + return phase ? (CANONICAL_PHASES.get(phase.toLowerCase()) ?? phase) : null; } function targetArgs( @@ -180,13 +187,21 @@ function commandError(result: CapturedSandboxCommandResult): OpenShellSandboxErr message: "OpenShell could not authenticate the sandbox observation.", }; } + if (/\bhandshake verification failed\b/iu.test(output)) { + return { + kind: "transport", + reason: "identity_mismatch", + message: "The selected OpenShell gateway identity does not match the recorded identity.", + }; + } if ( - /\b(?:connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured|handshake verification failed)\b/iu.test( + /\b(?:connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured)\b/iu.test( output, ) ) { return { kind: "transport", + reason: "unreachable", message: "OpenShell could not reach the selected gateway.", }; } diff --git a/src/lib/adapters/openshell/sandbox-observer.ts b/src/lib/adapters/openshell/sandbox-observer.ts index 391c382ac0e..2e3735699c0 100644 --- a/src/lib/adapters/openshell/sandbox-observer.ts +++ b/src/lib/adapters/openshell/sandbox-observer.ts @@ -26,9 +26,16 @@ export type OpenShellSandboxErrorKind = | "timeout" | "transport"; +export type OpenShellSandboxTransportReason = "identity_mismatch" | "unreachable"; + export type OpenShellSandboxError = | Readonly<{ - kind: Exclude; + kind: Exclude; + message: string; + }> + | Readonly<{ + kind: "transport"; + reason: OpenShellSandboxTransportReason; message: string; }> | Readonly<{ diff --git a/src/lib/openshell-sandbox-list.ts b/src/lib/openshell-sandbox-list.ts index 7b42844de56..045e788058c 100644 --- a/src/lib/openshell-sandbox-list.ts +++ b/src/lib/openshell-sandbox-list.ts @@ -67,6 +67,7 @@ export async function captureSandboxListWithGatewayRecovery( ok: false, error: { kind: "transport", + reason: "unreachable", message: "OpenShell could not reach the selected gateway.", }, }, @@ -119,8 +120,7 @@ export async function captureSandboxListWithGatewayPreflightOrExit( if (!recovery.result.ok) { printSandboxListFailureWithRecoveryContext(recovery); process.exit( - recovery.result.error.kind === "command" && - recovery.result.error.reason === "invalid_request" + recovery.result.error.kind === "command" && recovery.result.error.reason === "invalid_request" ? 2 : 1, ); diff --git a/test/cli/connect-readiness.test.ts b/test/cli/connect-readiness.test.ts index e977a475c34..487becb062a 100644 --- a/test/cli/connect-readiness.test.ts +++ b/test/cli/connect-readiness.test.ts @@ -187,6 +187,7 @@ describe("CLI connect readiness", () => { expect(r.out).not.toContain("Timed out after 1s"); const calls = fs.readFileSync(markerFile, "utf8").trim().split("\n").filter(Boolean); expect(calls).toContain("status"); + expect(calls).toContain("sandbox list -g nemoclaw"); expect(calls).not.toContain("should-not-connect"); }, testTimeout(15_000), diff --git a/test/cli/doctor-gateway-token.test.ts b/test/cli/doctor-gateway-token.test.ts index 40549669a77..de89e5174b0 100644 --- a/test/cli/doctor-gateway-token.test.ts +++ b/test/cli/doctor-gateway-token.test.ts @@ -112,7 +112,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Creating\\n"; exit 0 ;;', + ' "sandbox list -g nemoclaw") printf "NAME STATUS\\nalpha Creating\\n"; exit 0 ;;', ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ]); @@ -141,7 +141,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "sandbox list -g nemoclaw") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ]); @@ -205,7 +205,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw-8090\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw-8090") printf "Gateway: nemoclaw-8090\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "sandbox list -g nemoclaw-8090") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ]); @@ -242,7 +242,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "sandbox list -g nemoclaw") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ]); @@ -272,7 +272,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "sandbox list -g nemoclaw") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ]); @@ -325,7 +325,7 @@ describe("CLI dispatch", () => { ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', ' "gateway select nemoclaw") exit 1 ;;', ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', - ' "sandbox list") echo "should not query sandbox list" >> "$marker_file"; exit 0 ;;', + ' "sandbox list -g nemoclaw") echo "should not query sandbox list" >> "$marker_file"; exit 0 ;;', "esac", ], ); @@ -365,7 +365,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "sandbox list -g nemoclaw") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ], @@ -407,7 +407,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "sandbox list -g nemoclaw") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ]); @@ -438,7 +438,7 @@ describe("CLI dispatch", () => { ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', ' "gateway select nemoclaw") exit 1 ;;', ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', - ' "sandbox list") echo "queried wrong gateway sandbox list" >> "$marker_file"; exit 0 ;;', + ' "sandbox list -g nemoclaw") echo "queried wrong gateway sandbox list" >> "$marker_file"; exit 0 ;;', "esac", ]); @@ -464,7 +464,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` "sandbox list") printf "NAME STATUS\\n${sandboxName} Ready\\n"; exit 0 ;;`, + ` "sandbox list -g nemoclaw") printf "NAME STATUS\\n${sandboxName} Ready\\n"; exit 0 ;;`, ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ], @@ -506,7 +506,7 @@ describe("CLI dispatch", () => { 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` "sandbox list") printf "NAME STATUS\\n${sandboxName} Ready\\n"; exit 0 ;;`, + ` "sandbox list -g nemoclaw") printf "NAME STATUS\\n${sandboxName} Ready\\n"; exit 0 ;;`, ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', "esac", ], From cd705b158a2d40aeb84145eaf725ae0a24a4538c Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:34:58 -0700 Subject: [PATCH 03/57] refactor(cli): tighten OpenShell observation boundary Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ci/source-architecture-budget.json | 7 +- src/lib/actions/sandbox/connect.ts | 9 +- src/lib/actions/sandbox/doctor-flow.test.ts | 41 +++++ src/lib/actions/sandbox/doctor.ts | 44 +++++- .../sandbox/gateway-state-hints.test.ts | 47 ++++++ src/lib/actions/sandbox/gateway-state.ts | 66 +++++++- .../rebuild-dcode-recovered-provider.test.ts | 4 +- .../sandbox/rebuild-dcode-recovery.test.ts | 6 +- .../rebuild-flow-credential-preflight.test.ts | 4 +- .../actions/sandbox/rebuild-flow-helpers.ts | 4 +- .../sandbox/rebuild-flow-lifecycle.test.ts | 2 +- .../sandbox/rebuild-prepared-recovery.test.ts | 16 +- src/lib/actions/sandbox/start.test.ts | 2 - src/lib/actions/sandbox/start.ts | 2 +- .../sandbox/status-lookup-rendering.ts | 4 +- .../openshell/sandbox-observer-cli.test.ts | 142 ++++-------------- .../openshell/sandbox-observer-cli.ts | 103 ++----------- .../adapters/openshell/sandbox-observer.ts | 33 ---- src/lib/openshell-sandbox-list.test.ts | 6 +- src/lib/openshell-sandbox-list.ts | 4 +- src/lib/registry-recovery-action.ts | 6 +- src/lib/runtime-recovery.ts | 106 +++---------- test/helpers/rebuild-flow-dcode-harness.ts | 8 +- test/helpers/rebuild-flow-generic-harness.ts | 11 +- test/helpers/rebuild-flow-harness.ts | 33 ---- test/helpers/rebuild-flow-test-support.ts | 3 +- test/rebuild-stale-recovery.test.ts | 4 +- 27 files changed, 301 insertions(+), 416 deletions(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 0a0240c09b7..a21c47c8fa8 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -9,7 +9,7 @@ "src/lib/adapters/openshell/client.ts": 20, "src/lib/adapters/openshell/resolve.ts": 27, "src/lib/adapters/openshell/runtime.ts": 54, - "src/lib/adapters/openshell/timeouts.ts": 38, + "src/lib/adapters/openshell/timeouts.ts": 39, "src/lib/agent/defs.ts": 33, "src/lib/cli/branding.ts": 87, "src/lib/cli/nemoclaw-oclif-command.ts": 106, @@ -37,9 +37,10 @@ "defaultMax": 20, "maxByFile": { "src/lib/actions/inference-set.ts": 32, - "src/lib/actions/sandbox/connect.ts": 42, + "src/lib/actions/sandbox/connect.ts": 43, "src/lib/actions/sandbox/destroy.ts": 29, - "src/lib/actions/sandbox/doctor.ts": 28, + "src/lib/actions/sandbox/doctor.ts": 29, + "src/lib/actions/sandbox/gateway-state.ts": 21, "src/lib/actions/sandbox/status-snapshot.ts": 19, "src/lib/actions/sandbox/policy-channel.ts": 30, "src/lib/actions/sandbox/process-recovery.ts": 21, diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index cb173a760d7..aefd5f0b66e 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { createCliOpenShellSandboxObserver } from "../../adapters/openshell/sandbox-observer-cli"; import { - createCliOpenShellSandboxObserver, namedOpenShellGateway, type OpenShellSandboxError, type OpenShellSandboxObservation, type OpenShellSandboxObserver, -} from "../../adapters/openshell/sandbox-observer-cli"; +} from "../../adapters/openshell/sandbox-observer"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { captureOpenshell, @@ -567,10 +567,7 @@ function failConnectReadinessGatewayUnavailable(sandboxName: string, detailOutpu process.exit(1); } -function failConnectReadinessObservation( - sandboxName: string, - error: OpenShellSandboxError, -): never { +function failConnectReadinessObservation(sandboxName: string, error: OpenShellSandboxError): never { if (error.kind === "transport") { failConnectReadinessGatewayUnavailable(sandboxName, error.message); } diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index c7f1147a987..37ef80d91c6 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -503,6 +503,47 @@ describe("runSandboxDoctor flow", () => { }, ); + it.each([ + { + label: "authentication", + commandOutput: "Error: authentication failed: bearer credential-value", + expectedDetail: "OpenShell could not authenticate the sandbox observation.", + expectedHint: "restore OpenShell authentication for gateway 'nemoclaw-19080'", + }, + { + label: "transport", + commandOutput: "Status: Disconnected", + expectedDetail: "OpenShell could not reach the selected gateway.", + expectedHint: "run `openshell status`, restore gateway 'nemoclaw-19080'", + }, + ])( + "reports a failed $label observation without classifying the sandbox as absent (#9803)", + async ({ commandOutput, expectedDetail, expectedHint }) => { + const harness = createDoctorHarness(); + harness.captureOpenShellSpy.mockImplementation((args: unknown) => { + const argv = Array.isArray(args) ? args : []; + return argv[0] === "sandbox" && argv[1] === "list" + ? { status: 1, output: commandOutput } + : { status: 0, output: "" }; + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const liveSandbox = report?.checks.find( + (check) => check.group === "Sandbox" && check.label === "Live sandbox", + ); + + expect(liveSandbox).toMatchObject({ + status: "fail", + detail: expect.stringContaining(expectedDetail), + hint: expect.stringContaining(expectedHint), + }); + const rendered = `${liveSandbox?.detail ?? ""}\n${liveSandbox?.hint ?? ""}`; + expect(rendered).not.toContain("not present"); + expect(rendered).not.toContain("recreate"); + expect(rendered).not.toContain("credential-value"); + }, + ); + it("fails the JSON host check for an unknown durable runtime provider", async () => { const harness = createDoctorHarness(); harness.getSandboxSpy.mockReturnValue({ diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index ed50ad543ee..68d61a1c441 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -5,9 +5,12 @@ import fs from "node:fs"; import path from "node:path"; import { createCliOpenShellSandboxObserver, - namedOpenShellGateway, stripOpenShellCliAnsi, } from "../../adapters/openshell/sandbox-observer-cli"; +import { + namedOpenShellGateway, + type OpenShellSandboxError, +} from "../../adapters/openshell/sandbox-observer"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { captureOpenshell } from "../../adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; @@ -282,6 +285,27 @@ function liveSandboxHint( return `run \`${CLI_NAME} ${sandboxName} status\` or \`${CLI_NAME} ${sandboxName} logs --follow\``; } +function liveSandboxObservationFailureHint( + sandboxName: string, + gatewayName: string, + error: OpenShellSandboxError, +): string { + switch (error.kind) { + case "authentication": + return `restore OpenShell authentication for gateway '${gatewayName}', then retry`; + case "transport": + return error.reason === "identity_mismatch" + ? `run \`${CLI_NAME} ${sandboxName} status\` to inspect the recorded gateway identity, then retry` + : `run \`openshell status\`, restore gateway '${gatewayName}', then retry`; + case "schema": + return "use matching supported OpenShell CLI and gateway versions, then retry"; + case "timeout": + return `check that gateway '${gatewayName}' responds, then retry`; + case "command": + return `run \`openshell sandbox list -g ${gatewayName}\` and correct the reported command failure`; + } +} + async function liveSandboxCheck(sandboxName: string, gatewayName: string): Promise { const list = await createCliOpenShellSandboxObserver({ capture: captureOpenshell, @@ -290,9 +314,21 @@ async function liveSandboxCheck(sandboxName: string, gatewayName: string): Promi target: namedOpenShellGateway(gatewayName), timeoutMs: OPENSHELL_PROBE_TIMEOUT_MS, }); - const observed = list.ok - ? (list.value.sandboxes.find((sandbox) => sandbox.name === sandboxName) ?? null) - : null; + if (!list.ok) { + return { + reachable: false, + checks: [ + { + group: "Sandbox", + label: "Live sandbox", + status: "fail", + detail: `OpenShell sandbox observation failed: ${oneLine(list.error.message)}`, + hint: liveSandboxObservationFailureHint(sandboxName, gatewayName, list.error), + }, + ], + }; + } + const observed = list.value.sandboxes.find((sandbox) => sandbox.name === sandboxName) ?? null; const present = observed !== null; const ready = observed ? observed.readiness === "ready" : null; const reachable = present && ready === true; diff --git a/src/lib/actions/sandbox/gateway-state-hints.test.ts b/src/lib/actions/sandbox/gateway-state-hints.test.ts index 401f3a4149e..d755cc09f30 100644 --- a/src/lib/actions/sandbox/gateway-state-hints.test.ts +++ b/src/lib/actions/sandbox/gateway-state-hints.test.ts @@ -125,6 +125,53 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { expect(lines.join("\n")).toContain(expected); }); + it.each([ + { + label: "unreachable transport", + result: { status: 1, output: "Connection refused credential-value" }, + expected: "gateway 'nemoclaw' is not reachable", + }, + { + label: "gateway identity", + result: { status: 1, output: "handshake verification failed credential-value" }, + expected: "gateway identity drift after restart", + }, + { + label: "authentication", + result: { status: 1, output: "authentication failed credential-value" }, + expected: "restore its authentication before retrying", + }, + { + label: "timeout", + result: { + status: null, + output: "credential-value", + error: Object.assign(new Error("credential-value"), { code: "ETIMEDOUT" }), + }, + expected: "did not answer before the sandbox observation timeout", + }, + ])( + "prints typed $label guidance without raw diagnostics (#9803)", + async ({ result, expected }) => { + captureOpenshellSpy.mockReturnValue(result); + const lines: string[] = []; + vi.spyOn(console, "error").mockImplementation((line = "") => { + lines.push(String(line)); + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as never); + + await expect( + gatewayState.ensureLiveSandboxOrExit("instance-a", { gatewayRecovery: "observe" }), + ).rejects.toThrow("process.exit(1)"); + + expect(lines.join("\n")).toContain(expected); + expect(lines.join("\n")).not.toContain("credential-value"); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + ); + it("classifies a failed post-recovery handshake as identity drift", async () => { recoverNamedGatewayRuntimeSpy.mockResolvedValue({ recovered: true, via: "start" }); const getState = vi diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 259132abad4..8fd785772a6 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -32,13 +32,16 @@ const { pruneKnownHostsEntries } = require("../../onboard/known-hosts") as { import { dockerStart } from "../../adapters/docker/container"; import { createCliOpenShellSandboxLookup, - namedOpenShellGateway, - selectedOpenShellGateway, stripOpenShellCliAnsi, type CliOpenShellSandboxLookup, +} from "../../adapters/openshell/sandbox-observer-cli"; +import { + namedOpenShellGateway, + selectedOpenShellGateway, type OpenShellSandboxError, + type OpenShellSandboxErrorKind, type OpenShellSandboxTransportReason, -} from "../../adapters/openshell/sandbox-observer-cli"; +} from "../../adapters/openshell/sandbox-observer"; import { detectOpenShellStateRpcPreflightIssue, formatOpenShellStateRpcIssue, @@ -83,6 +86,7 @@ export type SandboxGatewayState = { activeGateway?: string | null; recoveredGateway?: boolean; recoveryVia?: string | null; + observationErrorKind?: OpenShellSandboxErrorKind; transportReason?: OpenShellSandboxTransportReason; gatewayRecoveryFailed?: boolean; /** @@ -261,12 +265,17 @@ function sandboxObservationErrorState( ): SandboxGatewayState { if (error.kind === "schema") return schemaMismatchState(action); if (error.kind === "transport") { - return { state: "gateway_error", output: error.message, transportReason: error.reason }; + return { + state: "gateway_error", + output: error.message, + observationErrorKind: error.kind, + transportReason: error.reason, + }; } if (error.kind === "authentication" || error.kind === "timeout") { - return { state: "gateway_error", output: error.message }; + return { state: "gateway_error", output: error.message, observationErrorKind: error.kind }; } - return { state: "unknown_error", output: error.message }; + return { state: "unknown_error", output: error.message, observationErrorKind: error.kind }; } export async function getSandboxGatewayState( @@ -587,6 +596,47 @@ export function printGatewayLifecycleHint( } } +/** Print recovery guidance from a typed observation error or legacy CLI output. */ +export function printSandboxGatewayStateHint( + lookup: Pick, + sandboxName: string, + writer: (message: string) => void = console.error, +): void { + const targetGatewayName = getSandboxTargetGatewayName(sandboxName); + switch (lookup.observationErrorKind) { + case "transport": + if (lookup.transportReason === "identity_mismatch") { + writer(" This looks like gateway identity drift after restart."); + writer( + " Existing sandboxes may still be recorded locally, but the current gateway no longer trusts their prior connection state.", + ); + writer( + ` Re-establish the ${CLI_DISPLAY_NAME} gateway runtime first. If the sandbox stays unreachable, recreate only that sandbox with \`${CLI_NAME} onboard\`.`, + ); + return; + } + writer( + ` The sandbox '${sandboxName}' may still exist, but gateway '${targetGatewayName}' is not reachable.`, + ); + writer(" Check `openshell status`, verify the active gateway, and retry."); + return; + case "authentication": + writer(" OpenShell could not authenticate the sandbox observation."); + writer(" Verify the active gateway and restore its authentication before retrying."); + return; + case "timeout": + writer(" The OpenShell gateway did not answer before the sandbox observation timeout."); + writer(" Check `openshell status` and retry after the gateway responds."); + return; + case "command": + writer(" The OpenShell sandbox observation command failed."); + writer(" Run `openshell status`, inspect the gateway, and retry."); + return; + default: + printGatewayLifecycleHint(lookup.output, sandboxName, writer); + } +} + export type GatewayRecoveryMode = "observe" | "recover"; export async function getReconciledSandboxGatewayState( @@ -876,7 +926,7 @@ export async function ensureLiveSandboxOrExit( if (lookup.output) { console.error(lookup.output); } - printGatewayLifecycleHint(lookup.output, sandboxName); + printSandboxGatewayStateHint(lookup, sandboxName); console.error( ` This sandbox-scoped command will not restart the shared host gateway. ${gatewayStartGuidance(getSandboxTargetGatewayName(sandboxName))} Then retry this command.`, ); @@ -899,7 +949,7 @@ export async function ensureLiveSandboxOrExit( if (lookup.output) { console.error(lookup.output); } - printGatewayLifecycleHint(lookup.output, sandboxName); + printSandboxGatewayStateHint(lookup, sandboxName); console.error(" Check `openshell status` and the active gateway, then retry."); return exit(1); } diff --git a/src/lib/actions/sandbox/rebuild-dcode-recovered-provider.test.ts b/src/lib/actions/sandbox/rebuild-dcode-recovered-provider.test.ts index 72cba356e9b..dd1e33371dd 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-recovered-provider.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-recovered-provider.test.ts @@ -64,7 +64,9 @@ describe("rebuildSandbox DCode recovered provider", () => { const harness = createRebuildFlowHarness({ agentName: "langchain-deepagents-code", sandboxEntry: makeDcodeSandboxEntry(), - sandboxListOutput: "alpha Error", + sandboxInventory: { + sandboxes: [{ name: "alpha", phase: "Error", readiness: "terminal" }], + }, preDeleteLatestManifest: recoveryManifest, }); configureDcodeSession(harness); diff --git a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts index a6a8312fd40..ff8dc935287 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts @@ -25,7 +25,9 @@ describe("rebuildSandbox DCode flow: recovery", () => { const harness = createRebuildFlowHarness({ agentName: "langchain-deepagents-code", sandboxEntry: makeDcodeSandboxEntry(), - sandboxListOutput: "alpha Error", + sandboxInventory: { + sandboxes: [{ name: "alpha", phase: "Error", readiness: "terminal" }], + }, preDeleteLatestManifest: recoveryManifest, }); configureDcodeSession(harness); @@ -64,7 +66,7 @@ describe("rebuildSandbox DCode flow: recovery", () => { customPolicies: [customPolicy], policyPresetsFinalized: true, }, - sandboxListOutput: "", + sandboxInventory: { sandboxes: [] }, reconciledSandboxGatewayState: { state: "missing", output: "" }, }); configureDcodeSession(harness); diff --git a/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts b/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts index b8048ecb79a..c0f907c1711 100644 --- a/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts @@ -162,7 +162,9 @@ describe("rebuildSandbox flow: credential preflight", () => { }, hydrateCredentialEnv: () => "host-provider-key", runOpenshell: providerRuntime([]), - sandboxListOutput: "alpha Error", + sandboxInventory: { + sandboxes: [{ name: "alpha", phase: "Error", readiness: "terminal" }], + }, }); configureSession(harness, "compatible-endpoint", "COMPATIBLE_API_KEY", { endpointUrl: "https://inference.example.test/v1", diff --git a/src/lib/actions/sandbox/rebuild-flow-helpers.ts b/src/lib/actions/sandbox/rebuild-flow-helpers.ts index 4aabd35a4d3..877874b8baa 100644 --- a/src/lib/actions/sandbox/rebuild-flow-helpers.ts +++ b/src/lib/actions/sandbox/rebuild-flow-helpers.ts @@ -42,7 +42,7 @@ import * as sandboxState from "../../state/sandbox"; import * as userManagedFilesProbe from "../../state/user-managed-files-probe"; import { getReconciledSandboxGatewayState, - printGatewayLifecycleHint, + printSandboxGatewayStateHint, printWrongGatewayActiveGuidance, } from "./gateway-state"; import { openRebuildShieldsWindow, type RebuildShieldsWindow } from "./rebuild-shields"; @@ -250,7 +250,7 @@ export async function resolveRebuildLiveState( ` Sandbox '${sandboxName}' is not visible on gateway '${recordedGateway}' and its live state could not be confirmed.`, ); console.error(" Your local registry entry has been preserved — nothing was removed."); - printGatewayLifecycleHint(reconciled.output || "", sandboxName, console.error); + printSandboxGatewayStateHint(reconciled, sandboxName, console.error); } bail(`Could not confirm live state of '${sandboxName}' (gateway not in a known-good state).`); return null; diff --git a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts index 8a7b2c54391..06621396c43 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -529,7 +529,7 @@ network_policies: it("disposes the base-image handoff when live-state preflight fails (#7144)", async () => { const disposeImageRef = vi.fn(() => true); const harness = createRebuildFlowHarness({ - sandboxListOutput: "", + sandboxInventory: { sandboxes: [] }, reconciledSandboxGatewayState: { state: "unknown", output: "indeterminate" }, baseImagePreflight: { ok: true, diff --git a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts index 68875abda42..dd3cc163f44 100644 --- a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts @@ -15,7 +15,9 @@ describe("prepared rebuild recovery", () => { it("restores the validated pre-upgrade manifest without taking a second backup (#6114)", async () => { const harness = createRebuildFlowHarness({ applyPreset: () => true, - sandboxListOutput: "alpha Error", + sandboxInventory: { + sandboxes: [{ name: "alpha", phase: "Error", readiness: "terminal" }], + }, }); const recoveryManifest = makePreparedRecoveryManifest(); @@ -56,7 +58,9 @@ describe("prepared rebuild recovery", () => { it("carries confirmed legacy managed-image recovery through the delete edge (#6114)", async () => { const harness = createRebuildFlowHarness({ applyPreset: () => true, - sandboxListOutput: "alpha Error", + sandboxInventory: { + sandboxes: [{ name: "alpha", phase: "Error", readiness: "terminal" }], + }, sandboxEntry: { nemoclawVersion: null }, managedImageEvidence: false, }); @@ -84,7 +88,9 @@ describe("prepared rebuild recovery", () => { it("rejects an ambiguous legacy image without the scoped recovery capability (#6114)", async () => { const harness = createRebuildFlowHarness({ - sandboxListOutput: "alpha Error", + sandboxInventory: { + sandboxes: [{ name: "alpha", phase: "Error", readiness: "terminal" }], + }, sandboxEntry: { nemoclawVersion: null }, managedImageEvidence: false, }); @@ -102,7 +108,9 @@ describe("prepared rebuild recovery", () => { it("rejects recorded custom-image evidence despite the scoped recovery capability (#6114)", async () => { const harness = createRebuildFlowHarness({ - sandboxListOutput: "alpha Error", + sandboxInventory: { + sandboxes: [{ name: "alpha", phase: "Error", readiness: "terminal" }], + }, sandboxEntry: { nemoclawVersion: null, fromDockerfile: "/tmp/custom.Dockerfile", diff --git a/src/lib/actions/sandbox/start.test.ts b/src/lib/actions/sandbox/start.test.ts index 1fb50099514..e79b8683e35 100644 --- a/src/lib/actions/sandbox/start.test.ts +++ b/src/lib/actions/sandbox/start.test.ts @@ -242,8 +242,6 @@ describe("startSandbox", () => { }); const observer: OpenShellSandboxObserver = { listSandboxes, - lookupSandbox: vi.fn(), - waitForSandboxReady: vi.fn(), }; const restoreProcesses = vi.fn(() => SUCCESSFUL_RECOVERY); const h = harness({ diff --git a/src/lib/actions/sandbox/start.ts b/src/lib/actions/sandbox/start.ts index 17d250a8665..c80831a5aa4 100644 --- a/src/lib/actions/sandbox/start.ts +++ b/src/lib/actions/sandbox/start.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { OpenShellSandboxObserver } from "../../adapters/openshell/sandbox-observer-cli"; +import type { OpenShellSandboxObserver } from "../../adapters/openshell/sandbox-observer"; import { cliName } from "../../onboard/branding"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES, diff --git a/src/lib/actions/sandbox/status-lookup-rendering.ts b/src/lib/actions/sandbox/status-lookup-rendering.ts index e14fdba1eac..0785c151930 100644 --- a/src/lib/actions/sandbox/status-lookup-rendering.ts +++ b/src/lib/actions/sandbox/status-lookup-rendering.ts @@ -9,7 +9,7 @@ import { isTerminalSandboxPhase } from "../../state/gateway"; import { getSandboxDockerRuntime } from "./docker-health"; import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier"; import type { SandboxGatewayState } from "./gateway-state"; -import { printGatewayLifecycleHint, printWrongGatewayActiveGuidance } from "./gateway-state"; +import { printSandboxGatewayStateHint, printWrongGatewayActiveGuidance } from "./gateway-state"; import { getSandboxTargetGatewayName } from "./gateway-target"; import { printGatewayFailureLayerHeader, @@ -223,7 +223,7 @@ async function printUnknownGatewayLookupStatus({ console.log(lookup.output); } await printGatewayFailureLayerHeader(sandboxName, effectivePreflight.failureLayer); - printGatewayLifecycleHint(lookup.output, sandboxName, console.log); + printSandboxGatewayStateHint(lookup, sandboxName, console.log); deferSandboxLifecycleExit(1); } diff --git a/src/lib/adapters/openshell/sandbox-observer-cli.test.ts b/src/lib/adapters/openshell/sandbox-observer-cli.test.ts index 931cfbb98ae..30a3fa74b5a 100644 --- a/src/lib/adapters/openshell/sandbox-observer-cli.test.ts +++ b/src/lib/adapters/openshell/sandbox-observer-cli.test.ts @@ -78,6 +78,9 @@ describe("CLI OpenShell sandbox observer", () => { "\u001b[1malpha\u001b[0m 2m \u001b[32mReady\u001b[0m\n" + "beta Ready NotReady 1m ago\n" + "gamma unknown 1m ago\n" + + "delta Ready 2026-03-24 10:00:00 Provisioning\n" + + "epsilon 1m Running\n" + + "Error: command failed\n" + "No sandboxes found.", ), ).toEqual({ @@ -85,6 +88,8 @@ describe("CLI OpenShell sandbox observer", () => { { name: "alpha", phase: "Ready", readiness: "ready" }, { name: "beta", phase: "NotReady", readiness: "not_ready" }, { name: "gamma", phase: "Unknown", readiness: "terminal" }, + { name: "delta", phase: "Provisioning", readiness: "not_ready" }, + { name: "epsilon", phase: "Running", readiness: "ready" }, ], }); }); @@ -102,11 +107,11 @@ describe("CLI OpenShell sandbox observer", () => { }); }); - it("looks up a sandbox without exposing process-shaped results (#9803)", async () => { + it("keeps formatted get output on an explicit CLI-only compatibility path (#9803)", async () => { const capture = vi.fn(() => captured(0, "\u001b[1mName:\u001b[0m alpha\nPhase: Running\n")); - const observer = createCliOpenShellSandboxObserver({ capture }); + const lookup = createCliOpenShellSandboxLookup({ capture }); - const result = await observer.lookupSandbox({ + const result = await lookup({ sandboxName: "alpha", target: namedOpenShellGateway("nemoclaw"), timeoutMs: 1_000, @@ -119,33 +124,20 @@ describe("CLI OpenShell sandbox observer", () => { timeout: 1_000, }); expect(result).toEqual({ - ok: true, - value: { - state: "present", - sandbox: { name: "alpha", phase: "Running", readiness: "ready" }, + result: { + ok: true, + value: { + state: "present", + sandbox: { name: "alpha", phase: "Running", readiness: "ready" }, + }, }, + displayOutput: "Name: alpha\nPhase: Running", }); }); it("canonicalizes lookup phase tokens case-insensitively (#9803)", async () => { - const observer = createCliOpenShellSandboxObserver({ - capture: () => captured(0, "Name: alpha\nPhase: ready\n"), - }); - - await expect( - observer.lookupSandbox({ sandboxName: "alpha", target: selectedOpenShellGateway() }), - ).resolves.toEqual({ - ok: true, - value: { - state: "present", - sandbox: { name: "alpha", phase: "Ready", readiness: "ready" }, - }, - }); - }); - - it("keeps formatted get output on an explicit CLI-only compatibility path (#9803)", async () => { const lookup = createCliOpenShellSandboxLookup({ - capture: () => captured(0, "\u001b[1mName:\u001b[0m alpha\nPhase: Running\n"), + capture: () => captured(0, "Name: alpha\nPhase: ready\n"), }); await expect( @@ -155,10 +147,10 @@ describe("CLI OpenShell sandbox observer", () => { ok: true, value: { state: "present", - sandbox: { name: "alpha", phase: "Running", readiness: "ready" }, + sandbox: { name: "alpha", phase: "Ready", readiness: "ready" }, }, }, - displayOutput: "Name: alpha\nPhase: Running", + displayOutput: "Name: alpha\nPhase: ready", }); }); @@ -169,24 +161,28 @@ describe("CLI OpenShell sandbox observer", () => { .mockReturnValueOnce( captured(1, "", "Error: authentication failed: sandbox not found: bearer value"), ); - const observer = createCliOpenShellSandboxObserver({ capture }); + const lookup = createCliOpenShellSandboxLookup({ capture }); const request = { sandboxName: "alpha", target: selectedOpenShellGateway() }; - await expect(observer.lookupSandbox(request)).resolves.toEqual({ - ok: true, - value: { state: "missing" }, + await expect(lookup(request)).resolves.toEqual({ + result: { ok: true, value: { state: "missing" } }, + displayOutput: "", }); - await expect(observer.lookupSandbox(request)).resolves.toEqual({ - ok: false, - error: { - kind: "authentication", - message: "OpenShell could not authenticate the sandbox observation.", + await expect(lookup(request)).resolves.toEqual({ + result: { + ok: false, + error: { + kind: "authentication", + message: "OpenShell could not authenticate the sandbox observation.", + }, }, + displayOutput: "", }); }); it.each([ ["transport", "unreachable", captured(1, "", "client error (Connect): Connection refused")], + ["transport", "unreachable", captured(1, "", "Status: Disconnected")], ["transport", "identity_mismatch", captured(1, "", "handshake verification failed")], ["schema", undefined, captured(1, "", "protobuf decode error: invalid wire type")], ["command", "failed", captured(7, "", "unexpected opaque failure")], @@ -222,80 +218,4 @@ describe("CLI OpenShell sandbox observer", () => { error: { kind: "timeout", message: "OpenShell sandbox observation timed out." }, }); }); - - it("waits for stable readiness using typed observations (#9803)", async () => { - const outputs = ["alpha Provisioning", "alpha Error", "alpha Ready", "alpha Running"]; - let clock = 0; - const observer = createCliOpenShellSandboxObserver({ - capture: () => captured(0, outputs.shift() ?? ""), - now: () => clock, - sleep: (ms) => { - clock += ms; - }, - }); - - const result = await observer.waitForSandboxReady({ - sandboxName: "alpha", - target: namedOpenShellGateway("nemoclaw"), - timeoutMs: 1_000, - pollIntervalMs: 10, - stableReadyObservations: 2, - errorPhaseDebounceObservations: 2, - }); - - expect(result).toEqual({ - ok: true, - value: { - state: "ready", - sandbox: { name: "alpha", phase: "Running", readiness: "ready" }, - observations: 4, - }, - }); - }); - - it("returns a terminal phase before the readiness deadline (#9803)", async () => { - const observer = createCliOpenShellSandboxObserver({ - capture: () => captured(0, "alpha Failed"), - now: () => 0, - sleep: vi.fn(), - }); - - await expect( - observer.waitForSandboxReady({ - sandboxName: "alpha", - target: selectedOpenShellGateway(), - timeoutMs: 1_000, - }), - ).resolves.toEqual({ - ok: true, - value: { - state: "terminal", - sandbox: { name: "alpha", phase: "Failed", readiness: "terminal" }, - observations: 1, - }, - }); - }); - - it("returns missing as the last observation when readiness times out (#9803)", async () => { - let clock = 0; - const observer = createCliOpenShellSandboxObserver({ - capture: () => captured(0, "No sandboxes found."), - now: () => clock, - sleep: (ms) => { - clock += ms; - }, - }); - - await expect( - observer.waitForSandboxReady({ - sandboxName: "alpha", - target: selectedOpenShellGateway(), - timeoutMs: 20, - pollIntervalMs: 10, - }), - ).resolves.toEqual({ - ok: true, - value: { state: "timeout", lastObservation: null, observations: 2 }, - }); - }); }); diff --git a/src/lib/adapters/openshell/sandbox-observer-cli.ts b/src/lib/adapters/openshell/sandbox-observer-cli.ts index 9302d323dab..c264083b066 100644 --- a/src/lib/adapters/openshell/sandbox-observer-cli.ts +++ b/src/lib/adapters/openshell/sandbox-observer-cli.ts @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { setTimeout as sleepMsAsync } from "node:timers/promises"; - import { type ListOpenShellSandboxesRequest, type LookupOpenShellSandboxRequest, @@ -12,27 +10,11 @@ import { type OpenShellSandboxLookup, type OpenShellSandboxObservation, type OpenShellSandboxObserver, - type OpenShellSandboxReadinessWait, type OpenShellSandboxResult, - type WaitForOpenShellSandboxReadyRequest, -} from "./sandbox-observer"; - -export { namedOpenShellGateway, selectedOpenShellGateway } from "./sandbox-observer"; -export type { - OpenShellGatewayTarget, - OpenShellSandboxError, - OpenShellSandboxInventory, - OpenShellSandboxLookup, - OpenShellSandboxObservation, - OpenShellSandboxObserver, - OpenShellSandboxReadiness, - OpenShellSandboxReadinessWait, - OpenShellSandboxResult, - OpenShellSandboxTransportReason, } from "./sandbox-observer"; +import { OPENSHELL_PROBE_TIMEOUT_MS } from "./timeouts"; const ANSI_RE = /\x1b\[[0-9;]*m/gu; -const DEFAULT_SANDBOX_OBSERVATION_TIMEOUT_MS = 15_000; const READY_PHASES = new Set(["Ready", "Running"]); const TERMINAL_PHASES = new Set([ @@ -84,8 +66,6 @@ export type CaptureSandboxCommand = ( export type CliOpenShellSandboxObserverDeps = Readonly<{ capture: CaptureSandboxCommand; defaultTimeoutMs?: number; - now?: () => number; - sleep?: (ms: number) => void | Promise; }>; export type CliOpenShellSandboxLookupResult = Readonly<{ @@ -129,12 +109,10 @@ export function parseCliOpenShellSandboxInventory(output: string): OpenShellSand const columns = line.split(/\s+/u); const name = columns[0]; if (!name || isNonSandboxRow(line, name)) continue; - const phaseColumns = columns - .slice(1) - .map((column) => CANONICAL_PHASES.get(column.toLowerCase()) ?? null); - const phase = phaseColumns.includes("NotReady") - ? "NotReady" - : (phaseColumns.find((column) => column !== null) ?? null); + let phase: string | null = null; + for (const column of columns.slice(1)) { + phase = CANONICAL_PHASES.get(column.toLowerCase()) ?? phase; + } sandboxes.push(observation(name, phase)); } return { sandboxes }; @@ -195,7 +173,7 @@ function commandError(result: CapturedSandboxCommandResult): OpenShellSandboxErr }; } if ( - /\b(?:connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured)\b/iu.test( + /\b(?:connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured)\b|status:\s*disconnected/iu.test( output, ) ) { @@ -242,7 +220,7 @@ export function createCliOpenShellSandboxLookup( ignoreError: true, includeStderr: true, includeStreams: true, - timeout: request.timeoutMs ?? deps.defaultTimeoutMs ?? DEFAULT_SANDBOX_OBSERVATION_TIMEOUT_MS, + timeout: request.timeoutMs ?? deps.defaultTimeoutMs ?? OPENSHELL_PROBE_TIMEOUT_MS, }); const output = commandOutput(result); const error = commandError(result); @@ -268,9 +246,6 @@ export function createCliOpenShellSandboxObserver( deps: CliOpenShellSandboxObserverDeps, ): OpenShellSandboxObserver { const capture = deps.capture; - const now = deps.now ?? Date.now; - const sleep = deps.sleep ?? sleepMsAsync; - const cliLookup = createCliOpenShellSandboxLookup(deps); const listSandboxes = async ( request: ListOpenShellSandboxesRequest, @@ -279,72 +254,12 @@ export function createCliOpenShellSandboxObserver( ignoreError: true, includeStderr: true, includeStreams: true, - timeout: request.timeoutMs ?? deps.defaultTimeoutMs ?? DEFAULT_SANDBOX_OBSERVATION_TIMEOUT_MS, + timeout: request.timeoutMs ?? deps.defaultTimeoutMs ?? OPENSHELL_PROBE_TIMEOUT_MS, }); const error = commandError(result); if (error) return failure(error); return success(parseCliOpenShellSandboxInventory(successfulCommandOutput(result))); }; - const lookupSandbox = async ( - request: LookupOpenShellSandboxRequest, - ): Promise> => { - return (await cliLookup(request)).result; - }; - - const waitForSandboxReady = async ( - request: WaitForOpenShellSandboxReadyRequest, - ): Promise> => { - const timeoutMs = Math.max(0, request.timeoutMs); - const pollIntervalMs = Math.max(0, request.pollIntervalMs ?? 250); - const stableReadyObservations = Math.max(1, Math.round(request.stableReadyObservations ?? 1)); - const errorPhaseDebounceObservations = Math.max( - 1, - Math.round(request.errorPhaseDebounceObservations ?? 1), - ); - const deadline = now() + timeoutMs; - let observations = 0; - let consecutiveReady = 0; - let consecutiveError = 0; - let lastObservation: OpenShellSandboxObservation | null = null; - - while (now() < deadline) { - const remainingMs = Math.max(1, deadline - now()); - const listed = await listSandboxes({ - target: request.target, - timeoutMs: remainingMs, - }); - if (!listed.ok) return listed; - observations += 1; - const current = - listed.value.sandboxes.find((sandbox) => sandbox.name === request.sandboxName) ?? null; - lastObservation = current; - - if (current?.readiness === "ready") { - consecutiveReady += 1; - consecutiveError = 0; - if (consecutiveReady >= stableReadyObservations) { - return success({ state: "ready", sandbox: current, observations }); - } - } else { - consecutiveReady = 0; - if (current?.readiness === "terminal") { - consecutiveError = current.phase === "Error" ? consecutiveError + 1 : 0; - if (current.phase !== "Error" || consecutiveError >= errorPhaseDebounceObservations) { - return success({ state: "terminal", sandbox: current, observations }); - } - } else { - consecutiveError = 0; - } - } - - const remainingAfterObservationMs = deadline - now(); - if (remainingAfterObservationMs <= 0) break; - await sleep(Math.min(pollIntervalMs, remainingAfterObservationMs)); - } - - return success({ state: "timeout", lastObservation, observations }); - }; - - return { listSandboxes, lookupSandbox, waitForSandboxReady }; + return { listSandboxes }; } diff --git a/src/lib/adapters/openshell/sandbox-observer.ts b/src/lib/adapters/openshell/sandbox-observer.ts index 2e3735699c0..45102d10b3b 100644 --- a/src/lib/adapters/openshell/sandbox-observer.ts +++ b/src/lib/adapters/openshell/sandbox-observer.ts @@ -58,44 +58,11 @@ export type LookupOpenShellSandboxRequest = ListOpenShellSandboxesRequest & sandboxName: string; }>; -export type WaitForOpenShellSandboxReadyRequest = LookupOpenShellSandboxRequest & - Readonly<{ - timeoutMs: number; - pollIntervalMs?: number; - stableReadyObservations?: number; - errorPhaseDebounceObservations?: number; - }>; - -export type OpenShellSandboxReadinessWait = - | Readonly<{ - state: "ready"; - sandbox: OpenShellSandboxObservation; - observations: number; - }> - | Readonly<{ - state: "terminal"; - sandbox: OpenShellSandboxObservation; - observations: number; - }> - | Readonly<{ - state: "timeout"; - lastObservation: OpenShellSandboxObservation | null; - observations: number; - }>; - /** Transport-neutral sandbox observation capabilities used by NemoClaw. */ export interface OpenShellSandboxObserver { listSandboxes( request: ListOpenShellSandboxesRequest, ): Promise>; - - lookupSandbox( - request: LookupOpenShellSandboxRequest, - ): Promise>; - - waitForSandboxReady( - request: WaitForOpenShellSandboxReadyRequest, - ): Promise>; } export function namedOpenShellGateway(gatewayName: string): OpenShellGatewayTarget { diff --git a/src/lib/openshell-sandbox-list.test.ts b/src/lib/openshell-sandbox-list.test.ts index b041c8277cc..d431e717bc4 100644 --- a/src/lib/openshell-sandbox-list.test.ts +++ b/src/lib/openshell-sandbox-list.test.ts @@ -96,7 +96,7 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { expect(mocks.captureOpenshell).toHaveBeenCalledOnce(); expect(mocks.captureOpenshell).toHaveBeenCalledWith( ["sandbox", "list"], - expect.objectContaining({ ignoreError: true, includeStreams: true }), + expect.objectContaining({ ignoreError: true, includeStreams: true, timeout: 15_000 }), ); expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); @@ -105,7 +105,7 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { it("proves and recovers an explicit gateway instead of the current selection (#6114)", async () => { const options = { gatewayName: "nemoclaw-12345" }; mocks.captureOpenshell - .mockReturnValueOnce({ status: 1, output: "client error (Connect): Connection refused" }) + .mockReturnValueOnce({ status: 1, output: "Status: Disconnected" }) .mockReturnValueOnce({ status: 0, output: "alpha Ready" }); const result = await captureSandboxListWithGatewayPreflightOrExit(context, options); @@ -307,7 +307,7 @@ describe("read-only named-gateway sandbox list (#7279)", () => { expect(mocks.captureOpenshell).toHaveBeenCalledWith( ["sandbox", "list", "-g", "nemoclaw-18080"], - expect.objectContaining({ ignoreError: true, includeStreams: true }), + expect.objectContaining({ ignoreError: true, includeStreams: true, timeout: 15_000 }), ); expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); expect(result).toEqual({ diff --git a/src/lib/openshell-sandbox-list.ts b/src/lib/openshell-sandbox-list.ts index 045e788058c..8187c55e339 100644 --- a/src/lib/openshell-sandbox-list.ts +++ b/src/lib/openshell-sandbox-list.ts @@ -5,14 +5,14 @@ import { detectOpenShellStateRpcPreflightIssue, printOpenShellStateRpcIssue, } from "./adapters/openshell/gateway-drift"; +import { createCliOpenShellSandboxObserver } from "./adapters/openshell/sandbox-observer-cli"; import { - createCliOpenShellSandboxObserver, namedOpenShellGateway, selectedOpenShellGateway, type OpenShellSandboxInventory, type OpenShellSandboxObserver, type OpenShellSandboxResult, -} from "./adapters/openshell/sandbox-observer-cli"; +} from "./adapters/openshell/sandbox-observer"; import { captureOpenshell } from "./adapters/openshell/runtime"; import { recoverNamedGatewayRuntime } from "./gateway-runtime-action"; diff --git a/src/lib/registry-recovery-action.ts b/src/lib/registry-recovery-action.ts index 1fce01977b2..fbae893846a 100644 --- a/src/lib/registry-recovery-action.ts +++ b/src/lib/registry-recovery-action.ts @@ -2,10 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { resolveOpenshell } from "./adapters/openshell/resolve"; -import { - createCliOpenShellSandboxObserver, - namedOpenShellGateway, -} from "./adapters/openshell/sandbox-observer-cli"; +import { createCliOpenShellSandboxObserver } from "./adapters/openshell/sandbox-observer-cli"; +import { namedOpenShellGateway } from "./adapters/openshell/sandbox-observer"; import { captureOpenshell } from "./adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; import { GATEWAY_PORT } from "./core/ports"; diff --git a/src/lib/runtime-recovery.ts b/src/lib/runtime-recovery.ts index 0361fc7a24c..fd4b6479512 100644 --- a/src/lib/runtime-recovery.ts +++ b/src/lib/runtime-recovery.ts @@ -6,72 +6,22 @@ * output and determine recovery strategy. */ -const ANSI_RE = /\x1b\[[0-9;]*m/g; -const SANDBOX_PHASES = new Set(["Ready", "Running", "NotReady", "Provisioning", "Error"]); -// Broader phase vocabulary for surfacing the live PHASE on #5714 recovered list -// rows. Unions the lifecycle phases above with the terminal/failure phases used -// elsewhere (see state/gateway.ts TERMINAL_SANDBOX_PHASES) plus common -// transient phases, so a recovered row reports the real phase (e.g. Failed, -// CrashLoopBackOff, Creating) instead of "unknown". Kept separate from -// SANDBOX_PHASES so parseReadySandboxNames' Ready/Running gate is unchanged. -const LIVE_SANDBOX_DISPLAY_PHASES = new Set([ - "Ready", - "Running", - "NotReady", - "Provisioning", - "Creating", - "Pending", - "Deleting", - "Terminating", - "Error", - "Failed", - "CrashLoopBackOff", - "ImagePullBackOff", - "Evicted", - "Unknown", -]); - -/** Strip ANSI color escape sequences from CLI output. */ -function stripAnsi(text: string | null | undefined): string { - return String(text || "").replace(ANSI_RE, ""); -} +import { + parseCliOpenShellSandboxInventory, + stripOpenShellCliAnsi, +} from "./adapters/openshell/sandbox-observer-cli"; /** Detect an OpenShell protobuf/wire schema-mismatch error in command output. */ export function isOpenShellProtobufSchemaMismatch(output = ""): boolean { - const clean = stripAnsi(output); + const clean = stripOpenShellCliAnsi(output); return /invalid wire type/i.test(clean) || /proto(?:buf)?(?: decode| schema| wire)/i.test(clean); } -/** Whether a `sandbox list` line is a header/empty/error row rather than a sandbox. */ -function isNonSandboxRow(line: string, firstCol: string): boolean { - if (firstCol === "NAME") return true; - if (line === "No sandboxes found" || line === "No sandboxes found.") return true; - if (/^Error:/i.test(line)) return true; - if (isOpenShellProtobufSchemaMismatch(line)) return true; - return false; -} - -/** Extract the phase token from a `sandbox list` row's columns (compact or trailing). */ -function parseSandboxListPhase(cols: string[]): string | null { - const compactPhase = cols[1]; - if (cols.length <= 3 && SANDBOX_PHASES.has(compactPhase)) return compactPhase; - const trailingPhase = cols.at(-1); - return trailingPhase && SANDBOX_PHASES.has(trailingPhase) ? trailingPhase : null; -} - /** Parse the set of all live sandbox names from `openshell sandbox list` output. */ export function parseLiveSandboxNames(listOutput = ""): Set { - const clean = stripAnsi(listOutput); - const names = new Set(); - for (const rawLine of clean.split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - const cols = line.split(/\s+/); - if (!cols[0]) continue; - if (isNonSandboxRow(line, cols[0])) continue; - names.add(cols[0]); - } - return names; + return new Set( + parseCliOpenShellSandboxInventory(listOutput).sandboxes.map((sandbox) => sandbox.name), + ); } export interface LiveSandboxEntry { @@ -86,39 +36,17 @@ export interface LiveSandboxEntry { * output for any other (e.g. agent) metadata it does not contain. */ export function parseLiveSandboxEntries(listOutput = ""): LiveSandboxEntry[] { - const clean = stripAnsi(listOutput); - const entries: LiveSandboxEntry[] = []; - for (const rawLine of clean.split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - const cols = line.split(/\s+/); - if (!cols[0]) continue; - if (isNonSandboxRow(line, cols[0])) continue; - // Scan every column after the name for a known phase token so we read the - // phase regardless of column layout — trailing (`NAME CREATED PHASE`), - // compact (`NAME PHASE`), or with an age suffix (`NAME PHASE 2m ago`). The - // first column is the name and is never a phase. Uses the broader display - // vocabulary so terminal/transient phases (Failed, Creating, …) are kept. - const phase = cols.slice(1).find((col) => LIVE_SANDBOX_DISPLAY_PHASES.has(col)) ?? null; - entries.push({ name: cols[0], phase }); - } - return entries; + return parseCliOpenShellSandboxInventory(listOutput).sandboxes.map(({ name, phase }) => ({ + name, + phase, + })); } /** Parse the set of sandbox names in a Ready/Running phase from `sandbox list` output. */ export function parseReadySandboxNames(listOutput = ""): Set { - const clean = stripAnsi(listOutput); - const names = new Set(); - for (const rawLine of clean.split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - const cols = line.split(/\s+/); - if (!cols[0]) continue; - if (isNonSandboxRow(line, cols[0])) continue; - const phase = parseSandboxListPhase(cols); - const isReadyOrRunning = phase === "Ready" || phase === "Running"; - if (phase === "NotReady" || !isReadyOrRunning) continue; - names.add(cols[0]); - } - return names; + return new Set( + parseCliOpenShellSandboxInventory(listOutput) + .sandboxes.filter((sandbox) => sandbox.readiness === "ready") + .map((sandbox) => sandbox.name), + ); } diff --git a/test/helpers/rebuild-flow-dcode-harness.ts b/test/helpers/rebuild-flow-dcode-harness.ts index 85d050797f1..c1097ac7e13 100644 --- a/test/helpers/rebuild-flow-dcode-harness.ts +++ b/test/helpers/rebuild-flow-dcode-harness.ts @@ -3,12 +3,12 @@ import { type MockInstance, vi } from "vitest"; import type { GatewayRestartResult } from "../../src/lib/actions/sandbox/gateway-restart"; +import type { OpenShellSandboxInventory } from "../../src/lib/adapters/openshell/sandbox-observer"; import { makePreparedRecoveryManifest } from "../../src/lib/actions/sandbox/rebuild-flow-test-fixtures"; import { agentDefs, agentOnboard, agentRuntime, - createSandboxInventoryFake, createRebuildFlowSession, destroy, dockerImage, @@ -95,7 +95,7 @@ export type RebuildFlowOverrides = { sandboxEntry?: Record; sandboxEntryReads?: Array | null>; sessionSandboxName?: string; - sandboxListOutput?: string; + sandboxInventory?: OpenShellSandboxInventory; backupPolicyPresets?: string[]; gatewayPresets?: string[]; verificationUnavailableAfterPresetRemoval?: boolean; @@ -242,7 +242,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): vi.spyOn(sandboxList, "captureSandboxListWithGatewayRecovery").mockResolvedValue({ result: { ok: true, - value: createSandboxInventoryFake(overrides.sandboxListOutput ?? "alpha Ready"), + value: overrides.sandboxInventory ?? { + sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }], + }, }, recoveryAttempted: false, recoverySucceeded: false, diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index e9870483077..b68923d638a 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -10,7 +10,6 @@ import { agentDefs, agentRuntime, buildContextFingerprint, - createSandboxInventoryFake, createHarnessTempDir, createRebuildFlowSession, destroy, @@ -107,9 +106,13 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): vi.spyOn(sandboxList, "captureSandboxListWithGatewayRecovery").mockResolvedValue({ result: { ok: true, - value: createSandboxInventoryFake( - overrides.sandboxListOutput ?? (overrides.staleRecovery ? "" : "alpha Ready"), - ), + value: + overrides.sandboxInventory ?? + (overrides.staleRecovery + ? { sandboxes: [] } + : { + sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }], + }), }, recoveryAttempted: false, recoverySucceeded: false, diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index b5eb30ccc92..071594096bc 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -84,39 +84,6 @@ export function sourceSandboxGateway(argv: string[], verb: string): string | nul : null; } -export function createSandboxInventoryFake(output: string): { - sandboxes: Array<{ - name: string; - phase: string | null; - readiness: "ready" | "not_ready" | "terminal"; - }>; -} { - const terminalPhases = new Set([ - "CrashLoopBackOff", - "Error", - "Evicted", - "Failed", - "ImagePullBackOff", - "Unknown", - ]); - return { - sandboxes: output - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const [name = "", phase = null] = line.split(/\s+/u); - const readiness = - phase === "Ready" || phase === "Running" - ? "ready" - : phase && terminalPhases.has(phase) - ? "terminal" - : "not_ready"; - return { name, phase, readiness }; - }), - }; -} - const harnessTempDirs: string[] = []; export function createHarnessTempDir(prefix: string): string { diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index 57b2dd7e9f5..cab63d41c99 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -4,6 +4,7 @@ import { type MockInstance, vi } from "vitest"; import type { GatewayRestartResult } from "../../src/lib/actions/sandbox/gateway-restart"; import type { SandboxGatewayState } from "../../src/lib/actions/sandbox/gateway-state"; +import type { OpenShellSandboxInventory } from "../../src/lib/adapters/openshell/sandbox-observer"; import type { finalizePreparedRebuildImageMessagingPlan, RebuildImagePreflightResult, @@ -80,7 +81,7 @@ export type RebuildFlowOverrides = { sandboxEntry?: Record; sandboxBaseImageLabelsOutput?: string; sessionSandboxName?: string; - sandboxListOutput?: string; + sandboxInventory?: OpenShellSandboxInventory; defaultSandbox?: string | null; preDeleteSandboxEntry?: Record; preDeleteDefaultSandbox?: string | null; diff --git a/test/rebuild-stale-recovery.test.ts b/test/rebuild-stale-recovery.test.ts index 5fbab374915..6a34cd09154 100644 --- a/test/rebuild-stale-recovery.test.ts +++ b/test/rebuild-stale-recovery.test.ts @@ -95,7 +95,7 @@ describe("stale sandbox rebuild recovery (#4497)", () => { // refuse to recreate from scratch, or it would destroy live workspace // state in multi-gateway setups (#4497 / #4645). const harness = createRebuildFlowHarness({ - sandboxListOutput: "", + sandboxInventory: { sandboxes: [] }, reconciledSandboxGatewayState: { state: "wrong_gateway_active", output: "Gateway: other-gw", @@ -129,7 +129,7 @@ describe("stale sandbox rebuild recovery (#4497)", () => { // preserve the registry entry. const harness = createRebuildFlowHarness({ sandboxEntry: { gatewayName: "nemoclaw-9000", gatewayPort: 9000 }, - sandboxListOutput: "", + sandboxInventory: { sandboxes: [] }, reconciledSandboxGatewayState: { state: "wrong_gateway_active", output: "Gateway: nemoclaw", From eb9f6908092d42e2dbab36685f03d09d5632f3e5 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:41:26 -0700 Subject: [PATCH 04/57] docs(cli): clarify gateway identity drift Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/lib/actions/sandbox/gateway-state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 8fd785772a6..75c71faa257 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -608,7 +608,7 @@ export function printSandboxGatewayStateHint( if (lookup.transportReason === "identity_mismatch") { writer(" This looks like gateway identity drift after restart."); writer( - " Existing sandboxes may still be recorded locally, but the current gateway no longer trusts their prior connection state.", + " Existing sandboxes may still be recorded locally, but the selected gateway identity no longer matches the identity recorded before restart.", ); writer( ` Re-establish the ${CLI_DISPLAY_NAME} gateway runtime first. If the sandbox stays unreachable, recreate only that sandbox with \`${CLI_NAME} onboard\`.`, From 7418fa2e21c8d6abd784135d885506cba3967413 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:45:01 -0700 Subject: [PATCH 05/57] refactor(cli): add typed OpenShell provider adapter --- ci/source-architecture-budget.json | 5 +- src/lib/actions/credentials-add.ts | 140 +++------ .../credentials-provider-adapter.test.ts | 158 ++++++++++ src/lib/actions/credentials/list.ts | 26 +- src/lib/actions/credentials/reset.ts | 73 +++-- .../openshell/provider-adapter-cli.test.ts | 266 ++++++++++++++++ .../openshell/provider-adapter-cli.ts | 296 ++++++++++++++++++ .../adapters/openshell/provider-adapter.ts | 112 +++++++ src/lib/credentials/provider-list.ts | 16 +- test/credentials-reset-outcome.test.ts | 34 +- 10 files changed, 987 insertions(+), 139 deletions(-) create mode 100644 src/lib/actions/credentials-provider-adapter.test.ts create mode 100644 src/lib/adapters/openshell/provider-adapter-cli.test.ts create mode 100644 src/lib/adapters/openshell/provider-adapter-cli.ts create mode 100644 src/lib/adapters/openshell/provider-adapter.ts diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index a21c47c8fa8..72ad227a4fc 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -9,7 +9,7 @@ "src/lib/adapters/openshell/client.ts": 20, "src/lib/adapters/openshell/resolve.ts": 27, "src/lib/adapters/openshell/runtime.ts": 54, - "src/lib/adapters/openshell/timeouts.ts": 39, + "src/lib/adapters/openshell/timeouts.ts": 40, "src/lib/agent/defs.ts": 33, "src/lib/cli/branding.ts": 87, "src/lib/cli/nemoclaw-oclif-command.ts": 106, @@ -23,9 +23,10 @@ "src/lib/inference/config.ts": 30, "src/lib/inference/web-search.ts": 21, "src/lib/messaging/channels/index.ts": 25, + "src/lib/name-validation.ts": 21, "src/lib/onboard/gateway-binding.ts": 52, "src/lib/runner.ts": 86, - "src/lib/security/redact.ts": 54, + "src/lib/security/redact.ts": 53, "src/lib/state/onboard-session.ts": 37, "src/lib/state/registry.ts": 101, "src/lib/state/state-root.ts": 21, diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 927ceabf31f..8390b44e26b 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -3,14 +3,15 @@ import fs from "node:fs"; import path from "node:path"; -import { runOpenshellProviderCommand } from "../adapters/openshell/provider-command"; +import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; +import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; +import { selectedOpenShellGateway } from "../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; import { isBridgeProviderName, recoverGatewayForCredentialMutationOrExit, } from "../credentials/command-support"; -import { redact } from "../security/redact"; import { SECRET_PATTERNS } from "../security/secret-patterns"; import { withMcpCredentialOwnershipLock } from "../state/mcp-lifecycle-lock/credential-ownership"; import { ROOT } from "../state/paths"; @@ -34,6 +35,10 @@ export type CredentialsAddResult = { failureLines: readonly string[]; }; +export type CredentialsAddDeps = Readonly<{ + providerAdapter?: OpenShellProviderAdapter; +}>; + const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/; const CONFIG_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,127}$/; const CONFIG_KEY_DENYLIST = @@ -70,82 +75,36 @@ function managedMcpCollisionFailure( return null; } -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parseProviderProfileCredentialKeys(output: string): string[] | null { - let profile: unknown; - try { - profile = JSON.parse(output); - } catch { - return null; - } - if (!isObjectRecord(profile) || !Array.isArray(profile.credentials)) return null; - - const keys = new Set(); - for (const credential of profile.credentials) { - if (!isObjectRecord(credential) || !Array.isArray(credential.env_vars)) return null; - for (const key of credential.env_vars) { - if (typeof key !== "string" || !ENV_NAME_PATTERN.test(key)) return null; - keys.add(key); - } - } - return [...keys].sort(); -} - -function inspectProviderProfileCredentialKeys(type: string): { - credentialKeys: string[] | null; - diagnostic: string; -} { - const result = runOpenshellProviderCommand( - ["provider", "profile", "export", type, "--output", "json"], - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }, - ); - return { - credentialKeys: - result.status === 0 ? parseProviderProfileCredentialKeys(String(result.stdout || "")) : null, - diagnostic: redact(`${String(result.stderr || "")} ${String(result.stdout || "")}`).trim(), - }; -} - function bundledProviderProfilePath(type: string): string { return path.join(ROOT, "nemoclaw-blueprint", "provider-profiles", `${type.toLowerCase()}.yaml`); } -function ensureBundledProviderProfile(type: string): CredentialsAddResult | null { +async function ensureBundledProviderProfile( + type: string, + providerAdapter: OpenShellProviderAdapter, +): Promise { const profilePath = bundledProviderProfilePath(type); if (!fs.existsSync(profilePath)) return null; - const result = runOpenshellProviderCommand( - ["provider", "profile", "import", "--file", profilePath], - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }, - ); - if (result.status === 0) return null; - - const rawDiagnostic = `${String(result.stderr || "")} ${String(result.stdout || "")}`; - if (/already exists/i.test(rawDiagnostic)) return null; - - const redactedDiagnostic = redact(rawDiagnostic).trim(); + const result = await providerAdapter.importProviderProfile({ + target: selectedOpenShellGateway(), + profilePath, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + if (result.ok) return null; return fail([ ` Could not import bundled provider profile '${type}'.`, " Update OpenShell with scripts/install-openshell.sh and retry.", - ...(redactedDiagnostic ? [` ${redactedDiagnostic}`] : []), + ...(result.error.message ? [` ${result.error.message}`] : []), ]); } export async function runCredentialsAddAction( input: CredentialsAddInput, + deps: CredentialsAddDeps = {}, ): Promise { const { provider, type, credentials, configPairs, fromExisting } = input; + const providerAdapter = deps.providerAdapter ?? createCliOpenShellProviderAdapter(); if (!PROVIDER_NAME_PATTERN.test(provider)) { return fail([ @@ -251,35 +210,32 @@ export async function runCredentialsAddAction( return fail(recoveryFailureLines); } - const providerProfileFailure = ensureBundledProviderProfile(type); + const providerProfileFailure = await ensureBundledProviderProfile(type, providerAdapter); if (providerProfileFailure) return providerProfileFailure; let importedCredentialKeys: string[] | null = null; if (fromExisting) { - const inspection = inspectProviderProfileCredentialKeys(type); - if (!inspection.credentialKeys) { + const inspection = await providerAdapter.inspectProviderProfile({ + target: selectedOpenShellGateway(), + profileType: type, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + if (!inspection.ok) { return fail([ ` Could not inspect credential keys for provider profile '${type}'.`, " Refusing --from-existing because the provider profile credential keys could not be compared with managed MCP reservations.", - ...(inspection.diagnostic ? [` ${inspection.diagnostic}`] : []), + ...(inspection.error.message ? [` ${inspection.error.message}`] : []), ]); } - importedCredentialKeys = inspection.credentialKeys; + importedCredentialKeys = [...inspection.value.credentialKeys]; } - const openshellArgs: string[] = ["provider", "create", "--name", provider, "--type", type]; - if (fromExisting) { - openshellArgs.push("--from-existing"); - } else { - for (const credential of credentials) { - openshellArgs.push("--credential", credential); - } - } - for (const configPair of configPairs) { - openshellArgs.push("--config", configPair); - } + const config = configPairs.map((configPair) => { + const separator = configPair.indexOf("="); + return { key: configPair.slice(0, separator), value: configPair.slice(separator + 1) }; + }); - return withMcpCredentialOwnershipLock(() => { + return withMcpCredentialOwnershipLock(async () => { const providerCredentialKeys = importedCredentialKeys ?? credentials; const collision = managedMcpCollisionFailure( provider, @@ -291,16 +247,20 @@ export async function runCredentialsAddAction( const recordedReservation = recordExtraProvider(provider); let keepReservation = false; try { - const result = runOpenshellProviderCommand(openshellArgs, { - env: Object.fromEntries( - credentials.map((credential) => [credential, process.env[credential]]), - ), - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + const result = await providerAdapter.createProvider({ + target: selectedOpenShellGateway(), + name: provider, + type, + credentials: credentials.map((credential) => ({ + name: credential, + value: process.env[credential] ?? "", + })), + config, + fromExisting, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); - if (result.status === 0) { + if (result.ok) { keepReservation = true; return ok([ ` Registered provider '${provider}' with the OpenShell gateway.`, @@ -309,17 +269,15 @@ export async function runCredentialsAddAction( ]); } - const rawStderr = String(result.stderr || "").trim(); - const redactedStderr = redact(rawStderr); const lines = [` Could not register provider '${provider}'.`]; - if (/already exists/i.test(rawStderr)) { + if (result.error.kind === "command" && result.error.reason === "already_exists") { lines.push( "", ` '${provider}' is already registered.`, ` Run '${CLI_NAME} credentials reset ${provider} --yes' first if you need to replace it.`, ); - } else if (redactedStderr) { - lines.push(` ${redactedStderr}`); + } else if (result.error.message) { + lines.push(` ${result.error.message}`); } return fail(lines); } finally { diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts new file mode 100644 index 00000000000..8a77b756532 --- /dev/null +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; +import { setGlobalCliActionRuntimeHooksForTest } from "./global"; +import { runCredentialsAddAction } from "./credentials-add"; +import { runCredentialsListAction } from "./credentials/list"; +import { runCredentialsResetAction } from "./credentials/reset"; + +vi.mock("../onboard/gateway-teardown-authority", () => ({ + resolveGatewayCredentialMutationAuthority: vi.fn(() => ({})), +})); + +vi.mock("../state/mcp-lifecycle-lock/credential-ownership", () => ({ + withMcpCredentialOwnershipLock: (operation: () => Promise | T) => operation(), +})); + +function providerAdapter( + overrides: Partial = {}, +): OpenShellProviderAdapter { + const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ + ok: true, + value: { names: [] }, + }); + const createProvider: OpenShellProviderAdapter["createProvider"] = async () => ({ + ok: true, + value: { state: "created" }, + }); + const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ + ok: true, + value: { state: "imported" }, + }); + const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async () => ({ + ok: true, + value: { credentialKeys: [] }, + }); + const deleteProvider: OpenShellProviderAdapter["deleteProvider"] = async () => ({ + ok: true, + value: { state: "deleted" }, + }); + const detachProvider: OpenShellProviderAdapter["detachProvider"] = async () => ({ + ok: true, + value: { state: "detached" }, + }); + return { + listProviders: vi.fn(listProviders), + createProvider: vi.fn(createProvider), + importProviderProfile: vi.fn(importProviderProfile), + inspectProviderProfile: vi.fn(inspectProviderProfile), + deleteProvider: vi.fn(deleteProvider), + detachProvider: vi.fn(detachProvider), + ...overrides, + }; +} + +describe("credential actions use typed OpenShell provider results", () => { + beforeEach(() => { + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime: async () => ({ recovered: true }), + recordExtraProvider: () => true, + forgetExtraProvider: () => true, + listManagedMcpCredentialReservations: () => [], + }); + }); + + afterEach(() => { + setGlobalCliActionRuntimeHooksForTest({}); + vi.unstubAllEnvs(); + }); + + it("registers validated credential material without returning its value (#9806)", async () => { + vi.stubEnv("CUSTOM_TOKEN", "credential-value"); + const adapter = providerAdapter(); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "generic", + credentials: ["CUSTOM_TOKEN"], + configPairs: ["region=us-west"], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(adapter.createProvider).toHaveBeenCalledWith({ + target: { kind: "selected" }, + name: "custom-provider", + type: "generic", + credentials: [{ name: "CUSTOM_TOKEN", value: "credential-value" }], + config: [{ key: "region", value: "us-west" }], + fromExisting: false, + timeoutMs: 30_000, + }); + expect(JSON.stringify(result)).not.toContain("credential-value"); + }); + + it("lists credentials separately from messaging bridge providers (#9806)", async () => { + const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ + ok: true, + value: { names: ["alpha-telegram-bridge", "zeta", "alpha"] }, + }); + const adapter = providerAdapter({ + listProviders: vi.fn(listProviders), + }); + + const result = await runCredentialsListAction({ providerAdapter: adapter }); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toContain(" alpha"); + expect(result.outputLines).toContain(" zeta"); + expect(result.outputLines.join("\n")).not.toContain("alpha-telegram-bridge"); + }); + + it("preserves detach-before-delete recovery with typed failures (#9806)", async () => { + const operations: string[] = []; + const deleteProvider = vi + .fn() + .mockImplementationOnce(async () => { + operations.push("delete:first"); + return { + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha"], + }, + }; + }) + .mockImplementationOnce(async () => { + operations.push("delete:retry"); + return { ok: true, value: { state: "deleted" } }; + }); + const detachProvider = vi.fn(async () => { + operations.push("detach:alpha"); + return { ok: true, value: { state: "detached" } }; + }); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(operations).toEqual(["delete:first", "detach:alpha", "delete:retry"]); + expect(detachProvider).toHaveBeenCalledWith({ + target: { kind: "selected" }, + providerName: "custom-provider", + sandboxName: "alpha", + timeoutMs: 30_000, + }); + }); +}); diff --git a/src/lib/actions/credentials/list.ts b/src/lib/actions/credentials/list.ts index 2a77365f701..f014ac69bef 100644 --- a/src/lib/actions/credentials/list.ts +++ b/src/lib/actions/credentials/list.ts @@ -1,11 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import { createCliOpenShellProviderAdapter } from "../../adapters/openshell/provider-adapter-cli"; +import type { OpenShellProviderAdapter } from "../../adapters/openshell/provider-adapter"; +import { selectedOpenShellGateway } from "../../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import { CLI_NAME } from "../../cli/branding"; import { recoverGatewayOrExit } from "../../credentials/command-support"; -import { parseGatewayProviderNames } from "../../credentials/provider-list"; +import { classifyGatewayProviderNames } from "../../credentials/provider-list"; import { gatewayStartGuidance } from "../../gateway-start-guidance"; export type CredentialsListResult = { @@ -14,30 +16,36 @@ export type CredentialsListResult = { failureLines: readonly string[]; }; +export type CredentialsListDeps = Readonly<{ + providerAdapter?: OpenShellProviderAdapter; +}>; + function fail(failureLines: readonly string[]): CredentialsListResult { return { exitCode: 1, outputLines: [], failureLines }; } -export async function runCredentialsListAction(): Promise { +export async function runCredentialsListAction( + deps: CredentialsListDeps = {}, +): Promise { const recoveryFailureLines: string[] = []; const recovered = await recoverGatewayOrExit("query", (lines) => { recoveryFailureLines.push(...lines); }); if (!recovered) return fail(recoveryFailureLines); - const result = runOpenshellProviderCommand(["provider", "list", "--names"], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + const providerAdapter = deps.providerAdapter ?? createCliOpenShellProviderAdapter(); + const result = await providerAdapter.listProviders({ + target: selectedOpenShellGateway(), + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); - if (result.status !== 0) { + if (!result.ok) { return fail([ " Could not query OpenShell gateway. Is it running?", ` ${gatewayStartGuidance()}`, ]); } - const { bridgeNames, credentialNames } = parseGatewayProviderNames(result.stdout); + const { bridgeNames, credentialNames } = classifyGatewayProviderNames(result.value.names); const outputLines: string[] = []; if (credentialNames.length === 0) { outputLines.push(" No provider credentials registered."); diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index d240da6ad3b..0fb4244c3ab 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -1,7 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import { createCliOpenShellProviderAdapter } from "../../adapters/openshell/provider-adapter-cli"; +import type { + OpenShellProviderAdapter, + OpenShellProviderError, +} from "../../adapters/openshell/provider-adapter"; +import { selectedOpenShellGateway } from "../../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import { CLI_NAME } from "../../cli/branding"; import { @@ -9,11 +14,6 @@ import { recoverGatewayForCredentialMutationOrExit, } from "../../credentials/command-support"; import { prompt as askPrompt, KNOWN_CREDENTIAL_ENV_KEYS } from "../../credentials/store"; -import { - deleteProviderWithRecovery, - type ProviderDeleteWithRecoveryResult, -} from "../../onboard/sandbox-provider-cleanup"; -import { redact } from "../../security/redact"; import { forgetExtraProvider } from "../global"; export type CredentialsResetInput = { @@ -27,6 +27,19 @@ export type CredentialsResetResult = { failureLines: readonly string[]; }; +export type CredentialsResetDeps = Readonly<{ + providerAdapter?: OpenShellProviderAdapter; +}>; + +export type CredentialsProviderDeleteWithRecoveryResult = Readonly<{ + ok: boolean; + error?: OpenShellProviderError; + recoveryFailures: readonly Readonly<{ + sandbox: string; + error: OpenShellProviderError; + }>[]; +}>; + const KNOWN_CREDENTIAL_ENV_KEY_SET = new Set(KNOWN_CREDENTIAL_ENV_KEYS); function ok(outputLines: readonly string[]): CredentialsResetResult { @@ -39,6 +52,7 @@ function fail(failureLines: readonly string[]): CredentialsResetResult { export async function runCredentialsResetAction( input: CredentialsResetInput, + deps: CredentialsResetDeps = {}, ): Promise { const key = input.provider; if (isBridgeProviderName(key)) { @@ -65,22 +79,15 @@ export async function runCredentialsResetAction( }); if (!recovered) return fail(recoveryFailureLines); - // `provider delete` trips on FailedPrecondition while the provider is still - // attached to a sandbox. Detach listed sandboxes and retry once (#5560). - const recovery = deleteProviderWithRecovery(key, { - runOpenshell: (cmdArgs) => - runOpenshellProviderCommand(cmdArgs, { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }), - }); + const providerAdapter = deps.providerAdapter ?? createCliOpenShellProviderAdapter(); + const recovery = await deleteProviderWithRecovery(key, providerAdapter); if ( !recovery.ok && recovery.recoveryFailures.length === 0 && !KNOWN_CREDENTIAL_ENV_KEY_SET.has(key) && - /not found|does not exist|already absent/i.test(recovery.stderr.trim()) + recovery.error?.kind === "command" && + recovery.error.reason === "not_found" ) { const removedLocal = forgetExtraProvider(key); return ok([ @@ -101,7 +108,7 @@ export async function runCredentialsResetAction( /** Build the user-facing result after a provider delete attempt. */ export function formatResetOutcome( key: string, - recovery: ProviderDeleteWithRecoveryResult, + recovery: CredentialsProviderDeleteWithRecoveryResult, ): { ok: boolean; lines: string[] } { const onboardHint = ` Re-run '${CLI_NAME} onboard' to enter a new value.`; if (recovery.ok) { @@ -130,7 +137,33 @@ export function formatResetOutcome( ` for each, then re-run '${CLI_NAME} credentials reset ${key}'.`, ); } - const stderr = redact(recovery.stderr.trim()); - if (stderr) lines.push(` ${stderr}`); + if (recovery.error?.message) lines.push(` ${recovery.error.message}`); return { ok: false, lines }; } + +async function deleteProviderWithRecovery( + providerName: string, + providerAdapter: OpenShellProviderAdapter, +): Promise { + const request = { + target: selectedOpenShellGateway(), + providerName, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + } as const; + let result = await providerAdapter.deleteProvider(request); + const recoveryFailures: Array<{ sandbox: string; error: OpenShellProviderError }> = []; + if (result.ok || result.error.kind !== "command" || result.error.reason !== "attached") { + return result.ok + ? { ok: true, recoveryFailures } + : { ok: false, error: result.error, recoveryFailures }; + } + + for (const sandbox of result.error.attachedSandboxes ?? []) { + const detach = await providerAdapter.detachProvider({ ...request, sandboxName: sandbox }); + if (!detach.ok) recoveryFailures.push({ sandbox, error: detach.error }); + } + result = await providerAdapter.deleteProvider(request); + return result.ok + ? { ok: true, recoveryFailures } + : { ok: false, error: result.error, recoveryFailures }; +} diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts new file mode 100644 index 00000000000..12bb154a53c --- /dev/null +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { createCliOpenShellProviderAdapter, type RunProviderCommand } from "./provider-adapter-cli"; +import { namedOpenShellGateway, selectedOpenShellGateway } from "./sandbox-observer"; + +function captured(status: number | null, stdout = "", stderr = "", error?: Error) { + return { status, stdout, stderr, ...(error ? { error } : {}) }; +} + +describe("CLI OpenShell provider adapter", () => { + it("targets a named gateway and returns provider names (#9806)", async () => { + const run = vi.fn(() => captured(0, "zeta\nalpha\n")); + const adapter = createCliOpenShellProviderAdapter({ run }); + + await expect( + adapter.listProviders({ + target: namedOpenShellGateway("nemoclaw-18080"), + timeoutMs: 4_321, + }), + ).resolves.toEqual({ ok: true, value: { names: ["zeta", "alpha"] } }); + expect(run).toHaveBeenCalledWith(["provider", "list", "-g", "nemoclaw-18080", "--names"], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: 4_321, + }); + }); + + it("passes credential values only through the child environment (#9806)", async () => { + const run = vi.fn(() => captured(0)); + const adapter = createCliOpenShellProviderAdapter({ run }); + const credentialValue = "host-only-value"; + + await expect( + adapter.createProvider({ + target: selectedOpenShellGateway(), + name: "search-prod", + type: "tavily", + credentials: [{ name: "TAVILY_API_KEY", value: credentialValue }], + config: [{ key: "region", value: "us-west" }], + fromExisting: false, + }), + ).resolves.toEqual({ ok: true, value: { state: "created" } }); + + expect(run).toHaveBeenCalledWith( + [ + "provider", + "create", + "--name", + "search-prod", + "--type", + "tavily", + "--credential", + "TAVILY_API_KEY", + "--config", + "region=us-west", + ], + { + env: { TAVILY_API_KEY: credentialValue }, + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: 30_000, + }, + ); + expect(run.mock.calls[0]?.[0]).not.toContain(credentialValue); + }); + + it.each([ + [{ credentials: [], fromExisting: false }], + [{ credentials: [{ name: "TAVILY_API_KEY", value: "" }], fromExisting: false }], + [ + { + credentials: [{ name: "TAVILY_API_KEY", value: "credential-value" }], + fromExisting: true, + }, + ], + ])( + "rejects missing or conflicting credential material before provider creation (#9806)", + async (input) => { + const run = vi.fn(); + const adapter = createCliOpenShellProviderAdapter({ run }); + + await expect( + adapter.createProvider({ + target: selectedOpenShellGateway(), + name: "search-prod", + type: "tavily", + credentials: input.credentials, + config: [], + fromExisting: input.fromExisting, + }), + ).resolves.toEqual({ + ok: false, + error: { + kind: "validation", + message: "Provider credential input is missing or conflicts with imported credentials.", + }, + }); + expect(run).not.toHaveBeenCalled(); + }, + ); + + it("removes exact credential values from typed failures (#9806)", async () => { + const credentialValue = "unstructured-host-value"; + const adapter = createCliOpenShellProviderAdapter({ + run: () => captured(1, "", `provider rejected ${credentialValue}`), + }); + + const result = await adapter.createProvider({ + target: selectedOpenShellGateway(), + name: "search-prod", + type: "tavily", + credentials: [{ name: "TAVILY_API_KEY", value: credentialValue }], + config: [], + fromExisting: false, + }); + + expect(result).toEqual({ + ok: false, + error: { + kind: "command", + reason: "failed", + message: "provider rejected ", + }, + }); + expect(JSON.stringify(result)).not.toContain(credentialValue); + }); + + it("imports an existing profile and returns sorted credential keys (#9806)", async () => { + const run = vi + .fn() + .mockReturnValueOnce(captured(1, "", "provider profile already exists")) + .mockReturnValueOnce( + captured( + 0, + JSON.stringify({ + credentials: [ + { env_vars: ["ZETA_TOKEN", "ALPHA_TOKEN"] }, + { env_vars: ["ALPHA_TOKEN"] }, + ], + }), + ), + ); + const adapter = createCliOpenShellProviderAdapter({ run }); + + await expect( + adapter.importProviderProfile({ + target: selectedOpenShellGateway(), + profilePath: "/repo/profile.yaml", + }), + ).resolves.toEqual({ ok: true, value: { state: "already_present" } }); + await expect( + adapter.inspectProviderProfile({ + target: selectedOpenShellGateway(), + profileType: "custom", + }), + ).resolves.toEqual({ + ok: true, + value: { credentialKeys: ["ALPHA_TOKEN", "ZETA_TOKEN"] }, + }); + expect(run.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "profile", "import", "--file", "/repo/profile.yaml"], + ["provider", "profile", "export", "custom", "--output", "json"], + ]); + }); + + it("returns a schema failure for an invalid provider profile (#9806)", async () => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => captured(0, "not-json"), + }); + + await expect( + adapter.inspectProviderProfile({ + target: selectedOpenShellGateway(), + profileType: "custom", + }), + ).resolves.toEqual({ + ok: false, + error: { kind: "schema", message: "OpenShell returned an invalid provider profile." }, + }); + }); + + it("returns typed attachment names and exact detach arguments (#9806)", async () => { + const run = vi + .fn() + .mockReturnValueOnce(captured(1, "", "provider is attached to sandbox(es): alpha, beta")) + .mockReturnValueOnce(captured(0)); + const adapter = createCliOpenShellProviderAdapter({ run }); + + await expect( + adapter.deleteProvider({ + target: selectedOpenShellGateway(), + providerName: "search-prod", + }), + ).resolves.toEqual({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider is attached to sandbox(es): alpha, beta", + attachedSandboxes: ["alpha", "beta"], + }, + }); + await expect( + adapter.detachProvider({ + target: selectedOpenShellGateway(), + providerName: "search-prod", + sandboxName: "alpha", + }), + ).resolves.toEqual({ ok: true, value: { state: "detached" } }); + expect(run.mock.calls[1]?.[0]).toEqual([ + "sandbox", + "provider", + "detach", + "alpha", + "search-prod", + ]); + }); + + it.each([ + [ + "authentication", + captured(1, "", "authentication failed: credential-value"), + "OpenShell could not authenticate the provider operation.", + ], + [ + "transport", + captured(1, "", "client error (Connect): connection refused"), + "OpenShell could not reach the selected gateway.", + ], + [ + "timeout", + captured( + null, + "", + "credential-value", + Object.assign(new Error("provider create credential-value timed out"), { + code: "ETIMEDOUT", + }), + ), + "The OpenShell provider operation timed out.", + ], + [ + "transport", + captured( + null, + "", + "credential-value", + Object.assign(new Error("spawn openshell credential-value"), { code: "ENOENT" }), + ), + "OpenShell could not start the provider operation.", + ], + ])( + "maps %s failures without returning CLI diagnostics (#9806)", + async (kind, result, message) => { + const adapter = createCliOpenShellProviderAdapter({ run: () => result }); + + const mapped = await adapter.listProviders({ target: selectedOpenShellGateway() }); + + expect(mapped).toEqual({ ok: false, error: { kind, message } }); + expect(JSON.stringify(mapped)).not.toContain("credential-value"); + }, + ); +}); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts new file mode 100644 index 00000000000..ae9ee4f3422 --- /dev/null +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -0,0 +1,296 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../../name-validation"; +import { redactFull } from "../../security/redact"; +import { runOpenshellProviderCommand } from "./provider-command"; +import { + type CreateOpenShellProviderRequest, + type DeleteOpenShellProviderRequest, + type DetachOpenShellProviderRequest, + type ImportOpenShellProviderProfileRequest, + type InspectOpenShellProviderProfileRequest, + type OpenShellProviderAdapter, + type OpenShellProviderError, + type OpenShellProviderRequest, + type OpenShellProviderResult, +} from "./provider-adapter"; +import type { OpenShellGatewayTarget } from "./sandbox-observer"; +import { OPENSHELL_OPERATION_TIMEOUT_MS } from "./timeouts"; + +export type CapturedProviderCommandResult = Readonly<{ + status: number | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; + error?: Error; +}>; + +export type RunProviderCommand = ( + args: string[], + options: { + env?: Record; + ignoreError: true; + stdio: ["ignore", "pipe", "pipe"]; + timeout: number; + }, +) => CapturedProviderCommandResult; + +export type CliOpenShellProviderAdapterDeps = Readonly<{ + run?: RunProviderCommand; + defaultTimeoutMs?: number; +}>; + +const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/u; +const ANSI_RE = /\x1b\[[0-9;]*m/gu; +const ATTACHED_TO_SANDBOX_RE = /attached\s+to(?:\s|│)+sandbox\(\s*es?\s*\)?\s*:\s*([^"\n]+)/iu; +const TOLERATED_DETACH_OUTPUT_RE = + /\bNotAttached\b|\bnot\s+attached\b|provider[^\n]{0,200}?(?:\bNotFound\b|\bnot\s+found\b)/iu; + +function success(value: T): OpenShellProviderResult { + return { ok: true, value }; +} + +function failure(error: OpenShellProviderError): OpenShellProviderResult { + return { ok: false, error }; +} + +function bufferOrStringToText(value: string | Buffer | null | undefined): string { + if (typeof value === "string") return value; + return value?.toString() ?? ""; +} + +function commandOutput(result: CapturedProviderCommandResult): string { + return `${bufferOrStringToText(result.stderr)}\n${bufferOrStringToText(result.stdout)}` + .replace(ANSI_RE, "") + .trim(); +} + +function redactProviderDiagnostic(output: string, secrets: readonly string[]): string { + let safe = output; + for (const secret of secrets) { + if (secret) safe = safe.replaceAll(secret, ""); + } + return redactFull(safe).trim(); +} + +function attachedSandboxNames(output: string): string[] { + const match = ATTACHED_TO_SANDBOX_RE.exec(output); + if (!match?.[1]) return []; + return match[1] + .split(/[,\s]+/u) + .map((name) => name.trim().replace(/[.'"`]+$/u, "")) + .filter( + (name) => name.length > 0 && name.length <= NAME_MAX_LENGTH && NAME_VALID_PATTERN.test(name), + ); +} + +function commandError( + result: CapturedProviderCommandResult, + secrets: readonly string[] = [], +): OpenShellProviderError | null { + if (result.status === 0) return null; + const output = commandOutput(result); + const message = redactProviderDiagnostic(output, secrets); + const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code; + if (errorCode === "ETIMEDOUT") { + return { kind: "timeout", message: "The OpenShell provider operation timed out." }; + } + if (errorCode === "ENOENT" || errorCode === "EACCES") { + return { + kind: "transport", + message: "OpenShell could not start the provider operation.", + }; + } + if (/invalid wire type|proto(?:buf)?(?: decode| schema| wire)/iu.test(output)) { + return { + kind: "schema", + message: "The OpenShell CLI and gateway provider schemas do not match.", + }; + } + if ( + /\b(?:authentication failed|unauthorized|forbidden|permission denied|missing gateway auth token|device identity required|invalid token|expired token)\b/iu.test( + output, + ) + ) { + return { + kind: "authentication", + message: "OpenShell could not authenticate the provider operation.", + }; + } + if ( + /\b(?:handshake verification failed|connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured)\b/iu.test( + output, + ) + ) { + return { kind: "transport", message: "OpenShell could not reach the selected gateway." }; + } + if (/already exists/iu.test(output)) { + return { kind: "command", reason: "already_exists", message }; + } + const attachedSandboxes = attachedSandboxNames(output); + if (attachedSandboxes.length > 0) { + return { kind: "command", reason: "attached", message, attachedSandboxes }; + } + if (/\bNotFound\b|\bnot\s+found\b|does\s+not\s+exist|already\s+absent/iu.test(output)) { + return { kind: "command", reason: "not_found", message }; + } + return { + kind: "command", + reason: result.status === 2 ? "invalid_request" : "failed", + message: message || "The OpenShell provider operation failed.", + }; +} + +function scopedArgs(args: string[], target: OpenShellGatewayTarget): string[] { + if (target.kind === "selected") return args; + return [...args.slice(0, 2), "-g", target.gatewayName, ...args.slice(2)]; +} + +function parseProfileCredentialKeys(output: string): string[] | null { + let profile: unknown; + try { + profile = JSON.parse(output); + } catch { + return null; + } + if (typeof profile !== "object" || profile === null || Array.isArray(profile)) return null; + const credentials = Reflect.get(profile, "credentials"); + if (!Array.isArray(credentials)) return null; + const keys = new Set(); + for (const credential of credentials) { + if (typeof credential !== "object" || credential === null || Array.isArray(credential)) { + return null; + } + const envVars = Reflect.get(credential, "env_vars"); + if (!Array.isArray(envVars)) return null; + for (const key of envVars) { + if (typeof key !== "string" || !ENV_NAME_PATTERN.test(key)) return null; + keys.add(key); + } + } + return [...keys].sort(); +} + +export function createCliOpenShellProviderAdapter( + deps: CliOpenShellProviderAdapterDeps = {}, +): OpenShellProviderAdapter { + const run = deps.run ?? runOpenshellProviderCommand; + const timeoutFor = (request: OpenShellProviderRequest) => + request.timeoutMs ?? deps.defaultTimeoutMs ?? OPENSHELL_OPERATION_TIMEOUT_MS; + const invoke = ( + args: string[], + request: OpenShellProviderRequest, + env?: Record, + ) => + run(scopedArgs(args, request.target), { + ...(env ? { env } : {}), + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutFor(request), + }); + + const listProviders: OpenShellProviderAdapter["listProviders"] = async (request) => { + const result = invoke(["provider", "list", "--names"], request); + const error = commandError(result); + if (error) return failure(error); + return success({ + names: bufferOrStringToText(result.stdout) + .replace(ANSI_RE, "") + .split(/\r?\n/u) + .map((name) => name.trim()) + .filter(Boolean), + }); + }; + + const createProvider: OpenShellProviderAdapter["createProvider"] = async (request) => { + if ( + (!request.fromExisting && request.credentials.length === 0) || + (request.fromExisting && request.credentials.length > 0) || + request.credentials.some( + (credential) => !ENV_NAME_PATTERN.test(credential.name) || credential.value.length === 0, + ) + ) { + return failure({ + kind: "validation", + message: "Provider credential input is missing or conflicts with imported credentials.", + }); + } + const args = ["provider", "create", "--name", request.name, "--type", request.type]; + if (request.fromExisting) { + args.push("--from-existing"); + } else { + for (const credential of request.credentials) args.push("--credential", credential.name); + } + for (const entry of request.config) args.push("--config", `${entry.key}=${entry.value}`); + const env = Object.fromEntries( + request.credentials.map((credential) => [credential.name, credential.value]), + ); + const result = invoke(args, request, env); + const error = commandError(result, Object.values(env)); + return error ? failure(error) : success({ state: "created" }); + }; + + const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async ( + request: ImportOpenShellProviderProfileRequest, + ) => { + const result = invoke( + ["provider", "profile", "import", "--file", request.profilePath], + request, + ); + const error = commandError(result); + if (error?.kind === "command" && error.reason === "already_exists") { + return success({ state: "already_present" }); + } + return error ? failure(error) : success({ state: "imported" }); + }; + + const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async ( + request: InspectOpenShellProviderProfileRequest, + ) => { + const result = invoke( + ["provider", "profile", "export", request.profileType, "--output", "json"], + request, + ); + const error = commandError(result); + if (error) return failure(error); + const credentialKeys = parseProfileCredentialKeys(bufferOrStringToText(result.stdout)); + return credentialKeys + ? success({ credentialKeys }) + : failure({ + kind: "schema", + message: "OpenShell returned an invalid provider profile.", + }); + }; + + const deleteProvider: OpenShellProviderAdapter["deleteProvider"] = async ( + request: DeleteOpenShellProviderRequest, + ) => { + const result = invoke(["provider", "delete", request.providerName], request); + const error = commandError(result); + return error ? failure(error) : success({ state: "deleted" }); + }; + + const detachProvider: OpenShellProviderAdapter["detachProvider"] = async ( + request: DetachOpenShellProviderRequest, + ) => { + const result = invoke( + ["sandbox", "provider", "detach", request.sandboxName, request.providerName], + request, + ); + const output = commandOutput(result); + if (result.status !== 0 && TOLERATED_DETACH_OUTPUT_RE.test(output)) { + return success({ state: "absent" }); + } + const error = commandError(result); + return error ? failure(error) : success({ state: "detached" }); + }; + + return { + listProviders, + createProvider, + importProviderProfile, + inspectProviderProfile, + deleteProvider, + detachProvider, + }; +} diff --git a/src/lib/adapters/openshell/provider-adapter.ts b/src/lib/adapters/openshell/provider-adapter.ts new file mode 100644 index 00000000000..e5569a6568a --- /dev/null +++ b/src/lib/adapters/openshell/provider-adapter.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OpenShellGatewayTarget } from "./sandbox-observer"; + +export type OpenShellProviderCommandReason = + | "already_exists" + | "attached" + | "failed" + | "invalid_request" + | "not_found"; + +export type OpenShellProviderError = + | Readonly<{ + kind: "authentication" | "schema" | "timeout" | "transport" | "validation"; + message: string; + }> + | Readonly<{ + kind: "command"; + reason: OpenShellProviderCommandReason; + message: string; + attachedSandboxes?: readonly string[]; + }>; + +export type OpenShellProviderResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; error: OpenShellProviderError }>; + +export type OpenShellProviderRequest = Readonly<{ + target: OpenShellGatewayTarget; + timeoutMs?: number; +}>; + +export type OpenShellProviderInventory = Readonly<{ + names: readonly string[]; +}>; + +export type OpenShellProviderProfileInspection = Readonly<{ + credentialKeys: readonly string[]; +}>; + +export type CreateOpenShellProviderRequest = OpenShellProviderRequest & + Readonly<{ + name: string; + type: string; + credentials: readonly Readonly<{ name: string; value: string }>[]; + config: readonly Readonly<{ key: string; value: string }>[]; + fromExisting: boolean; + }>; + +export type ImportOpenShellProviderProfileRequest = OpenShellProviderRequest & + Readonly<{ + profilePath: string; + }>; + +export type InspectOpenShellProviderProfileRequest = OpenShellProviderRequest & + Readonly<{ + profileType: string; + }>; + +export type DeleteOpenShellProviderRequest = OpenShellProviderRequest & + Readonly<{ + providerName: string; + }>; + +export type DetachOpenShellProviderRequest = DeleteOpenShellProviderRequest & + Readonly<{ + sandboxName: string; + }>; + +export type OpenShellProviderProfileImport = Readonly<{ + state: "already_present" | "imported"; +}>; + +export type OpenShellProviderCreate = Readonly<{ + state: "created"; +}>; + +export type OpenShellProviderDelete = Readonly<{ + state: "deleted"; +}>; + +export type OpenShellProviderDetach = Readonly<{ + state: "absent" | "detached"; +}>; + +/** Transport-neutral provider capabilities used by NemoClaw credential actions. */ +export interface OpenShellProviderAdapter { + listProviders( + request: OpenShellProviderRequest, + ): Promise>; + + createProvider( + request: CreateOpenShellProviderRequest, + ): Promise>; + + importProviderProfile( + request: ImportOpenShellProviderProfileRequest, + ): Promise>; + + inspectProviderProfile( + request: InspectOpenShellProviderProfileRequest, + ): Promise>; + + deleteProvider( + request: DeleteOpenShellProviderRequest, + ): Promise>; + + detachProvider( + request: DetachOpenShellProviderRequest, + ): Promise>; +} diff --git a/src/lib/credentials/provider-list.ts b/src/lib/credentials/provider-list.ts index 7f7d8854799..c8dba019b5a 100644 --- a/src/lib/credentials/provider-list.ts +++ b/src/lib/credentials/provider-list.ts @@ -9,6 +9,17 @@ export function isBridgeProviderName(name: string): boolean { return BRIDGE_PROVIDER_SUFFIXES.some((suffix) => name.endsWith(suffix)); } +export function classifyGatewayProviderNames(names: readonly string[]): { + bridgeNames: string[]; + credentialNames: string[]; +} { + return { + bridgeNames: names.filter((name) => isBridgeProviderName(name)), + credentialNames: names.filter((name) => !isBridgeProviderName(name)).sort(), + }; +} + +/** @deprecated CLI output parsing belongs to the OpenShell CLI adapter. */ export function parseGatewayProviderNames(output: unknown): { bridgeNames: string[]; credentialNames: string[]; @@ -17,8 +28,5 @@ export function parseGatewayProviderNames(output: unknown): { .split("\n") .map((name) => name.trim()) .filter((name) => name.length > 0); - return { - bridgeNames: allNames.filter((name) => isBridgeProviderName(name)), - credentialNames: allNames.filter((name) => !isBridgeProviderName(name)).sort(), - }; + return classifyGatewayProviderNames(allNames); } diff --git a/test/credentials-reset-outcome.test.ts b/test/credentials-reset-outcome.test.ts index efbfe378b3a..974ca1ed2be 100644 --- a/test/credentials-reset-outcome.test.ts +++ b/test/credentials-reset-outcome.test.ts @@ -3,15 +3,16 @@ import { describe, expect, it } from "vitest"; -import { formatResetOutcome } from "../src/lib/actions/credentials/reset"; -import type { ProviderDeleteWithRecoveryResult } from "../src/lib/onboard/sandbox-provider-cleanup"; +import { + type CredentialsProviderDeleteWithRecoveryResult, + formatResetOutcome, +} from "../src/lib/actions/credentials/reset"; -function result(over: Partial): ProviderDeleteWithRecoveryResult { +function result( + over: Partial, +): CredentialsProviderDeleteWithRecoveryResult { return { ok: false, - status: 1, - stderr: "", - stdout: "", recoveryFailures: [], ...over, }; @@ -19,10 +20,7 @@ function result(over: Partial): ProviderDelete describe("formatResetOutcome (#5560)", () => { it("reports a clean removal when no detach was needed", () => { - const outcome = formatResetOutcome( - "my-assistant-brave-search", - result({ ok: true, status: 0 }), - ); + const outcome = formatResetOutcome("my-assistant-brave-search", result({ ok: true })); expect(outcome.ok).toBe(true); expect(outcome.lines[0]).toContain("Removed provider 'my-assistant-brave-search'"); expect(outcome.lines.join("\n")).toContain("onboard"); @@ -33,8 +31,18 @@ describe("formatResetOutcome (#5560)", () => { "my-assistant-brave-search", result({ ok: false, - stderr: "FailedPrecondition: provider attached to sandbox(es): my-assistant", - recoveryFailures: [{ sandbox: "my-assistant", output: "detach refused" }], + error: { + kind: "command", + reason: "attached", + message: "FailedPrecondition: provider attached to sandbox(es): my-assistant", + attachedSandboxes: ["my-assistant"], + }, + recoveryFailures: [ + { + sandbox: "my-assistant", + error: { kind: "command", reason: "failed", message: "detach refused" }, + }, + ], }), ); expect(outcome.ok).toBe(false); @@ -45,7 +53,7 @@ describe("formatResetOutcome (#5560)", () => { }); it("hints when the argument looks like an env var name instead of a provider", () => { - const outcome = formatResetOutcome("BRAVE_API_KEY", result({ ok: false, status: 1 })); + const outcome = formatResetOutcome("BRAVE_API_KEY", result({ ok: false })); expect(outcome.ok).toBe(false); expect(outcome.lines.join("\n")).toContain("looks like a credential env variable name"); }); From 101246c10c7a9811270e2d6d80424da4f80d858f Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:02:15 -0700 Subject: [PATCH 06/57] test(cli): exercise doctor observation from source Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/lib/actions/sandbox/doctor-flow.test.ts | 41 ----- .../doctor-observation-failure.test.ts | 149 ++++++++++++++++++ 2 files changed, 149 insertions(+), 41 deletions(-) create mode 100644 src/lib/actions/sandbox/doctor-observation-failure.test.ts diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index 37ef80d91c6..c7f1147a987 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -503,47 +503,6 @@ describe("runSandboxDoctor flow", () => { }, ); - it.each([ - { - label: "authentication", - commandOutput: "Error: authentication failed: bearer credential-value", - expectedDetail: "OpenShell could not authenticate the sandbox observation.", - expectedHint: "restore OpenShell authentication for gateway 'nemoclaw-19080'", - }, - { - label: "transport", - commandOutput: "Status: Disconnected", - expectedDetail: "OpenShell could not reach the selected gateway.", - expectedHint: "run `openshell status`, restore gateway 'nemoclaw-19080'", - }, - ])( - "reports a failed $label observation without classifying the sandbox as absent (#9803)", - async ({ commandOutput, expectedDetail, expectedHint }) => { - const harness = createDoctorHarness(); - harness.captureOpenShellSpy.mockImplementation((args: unknown) => { - const argv = Array.isArray(args) ? args : []; - return argv[0] === "sandbox" && argv[1] === "list" - ? { status: 1, output: commandOutput } - : { status: 0, output: "" }; - }); - - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - const liveSandbox = report?.checks.find( - (check) => check.group === "Sandbox" && check.label === "Live sandbox", - ); - - expect(liveSandbox).toMatchObject({ - status: "fail", - detail: expect.stringContaining(expectedDetail), - hint: expect.stringContaining(expectedHint), - }); - const rendered = `${liveSandbox?.detail ?? ""}\n${liveSandbox?.hint ?? ""}`; - expect(rendered).not.toContain("not present"); - expect(rendered).not.toContain("recreate"); - expect(rendered).not.toContain("credential-value"); - }, - ); - it("fails the JSON host check for an unknown durable runtime provider", async () => { const harness = createDoctorHarness(); harness.getSandboxSpy.mockReturnValue({ diff --git a/src/lib/actions/sandbox/doctor-observation-failure.test.ts b/src/lib/actions/sandbox/doctor-observation-failure.test.ts new file mode 100644 index 00000000000..17659f07b13 --- /dev/null +++ b/src/lib/actions/sandbox/doctor-observation-failure.test.ts @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenShellSandboxError } from "../../adapters/openshell/sandbox-observer"; + +const mocks = vi.hoisted(() => ({ + listSandboxes: vi.fn(), +})); + +vi.mock("../../adapters/openshell/sandbox-observer-cli", () => ({ + createCliOpenShellSandboxObserver: () => ({ listSandboxes: mocks.listSandboxes }), + stripOpenShellCliAnsi: (value: string) => value, +})); + +vi.mock("../../adapters/openshell/resolve", () => ({ + resolveOpenshell: () => "/usr/bin/openshell", +})); + +vi.mock("../../adapters/openshell/runtime", () => ({ + captureOpenshell: () => ({ status: 0, output: "" }), +})); + +vi.mock("../../agent/defs", () => ({ + getAgentRuntimeKind: () => "gateway", + loadAgent: () => ({ name: "openclaw" }), +})); + +vi.mock("../../gateway-runtime-action", () => ({ + getNamedGatewayLifecycleState: () => ({ + state: "healthy_named", + status: "Status: Connected", + gatewayInfo: "Gateway: nemoclaw-19080", + }), + recoverNamedGatewayRuntime: vi.fn(), +})); + +vi.mock("../../onboard/gateway-binding", () => ({ + resolveGatewayName: () => "nemoclaw-19080", + resolveSandboxGatewayName: () => "nemoclaw-19080", +})); + +vi.mock("../../onboard/runtime-provider/access", () => ({ + CURRENT_RUNTIME_PROVIDER_BUNDLES: [], + RuntimeProviderSelectionError: class RuntimeProviderSelectionError extends Error {}, + requireRuntimeProviderBundle: vi.fn(), + resolveCurrentRuntimeProviderBundle: () => ({ + preflightDoctor: { + inspectHost: () => ({ + group: "Host", + label: "Runtime provider", + status: "ok", + detail: "available", + }), + }, + }), +})); + +vi.mock("../../state/registry", () => ({ + getSandbox: () => null, + getBaselineExclusionTransition: () => null, + getBaselineExclusions: () => [], +})); + +vi.mock("./doctor-inference", () => ({ + collectInferenceChecks: () => [], + collectManagedLlamaCppDoctorChecks: () => [], + resolveDoctorReasoningEffort: () => undefined, +})); + +vi.mock("./doctor-system-checks", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cloudflaredDoctorCheck: () => ({ + group: "Local services", + label: "cloudflared", + status: "info", + detail: "not inspected", + }), + inspectSandboxDoctorPortableAuthority: () => ({ kind: "absent" }), + ollamaDoctorCheck: () => ({ + group: "Local services", + label: "Ollama", + status: "info", + detail: "not inspected", + }), + shouldInspectLegacyGatewayContainer: () => false, + withSandboxDoctorLifecycleLock: async ( + _sandboxName: string, + operation: () => Promise, + ) => await operation(), + }; +}); + +import { runSandboxDoctor } from "./doctor"; + +describe("doctor live sandbox observation", () => { + beforeEach(() => { + mocks.listSandboxes.mockReset(); + }); + + it.each<{ + label: string; + error: OpenShellSandboxError; + expectedDetail: string; + expectedHint: string; + }>([ + { + label: "authentication", + error: { + kind: "authentication", + message: "OpenShell could not authenticate the sandbox observation.", + }, + expectedDetail: "OpenShell could not authenticate the sandbox observation.", + expectedHint: "restore OpenShell authentication for gateway 'nemoclaw-19080'", + }, + { + label: "transport", + error: { + kind: "transport", + reason: "unreachable", + message: "OpenShell could not reach the selected gateway.", + }, + expectedDetail: "OpenShell could not reach the selected gateway.", + expectedHint: "run `openshell status`, restore gateway 'nemoclaw-19080'", + }, + ])( + "reports a failed $label observation without classifying the sandbox as absent (#9803)", + async ({ error, expectedDetail, expectedHint }) => { + mocks.listSandboxes.mockResolvedValue({ ok: false, error }); + + const report = await runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const liveSandbox = report?.checks.find( + (check) => check.group === "Sandbox" && check.label === "Live sandbox", + ); + + expect(liveSandbox).toMatchObject({ + status: "fail", + detail: expect.stringContaining(expectedDetail), + hint: expect.stringContaining(expectedHint), + }); + const rendered = `${liveSandbox?.detail ?? ""}\n${liveSandbox?.hint ?? ""}`; + expect(rendered).not.toContain("not present"); + expect(rendered).not.toContain("recreate"); + expect(rendered).not.toContain("credential-value"); + }, + ); +}); From 0e412dfd984e96fbf48361be18470719b15fc504 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:33:06 -0700 Subject: [PATCH 07/57] fix(cli): fail closed on sandbox observation errors Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/lib/openshell-sandbox-list.test.ts | 119 +++++++++++++++++++------ src/lib/openshell-sandbox-list.ts | 42 +++------ 2 files changed, 105 insertions(+), 56 deletions(-) diff --git a/src/lib/openshell-sandbox-list.test.ts b/src/lib/openshell-sandbox-list.test.ts index d431e717bc4..74232075ea8 100644 --- a/src/lib/openshell-sandbox-list.test.ts +++ b/src/lib/openshell-sandbox-list.test.ts @@ -28,13 +28,25 @@ vi.mock("./gateway-runtime-action", () => ({ import { captureNamedGatewaySandboxListReadOnly, captureSandboxListWithGatewayPreflightOrExit, + captureSandboxListWithGatewayRecovery, } from "./openshell-sandbox-list"; +import type { + OpenShellSandboxInventory, + OpenShellSandboxObserver, + OpenShellSandboxResult, +} from "./adapters/openshell/sandbox-observer"; const context = { action: "checking sandbox state", command: "nemoclaw test-command", }; +function observerReturning( + result: OpenShellSandboxResult, +): OpenShellSandboxObserver { + return { listSandboxes: vi.fn().mockResolvedValue(result) }; +} + const imageDriftIssue: OpenShellStateRpcIssue = { kind: "image_drift", drift: { @@ -102,7 +114,7 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { expect(exitSpy).not.toHaveBeenCalled(); }); - it("proves and recovers an explicit gateway instead of the current selection (#6114)", async () => { + it("recovers an unreachable explicit gateway after its scoped observation (#6114)", async () => { const options = { gatewayName: "nemoclaw-12345" }; mocks.captureOpenshell .mockReturnValueOnce({ status: 1, output: "Status: Disconnected" }) @@ -120,8 +132,8 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { "connected_other", ], }; - expect(mocks.recoverNamedGatewayRuntime).toHaveBeenNthCalledWith(1, expectedRecoveryOptions); - expect(mocks.recoverNamedGatewayRuntime).toHaveBeenNthCalledWith(2, expectedRecoveryOptions); + expect(mocks.recoverNamedGatewayRuntime).toHaveBeenCalledOnce(); + expect(mocks.recoverNamedGatewayRuntime).toHaveBeenCalledWith(expectedRecoveryOptions); expect(mocks.captureOpenshell).toHaveBeenCalledWith( ["sandbox", "list", "-g", "nemoclaw-12345"], expect.anything(), @@ -129,34 +141,19 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { expect(exitSpy).not.toHaveBeenCalled(); }); - it("fails closed when target selection fails while a sibling list is healthy (#6114)", async () => { + it("observes a healthy explicit gateway without mutating gateway state (#6114)", async () => { const options = { gatewayName: "nemoclaw-12345" }; - mocks.recoverNamedGatewayRuntime.mockResolvedValueOnce({ - recovered: false, - attempted: true, - before: { state: "connected_other", activeGateway: "nemoclaw" }, - after: { state: "connected_other", activeGateway: "nemoclaw" }, + mocks.captureOpenshell.mockReturnValue({ status: 0, output: "alpha Ready" }); + + await expect(captureSandboxListWithGatewayPreflightOrExit(context, options)).resolves.toEqual({ + sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }], }); - // This is the process-global sibling output that must never be accepted - // after the target gateway select/verification fails. - mocks.captureOpenshell.mockReturnValue({ status: 0, output: "default-box Ready" }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - await expect(captureSandboxListWithGatewayPreflightOrExit(context, options)).rejects.toThrow( - "process.exit(1)", + expect(mocks.captureOpenshell).toHaveBeenCalledWith( + ["sandbox", "list", "-g", "nemoclaw-12345"], + expect.anything(), ); - - expect(mocks.recoverNamedGatewayRuntime).toHaveBeenCalledWith({ - gatewayName: "nemoclaw-12345", - recoverableStates: [ - "missing_named", - "named_unhealthy", - "named_unreachable", - "connected_other", - ], - }); - expect(mocks.captureOpenshell).not.toHaveBeenCalled(); - expect(errorSpy.mock.calls.flat().join("\n")).toContain("recovery did not complete"); + expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); }); it("recovers a disconnected gateway once and retries the sandbox list", async () => { @@ -261,6 +258,29 @@ describe("sandbox list gateway preflight and recovery (#6237)", () => { expect(errorSpy.mock.calls.flat().join("\n")).toContain("Failed to query running sandboxes"); }); + it("does not mutate a named gateway after an identity mismatch (#9803)", async () => { + const result = { + ok: false, + error: { + kind: "transport", + reason: "identity_mismatch", + message: "The selected OpenShell gateway identity does not match the recorded identity.", + }, + } as const; + + await expect( + captureSandboxListWithGatewayRecovery({ + gatewayName: "nemoclaw-12345", + observer: observerReturning(result), + }), + ).resolves.toEqual({ + result, + recoveryAttempted: false, + recoverySucceeded: false, + }); + expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); + }); + it("classifies protobuf mismatch before recovery or generic failure handling", async () => { const issue: OpenShellStateRpcIssue = { kind: "protobuf_mismatch", @@ -328,6 +348,51 @@ describe("read-only named-gateway sandbox list (#7279)", () => { expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); }); + it.each([ + { + label: "authentication", + error: { + kind: "authentication", + message: "OpenShell could not authenticate the sandbox observation.", + }, + exitCode: 1, + }, + { + label: "identity mismatch", + error: { + kind: "transport", + reason: "identity_mismatch", + message: "The selected OpenShell gateway identity does not match the recorded identity.", + }, + exitCode: 1, + }, + { + label: "invalid command", + error: { + kind: "command", + reason: "invalid_request", + message: "OpenShell rejected the sandbox observation request.", + }, + exitCode: 2, + }, + ] as const)( + "fails closed on a $label observation failure (#9803)", + async ({ error, exitCode }) => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect( + captureNamedGatewaySandboxListReadOnly( + context, + "nemoclaw-18080", + observerReturning({ ok: false, error }), + ), + ).rejects.toThrow(`process.exit(${exitCode})`); + + expect(errorSpy.mock.calls.flat().join("\n")).toContain(error.message); + expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); + }, + ); + it("still exits on a state-RPC result drift issue", async () => { mocks.captureOpenshell.mockReturnValue({ status: 1, diff --git a/src/lib/openshell-sandbox-list.ts b/src/lib/openshell-sandbox-list.ts index 8187c55e339..11d891cbf1a 100644 --- a/src/lib/openshell-sandbox-list.ts +++ b/src/lib/openshell-sandbox-list.ts @@ -35,7 +35,7 @@ export type CaptureSandboxListWithGatewayRecoveryOptions = { }; function isRecoverableObservedSandboxListGatewayFailure(result: SandboxListResult): boolean { - return !result.ok && result.error.kind === "transport"; + return !result.ok && result.error.kind === "transport" && result.error.reason === "unreachable"; } export async function captureSandboxListWithGatewayRecovery( @@ -53,30 +53,6 @@ export async function captureSandboxListWithGatewayRecovery( recoveryOptions.gatewayName = options.gatewayName; } - // An explicit target must be proven healthy and active before an unscoped - // `sandbox list` can be trusted. OpenShell otherwise leaves a failed select - // on the current sibling gateway, whose successful list would be unsafe - // evidence for destructive recovery decisions (#6114). - let targetRecoveryAttempted = false; - if (options.gatewayName) { - const targetRecovery = await recoverNamedGatewayRuntime(recoveryOptions); - targetRecoveryAttempted = targetRecovery.attempted === true; - if (!targetRecovery.recovered) { - return { - result: { - ok: false, - error: { - kind: "transport", - reason: "unreachable", - message: "OpenShell could not reach the selected gateway.", - }, - }, - recoveryAttempted: targetRecovery.attempted === true, - recoverySucceeded: false, - }; - } - } - const target = options.gatewayName ? namedOpenShellGateway(options.gatewayName) : selectedOpenShellGateway(); @@ -84,8 +60,8 @@ export async function captureSandboxListWithGatewayRecovery( if (!isRecoverableObservedSandboxListGatewayFailure(initial)) { return { result: initial, - recoveryAttempted: targetRecoveryAttempted, - recoverySucceeded: targetRecoveryAttempted, + recoveryAttempted: false, + recoverySucceeded: false, }; } @@ -152,11 +128,19 @@ export async function captureNamedGatewaySandboxListReadOnly( } const result = await observer.listSandboxes({ target: namedOpenShellGateway(gatewayName) }); - if (!result.ok && result.error.kind === "schema") { + if (result.ok) return result.value; + if (result.error.kind === "transport" && result.error.reason === "unreachable") { + return { sandboxes: [] }; + } + if (result.error.kind === "schema") { printOpenShellStateRpcIssue({ kind: "protobuf_mismatch", drift: null, output: "" }, context); process.exit(1); } - return result.ok ? result.value : { sandboxes: [] }; + console.error(" Failed to query running sandboxes from OpenShell."); + console.error(` ${result.error.message}`); + process.exit( + result.error.kind === "command" && result.error.reason === "invalid_request" ? 2 : 1, + ); } export function printSandboxListFailureWithRecoveryContext( From 5dbab5d614129dc71efe8d418a2e0fe7123334ce Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:55:50 -0700 Subject: [PATCH 08/57] test(cli): align rebuild recovery expectations Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../actions/sandbox/rebuild-gateway-drift.test.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts index 9c1764f95c2..869e0ba4010 100644 --- a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts @@ -336,12 +336,8 @@ describe("rebuild gateway drift preflight", () => { staleRegistrySnapshot: registrySnapshot, }); expect(result?.staleRegistrySnapshot).not.toBe(registrySnapshot); - expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledTimes(2); - expect(recoverNamedGatewayRuntimeSpy).toHaveBeenNthCalledWith(1, { - gatewayName, - recoverableStates: recoveryStates, - }); - expect(recoverNamedGatewayRuntimeSpy).toHaveBeenNthCalledWith(2, { + expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledOnce(); + expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledWith({ gatewayName, recoverableStates: recoveryStates, }); @@ -381,11 +377,7 @@ describe("rebuild gateway drift preflight", () => { "Failed to query running sandboxes from OpenShell.", ); - expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledOnce(); - expect(recoverNamedGatewayRuntimeSpy).toHaveBeenCalledWith({ - gatewayName: "nemoclaw", - recoverableStates: recoveryStates, - }); + expect(recoverNamedGatewayRuntimeSpy).not.toHaveBeenCalled(); expect(captureOpenshellSpy).toHaveBeenCalledOnce(); expect(captureOpenshellSpy).toHaveBeenCalledWith( ["sandbox", "list", "-g", "nemoclaw"], From 0e7ea66c0fdf469f31e658bd7fe2f1e5a5cbadc1 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:06:34 -0700 Subject: [PATCH 09/57] fix(cli): address provider adapter review findings Signed-off-by: Rebecca Sliter --- docs/get-started/quickstart.mdx | 2 +- .../credentials-provider-adapter.test.ts | 71 +++++++++++++++++++ src/lib/actions/credentials/list.ts | 13 ++-- src/lib/actions/credentials/reset.ts | 2 +- ...inference-set-provider-diagnostics.test.ts | 15 ++-- .../inference-set-provider-diagnostics.ts | 6 +- .../openshell/provider-adapter-cli.test.ts | 48 ++++++++++++- .../openshell/provider-adapter-cli.ts | 41 ++++++++--- .../adapters/openshell/provider-adapter.ts | 9 ++- src/lib/credentials/provider-list.ts | 12 ---- src/lib/onboard/dashboard.ts | 2 +- test/onboarding/onboard-dashboard.test.ts | 2 + .../cli/credentials-cli-command.test.ts | 16 +++-- 13 files changed, 197 insertions(+), 42 deletions(-) diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 3c5479d1607..d71388621eb 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -430,7 +430,7 @@ Use these details when your first-run path needs more control. Logs: nemoclaw my-gpt-claw logs --follow Model: nemoclaw inference set --model --provider --sandbox my-gpt-claw Policies: nemoclaw my-gpt-claw policy add - Credentials: nemoclaw credentials reset && nemoclaw onboard + Credentials: nemoclaw credentials reset && nemoclaw onboard ────────────────────────────────────────────────── ``` diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 1de624c5964..21a8edfdd47 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -113,8 +113,50 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.outputLines).toContain(" alpha"); expect(result.outputLines).toContain(" zeta"); expect(result.outputLines.join("\n")).not.toContain("alpha-telegram-bridge"); + expect(result.outputLines).toContain(" Inspect: `nemoclaw channels list`"); + expect(result.outputLines).toContain( + " Retire and clear credentials: `nemoclaw channels remove `", + ); + expect(result.outputLines).toContain( + " Pause without clearing credentials: `nemoclaw channels stop `", + ); }); + it.each([ + [ + "authentication", + "OpenShell could not authenticate the provider operation.", + false, + undefined, + ], + ["schema", "The OpenShell CLI and gateway provider schemas do not match.", false, undefined], + ["timeout", "The OpenShell provider operation timed out.", false, undefined], + ["command", "OpenShell rejected the provider query.", false, undefined], + ["transport", "OpenShell could not start the provider operation.", false, "process_start"], + ["transport", "OpenShell could not reach the selected gateway.", true, "unreachable"], + ] as const)( + "uses the typed %s provider-list failure for recovery guidance (#9806)", + async (kind, message, includesStartGuidance, reason) => { + const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ + ok: false, + error: + kind === "command" + ? { kind, reason: "failed", message } + : kind === "transport" + ? { kind, reason, message } + : { kind, message }, + }); + const adapter = providerAdapter({ listProviders: vi.fn(listProviders) }); + + const result = await runCredentialsListAction("nemoclaw", { providerAdapter: adapter }); + const failure = result.failureLines.join("\n"); + + expect(result.exitCode).toBe(1); + expect(failure).toContain(message); + expect(result.failureLines).toHaveLength(includesStartGuidance ? 3 : 2); + }, + ); + it("preserves detach-before-delete recovery with typed failures (#9806)", async () => { const operations: string[] = []; const deleteProvider = vi @@ -155,4 +197,33 @@ describe("credential actions use typed OpenShell provider results", () => { timeoutMs: 30_000, }); }); + + it.each([ + ["absent", undefined], + ["empty", []], + ] as const)( + "does not detach an unvalidated %s attachment list (#9806)", + async (_label, attachedSandboxes) => { + const deleteProvider = vi.fn().mockResolvedValue({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + ...(attachedSandboxes ? { attachedSandboxes: [...attachedSandboxes] } : {}), + }, + }); + const detachProvider = vi.fn(); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(deleteProvider).toHaveBeenCalledTimes(2); + expect(detachProvider).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/lib/actions/credentials/list.ts b/src/lib/actions/credentials/list.ts index 877cfb9bc43..f5db5ed1d16 100644 --- a/src/lib/actions/credentials/list.ts +++ b/src/lib/actions/credentials/list.ts @@ -39,10 +39,11 @@ export async function runCredentialsListAction( timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); if (!result.ok) { - return fail([ - " Could not query OpenShell gateway. Is it running?", - ` ${gatewayStartGuidance()}`, - ]); + const failureLines = [" Could not query OpenShell providers.", ` ${result.error.message}`]; + if (result.error.kind === "transport" && result.error.reason === "unreachable") { + failureLines.push(` ${gatewayStartGuidance()}`); + } + return fail(failureLines); } const { bridgeNames, credentialNames } = classifyGatewayProviderNames(result.value.names); @@ -57,7 +58,9 @@ export async function runCredentialsListAction( outputLines.push( "", ` ${String(bridgeNames.length)} per-sandbox messaging bridge(s) are also registered.`, - ` Manage those with \`${cliName} channels list/remove/stop\` — not this command.`, + ` Inspect: \`${cliName} channels list\``, + ` Retire and clear credentials: \`${cliName} channels remove \``, + ` Pause without clearing credentials: \`${cliName} channels stop \``, ); } return { exitCode: 0, outputLines, failureLines: [] }; diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index 0fb4244c3ab..c09420b186e 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -60,7 +60,7 @@ export async function runCredentialsResetAction( ` '${key}' is a per-sandbox messaging bridge, not a credential.`, ` Use \`${CLI_NAME} channels remove \` to retire`, " the integration (it tears down the bridge provider and rebuilds the sandbox),", - ` or \`${CLI_NAME} channels stop <…>\` to pause it without clearing tokens.`, + ` or \`${CLI_NAME} channels stop \` to pause it without clearing tokens.`, ]); } diff --git a/src/lib/actions/inference-set-provider-diagnostics.test.ts b/src/lib/actions/inference-set-provider-diagnostics.test.ts index adcbb068076..e8d3197a638 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.test.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.test.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; -import { isBridgeProviderName, parseGatewayProviderNames } from "../credentials/provider-list"; +import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-adapter-cli"; +import { classifyGatewayProviderNames, isBridgeProviderName } from "../credentials/provider-list"; import { queryRegisteredGatewayProviders } from "./inference-set-provider-diagnostics"; const STATIC_WARNING = @@ -29,11 +30,17 @@ describe("inference set provider diagnostics", () => { }); it("partitions empty and messaging-only provider output", () => { - expect(parseGatewayProviderNames("")).toEqual({ bridgeNames: [], credentialNames: [] }); - expect(parseGatewayProviderNames("alpha-telegram-bridge\nalpha-slack-app\n")).toEqual({ - bridgeNames: ["alpha-telegram-bridge", "alpha-slack-app"], + expect(classifyGatewayProviderNames(parseCliOpenShellProviderNames(""))).toEqual({ + bridgeNames: [], credentialNames: [], }); + expect( + classifyGatewayProviderNames( + parseCliOpenShellProviderNames( + " \u001b[32malpha-telegram-bridge\u001b[0m \r\n alpha-slack-app \r\n", + ), + ), + ).toEqual({ bridgeNames: ["alpha-telegram-bridge", "alpha-slack-app"], credentialNames: [] }); expect(isBridgeProviderName("alpha-discord-bridge")).toBe(true); expect(isBridgeProviderName("nvidia-prod")).toBe(false); }); diff --git a/src/lib/actions/inference-set-provider-diagnostics.ts b/src/lib/actions/inference-set-provider-diagnostics.ts index eef83de9bf2..494dc06e7c9 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import type { CaptureOpenshellOptions, CaptureOpenshellResult } from "../adapters/openshell/client"; -import { parseGatewayProviderNames } from "../credentials/provider-list"; +import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-adapter-cli"; +import { classifyGatewayProviderNames } from "../credentials/provider-list"; import { buildOpenshellInferenceSetFailureMessage, OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER, @@ -29,7 +30,8 @@ export function queryRegisteredGatewayProviders( timeout: OPEN_SHELL_DIAGNOSTIC_TIMEOUT_MS, }); if (result.status === 0) { - return parseGatewayProviderNames(result.output).credentialNames; + return classifyGatewayProviderNames(parseCliOpenShellProviderNames(result.output)) + .credentialNames; } } catch (_error: unknown) { // #5924: intentionally treat every thrown query or parsing error identically. diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 12bb154a53c..b750b2e41c8 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -219,16 +219,55 @@ describe("CLI OpenShell provider adapter", () => { ]); }); + it("places a named gateway flag before detach arguments (#9806)", async () => { + const run = vi.fn(() => captured(0)); + const adapter = createCliOpenShellProviderAdapter({ run }); + + await expect( + adapter.detachProvider({ + target: namedOpenShellGateway("nemoclaw-18080"), + providerName: "search-prod", + sandboxName: "alpha", + }), + ).resolves.toEqual({ ok: true, value: { state: "detached" } }); + expect(run).toHaveBeenCalledWith( + ["sandbox", "provider", "detach", "-g", "nemoclaw-18080", "alpha", "search-prod"], + expect.objectContaining({ ignoreError: true, timeout: 30_000 }), + ); + }); + + it.each([ + "provider is attached to sandbox(es): --gateway, invalid/name", + "provider is attached to sandbox(es):", + ])("does not return unvalidated attachment targets from %s (#9806)", async (diagnostic) => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => captured(1, "", diagnostic), + }); + + const result = await adapter.deleteProvider({ + target: selectedOpenShellGateway(), + providerName: "search-prod", + }); + + expect(result).toMatchObject({ + ok: false, + error: { kind: "command", reason: "failed" }, + }); + expect(JSON.stringify(result)).not.toContain("attachedSandboxes"); + }); + it.each([ [ "authentication", captured(1, "", "authentication failed: credential-value"), "OpenShell could not authenticate the provider operation.", + undefined, ], [ "transport", captured(1, "", "client error (Connect): connection refused"), "OpenShell could not reach the selected gateway.", + "unreachable", ], [ "timeout", @@ -241,6 +280,7 @@ describe("CLI OpenShell provider adapter", () => { }), ), "The OpenShell provider operation timed out.", + undefined, ], [ "transport", @@ -251,15 +291,19 @@ describe("CLI OpenShell provider adapter", () => { Object.assign(new Error("spawn openshell credential-value"), { code: "ENOENT" }), ), "OpenShell could not start the provider operation.", + "process_start", ], ])( "maps %s failures without returning CLI diagnostics (#9806)", - async (kind, result, message) => { + async (kind, result, message, reason) => { const adapter = createCliOpenShellProviderAdapter({ run: () => result }); const mapped = await adapter.listProviders({ target: selectedOpenShellGateway() }); - expect(mapped).toEqual({ ok: false, error: { kind, message } }); + expect(mapped).toEqual({ + ok: false, + error: { kind, ...(reason ? { reason } : {}), message }, + }); expect(JSON.stringify(mapped)).not.toContain("credential-value"); }, ); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index ae9ee4f3422..caec8dd76ab 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -98,6 +98,7 @@ function commandError( if (errorCode === "ENOENT" || errorCode === "EACCES") { return { kind: "transport", + reason: "process_start", message: "OpenShell could not start the provider operation.", }; } @@ -122,7 +123,11 @@ function commandError( output, ) ) { - return { kind: "transport", message: "OpenShell could not reach the selected gateway." }; + return { + kind: "transport", + reason: "unreachable", + message: "OpenShell could not reach the selected gateway.", + }; } if (/already exists/iu.test(output)) { return { kind: "command", reason: "already_exists", message }; @@ -141,9 +146,28 @@ function commandError( }; } -function scopedArgs(args: string[], target: OpenShellGatewayTarget): string[] { +function scopedArgs( + args: string[], + target: OpenShellGatewayTarget, + gatewayFlagIndex = 2, +): string[] { if (target.kind === "selected") return args; - return [...args.slice(0, 2), "-g", target.gatewayName, ...args.slice(2)]; + return [ + ...args.slice(0, gatewayFlagIndex), + "-g", + target.gatewayName, + ...args.slice(gatewayFlagIndex), + ]; +} + +export function parseCliOpenShellProviderNames(output: unknown): string[] { + return bufferOrStringToText( + typeof output === "string" || Buffer.isBuffer(output) ? output : String(output ?? ""), + ) + .replace(ANSI_RE, "") + .split(/\r?\n/u) + .map((name) => name.trim()) + .filter(Boolean); } function parseProfileCredentialKeys(output: string): string[] | null { @@ -181,8 +205,9 @@ export function createCliOpenShellProviderAdapter( args: string[], request: OpenShellProviderRequest, env?: Record, + gatewayFlagIndex = 2, ) => - run(scopedArgs(args, request.target), { + run(scopedArgs(args, request.target, gatewayFlagIndex), { ...(env ? { env } : {}), ignoreError: true, stdio: ["ignore", "pipe", "pipe"], @@ -194,11 +219,7 @@ export function createCliOpenShellProviderAdapter( const error = commandError(result); if (error) return failure(error); return success({ - names: bufferOrStringToText(result.stdout) - .replace(ANSI_RE, "") - .split(/\r?\n/u) - .map((name) => name.trim()) - .filter(Boolean), + names: parseCliOpenShellProviderNames(result.stdout), }); }; @@ -276,6 +297,8 @@ export function createCliOpenShellProviderAdapter( const result = invoke( ["sandbox", "provider", "detach", request.sandboxName, request.providerName], request, + undefined, + 3, ); const output = commandOutput(result); if (result.status !== 0 && TOLERATED_DETACH_OUTPUT_RE.test(output)) { diff --git a/src/lib/adapters/openshell/provider-adapter.ts b/src/lib/adapters/openshell/provider-adapter.ts index e5569a6568a..5aa15d3dc8e 100644 --- a/src/lib/adapters/openshell/provider-adapter.ts +++ b/src/lib/adapters/openshell/provider-adapter.ts @@ -10,9 +10,16 @@ export type OpenShellProviderCommandReason = | "invalid_request" | "not_found"; +export type OpenShellProviderTransportReason = "process_start" | "unreachable"; + export type OpenShellProviderError = | Readonly<{ - kind: "authentication" | "schema" | "timeout" | "transport" | "validation"; + kind: "authentication" | "schema" | "timeout" | "validation"; + message: string; + }> + | Readonly<{ + kind: "transport"; + reason: OpenShellProviderTransportReason; message: string; }> | Readonly<{ diff --git a/src/lib/credentials/provider-list.ts b/src/lib/credentials/provider-list.ts index c8dba019b5a..ce70520d6c6 100644 --- a/src/lib/credentials/provider-list.ts +++ b/src/lib/credentials/provider-list.ts @@ -18,15 +18,3 @@ export function classifyGatewayProviderNames(names: readonly string[]): { credentialNames: names.filter((name) => !isBridgeProviderName(name)).sort(), }; } - -/** @deprecated CLI output parsing belongs to the OpenShell CLI adapter. */ -export function parseGatewayProviderNames(output: unknown): { - bridgeNames: string[]; - credentialNames: string[]; -} { - const allNames = String(output ?? "") - .split("\n") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - return classifyGatewayProviderNames(allNames); -} diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index a3ad884771b..321d469e5fa 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -757,7 +757,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa ); console.log(` Policies: ${deps.cliName()} ${sandboxName} policy add`); console.log( - ` Credentials: ${deps.cliName()} credentials reset && ${deps.cliName()} onboard`, + ` Credentials: ${deps.cliName()} credentials reset && ${deps.cliName()} onboard`, ); console.log(` ${"─".repeat(50)}`); console.log(""); diff --git a/test/onboarding/onboard-dashboard.test.ts b/test/onboarding/onboard-dashboard.test.ts index 8d68cc2f080..3b5d4c00a7a 100644 --- a/test/onboarding/onboard-dashboard.test.ts +++ b/test/onboarding/onboard-dashboard.test.ts @@ -498,6 +498,8 @@ describe("onboard dashboard helpers", () => { expect(output).not.toContain("gateway-token --quiet"); expect(output).not.toContain("append #token="); expect(output).not.toMatch(/secret[-_]?token/); + expect(output).toContain("nemoclaw credentials reset && nemoclaw onboard"); + expect(output).not.toContain("credentials reset "); expect(nimStatus).toHaveBeenCalledWith("my-gpt-claw"); }); diff --git a/test/package-contract/cli/credentials-cli-command.test.ts b/test/package-contract/cli/credentials-cli-command.test.ts index 73f96673143..9626fa839ce 100644 --- a/test/package-contract/cli/credentials-cli-command.test.ts +++ b/test/package-contract/cli/credentials-cli-command.test.ts @@ -162,7 +162,12 @@ afterEach(() => { delete require.cache[modulePath]; } delete require.cache[GLOBAL_ACTIONS_PATH]; - delete require.cache[PROVIDER_COMMAND_PATH]; + const providerCommands = require(PROVIDER_COMMAND_PATH) as { + setProviderCommandRuntimeHooksForTest: (hooks: { + runOpenshell?: RuntimeBridge["runOpenshell"]; + }) => void; + }; + providerCommands.setProviderCommandRuntimeHooksForTest({}); }); describe("credentials oclif commands", () => { @@ -211,7 +216,9 @@ describe("credentials oclif commands", () => { expect(output.stdout).toContain("openai-prod"); expect(output.stdout).toContain("nvidia-prod"); expect(output.stdout).toContain("2 per-sandbox messaging bridge(s)"); - expect(output.stdout).toContain("channels list/remove/stop"); + expect(output.stdout).toContain("oclif channels list"); + expect(output.stdout).toContain("oclif channels remove "); + expect(output.stdout).toContain("oclif channels stop "); expect(output.stdout).not.toContain("alpha-telegram-bridge"); }); @@ -237,8 +244,9 @@ describe("credentials oclif commands", () => { expectExitCode(() => CredentialsListCommand.run([]), 1), ); - expect(output.stderr).toContain("Could not query OpenShell gateway"); - expect(output.stderr).toContain("Start the gateway again with `nemoclaw onboard`."); + expect(output.stderr).toContain("Could not query OpenShell providers"); + expect(output.stderr).toContain("gateway unavailable"); + expect(output.stderr).not.toContain("Start the gateway again"); }); it("records gateway recovery failures without calling provider list", async () => { From 87d3c759c38e577f94a1b1c465b97fc5555fc26a Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:19:03 -0700 Subject: [PATCH 10/57] fix(cli): close provider adapter trust gaps Signed-off-by: Rebecca Sliter --- ci/source-architecture-budget.json | 2 +- src/lib/actions/credentials-add.ts | 45 ++++++++++---- .../credentials-provider-adapter.test.ts | 61 ++++++++++++++++++- src/lib/actions/credentials/reset.ts | 16 ++++- .../openshell/provider-adapter-cli.test.ts | 28 +++++++++ .../openshell/provider-adapter-cli.ts | 59 +++++++++++++++--- .../adapters/openshell/provider-adapter.ts | 25 +++++++- .../adapters/openshell/provider-command.ts | 1 + 8 files changed, 211 insertions(+), 26 deletions(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index c0e4e8f868f..55139a057b8 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -23,7 +23,7 @@ "src/lib/inference/config.ts": 30, "src/lib/inference/web-search.ts": 21, "src/lib/messaging/channels/index.ts": 25, - "src/lib/name-validation.ts": 21, + "src/lib/name-validation.ts": 22, "src/lib/onboard/gateway-binding.ts": 52, "src/lib/runner.ts": 86, "src/lib/security/redact.ts": 53, diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 3396ac0a5ae..83a8607e55e 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -4,12 +4,11 @@ import fs from "node:fs"; import path from "node:path"; import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; -import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; -import { runOpenshellProviderCommand } from "../adapters/openshell/provider-command"; -import { - checkOpenAiInferenceProviderProfile, - OPENAI_GATEWAY_PROVIDER_TYPE, -} from "../adapters/openshell/provider-profile"; +import type { + OpenShellProviderAdapter, + OpenShellProviderError, +} from "../adapters/openshell/provider-adapter"; +import { OPENAI_GATEWAY_PROVIDER_TYPE } from "../adapters/openshell/provider-profile"; import { selectedOpenShellGateway } from "../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; @@ -111,14 +110,34 @@ async function ensureCredentialProviderProfile( if (type.toLowerCase() !== OPENAI_GATEWAY_PROVIDER_TYPE) { return ensureBundledProviderProfile(type, providerAdapter); } - const profile = checkOpenAiInferenceProviderProfile({ - runOpenshell: (args, options) => - runOpenshellProviderCommand(args, { - ...options, - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }), + const profile = await providerAdapter.ensureEndpointlessProviderProfile({ + target: selectedOpenShellGateway(), + profileType: OPENAI_GATEWAY_PROVIDER_TYPE, + profilePath: bundledProviderProfilePath(OPENAI_GATEWAY_PROVIDER_TYPE), + inferenceCapable: true, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); - return profile.ok ? null : fail(profile.messages); + if (profile.ok) return null; + return fail(openAiProviderProfileFailureLines(profile.error)); +} + +function openAiProviderProfileFailureLines(error: OpenShellProviderError): string[] { + if (error.kind === "command" && error.reason === "profile_import_failed") { + return [ + `\n ✗ OpenShell could not import the checked-in '${OPENAI_GATEWAY_PROVIDER_TYPE}' inference provider profile.`, + " Confirm OpenShell is available and authorized, then retry this command.", + ]; + } + if (error.kind === "command" && error.reason === "profile_incompatible") { + return [ + `\n ✗ OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' already exists but does not match NemoClaw's endpointless inference contract.`, + " Remove the conflicting profile, then retry this command.", + ]; + } + return [ + `\n ✗ OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' could not be read for validation.`, + " Confirm OpenShell is available, authorized, and the profile is readable, then retry this command.", + ]; } export async function runCredentialsAddAction( diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 21a8edfdd47..f938b5906b3 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; import { setGlobalCliActionRuntimeHooksForTest } from "./global"; import { runCredentialsAddAction } from "./credentials-add"; @@ -32,6 +33,8 @@ function providerAdapter( ok: true, value: { state: "imported" }, }); + const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = + async () => ({ ok: true, value: { state: "ready" } }); const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async () => ({ ok: true, value: { credentialKeys: [] }, @@ -48,6 +51,7 @@ function providerAdapter( listProviders: vi.fn(listProviders), createProvider: vi.fn(createProvider), importProviderProfile: vi.fn(importProviderProfile), + ensureEndpointlessProviderProfile: vi.fn(ensureEndpointlessProviderProfile), inspectProviderProfile: vi.fn(inspectProviderProfile), deleteProvider: vi.fn(deleteProvider), detachProvider: vi.fn(detachProvider), @@ -98,6 +102,32 @@ describe("credential actions use typed OpenShell provider results", () => { expect(JSON.stringify(result)).not.toContain("credential-value"); }); + it("reconciles the OpenAI profile through the injected provider adapter (#9806)", async () => { + vi.stubEnv("OPENAI_API_KEY", "host-only-value"); + const adapter = providerAdapter(); + + const result = await runCredentialsAddAction( + { + provider: "openai-prod", + type: "openai", + credentials: ["OPENAI_API_KEY"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(adapter.ensureEndpointlessProviderProfile).toHaveBeenCalledWith({ + target: { kind: "selected" }, + profileType: "openai", + profilePath: expect.stringMatching(/provider-profiles\/openai\.yaml$/u), + inferenceCapable: true, + timeoutMs: 30_000, + }); + expect(adapter.createProvider).toHaveBeenCalledOnce(); + }); + it("lists credentials separately from messaging bridge providers (#9806)", async () => { const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ ok: true, @@ -133,6 +163,12 @@ describe("credential actions use typed OpenShell provider results", () => { ["timeout", "The OpenShell provider operation timed out.", false, undefined], ["command", "OpenShell rejected the provider query.", false, undefined], ["transport", "OpenShell could not start the provider operation.", false, "process_start"], + [ + "transport", + "The selected OpenShell gateway identity does not match the recorded identity.", + false, + "identity_mismatch", + ], ["transport", "OpenShell could not reach the selected gateway.", true, "unreachable"], ] as const)( "uses the typed %s provider-list failure for recovery guidance (#9806)", @@ -222,8 +258,31 @@ describe("credential actions use typed OpenShell provider results", () => { ); expect(result.exitCode).toBe(1); - expect(deleteProvider).toHaveBeenCalledTimes(2); + expect(deleteProvider).toHaveBeenCalledOnce(); expect(detachProvider).not.toHaveBeenCalled(); }, ); + + it("does not partially detach a mixed valid and invalid attachment list (#9806)", async () => { + const deleteProvider = vi.fn().mockResolvedValue({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha", "invalid/name"], + }, + }); + const detachProvider = vi.fn(); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(deleteProvider).toHaveBeenCalledOnce(); + expect(detachProvider).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index c09420b186e..67b7bcbf8dd 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -8,6 +8,7 @@ import type { } from "../../adapters/openshell/provider-adapter"; import { selectedOpenShellGateway } from "../../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; +import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../../name-validation"; import { CLI_NAME } from "../../cli/branding"; import { isBridgeProviderName, @@ -158,7 +159,20 @@ async function deleteProviderWithRecovery( : { ok: false, error: result.error, recoveryFailures }; } - for (const sandbox of result.error.attachedSandboxes ?? []) { + const attachedSandboxes = result.error.attachedSandboxes ?? []; + if ( + attachedSandboxes.length === 0 || + attachedSandboxes.some( + (sandbox) => + sandbox.length === 0 || + sandbox.length > NAME_MAX_LENGTH || + !NAME_VALID_PATTERN.test(sandbox), + ) + ) { + return { ok: false, error: result.error, recoveryFailures }; + } + + for (const sandbox of attachedSandboxes) { const detach = await providerAdapter.detachProvider({ ...request, sandboxName: sandbox }); if (!detach.ok) recoveryFailures.push({ sandbox, error: detach.error }); } diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index b750b2e41c8..cf6c49bff84 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -166,6 +166,27 @@ describe("CLI OpenShell provider adapter", () => { ]); }); + it("reconciles an endpointless profile inside the CLI adapter (#9806)", async () => { + const run = vi + .fn() + .mockReturnValueOnce(captured(1, "", "provider profile not found")) + .mockReturnValueOnce(captured(0)); + const adapter = createCliOpenShellProviderAdapter({ run }); + + await expect( + adapter.ensureEndpointlessProviderProfile({ + target: selectedOpenShellGateway(), + profileType: "openai", + profilePath: "/repo/provider-profiles/openai.yaml", + inferenceCapable: true, + }), + ).resolves.toEqual({ ok: true, value: { state: "ready" } }); + expect(run.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "profile", "export", "openai", "--output", "json"], + ["provider", "profile", "import", "--file", "/repo/provider-profiles/openai.yaml"], + ]); + }); + it("returns a schema failure for an invalid provider profile (#9806)", async () => { const adapter = createCliOpenShellProviderAdapter({ run: () => captured(0, "not-json"), @@ -237,6 +258,7 @@ describe("CLI OpenShell provider adapter", () => { }); it.each([ + "provider is attached to sandbox(es): alpha, invalid/name", "provider is attached to sandbox(es): --gateway, invalid/name", "provider is attached to sandbox(es):", ])("does not return unvalidated attachment targets from %s (#9806)", async (diagnostic) => { @@ -263,6 +285,12 @@ describe("CLI OpenShell provider adapter", () => { "OpenShell could not authenticate the provider operation.", undefined, ], + [ + "transport", + captured(1, "", "handshake verification failed"), + "The selected OpenShell gateway identity does not match the recorded identity.", + "identity_mismatch", + ], [ "transport", captured(1, "", "client error (Connect): connection refused"), diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index caec8dd76ab..106b70494e1 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -8,6 +8,7 @@ import { type CreateOpenShellProviderRequest, type DeleteOpenShellProviderRequest, type DetachOpenShellProviderRequest, + type EnsureOpenShellEndpointlessProviderProfileRequest, type ImportOpenShellProviderProfileRequest, type InspectOpenShellProviderProfileRequest, type OpenShellProviderAdapter, @@ -16,6 +17,7 @@ import { type OpenShellProviderResult, } from "./provider-adapter"; import type { OpenShellGatewayTarget } from "./sandbox-observer"; +import { ensureEndpointlessProviderProfile as reconcileEndpointlessProviderProfile } from "./provider-profile"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "./timeouts"; export type CapturedProviderCommandResult = Readonly<{ @@ -31,6 +33,7 @@ export type RunProviderCommand = ( env?: Record; ignoreError: true; stdio: ["ignore", "pipe", "pipe"]; + suppressOutput?: boolean; timeout: number; }, ) => CapturedProviderCommandResult; @@ -73,15 +76,20 @@ function redactProviderDiagnostic(output: string, secrets: readonly string[]): s return redactFull(safe).trim(); } -function attachedSandboxNames(output: string): string[] { +function attachedSandboxNames(output: string): string[] | null { const match = ATTACHED_TO_SANDBOX_RE.exec(output); - if (!match?.[1]) return []; - return match[1] + if (!match?.[1]) return null; + const names = match[1] .split(/[,\s]+/u) .map((name) => name.trim().replace(/[.'"`]+$/u, "")) - .filter( - (name) => name.length > 0 && name.length <= NAME_MAX_LENGTH && NAME_VALID_PATTERN.test(name), - ); + .filter(Boolean); + if ( + names.length === 0 || + names.some((name) => name.length > NAME_MAX_LENGTH || !NAME_VALID_PATTERN.test(name)) + ) { + return null; + } + return names; } function commandError( @@ -118,8 +126,15 @@ function commandError( message: "OpenShell could not authenticate the provider operation.", }; } + if (/\bhandshake verification failed\b/iu.test(output)) { + return { + kind: "transport", + reason: "identity_mismatch", + message: "The selected OpenShell gateway identity does not match the recorded identity.", + }; + } if ( - /\b(?:handshake verification failed|connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured)\b/iu.test( + /\b(?:connection refused|client error \(connect\)|tcp connect error|transport error|connection reset|connection aborted|connection closed|no active gateway|no gateway configured)\b/iu.test( output, ) ) { @@ -133,7 +148,7 @@ function commandError( return { kind: "command", reason: "already_exists", message }; } const attachedSandboxes = attachedSandboxNames(output); - if (attachedSandboxes.length > 0) { + if (attachedSandboxes) { return { kind: "command", reason: "attached", message, attachedSandboxes }; } if (/\bNotFound\b|\bnot\s+found\b|does\s+not\s+exist|already\s+absent/iu.test(output)) { @@ -206,11 +221,13 @@ export function createCliOpenShellProviderAdapter( request: OpenShellProviderRequest, env?: Record, gatewayFlagIndex = 2, + suppressOutput = false, ) => run(scopedArgs(args, request.target, gatewayFlagIndex), { ...(env ? { env } : {}), ignoreError: true, stdio: ["ignore", "pipe", "pipe"], + ...(suppressOutput ? { suppressOutput: true } : {}), timeout: timeoutFor(request), }); @@ -265,6 +282,31 @@ export function createCliOpenShellProviderAdapter( return error ? failure(error) : success({ state: "imported" }); }; + const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = + async (request: EnsureOpenShellEndpointlessProviderProfileRequest) => { + const result = reconcileEndpointlessProviderProfile({ + profileId: request.profileType, + inferenceCapable: request.inferenceCapable, + profilePath: request.profilePath, + runOpenshell: (args, options) => + invoke(args, request, undefined, 2, options?.suppressOutput === true), + }); + if (result.ok) return success({ state: "ready" }); + const reason = + result.reason === "export-failed" + ? "profile_export_failed" + : result.reason === "import-failed" + ? "profile_import_failed" + : "profile_incompatible"; + const message = + result.reason === "export-failed" + ? "OpenShell could not read the provider profile for validation." + : result.reason === "import-failed" + ? "OpenShell could not import the provider profile." + : "The existing OpenShell provider profile does not match the required contract."; + return failure({ kind: "command", reason, message }); + }; + const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async ( request: InspectOpenShellProviderProfileRequest, ) => { @@ -312,6 +354,7 @@ export function createCliOpenShellProviderAdapter( listProviders, createProvider, importProviderProfile, + ensureEndpointlessProviderProfile, inspectProviderProfile, deleteProvider, detachProvider, diff --git a/src/lib/adapters/openshell/provider-adapter.ts b/src/lib/adapters/openshell/provider-adapter.ts index 5aa15d3dc8e..ee093341aec 100644 --- a/src/lib/adapters/openshell/provider-adapter.ts +++ b/src/lib/adapters/openshell/provider-adapter.ts @@ -8,9 +8,15 @@ export type OpenShellProviderCommandReason = | "attached" | "failed" | "invalid_request" - | "not_found"; + | "not_found" + | "profile_export_failed" + | "profile_import_failed" + | "profile_incompatible"; -export type OpenShellProviderTransportReason = "process_start" | "unreachable"; +export type OpenShellProviderTransportReason = + | "identity_mismatch" + | "process_start" + | "unreachable"; export type OpenShellProviderError = | Readonly<{ @@ -60,6 +66,13 @@ export type ImportOpenShellProviderProfileRequest = OpenShellProviderRequest & profilePath: string; }>; +export type EnsureOpenShellEndpointlessProviderProfileRequest = + ImportOpenShellProviderProfileRequest & + Readonly<{ + profileType: string; + inferenceCapable: boolean; + }>; + export type InspectOpenShellProviderProfileRequest = OpenShellProviderRequest & Readonly<{ profileType: string; @@ -79,6 +92,10 @@ export type OpenShellProviderProfileImport = Readonly<{ state: "already_present" | "imported"; }>; +export type OpenShellEndpointlessProviderProfile = Readonly<{ + state: "ready"; +}>; + export type OpenShellProviderCreate = Readonly<{ state: "created"; }>; @@ -105,6 +122,10 @@ export interface OpenShellProviderAdapter { request: ImportOpenShellProviderProfileRequest, ): Promise>; + ensureEndpointlessProviderProfile( + request: EnsureOpenShellEndpointlessProviderProfileRequest, + ): Promise>; + inspectProviderProfile( request: InspectOpenShellProviderProfileRequest, ): Promise>; diff --git a/src/lib/adapters/openshell/provider-command.ts b/src/lib/adapters/openshell/provider-command.ts index e332155a8d0..aa9b4eeeeaf 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -12,6 +12,7 @@ export type ProviderCommandOptions = { env?: Record; ignoreError?: boolean; stdio?: StdioOptions; + suppressOutput?: boolean; timeout?: number; }; From a1743bc8be77a250e13f69d64be5566ea779dffe Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:17:52 -0700 Subject: [PATCH 11/57] test(cli): remove stale provider adapter import Signed-off-by: Rebecca Sliter --- src/lib/actions/credentials-provider-adapter.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index f938b5906b3..aa00c6179b0 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; import { setGlobalCliActionRuntimeHooksForTest } from "./global"; import { runCredentialsAddAction } from "./credentials-add"; From 10b4999928a70f204834d4e687c1b013174b94f2 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:00:01 -0700 Subject: [PATCH 12/57] fix(cli): address provider adapter review findings Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../credentials-provider-adapter.test.ts | 37 +++++++++++++++++++ src/lib/actions/credentials/reset.ts | 4 ++ ...inference-set-provider-diagnostics.test.ts | 2 +- .../inference-set-provider-diagnostics.ts | 2 +- .../openshell/provider-adapter-cli.test.ts | 21 +++++++++++ .../openshell/provider-adapter-cli.ts | 14 +------ .../openshell/provider-command.test.ts | 7 ++++ .../adapters/openshell/provider-command.ts | 12 ++++++ 8 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index aa00c6179b0..48a93f35382 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -233,6 +233,43 @@ describe("credential actions use typed OpenShell provider results", () => { }); }); + it("reports the typed detach failure that blocks provider removal (#9806)", async () => { + const deleteProvider = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha"], + }, + }) + .mockResolvedValueOnce({ + ok: false, + error: { kind: "command", reason: "failed", message: "provider deletion failed" }, + }); + const detachProvider = vi.fn(async () => ({ + ok: false, + error: { + kind: "authentication", + message: "OpenShell could not authenticate the provider operation.", + }, + })); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toContain( + " Could not detach provider 'custom-provider' from sandbox 'alpha': OpenShell could not authenticate the provider operation.", + ); + expect(result.failureLines).toContain(" provider deletion failed"); + }); + it.each([ ["absent", undefined], ["empty", []], diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index 67b7bcbf8dd..a71f7af3987 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -134,6 +134,10 @@ export function formatResetOutcome( lines.push( "", ` '${key}' is still attached to sandbox(es): ${stuck}.`, + ...recovery.recoveryFailures.map( + (failure) => + ` Could not detach provider '${key}' from sandbox '${failure.sandbox}': ${failure.error.message}`, + ), ` Detach it with 'openshell sandbox provider detach ${key}'`, ` for each, then re-run '${CLI_NAME} credentials reset ${key}'.`, ); diff --git a/src/lib/actions/inference-set-provider-diagnostics.test.ts b/src/lib/actions/inference-set-provider-diagnostics.test.ts index e8d3197a638..b247e51c157 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.test.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; -import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-adapter-cli"; +import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-command"; import { classifyGatewayProviderNames, isBridgeProviderName } from "../credentials/provider-list"; import { queryRegisteredGatewayProviders } from "./inference-set-provider-diagnostics"; diff --git a/src/lib/actions/inference-set-provider-diagnostics.ts b/src/lib/actions/inference-set-provider-diagnostics.ts index 494dc06e7c9..64c8c70a663 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { CaptureOpenshellOptions, CaptureOpenshellResult } from "../adapters/openshell/client"; -import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-adapter-cli"; +import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-command"; import { classifyGatewayProviderNames } from "../credentials/provider-list"; import { buildOpenshellInferenceSetFailureMessage, diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index cf6c49bff84..a31b4ba5d16 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -240,6 +240,27 @@ describe("CLI OpenShell provider adapter", () => { ]); }); + it("stops attachment parsing before trailing diagnostic prose (#9806)", async () => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => + captured(1, "", "provider is attached to sandbox(es): alpha, beta. Detach them first."), + }); + + await expect( + adapter.deleteProvider({ + target: selectedOpenShellGateway(), + providerName: "search-prod", + }), + ).resolves.toMatchObject({ + ok: false, + error: { + kind: "command", + reason: "attached", + attachedSandboxes: ["alpha", "beta"], + }, + }); + }); + it("places a named gateway flag before detach arguments (#9806)", async () => { const run = vi.fn(() => captured(0)); const adapter = createCliOpenShellProviderAdapter({ run }); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 106b70494e1..b55d2e16f8a 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -3,7 +3,7 @@ import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../../name-validation"; import { redactFull } from "../../security/redact"; -import { runOpenshellProviderCommand } from "./provider-command"; +import { parseCliOpenShellProviderNames, runOpenshellProviderCommand } from "./provider-command"; import { type CreateOpenShellProviderRequest, type DeleteOpenShellProviderRequest, @@ -45,7 +45,7 @@ export type CliOpenShellProviderAdapterDeps = Readonly<{ const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/u; const ANSI_RE = /\x1b\[[0-9;]*m/gu; -const ATTACHED_TO_SANDBOX_RE = /attached\s+to(?:\s|│)+sandbox\(\s*es?\s*\)?\s*:\s*([^"\n]+)/iu; +const ATTACHED_TO_SANDBOX_RE = /attached\s+to(?:\s|│)+sandbox\(\s*es?\s*\)?\s*:\s*([^".\n]+)/iu; const TOLERATED_DETACH_OUTPUT_RE = /\bNotAttached\b|\bnot\s+attached\b|provider[^\n]{0,200}?(?:\bNotFound\b|\bnot\s+found\b)/iu; @@ -175,16 +175,6 @@ function scopedArgs( ]; } -export function parseCliOpenShellProviderNames(output: unknown): string[] { - return bufferOrStringToText( - typeof output === "string" || Buffer.isBuffer(output) ? output : String(output ?? ""), - ) - .replace(ANSI_RE, "") - .split(/\r?\n/u) - .map((name) => name.trim()) - .filter(Boolean); -} - function parseProfileCredentialKeys(output: string): string[] | null { let profile: unknown; try { diff --git a/src/lib/adapters/openshell/provider-command.test.ts b/src/lib/adapters/openshell/provider-command.test.ts index 871e2d70eeb..40d65a39614 100644 --- a/src/lib/adapters/openshell/provider-command.test.ts +++ b/src/lib/adapters/openshell/provider-command.test.ts @@ -17,6 +17,7 @@ vi.mock("./runtime", () => ({ })); import { + parseCliOpenShellProviderNames, runOpenshellProviderCommand, setProviderCommandRuntimeHooksForTest, } from "./provider-command"; @@ -66,4 +67,10 @@ describe("OpenShell provider command runtime", () => { ); expect(result).toEqual({ status: 0 }); }); + + it("parses provider names without ANSI or blank lines (#9806)", () => { + expect( + parseCliOpenShellProviderNames(" \u001b[32malpha\u001b[0m \r\n\r\nbeta\n"), + ).toEqual(["alpha", "beta"]); + }); }); diff --git a/src/lib/adapters/openshell/provider-command.ts b/src/lib/adapters/openshell/provider-command.ts index aa9b4eeeeaf..177ea4382a1 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -8,6 +8,8 @@ import { OPENSHELL_OPERATION_TIMEOUT_MS, runOpenshell } from "./runtime"; export { OPENSHELL_OPERATION_TIMEOUT_MS }; +const ANSI_RE = /\x1b\[[0-9;]*m/gu; + export type ProviderCommandOptions = { env?: Record; ignoreError?: boolean; @@ -26,6 +28,16 @@ export function setProviderCommandRuntimeHooksForTest(hooks: ProviderCommandRunt runtimeHooks = hooks; } +export function parseCliOpenShellProviderNames(output: unknown): string[] { + const text = + typeof output === "string" || Buffer.isBuffer(output) ? output.toString() : String(output ?? ""); + return text + .replace(ANSI_RE, "") + .split(/\r?\n/u) + .map((name) => name.trim()) + .filter(Boolean); +} + export function runOpenshellProviderCommand(args: string[], opts?: ProviderCommandOptions) { const explicitEnv = Object.fromEntries( Object.entries(opts?.env ?? {}).filter( From abda0e7358bdcfc02a8c54d5c7ddc63a53ca5bff Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:24:10 -0700 Subject: [PATCH 13/57] test(cli): preserve provider command partial mock Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/commands/credentials.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index 320a5542b88..3acbc5b47fd 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -27,10 +27,14 @@ vi.mock("../lib/actions/global", () => ({ forgetExtraProvider: mocks.forgetExtraProvider, listManagedMcpCredentialReservations: mocks.listManagedMcpCredentialReservations, })); -vi.mock("../lib/adapters/openshell/provider-command", () => ({ - OPENSHELL_OPERATION_TIMEOUT_MS: 30_000, - runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, -})); +vi.mock("../lib/adapters/openshell/provider-command", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + OPENSHELL_OPERATION_TIMEOUT_MS: 30_000, + runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, + }; +}); vi.mock("../lib/onboard/gateway-teardown-authority", () => ({ resolveGatewayCredentialMutationAuthority: mocks.resolveGatewayCredentialMutationAuthority, })); From 140a2c63d1fbbf265663800dbb839d03a7c779b0 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:52:24 -0700 Subject: [PATCH 14/57] test(cli): cover provider detach parity Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../inference-set-provider-diagnostics.test.ts | 9 ++------- .../openshell/provider-adapter-cli.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/lib/actions/inference-set-provider-diagnostics.test.ts b/src/lib/actions/inference-set-provider-diagnostics.test.ts index b247e51c157..094d82256d6 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.test.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.test.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; -import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-command"; import { classifyGatewayProviderNames, isBridgeProviderName } from "../credentials/provider-list"; import { queryRegisteredGatewayProviders } from "./inference-set-provider-diagnostics"; @@ -30,16 +29,12 @@ describe("inference set provider diagnostics", () => { }); it("partitions empty and messaging-only provider output", () => { - expect(classifyGatewayProviderNames(parseCliOpenShellProviderNames(""))).toEqual({ + expect(classifyGatewayProviderNames([])).toEqual({ bridgeNames: [], credentialNames: [], }); expect( - classifyGatewayProviderNames( - parseCliOpenShellProviderNames( - " \u001b[32malpha-telegram-bridge\u001b[0m \r\n alpha-slack-app \r\n", - ), - ), + classifyGatewayProviderNames(["alpha-telegram-bridge", "alpha-slack-app"]), ).toEqual({ bridgeNames: ["alpha-telegram-bridge", "alpha-slack-app"], credentialNames: [] }); expect(isBridgeProviderName("alpha-discord-bridge")).toBe(true); expect(isBridgeProviderName("nvidia-prod")).toBe(false); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index a31b4ba5d16..00bf63060d1 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -278,6 +278,23 @@ describe("CLI OpenShell provider adapter", () => { ); }); + it.each(["NotAttached", "provider search-prod NotFound", "provider search-prod not found"])( + "treats a stale detach result as already absent: %s (#9806)", + async (diagnostic) => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => captured(1, "", diagnostic), + }); + + await expect( + adapter.detachProvider({ + target: selectedOpenShellGateway(), + providerName: "search-prod", + sandboxName: "alpha", + }), + ).resolves.toEqual({ ok: true, value: { state: "absent" } }); + }, + ); + it.each([ "provider is attached to sandbox(es): alpha, invalid/name", "provider is attached to sandbox(es): --gateway, invalid/name", From 1de41cb28da7783648b0101a6fa37a091b43b104 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:27:37 -0700 Subject: [PATCH 15/57] fix(cli): close provider recovery review gaps Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/lib/actions/credentials-add.ts | 37 ++++-------- .../credentials-provider-adapter.test.ts | 44 +++++++++++++++ src/lib/actions/credentials/reset.ts | 39 +++++++++---- .../openshell/provider-adapter-cli.test.ts | 26 +++++++++ .../openshell/provider-adapter-cli.ts | 7 +++ .../openshell/provider-profile.test.ts | 21 +++++++ .../adapters/openshell/provider-profile.ts | 56 ++++++++++--------- 7 files changed, 166 insertions(+), 64 deletions(-) diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 83a8607e55e..69a859dbfc4 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -4,11 +4,11 @@ import fs from "node:fs"; import path from "node:path"; import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; -import type { - OpenShellProviderAdapter, - OpenShellProviderError, -} from "../adapters/openshell/provider-adapter"; -import { OPENAI_GATEWAY_PROVIDER_TYPE } from "../adapters/openshell/provider-profile"; +import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; +import { + endpointlessProviderProfileFailureMessages, + OPENAI_GATEWAY_PROVIDER_TYPE, +} from "../adapters/openshell/provider-profile"; import { selectedOpenShellGateway } from "../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; @@ -118,26 +118,13 @@ async function ensureCredentialProviderProfile( timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); if (profile.ok) return null; - return fail(openAiProviderProfileFailureLines(profile.error)); -} - -function openAiProviderProfileFailureLines(error: OpenShellProviderError): string[] { - if (error.kind === "command" && error.reason === "profile_import_failed") { - return [ - `\n ✗ OpenShell could not import the checked-in '${OPENAI_GATEWAY_PROVIDER_TYPE}' inference provider profile.`, - " Confirm OpenShell is available and authorized, then retry this command.", - ]; - } - if (error.kind === "command" && error.reason === "profile_incompatible") { - return [ - `\n ✗ OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' already exists but does not match NemoClaw's endpointless inference contract.`, - " Remove the conflicting profile, then retry this command.", - ]; - } - return [ - `\n ✗ OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' could not be read for validation.`, - " Confirm OpenShell is available, authorized, and the profile is readable, then retry this command.", - ]; + const reason = + profile.error.kind === "command" && profile.error.reason === "profile_import_failed" + ? "import-failed" + : profile.error.kind === "command" && profile.error.reason === "profile_incompatible" + ? "incompatible" + : "export-failed"; + return fail(endpointlessProviderProfileFailureMessages(reason)); } export async function runCredentialsAddAction( diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 48a93f35382..f8ab98920fb 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -270,6 +270,50 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.failureLines).toContain(" provider deletion failed"); }); + it("reports final attachments after successful detach recovery (#9806)", async () => { + const deleteProvider = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha"], + }, + }) + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider has a new attachment", + attachedSandboxes: ["beta"], + }, + }); + const detachProvider = vi.fn(async () => ({ + ok: true, + value: { state: "detached" }, + })); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toContain( + " 'custom-provider' is still attached to sandbox(es): beta.", + ); + expect(result.failureLines).toContain( + " Detach it with 'openshell sandbox provider detach custom-provider'", + ); + expect(result.failureLines).toContain( + " for each, then re-run 'nemoclaw credentials reset custom-provider'.", + ); + }); + it.each([ ["absent", undefined], ["empty", []], diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index a71f7af3987..e867d0defbb 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -43,6 +43,23 @@ export type CredentialsProviderDeleteWithRecoveryResult = Readonly<{ const KNOWN_CREDENTIAL_ENV_KEY_SET = new Set(KNOWN_CREDENTIAL_ENV_KEYS); +function validatedAttachedSandboxes(error: OpenShellProviderError | undefined): readonly string[] { + if (error?.kind !== "command" || error.reason !== "attached") return []; + const attachedSandboxes = error.attachedSandboxes ?? []; + if ( + attachedSandboxes.length === 0 || + attachedSandboxes.some( + (sandbox) => + sandbox.length === 0 || + sandbox.length > NAME_MAX_LENGTH || + !NAME_VALID_PATTERN.test(sandbox), + ) + ) { + return []; + } + return attachedSandboxes; +} + function ok(outputLines: readonly string[]): CredentialsResetResult { return { exitCode: 0, outputLines, failureLines: [] }; } @@ -129,8 +146,14 @@ export function formatResetOutcome( " registered providers, then retry with one of those names.", ); } - if (recovery.recoveryFailures.length > 0) { - const stuck = recovery.recoveryFailures.map((failure) => failure.sandbox).join(", "); + const stuckSandboxes = [ + ...new Set([ + ...recovery.recoveryFailures.map((failure) => failure.sandbox), + ...validatedAttachedSandboxes(recovery.error), + ]), + ]; + if (stuckSandboxes.length > 0) { + const stuck = stuckSandboxes.join(", "); lines.push( "", ` '${key}' is still attached to sandbox(es): ${stuck}.`, @@ -163,16 +186,8 @@ async function deleteProviderWithRecovery( : { ok: false, error: result.error, recoveryFailures }; } - const attachedSandboxes = result.error.attachedSandboxes ?? []; - if ( - attachedSandboxes.length === 0 || - attachedSandboxes.some( - (sandbox) => - sandbox.length === 0 || - sandbox.length > NAME_MAX_LENGTH || - !NAME_VALID_PATTERN.test(sandbox), - ) - ) { + const attachedSandboxes = validatedAttachedSandboxes(result.error); + if (attachedSandboxes.length === 0) { return { ok: false, error: result.error, recoveryFailures }; } diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 00bf63060d1..20286ae0d60 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -128,6 +128,32 @@ describe("CLI OpenShell provider adapter", () => { expect(JSON.stringify(result)).not.toContain(credentialValue); }); + it("does not expose an imported credential value in a provider failure (#9806)", async () => { + const storedCredentialValue = "arbitrary-stored-value"; + const adapter = createCliOpenShellProviderAdapter({ + run: () => captured(1, "", `provider rejected ${storedCredentialValue}`), + }); + + const result = await adapter.createProvider({ + target: selectedOpenShellGateway(), + name: "search-prod", + type: "tavily", + credentials: [], + config: [], + fromExisting: true, + }); + + expect(result).toEqual({ + ok: false, + error: { + kind: "command", + reason: "failed", + message: "OpenShell could not create the provider from existing credentials.", + }, + }); + expect(JSON.stringify(result)).not.toContain(storedCredentialValue); + }); + it("imports an existing profile and returns sorted credential keys (#9806)", async () => { const run = vi .fn() diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index b55d2e16f8a..64691e0c2b6 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -255,6 +255,13 @@ export function createCliOpenShellProviderAdapter( ); const result = invoke(args, request, env); const error = commandError(result, Object.values(env)); + if (request.fromExisting && error?.kind === "command") { + return failure({ + kind: "command", + reason: error.reason, + message: "OpenShell could not create the provider from existing credentials.", + }); + } return error ? failure(error) : success({ state: "created" }); }; diff --git a/src/lib/adapters/openshell/provider-profile.test.ts b/src/lib/adapters/openshell/provider-profile.test.ts index e5658de01c1..e19fc0e1a81 100644 --- a/src/lib/adapters/openshell/provider-profile.test.ts +++ b/src/lib/adapters/openshell/provider-profile.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { + endpointlessProviderProfileFailureMessages, endpointlessProviderProfilePath, ensureEndpointlessProviderProfile, type EndpointlessProviderProfileRunner, @@ -30,6 +31,26 @@ function ensureProfile(runOpenshell: ReturnType) { } describe("OpenShell endpointless provider profiles", () => { + it.each([ + [ + "import-failed", + `\n ✗ OpenShell could not import the checked-in '${PROFILE_ID}' inference provider profile.`, + " Confirm OpenShell is available and authorized, then retry this command.", + ], + [ + "export-failed", + `\n ✗ OpenShell provider profile '${PROFILE_ID}' could not be read for validation.`, + " Confirm OpenShell is available, authorized, and the profile is readable, then retry this command.", + ], + [ + "incompatible", + `\n ✗ OpenShell provider profile '${PROFILE_ID}' already exists but does not match NemoClaw's endpointless inference contract.`, + " Remove the conflicting profile, then retry this command.", + ], + ] as const)("returns recovery guidance for a %s profile result", (reason, summary, action) => { + expect(endpointlessProviderProfileFailureMessages(reason)).toEqual([summary, action]); + }); + it("resolves a checked-in profile path for the requested profile", () => { expect(endpointlessProviderProfilePath("/repo", PROFILE_ID)).toBe( path.join("/repo", "nemoclaw-blueprint", "provider-profiles", "openai.yaml"), diff --git a/src/lib/adapters/openshell/provider-profile.ts b/src/lib/adapters/openshell/provider-profile.ts index a891b4480be..e6769938b7b 100644 --- a/src/lib/adapters/openshell/provider-profile.ts +++ b/src/lib/adapters/openshell/provider-profile.ts @@ -88,11 +88,16 @@ export function endpointlessProviderProfilePath(root: string, profileId: string) return path.join(root, "nemoclaw-blueprint", "provider-profiles", `${profileId}.yaml`); } +export type EndpointlessProviderProfileFailureReason = + | "export-failed" + | "import-failed" + | "incompatible"; + export type EndpointlessProviderProfileResult = | { readonly ok: true } | { readonly ok: false; - readonly reason: "export-failed" | "import-failed" | "incompatible"; + readonly reason: EndpointlessProviderProfileFailureReason; }; /** OpenShell provider type registered for every OpenAI-surface inference route. */ @@ -102,6 +107,28 @@ export type OpenAiProviderProfileCheck = | { readonly ok: true } | { readonly ok: false; readonly messages: readonly string[] }; +/** Return the recovery guidance for an endpointless OpenAI profile failure. */ +export function endpointlessProviderProfileFailureMessages( + reason: EndpointlessProviderProfileFailureReason, +): readonly string[] { + if (reason === "import-failed") { + return [ + `\n ✗ OpenShell could not import the checked-in '${OPENAI_GATEWAY_PROVIDER_TYPE}' inference provider profile.`, + " Confirm OpenShell is available and authorized, then retry this command.", + ]; + } + if (reason === "export-failed") { + return [ + `\n ✗ OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' could not be read for validation.`, + " Confirm OpenShell is available, authorized, and the profile is readable, then retry this command.", + ]; + } + return [ + `\n ✗ OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' already exists but does not match NemoClaw's endpointless inference contract.`, + " Remove the conflicting profile, then retry this command.", + ]; +} + /** Import one endpointless profile or validate the exact existing contract. */ export function ensureEndpointlessProviderProfile(input: { readonly profileId: string; @@ -181,30 +208,5 @@ export function checkOpenAiInferenceProviderProfile(deps: { runOpenshell: deps.runOpenshell, }); if (result.ok) return { ok: true }; - - if (result.reason === "import-failed") { - return { - ok: false, - messages: [ - `\n ✗ OpenShell could not import the checked-in '${OPENAI_GATEWAY_PROVIDER_TYPE}' inference provider profile.`, - " Confirm OpenShell is available and authorized, then retry this command.", - ], - }; - } - if (result.reason === "export-failed") { - return { - ok: false, - messages: [ - `\n ✗ OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' could not be read for validation.`, - " Confirm OpenShell is available, authorized, and the profile is readable, then retry this command.", - ], - }; - } - return { - ok: false, - messages: [ - `\n ✗ OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' already exists but does not match NemoClaw's endpointless inference contract.`, - " Remove the conflicting profile, then retry this command.", - ], - }; + return { ok: false, messages: endpointlessProviderProfileFailureMessages(result.reason) }; } From dfebbd7fbc132fdd8bccd6203ca9ca525668abb3 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:30:15 -0700 Subject: [PATCH 16/57] test(cli): cover empty provider inventory Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/lib/adapters/openshell/provider-command.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/adapters/openshell/provider-command.test.ts b/src/lib/adapters/openshell/provider-command.test.ts index 40d65a39614..7ab0e746f59 100644 --- a/src/lib/adapters/openshell/provider-command.test.ts +++ b/src/lib/adapters/openshell/provider-command.test.ts @@ -73,4 +73,8 @@ describe("OpenShell provider command runtime", () => { parseCliOpenShellProviderNames(" \u001b[32malpha\u001b[0m \r\n\r\nbeta\n"), ).toEqual(["alpha", "beta"]); }); + + it("returns no provider names for empty CLI output (#9806)", () => { + expect(parseCliOpenShellProviderNames("")).toEqual([]); + }); }); From ff3b4a237d1c59f7ee1d6ca2fbe8088abdca2ffa Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:26:28 -0700 Subject: [PATCH 17/57] chore(cli): lower source architecture ratchets Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ci/source-architecture-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 26296538f8c..e729c6452e0 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -9,7 +9,7 @@ "src/lib/adapters/openshell/client.ts": 20, "src/lib/adapters/openshell/resolve.ts": 27, "src/lib/adapters/openshell/runtime.ts": 55, - "src/lib/adapters/openshell/timeouts.ts": 40, + "src/lib/adapters/openshell/timeouts.ts": 39, "src/lib/agent/defs.ts": 33, "src/lib/cli/branding.ts": 85, "src/lib/cli/nemoclaw-oclif-command.ts": 106, @@ -22,7 +22,7 @@ "src/lib/credentials/store.ts": 45, "src/lib/inference/config.ts": 30, "src/lib/messaging/channels/index.ts": 25, - "src/lib/name-validation.ts": 22, + "src/lib/name-validation.ts": 19, "src/lib/onboard/gateway-binding.ts": 53, "src/lib/runner.ts": 85, "src/lib/security/redact.ts": 53, From 78cbe7f20de88a84ea8db4777f6a936cafad2088 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:52:33 -0700 Subject: [PATCH 18/57] fix(cli): validate reset provider names Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../credentials-provider-adapter.test.ts | 35 ++++++++++++++++ src/lib/actions/credentials/reset.ts | 11 ++++- .../openshell/provider-adapter-cli.test.ts | 40 ++++++++++--------- .../cli/credentials-cli-command.test.ts | 19 +++++++++ 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index f8ab98920fb..75e70997bf6 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import readline from "node:readline"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; @@ -192,6 +193,40 @@ describe("credential actions use typed OpenShell provider results", () => { }, ); + it("rejects an invalid reset provider before prompting or gateway mutation (#9806)", async () => { + const recoverNamedGatewayRuntime = vi.fn(async () => ({ recovered: true })); + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime, + recordExtraProvider: () => true, + forgetExtraProvider: () => true, + listManagedMcpCredentialReservations: () => [], + }); + const promptSpy = vi.spyOn(readline, "createInterface").mockImplementation(() => { + throw new Error("credentials reset prompted for an invalid provider name"); + }); + const adapter = providerAdapter(); + + try { + const result = await runCredentialsResetAction( + { provider: "bad name/with*chars", confirmed: false }, + { providerAdapter: adapter }, + ); + + expect(result).toEqual({ + exitCode: 1, + outputLines: [], + failureLines: [ + " Provider name must be 1-128 chars, start with a letter, and use only letters, digits, '.', '_', or '-'.", + ], + }); + expect(promptSpy).not.toHaveBeenCalled(); + expect(recoverNamedGatewayRuntime).not.toHaveBeenCalled(); + expect(adapter.deleteProvider).not.toHaveBeenCalled(); + } finally { + promptSpy.mockRestore(); + } + }); + it("preserves detach-before-delete recovery with typed failures (#9806)", async () => { const operations: string[] = []; const deleteProvider = vi diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index e867d0defbb..f954b222871 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -8,7 +8,11 @@ import type { } from "../../adapters/openshell/provider-adapter"; import { selectedOpenShellGateway } from "../../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; -import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../../name-validation"; +import { + NAME_MAX_LENGTH, + NAME_VALID_PATTERN, + PROVIDER_NAME_VALID_PATTERN, +} from "../../name-validation"; import { CLI_NAME } from "../../cli/branding"; import { isBridgeProviderName, @@ -73,6 +77,11 @@ export async function runCredentialsResetAction( deps: CredentialsResetDeps = {}, ): Promise { const key = input.provider; + if (!PROVIDER_NAME_VALID_PATTERN.test(key)) { + return fail([ + " Provider name must be 1-128 chars, start with a letter, and use only letters, digits, '.', '_', or '-'.", + ]); + } if (isBridgeProviderName(key)) { return fail([ ` '${key}' is a per-sandbox messaging bridge, not a credential.`, diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 20286ae0d60..9edc72239ea 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -154,21 +154,8 @@ describe("CLI OpenShell provider adapter", () => { expect(JSON.stringify(result)).not.toContain(storedCredentialValue); }); - it("imports an existing profile and returns sorted credential keys (#9806)", async () => { - const run = vi - .fn() - .mockReturnValueOnce(captured(1, "", "provider profile already exists")) - .mockReturnValueOnce( - captured( - 0, - JSON.stringify({ - credentials: [ - { env_vars: ["ZETA_TOKEN", "ALPHA_TOKEN"] }, - { env_vars: ["ALPHA_TOKEN"] }, - ], - }), - ), - ); + it("treats an existing provider profile as already present (#9806)", async () => { + const run = vi.fn(() => captured(1, "", "provider profile already exists")); const adapter = createCliOpenShellProviderAdapter({ run }); await expect( @@ -177,6 +164,23 @@ describe("CLI OpenShell provider adapter", () => { profilePath: "/repo/profile.yaml", }), ).resolves.toEqual({ ok: true, value: { state: "already_present" } }); + expect(run).toHaveBeenCalledWith( + ["provider", "profile", "import", "--file", "/repo/profile.yaml"], + expect.any(Object), + ); + }); + + it("returns sorted unique credential keys from a provider profile (#9806)", async () => { + const run = vi.fn(() => + captured( + 0, + JSON.stringify({ + credentials: [{ env_vars: ["ZETA_TOKEN", "ALPHA_TOKEN"] }, { env_vars: ["ALPHA_TOKEN"] }], + }), + ), + ); + const adapter = createCliOpenShellProviderAdapter({ run }); + await expect( adapter.inspectProviderProfile({ target: selectedOpenShellGateway(), @@ -186,10 +190,10 @@ describe("CLI OpenShell provider adapter", () => { ok: true, value: { credentialKeys: ["ALPHA_TOKEN", "ZETA_TOKEN"] }, }); - expect(run.mock.calls.map(([args]) => args)).toEqual([ - ["provider", "profile", "import", "--file", "/repo/profile.yaml"], + expect(run).toHaveBeenCalledWith( ["provider", "profile", "export", "custom", "--output", "json"], - ]); + expect.any(Object), + ); }); it("reconciles an endpointless profile inside the CLI adapter (#9806)", async () => { diff --git a/test/package-contract/cli/credentials-cli-command.test.ts b/test/package-contract/cli/credentials-cli-command.test.ts index 9626fa839ce..33c56d83edb 100644 --- a/test/package-contract/cli/credentials-cli-command.test.ts +++ b/test/package-contract/cli/credentials-cli-command.test.ts @@ -772,6 +772,25 @@ describe("credentials oclif commands", () => { expect(output.stderr).toContain("delete failed"); }); + it("credentials reset rejects invalid provider names before gateway mutation", async () => { + const gatewayRecoveries: string[] = []; + const openshellCalls = installRuntimeBridge({ + recoverNamedGatewayRuntime: async () => { + gatewayRecoveries.push("recover"); + return { recovered: true }; + }, + }); + const { CredentialsResetCommand } = loadCommands(); + + const output = await captureOutput(() => + expectExitCode(() => CredentialsResetCommand.run(["bad name/with*chars", "--yes"]), 1), + ); + + expect(output.stderr).toContain("Provider name must be"); + expect(gatewayRecoveries).toEqual([]); + expect(openshellCalls).toEqual([]); + }); + it("credentials add rejects --config values that look secret-shaped", async () => { process.env.TAVILY_API_KEY = "tvly-test-12345"; installRuntimeBridge(); From c01f776ab521c2a2e2abe0b22201997c9d9fadfd Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:06:11 -0700 Subject: [PATCH 19/57] fix(cli): settle concurrent provider deletion Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../credentials-provider-adapter.test.ts | 46 +++++++++++++++++++ src/lib/actions/credentials/reset.ts | 1 - 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 75e70997bf6..c985022a0cf 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -305,6 +305,52 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.failureLines).toContain(" provider deletion failed"); }); + it("cleans local state when concurrent deletion settles detach recovery (#9806)", async () => { + const forgetExtraProvider = vi.fn(() => true); + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime: async () => ({ recovered: true }), + recordExtraProvider: () => true, + forgetExtraProvider, + listManagedMcpCredentialReservations: () => [], + }); + const deleteProvider = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha"], + }, + }) + .mockResolvedValueOnce({ + ok: false, + error: { kind: "command", reason: "not_found", message: "provider not found" }, + }); + const detachProvider = vi.fn(async () => ({ + ok: false, + error: { + kind: "authentication", + message: "OpenShell could not authenticate the provider operation.", + }, + })); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toContain( + " Provider 'custom-provider' is already absent from the OpenShell gateway. Local state was cleaned up.", + ); + expect(result.outputLines.join("\n")).not.toContain("Detach it with"); + expect(detachProvider).toHaveBeenCalledOnce(); + expect(forgetExtraProvider).toHaveBeenCalledWith("custom-provider"); + }); + it("reports final attachments after successful detach recovery (#9806)", async () => { const deleteProvider = vi .fn() diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index f954b222871..ed7891dbd28 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -111,7 +111,6 @@ export async function runCredentialsResetAction( if ( !recovery.ok && - recovery.recoveryFailures.length === 0 && !KNOWN_CREDENTIAL_ENV_KEY_SET.has(key) && recovery.error?.kind === "command" && recovery.error.reason === "not_found" From ad946853554656b85657ac0d4fba0c7c3facef8b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 31 Aug 2026 20:21:21 -0700 Subject: [PATCH 20/57] refactor(cli): reuse provider command timeout Signed-off-by: Prekshi Vyas --- ci/source-architecture-budget.json | 3 +-- src/lib/adapters/openshell/provider-adapter-cli.ts | 7 +++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index e729c6452e0..f29b64b3ef0 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -9,7 +9,7 @@ "src/lib/adapters/openshell/client.ts": 20, "src/lib/adapters/openshell/resolve.ts": 27, "src/lib/adapters/openshell/runtime.ts": 55, - "src/lib/adapters/openshell/timeouts.ts": 39, + "src/lib/adapters/openshell/timeouts.ts": 38, "src/lib/agent/defs.ts": 33, "src/lib/cli/branding.ts": 85, "src/lib/cli/nemoclaw-oclif-command.ts": 106, @@ -22,7 +22,6 @@ "src/lib/credentials/store.ts": 45, "src/lib/inference/config.ts": 30, "src/lib/messaging/channels/index.ts": 25, - "src/lib/name-validation.ts": 19, "src/lib/onboard/gateway-binding.ts": 53, "src/lib/runner.ts": 85, "src/lib/security/redact.ts": 53, diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 64691e0c2b6..d8e11ba86ef 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -3,7 +3,11 @@ import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../../name-validation"; import { redactFull } from "../../security/redact"; -import { parseCliOpenShellProviderNames, runOpenshellProviderCommand } from "./provider-command"; +import { + OPENSHELL_OPERATION_TIMEOUT_MS, + parseCliOpenShellProviderNames, + runOpenshellProviderCommand, +} from "./provider-command"; import { type CreateOpenShellProviderRequest, type DeleteOpenShellProviderRequest, @@ -18,7 +22,6 @@ import { } from "./provider-adapter"; import type { OpenShellGatewayTarget } from "./sandbox-observer"; import { ensureEndpointlessProviderProfile as reconcileEndpointlessProviderProfile } from "./provider-profile"; -import { OPENSHELL_OPERATION_TIMEOUT_MS } from "./timeouts"; export type CapturedProviderCommandResult = Readonly<{ status: number | null; From c8b89fe2cf9b5a74462007cbd8cc48f7cf41874b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 31 Aug 2026 20:46:05 -0700 Subject: [PATCH 21/57] test(e2e): cover provider credential lifecycle Signed-off-by: Prekshi Vyas --- test/e2e/live/sandbox-operations.test.ts | 104 ++++++++++++++++++++++- tools/e2e/target-catalogue.mts | 2 +- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index 801b8fc9922..92229a5c095 100644 --- a/test/e2e/live/sandbox-operations.test.ts +++ b/test/e2e/live/sandbox-operations.test.ts @@ -27,6 +27,7 @@ import { type HostedInferenceConfig, requireHostedInferenceConfig, } from "../fixtures/hosted-inference.ts"; +import { expectSandboxProviderAttachment } from "../fixtures/gateway-providers.ts"; import { RESOURCE_LIMIT_CONNECT_BEGIN_MARKER, RESOURCE_LIMIT_CONNECT_END_MARKER, @@ -39,6 +40,9 @@ import { ubuntuRepoDocker } from "../registry/matrix.ts"; const ENVIRONMENT = ubuntuRepoDocker("cloud-openclaw"); const SANDBOX_A = "e2e-sbx-a"; const SANDBOX_B = "e2e-sbx-b"; +const CREDENTIAL_PROVIDER = "e2e-sandbox-tavily"; +const CREDENTIAL_ENV_NAME = "TAVILY_API_KEY"; +const CREDENTIAL_VALUE = "e2e-sandbox-operations-provider-secret"; const REGISTRY_FILE = path.join(process.env.HOME ?? os.homedir(), ".nemoclaw", "sandboxes.json"); const GATEWAY_CONTAINER = "openshell-cluster-nemoclaw"; const GATEWAY_PORT = process.env.NEMOCLAW_GATEWAY_PORT ?? "8080"; @@ -149,6 +153,97 @@ async function onboardSandbox( return result; } +async function resetCredentialProvider( + host: HostCliClient, + artifactName: string, +): Promise { + const reset = await host.nemoclaw(["credentials", "reset", CREDENTIAL_PROVIDER, "--yes"], { + artifactName, + env: buildAvailabilityProbeEnv(), + redactionValues: [CREDENTIAL_VALUE], + timeoutMs: 3 * 60_000, + }); + expectExitZero(reset, `nemoclaw credentials reset ${CREDENTIAL_PROVIDER} --yes`); + expect(resultText(reset)).not.toContain(CREDENTIAL_VALUE); + return reset; +} + +async function assertCredentialProviderAdapterLifecycle( + host: HostCliClient, + sandbox: SandboxClient, + cleanup: CleanupRegistry, + hosted: HostedInferenceConfig, +): Promise { + await resetCredentialProvider(host, "tc-sbx-14-clear-stale-credential-provider"); + cleanup.add(`remove credential provider ${CREDENTIAL_PROVIDER}`, async () => { + await resetCredentialProvider(host, "cleanup-tc-sbx-14-credential-provider"); + }); + + const add = await host.nemoclaw( + [ + "credentials", + "add", + CREDENTIAL_PROVIDER, + "--type", + "tavily", + "--credential", + CREDENTIAL_ENV_NAME, + ], + { + artifactName: "tc-sbx-14-credentials-add", + env: { + ...buildAvailabilityProbeEnv(), + [CREDENTIAL_ENV_NAME]: CREDENTIAL_VALUE, + }, + redactionValues: [CREDENTIAL_VALUE], + timeoutMs: 3 * 60_000, + }, + ); + expectExitZero(add, `nemoclaw credentials add ${CREDENTIAL_PROVIDER}`); + expect(resultText(add)).toContain(`Registered provider '${CREDENTIAL_PROVIDER}'`); + expect(resultText(add)).not.toContain(CREDENTIAL_VALUE); + + const beforeRebuild = await host.nemoclaw(["credentials", "list"], { + artifactName: "tc-sbx-14-credentials-list-before-rebuild", + env: buildAvailabilityProbeEnv(), + timeoutMs: 60_000, + }); + expectExitZero(beforeRebuild, "nemoclaw credentials list before rebuild"); + expect(resultText(beforeRebuild)).toContain(CREDENTIAL_PROVIDER); + + const rebuild = await host.nemoclaw([SANDBOX_A, "rebuild", "--yes"], { + artifactName: "tc-sbx-14-rebuild-with-credential-provider", + env: { + ...buildAvailabilityProbeEnv(), + ...hosted.env, + }, + redactionValues: [hosted.apiKey, CREDENTIAL_VALUE], + timeoutMs: 20 * 60_000, + }); + expectExitZero(rebuild, `nemoclaw ${SANDBOX_A} rebuild --yes`); + expect(resultText(rebuild)).not.toContain(CREDENTIAL_VALUE); + + await expectSandboxProviderAttachment(sandbox, SANDBOX_A, CREDENTIAL_PROVIDER, "present", { + artifactName: "tc-sbx-14-provider-attached-after-rebuild", + env: buildAvailabilityProbeEnv(), + }); + + const reset = await resetCredentialProvider(host, "tc-sbx-14-credentials-reset-attached"); + expect(resultText(reset)).toContain(`Removed provider '${CREDENTIAL_PROVIDER}'`); + await expectSandboxProviderAttachment(sandbox, SANDBOX_A, CREDENTIAL_PROVIDER, "absent", { + artifactName: "tc-sbx-14-provider-detached-after-reset", + env: buildAvailabilityProbeEnv(), + }); + + const afterReset = await host.nemoclaw(["credentials", "list"], { + artifactName: "tc-sbx-14-credentials-list-after-reset", + env: buildAvailabilityProbeEnv(), + timeoutMs: 60_000, + }); + expectExitZero(afterReset, "nemoclaw credentials list after reset"); + expect(resultText(afterReset)).not.toContain(CREDENTIAL_PROVIDER); +} + async function expectListed(host: HostCliClient, sandboxName: string, artifactName: string) { const list = await host.nemoclaw(["list"], { artifactName, @@ -174,7 +269,6 @@ async function execInSandbox( }); } - async function assertAgentCanAnswer( host: HostCliClient, sandboxName: string, @@ -599,11 +693,12 @@ async function assertGatewayRecovery( test( "sandbox operations preserve list/status/logs/recovery/multi-sandbox contracts", { - timeout: 45 * 60_000, + timeout: 55 * 60_000, meta: { e2ePhases: [ "confirm Docker and clear the sandbox operation fixtures", "onboard the primary sandbox", + "exercise credential provider adapter lifecycle", "validate connected shell resource limits", "exercise primary CLI inference and logs", "exercise terminal registry and process recovery", @@ -636,6 +731,7 @@ test( "TC-SBX-11 sandboxes cannot reach each other by hostname", "TC-SBX-12 destroying the non-final sandbox preserves the survivor and final destroy releases the gateway port through the macOS default or explicit non-macOS cleanup", "TC-SBX-13 bare connect routes to the default sandbox and enforces login and interactive shell resource limits without startup diagnostics (#2173)", + "TC-SBX-14 credentials add/list/reset crosses the real OpenShell provider boundary, attaches on rebuild, and detaches before deletion", ], }); @@ -652,6 +748,10 @@ test( progress.phase("onboard the primary sandbox"); await onboardSandbox(host, cleanup, SANDBOX_A, "onboard-sandbox-a", hosted); + artifacts.addRedactionValues([CREDENTIAL_VALUE]); + progress.phase("exercise credential provider adapter lifecycle"); + await assertCredentialProviderAdapterLifecycle(host, sandbox, cleanup, hosted); + progress.phase("validate connected shell resource limits"); const connectRlimitSummary = await assertConnectResourceLimits(host); await artifacts.writeText("connect-rlimits-summary.txt", connectRlimitSummary); diff --git a/tools/e2e/target-catalogue.mts b/tools/e2e/target-catalogue.mts index e25b97db5ad..c4137a9942a 100644 --- a/tools/e2e/target-catalogue.mts +++ b/tools/e2e/target-catalogue.mts @@ -1315,7 +1315,7 @@ export const E2E_TARGET_CATALOGUE: readonly E2eCatalogueTarget[] = [ agentRuntime: "openclaw", environmentOrInferenceEndpoint: "Ubuntu; NVIDIA hosted inference", profile: "nvidia-inference", - timeoutMinutes: 60, + timeoutMinutes: 75, installMode: "credential-free", installNonInteractive: true, restoreCli: true, From a1f6c6a1878a4de6e040db88234db2cc3a49d9c3 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 31 Aug 2026 23:36:41 -0700 Subject: [PATCH 22/57] chore(cli): lower source architecture ratchet Signed-off-by: Prekshi Vyas --- ci/source-architecture-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 31473c3d408..a57ecfa5c59 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -24,7 +24,7 @@ "src/lib/messaging/channels/index.ts": 25, "src/lib/onboard/gateway-binding.ts": 53, "src/lib/runner.ts": 85, - "src/lib/security/redact.ts": 55, + "src/lib/security/redact.ts": 54, "src/lib/state/mcp-lifecycle-lock.ts": 21, "src/lib/state/onboard-session.ts": 35, "src/lib/state/registry.ts": 97, From 230c4a307897a617f737e4b20ed2a1b542789579 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 00:33:20 -0700 Subject: [PATCH 23/57] test(e2e): isolate provider credential lifecycle --- test/e2e/live/sandbox-operations.test.ts | 48 ++++++++++++++++++++---- tools/e2e/target-catalogue.mts | 2 +- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index 92229a5c095..c8cc1b92bdd 100644 --- a/test/e2e/live/sandbox-operations.test.ts +++ b/test/e2e/live/sandbox-operations.test.ts @@ -690,15 +690,54 @@ async function assertGatewayRecovery( return recoveryOutcome; } +test( + "credentials reset detaches a rebuilt sandbox provider before deletion", + { + timeout: 45 * 60_000, + meta: { + e2ePhases: [ + "confirm Docker and clear the credential provider fixture", + "onboard the credential lifecycle sandbox", + "add, attach, reset, and remove the credential provider", + ], + }, + }, + async ({ artifacts, cleanup, docker, environment, host, progress, sandbox, secrets }) => { + const hosted = requireHostedInferenceConfig(secrets); + + await artifacts.target.declare({ + id: "sandbox-operations", + boundary: "repo-cli-openshell-provider-sandbox-attachment", + contracts: [ + "TC-SBX-14 credentials add/list/reset crosses the real OpenShell provider boundary, attaches on rebuild, and detaches before deletion", + ], + }); + + artifacts.addRedactionValues([CREDENTIAL_VALUE]); + await docker.requireDocker(); + await environment.assertReady(ENVIRONMENT); + cleanup.trackGateway(host, "nemoclaw", { + env: buildAvailabilityProbeEnv(), + timeoutMs: 5 * 60_000, + }); + await host.cleanupSandbox(SANDBOX_A); + + progress.phase("onboard the credential lifecycle sandbox"); + await onboardSandbox(host, cleanup, SANDBOX_A, "tc-sbx-14-onboard-sandbox", hosted); + + progress.phase("add, attach, reset, and remove the credential provider"); + await assertCredentialProviderAdapterLifecycle(host, sandbox, cleanup, hosted); + }, +); + test( "sandbox operations preserve list/status/logs/recovery/multi-sandbox contracts", { - timeout: 55 * 60_000, + timeout: 50 * 60_000, meta: { e2ePhases: [ "confirm Docker and clear the sandbox operation fixtures", "onboard the primary sandbox", - "exercise credential provider adapter lifecycle", "validate connected shell resource limits", "exercise primary CLI inference and logs", "exercise terminal registry and process recovery", @@ -731,7 +770,6 @@ test( "TC-SBX-11 sandboxes cannot reach each other by hostname", "TC-SBX-12 destroying the non-final sandbox preserves the survivor and final destroy releases the gateway port through the macOS default or explicit non-macOS cleanup", "TC-SBX-13 bare connect routes to the default sandbox and enforces login and interactive shell resource limits without startup diagnostics (#2173)", - "TC-SBX-14 credentials add/list/reset crosses the real OpenShell provider boundary, attaches on rebuild, and detaches before deletion", ], }); @@ -748,10 +786,6 @@ test( progress.phase("onboard the primary sandbox"); await onboardSandbox(host, cleanup, SANDBOX_A, "onboard-sandbox-a", hosted); - artifacts.addRedactionValues([CREDENTIAL_VALUE]); - progress.phase("exercise credential provider adapter lifecycle"); - await assertCredentialProviderAdapterLifecycle(host, sandbox, cleanup, hosted); - progress.phase("validate connected shell resource limits"); const connectRlimitSummary = await assertConnectResourceLimits(host); await artifacts.writeText("connect-rlimits-summary.txt", connectRlimitSummary); diff --git a/tools/e2e/target-catalogue.mts b/tools/e2e/target-catalogue.mts index c4137a9942a..5a5daf32405 100644 --- a/tools/e2e/target-catalogue.mts +++ b/tools/e2e/target-catalogue.mts @@ -1315,7 +1315,7 @@ export const E2E_TARGET_CATALOGUE: readonly E2eCatalogueTarget[] = [ agentRuntime: "openclaw", environmentOrInferenceEndpoint: "Ubuntu; NVIDIA hosted inference", profile: "nvidia-inference", - timeoutMinutes: 75, + timeoutMinutes: 120, installMode: "credential-free", installNonInteractive: true, restoreCli: true, From 9d9e3281bd6d6b268f7b10293245416c8f649272 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 00:58:02 -0700 Subject: [PATCH 24/57] fix(cli): validate bundled provider profiles --- src/lib/actions/credentials-add.ts | 6 + .../credentials-provider-adapter.test.ts | 30 +++ .../openshell/provider-adapter-cli.test.ts | 222 +++++++++++++++++- .../openshell/provider-adapter-cli.ts | 50 +++- .../openshell/provider-profile.test.ts | 10 +- .../adapters/openshell/provider-profile.ts | 95 ++++++++ 6 files changed, 402 insertions(+), 11 deletions(-) diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 69a859dbfc4..943a4302070 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -96,6 +96,12 @@ async function ensureBundledProviderProfile( timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); if (result.ok) return null; + if (result.error.kind === "command" && result.error.reason === "profile_incompatible") { + return fail([ + ` OpenShell provider profile '${type}' does not match NemoClaw's checked-in credential boundary.`, + " Remove the conflicting provider profile, then retry this command.", + ]); + } return fail([ ` Could not import bundled provider profile '${type}'.`, " Update OpenShell with scripts/install-openshell.sh and retry.", diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index c985022a0cf..bb3edf593d1 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -128,6 +128,36 @@ describe("credential actions use typed OpenShell provider results", () => { expect(adapter.createProvider).toHaveBeenCalledOnce(); }); + it("does not create a provider from an incompatible bundled profile (#9806)", async () => { + vi.stubEnv("TAVILY_API_KEY", "host-only-value"); + const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ + ok: false, + error: { + kind: "command", + reason: "profile_incompatible", + message: "The OpenShell provider profile does not match the checked-in boundary.", + }, + }); + const adapter = providerAdapter({ importProviderProfile: vi.fn(importProviderProfile) }); + + const result = await runCredentialsAddAction( + { + provider: "tavily-prod", + type: "tavily", + credentials: ["TAVILY_API_KEY"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toContain( + " OpenShell provider profile 'tavily' does not match NemoClaw's checked-in credential boundary.", + ); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }); + it("lists credentials separately from messaging bridge providers (#9806)", async () => { const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ ok: true, diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 9edc72239ea..0b1f2ded039 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -10,6 +10,68 @@ function captured(status: number | null, stdout = "", stderr = "", error?: Error return { status, stdout, stderr, ...(error ? { error } : {}) }; } +const TAVILY_PROFILE = { + id: "tavily", + credentials: [ + { + name: "api_key", + env_vars: ["TAVILY_API_KEY"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", + }, + ], + endpoints: [ + { + host: "api.tavily.com", + port: 443, + protocol: "rest", + enforcement: "enforce", + request_body_credential_rewrite: true, + rules: [ + { allow: { method: "POST", path: "/search" } }, + { allow: { method: "POST", path: "/extract" } }, + ], + }, + ], + binaries: [ + "/opt/venv/bin/python3*", + "/usr/local/bin/node", + "/usr/bin/node", + "/usr/local/bin/curl", + "/usr/bin/curl", + ], + inference_capable: false, +} as const; + +const TAVILY_PROFILE_YAML = ` +id: tavily +credentials: + - name: api_key + env_vars: [TAVILY_API_KEY] + required: true + auth_style: bearer + header_name: authorization + query_param: '' +endpoints: + - host: api.tavily.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: POST, path: /search } + - allow: { method: POST, path: /extract } +binaries: + - /opt/venv/bin/python3* + - /usr/local/bin/node + - /usr/bin/node + - /usr/local/bin/curl + - /usr/bin/curl +inference_capable: false +`; + describe("CLI OpenShell provider adapter", () => { it("targets a named gateway and returns provider names (#9806)", async () => { const run = vi.fn(() => captured(0, "zeta\nalpha\n")); @@ -155,8 +217,14 @@ describe("CLI OpenShell provider adapter", () => { }); it("treats an existing provider profile as already present (#9806)", async () => { - const run = vi.fn(() => captured(1, "", "provider profile already exists")); - const adapter = createCliOpenShellProviderAdapter({ run }); + const run = vi + .fn() + .mockReturnValueOnce(captured(1, "", "provider profile already exists")) + .mockReturnValueOnce(captured(0, JSON.stringify(TAVILY_PROFILE))); + const adapter = createCliOpenShellProviderAdapter({ + run, + readProfileFile: () => TAVILY_PROFILE_YAML, + }); await expect( adapter.importProviderProfile({ @@ -164,10 +232,154 @@ describe("CLI OpenShell provider adapter", () => { profilePath: "/repo/profile.yaml", }), ).resolves.toEqual({ ok: true, value: { state: "already_present" } }); - expect(run).toHaveBeenCalledWith( + expect(run.mock.calls.map(([args]) => args)).toEqual([ ["provider", "profile", "import", "--file", "/repo/profile.yaml"], - expect.any(Object), - ); + ["provider", "profile", "export", "tavily", "--output", "json"], + ]); + expect(run.mock.calls[1]?.[1]).toMatchObject({ suppressOutput: true }); + }); + + it("validates a newly imported provider profile before returning success (#9806)", async () => { + const run = vi + .fn() + .mockReturnValueOnce(captured(0)) + .mockReturnValueOnce(captured(0, JSON.stringify(TAVILY_PROFILE))); + const adapter = createCliOpenShellProviderAdapter({ + run, + readProfileFile: () => TAVILY_PROFILE_YAML, + }); + + await expect( + adapter.importProviderProfile({ + target: selectedOpenShellGateway(), + profilePath: "/repo/profile.yaml", + }), + ).resolves.toEqual({ ok: true, value: { state: "imported" } }); + expect(run.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "profile", "import", "--file", "/repo/profile.yaml"], + ["provider", "profile", "export", "tavily", "--output", "json"], + ]); + }); + + it.each([ + [ + "endpoint", + { + ...TAVILY_PROFILE, + endpoints: [ + ...TAVILY_PROFILE.endpoints, + { host: "attacker.example", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + }, + ], + ["binary", { ...TAVILY_PROFILE, binaries: [...TAVILY_PROFILE.binaries, "/tmp/widened"] }], + [ + "credential", + { + ...TAVILY_PROFILE, + credentials: [ + ...TAVILY_PROFILE.credentials, + { + name: "extra", + env_vars: ["EXTRA_TOKEN"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", + }, + ], + }, + ], + ])("rejects an existing profile with a widened %s boundary (#9806)", async (_field, profile) => { + const run = vi + .fn() + .mockReturnValueOnce(captured(1, "", "provider profile already exists")) + .mockReturnValueOnce(captured(0, JSON.stringify(profile))); + const adapter = createCliOpenShellProviderAdapter({ + run, + readProfileFile: () => TAVILY_PROFILE_YAML, + }); + + await expect( + adapter.importProviderProfile({ + target: selectedOpenShellGateway(), + profilePath: "/repo/profile.yaml", + }), + ).resolves.toEqual({ + ok: false, + error: { + kind: "command", + reason: "profile_incompatible", + message: + "The OpenShell provider profile does not match the checked-in credential boundary.", + }, + }); + }); + + it("rejects a malformed exported profile after import (#9806)", async () => { + const run = vi + .fn() + .mockReturnValueOnce(captured(0)) + .mockReturnValueOnce(captured(0, "not-json")); + const adapter = createCliOpenShellProviderAdapter({ + run, + readProfileFile: () => TAVILY_PROFILE_YAML, + }); + + await expect( + adapter.importProviderProfile({ + target: selectedOpenShellGateway(), + profilePath: "/repo/profile.yaml", + }), + ).resolves.toMatchObject({ + ok: false, + error: { kind: "command", reason: "profile_incompatible" }, + }); + }); + + it("rejects a missing exported profile after import (#9806)", async () => { + const run = vi + .fn() + .mockReturnValueOnce(captured(0)) + .mockReturnValueOnce(captured(1, "", "provider profile not found")); + const adapter = createCliOpenShellProviderAdapter({ + run, + readProfileFile: () => TAVILY_PROFILE_YAML, + }); + + await expect( + adapter.importProviderProfile({ + target: selectedOpenShellGateway(), + profilePath: "/repo/profile.yaml", + }), + ).resolves.toMatchObject({ + ok: false, + error: { kind: "command", reason: "not_found" }, + }); + }); + + it("rejects an unreadable checked-in profile before invoking OpenShell (#9806)", async () => { + const run = vi.fn(); + const adapter = createCliOpenShellProviderAdapter({ + run, + readProfileFile: () => { + throw new Error("host path detail"); + }, + }); + + await expect( + adapter.importProviderProfile({ + target: selectedOpenShellGateway(), + profilePath: "/repo/profile.yaml", + }), + ).resolves.toEqual({ + ok: false, + error: { + kind: "validation", + message: "The checked-in OpenShell provider profile is invalid or unreadable.", + }, + }); + expect(run).not.toHaveBeenCalled(); }); it("returns sorted unique credential keys from a provider profile (#9806)", async () => { diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index d8e11ba86ef..578d1034aef 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../../name-validation"; import { redactFull } from "../../security/redact"; import { @@ -21,7 +22,11 @@ import { type OpenShellProviderResult, } from "./provider-adapter"; import type { OpenShellGatewayTarget } from "./sandbox-observer"; -import { ensureEndpointlessProviderProfile as reconcileEndpointlessProviderProfile } from "./provider-profile"; +import { + ensureEndpointlessProviderProfile as reconcileEndpointlessProviderProfile, + exportedProviderProfileMatchesContract, + parseCheckedInProviderProfileContract, +} from "./provider-profile"; export type CapturedProviderCommandResult = Readonly<{ status: number | null; @@ -44,6 +49,7 @@ export type RunProviderCommand = ( export type CliOpenShellProviderAdapterDeps = Readonly<{ run?: RunProviderCommand; defaultTimeoutMs?: number; + readProfileFile?: (profilePath: string) => string; }>; const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/u; @@ -271,15 +277,51 @@ export function createCliOpenShellProviderAdapter( const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async ( request: ImportOpenShellProviderProfileRequest, ) => { + const readProfileFile = + deps.readProfileFile ?? ((file: string) => fs.readFileSync(file, "utf8")); + const contract = (() => { + try { + return parseCheckedInProviderProfileContract(readProfileFile(request.profilePath)); + } catch { + // Report a fixed validation error below; never return host filesystem diagnostics. + return null; + } + })(); + if (!contract) { + return failure({ + kind: "validation", + message: "The checked-in OpenShell provider profile is invalid or unreadable.", + }); + } const result = invoke( ["provider", "profile", "import", "--file", request.profilePath], request, ); const error = commandError(result); - if (error?.kind === "command" && error.reason === "already_exists") { - return success({ state: "already_present" }); + const state = + error?.kind === "command" && error.reason === "already_exists" + ? "already_present" + : "imported"; + if (error && state !== "already_present") return failure(error); + + const exported = invoke( + ["provider", "profile", "export", contract.profileId, "--output", "json"], + request, + undefined, + 2, + true, + ); + const exportError = commandError(exported); + if (exportError) return failure(exportError); + if (!exportedProviderProfileMatchesContract(bufferOrStringToText(exported.stdout), contract)) { + return failure({ + kind: "command", + reason: "profile_incompatible", + message: + "The OpenShell provider profile does not match the checked-in credential boundary.", + }); } - return error ? failure(error) : success({ state: "imported" }); + return success({ state }); }; const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = diff --git a/src/lib/adapters/openshell/provider-profile.test.ts b/src/lib/adapters/openshell/provider-profile.test.ts index e19fc0e1a81..78e02ad688e 100644 --- a/src/lib/adapters/openshell/provider-profile.test.ts +++ b/src/lib/adapters/openshell/provider-profile.test.ts @@ -100,7 +100,10 @@ describe("OpenShell endpointless provider profiles", () => { .mockReturnValueOnce({ status: 0 }); expect(ensureProfile(runOpenshell)).toEqual({ ok: true }); - expect(runOpenshell).toHaveBeenCalledTimes(2); + expect(runOpenshell.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "profile", "export", PROFILE_ID, "--output", "json"], + ["provider", "profile", "import", "--file", PROFILE_PATH], + ]); }); it("imports after OpenShell wraps the missing-profile message (#10155)", () => { @@ -114,7 +117,10 @@ describe("OpenShell endpointless provider profiles", () => { .mockReturnValueOnce({ status: 0 }); expect(ensureProfile(runOpenshell)).toEqual({ ok: true }); - expect(runOpenshell).toHaveBeenCalledTimes(2); + expect(runOpenshell.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "profile", "export", PROFILE_ID, "--output", "json"], + ["provider", "profile", "import", "--file", PROFILE_PATH], + ]); }); it("does not import after an unrelated structured not-found response (#10155)", () => { diff --git a/src/lib/adapters/openshell/provider-profile.ts b/src/lib/adapters/openshell/provider-profile.ts index e6769938b7b..5df1ca77c6e 100644 --- a/src/lib/adapters/openshell/provider-profile.ts +++ b/src/lib/adapters/openshell/provider-profile.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import YAML from "yaml"; import { REPOSITORY_ROOT } from "../../core/repository-root"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "./provider-command"; @@ -44,6 +46,99 @@ function commandStdout(result: { readonly output?: unknown; readonly stdout?: un return Array.isArray(result.output) ? outputText(result.output[1]) : outputText(result.output); } +type ProviderProfileBoundary = Readonly<{ + id: string; + credentials: readonly Readonly>[]; + endpoints: readonly unknown[]; + binaries: readonly string[]; + inference_capable: boolean; +}>; + +export type CheckedInProviderProfileContract = Readonly<{ + profileId: string; + boundary: ProviderProfileBoundary; +}>; + +function recordValue(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function providerProfileBoundary(value: unknown): ProviderProfileBoundary | null { + const profile = recordValue(value); + if ( + !profile || + typeof profile.id !== "string" || + !Array.isArray(profile.credentials) || + !Array.isArray(profile.endpoints) || + !Array.isArray(profile.binaries) || + profile.binaries.some((binary) => typeof binary !== "string") || + typeof profile.inference_capable !== "boolean" + ) { + return null; + } + const credentials = profile.credentials.map((value) => { + const credential = recordValue(value); + if ( + !credential || + typeof credential.name !== "string" || + !Array.isArray(credential.env_vars) || + credential.env_vars.some((envVar) => typeof envVar !== "string") || + typeof credential.required !== "boolean" || + typeof credential.auth_style !== "string" || + typeof credential.header_name !== "string" || + (credential.query_param !== undefined && typeof credential.query_param !== "string") || + (credential.refresh !== undefined && recordValue(credential.refresh) === null) + ) { + return null; + } + return { + name: credential.name, + env_vars: credential.env_vars, + required: credential.required, + auth_style: credential.auth_style, + header_name: credential.header_name, + query_param: credential.query_param, + refresh: credential.refresh ?? null, + }; + }); + if (credentials.some((credential) => credential === null)) return null; + if (profile.endpoints.some((endpoint) => recordValue(endpoint) === null)) return null; + return { + id: profile.id, + credentials: credentials as readonly Readonly>[], + endpoints: profile.endpoints, + binaries: profile.binaries as string[], + inference_capable: profile.inference_capable, + }; +} + +/** Parse the credential boundary owned by one checked-in provider profile. */ +export function parseCheckedInProviderProfileContract( + source: string, +): CheckedInProviderProfileContract | null { + try { + const boundary = providerProfileBoundary(YAML.parse(source) as unknown); + return boundary ? { profileId: boundary.id, boundary } : null; + } catch { + return null; + } +} + +/** Compare an exported gateway profile with its checked-in credential boundary. */ +export function exportedProviderProfileMatchesContract( + exported: string, + expected: CheckedInProviderProfileContract, +): boolean { + try { + const actual = providerProfileBoundary(JSON.parse(exported) as unknown); + return actual !== null && isDeepStrictEqual(actual, expected.boundary); + } catch { + return false; + } +} + function isMissingProviderProfile(output: string, profileId: string): boolean { const normalized = output .replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "") From 921d95df276c36c1bd15e52dae02806a35022ca3 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 01:22:52 -0700 Subject: [PATCH 25/57] fix(cli): verify provider trust boundaries --- .../credentials-provider-adapter.test.ts | 47 +++++++++++++++++++ ...inference-set-provider-diagnostics.test.ts | 11 +++-- .../inference-set-provider-diagnostics.ts | 6 ++- .../openshell/provider-adapter-cli.test.ts | 35 +++++++++++++- .../openshell/provider-adapter-cli.ts | 11 +++-- .../openshell/provider-command.test.ts | 14 ++++-- .../adapters/openshell/provider-command.ts | 17 ++++--- .../openshell/provider-profile.test.ts | 45 ++++++++++++++++-- .../adapters/openshell/provider-profile.ts | 34 +++++--------- 9 files changed, 176 insertions(+), 44 deletions(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index bb3edf593d1..224bb4feeff 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -4,6 +4,7 @@ import readline from "node:readline"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; import { setGlobalCliActionRuntimeHooksForTest } from "./global"; import { runCredentialsAddAction } from "./credentials-add"; @@ -128,6 +129,36 @@ describe("credential actions use typed OpenShell provider results", () => { expect(adapter.createProvider).toHaveBeenCalledOnce(); }); + it("does not create an OpenAI provider after incompatible profile reconciliation (#9806)", async () => { + vi.stubEnv("OPENAI_API_KEY", "host-only-value"); + const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = + async () => ({ + ok: false, + error: { + kind: "command", + reason: "profile_incompatible", + message: "The installed endpointless profile does not match.", + }, + }); + const adapter = providerAdapter({ + ensureEndpointlessProviderProfile: vi.fn(ensureEndpointlessProviderProfile), + }); + + const result = await runCredentialsAddAction( + { + provider: "openai-prod", + type: "openai", + credentials: ["OPENAI_API_KEY"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }); + it("does not create a provider from an incompatible bundled profile (#9806)", async () => { vi.stubEnv("TAVILY_API_KEY", "host-only-value"); const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ @@ -182,6 +213,22 @@ describe("credential actions use typed OpenShell provider results", () => { ); }); + it.each([ + ["OSC control", "alpha\n\u001b]52;c;YXR0YWNr\u0007"], + ["invalid name", "alpha\nbad/name"], + ])("does not render an unsafe gateway provider inventory: %s (#9806)", async (_case, output) => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => ({ status: 0, stdout: output }), + }); + + const result = await runCredentialsListAction("nemoclaw", { providerAdapter: adapter }); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toContain(" OpenShell returned an invalid provider inventory."); + expect(result.outputLines).toEqual([]); + expect(JSON.stringify(result)).not.toContain(output); + }); + it.each([ [ "authentication", diff --git a/src/lib/actions/inference-set-provider-diagnostics.test.ts b/src/lib/actions/inference-set-provider-diagnostics.test.ts index 094d82256d6..0822baf8761 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.test.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.test.ts @@ -33,9 +33,10 @@ describe("inference set provider diagnostics", () => { bridgeNames: [], credentialNames: [], }); - expect( - classifyGatewayProviderNames(["alpha-telegram-bridge", "alpha-slack-app"]), - ).toEqual({ bridgeNames: ["alpha-telegram-bridge", "alpha-slack-app"], credentialNames: [] }); + expect(classifyGatewayProviderNames(["alpha-telegram-bridge", "alpha-slack-app"])).toEqual({ + bridgeNames: ["alpha-telegram-bridge", "alpha-slack-app"], + credentialNames: [], + }); expect(isBridgeProviderName("alpha-discord-bridge")).toBe(true); expect(isBridgeProviderName("nvidia-prod")).toBe(false); }); @@ -67,6 +68,10 @@ describe("inference set provider diagnostics", () => { name: "nonzero status", capture: () => ({ status: 17, output: "query-secret" }), }, + { + name: "unsafe provider name", + capture: () => ({ status: 0, output: "alpha\n\u001b]52;c;YXR0YWNr\u0007" }), + }, ])("uses the static fallback for $name", ({ capture }) => { const captureOpenshell = vi.fn(capture); const log = vi.fn(); diff --git a/src/lib/actions/inference-set-provider-diagnostics.ts b/src/lib/actions/inference-set-provider-diagnostics.ts index 64c8c70a663..7234e966815 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.ts @@ -30,8 +30,10 @@ export function queryRegisteredGatewayProviders( timeout: OPEN_SHELL_DIAGNOSTIC_TIMEOUT_MS, }); if (result.status === 0) { - return classifyGatewayProviderNames(parseCliOpenShellProviderNames(result.output)) - .credentialNames; + const providerNames = parseCliOpenShellProviderNames(result.output); + if (providerNames) { + return classifyGatewayProviderNames(providerNames).credentialNames; + } } } catch (_error: unknown) { // #5924: intentionally treat every thrown query or parsing error identically. diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 0b1f2ded039..bdb53afc09f 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -90,6 +90,26 @@ describe("CLI OpenShell provider adapter", () => { }); }); + it.each([ + ["OSC control", "alpha\n\u001b]52;c;YXR0YWNr\u0007"], + ["invalid name", "alpha\nbad/name"], + ])( + "rejects unsafe provider inventory output before returning names: %s (#9806)", + async (_case, output) => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => captured(0, output), + }); + + await expect(adapter.listProviders({ target: selectedOpenShellGateway() })).resolves.toEqual({ + ok: false, + error: { + kind: "schema", + message: "OpenShell returned an invalid provider inventory.", + }, + }); + }, + ); + it("passes credential values only through the child environment (#9806)", async () => { const run = vi.fn(() => captured(0)); const adapter = createCliOpenShellProviderAdapter({ run }); @@ -412,7 +432,19 @@ describe("CLI OpenShell provider adapter", () => { const run = vi .fn() .mockReturnValueOnce(captured(1, "", "provider profile not found")) - .mockReturnValueOnce(captured(0)); + .mockReturnValueOnce(captured(0)) + .mockReturnValueOnce( + captured( + 0, + JSON.stringify({ + id: "openai", + credentials: [], + endpoints: [], + binaries: [], + inference_capable: true, + }), + ), + ); const adapter = createCliOpenShellProviderAdapter({ run }); await expect( @@ -426,6 +458,7 @@ describe("CLI OpenShell provider adapter", () => { expect(run.mock.calls.map(([args]) => args)).toEqual([ ["provider", "profile", "export", "openai", "--output", "json"], ["provider", "profile", "import", "--file", "/repo/provider-profiles/openai.yaml"], + ["provider", "profile", "export", "openai", "--output", "json"], ]); }); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 578d1034aef..0950b5f8cf5 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -234,9 +234,14 @@ export function createCliOpenShellProviderAdapter( const result = invoke(["provider", "list", "--names"], request); const error = commandError(result); if (error) return failure(error); - return success({ - names: parseCliOpenShellProviderNames(result.stdout), - }); + const names = parseCliOpenShellProviderNames(result.stdout); + if (!names) { + return failure({ + kind: "schema", + message: "OpenShell returned an invalid provider inventory.", + }); + } + return success({ names }); }; const createProvider: OpenShellProviderAdapter["createProvider"] = async (request) => { diff --git a/src/lib/adapters/openshell/provider-command.test.ts b/src/lib/adapters/openshell/provider-command.test.ts index 7ab0e746f59..0fd080c95c3 100644 --- a/src/lib/adapters/openshell/provider-command.test.ts +++ b/src/lib/adapters/openshell/provider-command.test.ts @@ -68,13 +68,19 @@ describe("OpenShell provider command runtime", () => { expect(result).toEqual({ status: 0 }); }); - it("parses provider names without ANSI or blank lines (#9806)", () => { - expect( - parseCliOpenShellProviderNames(" \u001b[32malpha\u001b[0m \r\n\r\nbeta\n"), - ).toEqual(["alpha", "beta"]); + it("parses valid provider names without blank lines (#9806)", () => { + expect(parseCliOpenShellProviderNames(" alpha \r\n\r\nbeta\n")).toEqual(["alpha", "beta"]); }); it("returns no provider names for empty CLI output (#9806)", () => { expect(parseCliOpenShellProviderNames("")).toEqual([]); }); + + it.each([ + ["OSC control", "alpha\n\u001b]52;c;YXR0YWNr\u0007"], + ["embedded escape", "alpha\u001b[31m"], + ["invalid name", "alpha\nbad/name"], + ])("rejects the entire provider inventory for an unsafe %s (#9806)", (_case, output) => { + expect(parseCliOpenShellProviderNames(output)).toBeNull(); + }); }); diff --git a/src/lib/adapters/openshell/provider-command.ts b/src/lib/adapters/openshell/provider-command.ts index 177ea4382a1..62c0cad7d45 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -3,13 +3,12 @@ import type { StdioOptions } from "node:child_process"; +import { PROVIDER_NAME_MAX_LENGTH, PROVIDER_NAME_VALID_PATTERN } from "../../name-validation"; import { buildSubprocessEnv } from "../../subprocess-env"; import { OPENSHELL_OPERATION_TIMEOUT_MS, runOpenshell } from "./runtime"; export { OPENSHELL_OPERATION_TIMEOUT_MS }; -const ANSI_RE = /\x1b\[[0-9;]*m/gu; - export type ProviderCommandOptions = { env?: Record; ignoreError?: boolean; @@ -28,14 +27,20 @@ export function setProviderCommandRuntimeHooksForTest(hooks: ProviderCommandRunt runtimeHooks = hooks; } -export function parseCliOpenShellProviderNames(output: unknown): string[] { +export function parseCliOpenShellProviderNames(output: unknown): string[] | null { const text = - typeof output === "string" || Buffer.isBuffer(output) ? output.toString() : String(output ?? ""); - return text - .replace(ANSI_RE, "") + typeof output === "string" || Buffer.isBuffer(output) + ? output.toString() + : String(output ?? ""); + const names = text .split(/\r?\n/u) .map((name) => name.trim()) .filter(Boolean); + return names.every( + (name) => name.length <= PROVIDER_NAME_MAX_LENGTH && PROVIDER_NAME_VALID_PATTERN.test(name), + ) + ? names + : null; } export function runOpenshellProviderCommand(args: string[], opts?: ProviderCommandOptions) { diff --git a/src/lib/adapters/openshell/provider-profile.test.ts b/src/lib/adapters/openshell/provider-profile.test.ts index 78e02ad688e..c6c0ccc925d 100644 --- a/src/lib/adapters/openshell/provider-profile.test.ts +++ b/src/lib/adapters/openshell/provider-profile.test.ts @@ -61,7 +61,8 @@ describe("OpenShell endpointless provider profiles", () => { const runOpenshell = vi .fn() .mockReturnValueOnce({ status: 1, stderr: "provider profile not found" }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 0, stdout: EXPECTED_PROFILE }); expect(ensureProfile(runOpenshell)).toEqual({ ok: true }); expect(runOpenshell).toHaveBeenNthCalledWith( @@ -84,6 +85,16 @@ describe("OpenShell endpointless provider profiles", () => { timeout: 30_000, }, ); + expect(runOpenshell).toHaveBeenNthCalledWith( + 3, + ["provider", "profile", "export", PROFILE_ID, "--output", "json"], + { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: 30_000, + }, + ); }); it("imports after the supported structured missing-profile response (#10155)", () => { @@ -97,12 +108,14 @@ describe("OpenShell endpointless provider profiles", () => { Buffer.from("Error: × status: 'NotFound', message: \"provider profile not found\"\n"), ], }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 0, stdout: EXPECTED_PROFILE }); expect(ensureProfile(runOpenshell)).toEqual({ ok: true }); expect(runOpenshell.mock.calls.map(([args]) => args)).toEqual([ ["provider", "profile", "export", PROFILE_ID, "--output", "json"], ["provider", "profile", "import", "--file", PROFILE_PATH], + ["provider", "profile", "export", PROFILE_ID, "--output", "json"], ]); }); @@ -114,12 +127,38 @@ describe("OpenShell endpointless provider profiles", () => { stderr: "Error: × code: 'Some requested entity was not found', message: \"provider profile\n │ not found\"", }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 0, stdout: EXPECTED_PROFILE }); expect(ensureProfile(runOpenshell)).toEqual({ ok: true }); expect(runOpenshell.mock.calls.map(([args]) => args)).toEqual([ ["provider", "profile", "export", PROFILE_ID, "--output", "json"], ["provider", "profile", "import", "--file", PROFILE_PATH], + ["provider", "profile", "export", PROFILE_ID, "--output", "json"], + ]); + }); + + it("rejects a widened profile returned after a successful import (#9875)", () => { + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 1, stderr: "provider profile not found" }) + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + id: PROFILE_ID, + credentials: [], + endpoints: [{ host: "attacker.example" }], + binaries: [], + inference_capable: true, + }), + }); + + expect(ensureProfile(runOpenshell)).toEqual({ ok: false, reason: "incompatible" }); + expect(runOpenshell.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "profile", "export", PROFILE_ID, "--output", "json"], + ["provider", "profile", "import", "--file", PROFILE_PATH], + ["provider", "profile", "export", PROFILE_ID, "--output", "json"], ]); }); diff --git a/src/lib/adapters/openshell/provider-profile.ts b/src/lib/adapters/openshell/provider-profile.ts index 5df1ca77c6e..01a300d3382 100644 --- a/src/lib/adapters/openshell/provider-profile.ts +++ b/src/lib/adapters/openshell/provider-profile.ts @@ -238,15 +238,21 @@ export function ensureEndpointlessProviderProfile(input: { stdio: ["ignore", "pipe", "pipe"], timeout: OPENSHELL_OPERATION_TIMEOUT_MS, }); - - const exported = exportProfile(); - if (exported.status === 0) { - return profileHasExpectedCredentialBoundary(commandStdout(exported), { + const validateExportedProfile = ( + result: ReturnType, + ): EndpointlessProviderProfileResult => { + if (result.status !== 0) return { ok: false, reason: "export-failed" }; + return profileHasExpectedCredentialBoundary(commandStdout(result), { id: input.profileId, inferenceCapable: input.inferenceCapable, }) ? { ok: true } : { ok: false, reason: "incompatible" }; + }; + + const exported = exportProfile(); + if (exported.status === 0) { + return validateExportedProfile(exported); } if (!Number.isInteger(exported.status)) { @@ -266,26 +272,10 @@ export function ensureEndpointlessProviderProfile(input: { timeout: OPENSHELL_OPERATION_TIMEOUT_MS, }, ); - if (imported.status === 0) return { ok: true }; - - const importOutput = commandOutput(imported); - if (!/already exists/iu.test(importOutput)) { + if (imported.status !== 0 && !/already exists/iu.test(commandOutput(imported))) { return { ok: false, reason: "import-failed" }; } - - const racedExport = exportProfile(); - if (racedExport.status !== 0) { - return { ok: false, reason: "export-failed" }; - } - if ( - !profileHasExpectedCredentialBoundary(commandStdout(racedExport), { - id: input.profileId, - inferenceCapable: input.inferenceCapable, - }) - ) { - return { ok: false, reason: "incompatible" }; - } - return { ok: true }; + return validateExportedProfile(exportProfile()); } /** Validate or import the endpointless OpenAI profile through the OpenShell adapter. */ From 7fe13b597003d9f10249732b5a8607f04fca7952 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 01:42:20 -0700 Subject: [PATCH 26/57] fix(cli): report partial provider reset recovery --- src/commands/credentials.test.ts | 23 +++++++- .../credentials-provider-adapter.test.ts | 53 ++++++++++++++++--- src/lib/actions/credentials/reset.ts | 25 ++++++--- .../openshell/provider-adapter-cli.test.ts | 14 ++--- .../openshell/provider-adapter-cli.ts | 24 +++++---- .../adapters/openshell/provider-adapter.ts | 40 ++++---------- .../credentials-reset-outcome.test.ts | 1 + 7 files changed, 115 insertions(+), 65 deletions(-) diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index 3acbc5b47fd..ba158d301a6 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -28,7 +28,8 @@ vi.mock("../lib/actions/global", () => ({ listManagedMcpCredentialReservations: mocks.listManagedMcpCredentialReservations, })); vi.mock("../lib/adapters/openshell/provider-command", async (importOriginal) => { - const actual = await importOriginal(); + const actual = + await importOriginal(); return { ...actual, OPENSHELL_OPERATION_TIMEOUT_MS: 30_000, @@ -305,6 +306,17 @@ describe("credentials oclif adapter source coverage", () => { mocks.runOpenshellProviderCommand .mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider profile not found" }) .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + id: "openai", + credentials: [], + endpoints: [], + binaries: [], + inference_capable: true, + }), + stderr: "", + }) .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); const result = await runCredentialsAddAction({ @@ -325,6 +337,7 @@ describe("credentials oclif adapter source coverage", () => { "--file", expect.stringMatching(/provider-profiles\/openai\.yaml$/u), ], + ["provider", "profile", "export", "openai", "--output", "json"], [ "provider", "create", @@ -337,7 +350,7 @@ describe("credentials oclif adapter source coverage", () => { ], ]); expect( - mocks.runOpenshellProviderCommand.mock.calls.slice(0, 2).map(([, options]) => options), + mocks.runOpenshellProviderCommand.mock.calls.slice(0, 3).map(([, options]) => options), ).toEqual([ { ignoreError: true, @@ -351,6 +364,12 @@ describe("credentials oclif adapter source coverage", () => { stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, }, + { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: 30_000, + }, ]); }); diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 224bb4feeff..918579851bc 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -28,25 +28,21 @@ function providerAdapter( }); const createProvider: OpenShellProviderAdapter["createProvider"] = async () => ({ ok: true, - value: { state: "created" }, }); const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ ok: true, - value: { state: "imported" }, }); const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = - async () => ({ ok: true, value: { state: "ready" } }); + async () => ({ ok: true }); const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async () => ({ ok: true, value: { credentialKeys: [] }, }); const deleteProvider: OpenShellProviderAdapter["deleteProvider"] = async () => ({ ok: true, - value: { state: "deleted" }, }); const detachProvider: OpenShellProviderAdapter["detachProvider"] = async () => ({ ok: true, - value: { state: "detached" }, }); return { listProviders: vi.fn(listProviders), @@ -322,11 +318,11 @@ describe("credential actions use typed OpenShell provider results", () => { }) .mockImplementationOnce(async () => { operations.push("delete:retry"); - return { ok: true, value: { state: "deleted" } }; + return { ok: true }; }); const detachProvider = vi.fn(async () => { operations.push("detach:alpha"); - return { ok: true, value: { state: "detached" } }; + return { ok: true }; }); const adapter = providerAdapter({ deleteProvider, detachProvider }); @@ -345,6 +341,48 @@ describe("credential actions use typed OpenShell provider results", () => { }); }); + it("reports recovery for sandboxes detached before final deletion fails (#9806)", async () => { + const deleteProvider = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha", "beta"], + }, + }) + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "transport", + reason: "unreachable", + message: "OpenShell could not reach the selected gateway.", + }, + }); + const detachProvider = vi.fn(async () => ({ + ok: true, + })); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + const failure = result.failureLines.join("\n"); + expect(result.exitCode).toBe(1); + expect(failure).toContain( + "Provider 'custom-provider' was detached from sandbox(es): alpha, beta, but provider removal was not confirmed.", + ); + expect(failure).toContain( + "Re-run 'nemoclaw credentials reset custom-provider' to complete provider removal.", + ); + expect(failure).toContain("nemoclaw alpha rebuild"); + expect(failure).toContain("nemoclaw beta rebuild"); + }); + it("reports the typed detach failure that blocks provider removal (#9806)", async () => { const deleteProvider = vi .fn() @@ -451,7 +489,6 @@ describe("credential actions use typed OpenShell provider results", () => { }); const detachProvider = vi.fn(async () => ({ ok: true, - value: { state: "detached" }, })); const adapter = providerAdapter({ deleteProvider, detachProvider }); diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index ed7891dbd28..19599c864fe 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -39,6 +39,7 @@ export type CredentialsResetDeps = Readonly<{ export type CredentialsProviderDeleteWithRecoveryResult = Readonly<{ ok: boolean; error?: OpenShellProviderError; + detachedSandboxes: readonly string[]; recoveryFailures: readonly Readonly<{ sandbox: string; error: OpenShellProviderError; @@ -173,6 +174,16 @@ export function formatResetOutcome( ` for each, then re-run '${CLI_NAME} credentials reset ${key}'.`, ); } + const detachedSandboxes = [...new Set(recovery.detachedSandboxes)]; + if (detachedSandboxes.length > 0) { + lines.push( + "", + ` Provider '${key}' was detached from sandbox(es): ${detachedSandboxes.join(", ")}, but provider removal was not confirmed.`, + ` Re-run '${CLI_NAME} credentials reset ${key}' to complete provider removal.`, + " If the provider remains registered, restore it by rebuilding the detached sandbox(es):", + ...detachedSandboxes.map((sandbox) => ` ${CLI_NAME} ${sandbox} rebuild`), + ); + } if (recovery.error?.message) lines.push(` ${recovery.error.message}`); return { ok: false, lines }; } @@ -187,24 +198,26 @@ async function deleteProviderWithRecovery( timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, } as const; let result = await providerAdapter.deleteProvider(request); + const detachedSandboxes: string[] = []; const recoveryFailures: Array<{ sandbox: string; error: OpenShellProviderError }> = []; if (result.ok || result.error.kind !== "command" || result.error.reason !== "attached") { return result.ok - ? { ok: true, recoveryFailures } - : { ok: false, error: result.error, recoveryFailures }; + ? { ok: true, detachedSandboxes, recoveryFailures } + : { ok: false, error: result.error, detachedSandboxes, recoveryFailures }; } const attachedSandboxes = validatedAttachedSandboxes(result.error); if (attachedSandboxes.length === 0) { - return { ok: false, error: result.error, recoveryFailures }; + return { ok: false, error: result.error, detachedSandboxes, recoveryFailures }; } for (const sandbox of attachedSandboxes) { const detach = await providerAdapter.detachProvider({ ...request, sandboxName: sandbox }); - if (!detach.ok) recoveryFailures.push({ sandbox, error: detach.error }); + if (detach.ok) detachedSandboxes.push(sandbox); + else recoveryFailures.push({ sandbox, error: detach.error }); } result = await providerAdapter.deleteProvider(request); return result.ok - ? { ok: true, recoveryFailures } - : { ok: false, error: result.error, recoveryFailures }; + ? { ok: true, detachedSandboxes, recoveryFailures } + : { ok: false, error: result.error, detachedSandboxes, recoveryFailures }; } diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index bdb53afc09f..9e7d9eef3c8 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -124,7 +124,7 @@ describe("CLI OpenShell provider adapter", () => { config: [{ key: "region", value: "us-west" }], fromExisting: false, }), - ).resolves.toEqual({ ok: true, value: { state: "created" } }); + ).resolves.toEqual({ ok: true }); expect(run).toHaveBeenCalledWith( [ @@ -251,7 +251,7 @@ describe("CLI OpenShell provider adapter", () => { target: selectedOpenShellGateway(), profilePath: "/repo/profile.yaml", }), - ).resolves.toEqual({ ok: true, value: { state: "already_present" } }); + ).resolves.toEqual({ ok: true }); expect(run.mock.calls.map(([args]) => args)).toEqual([ ["provider", "profile", "import", "--file", "/repo/profile.yaml"], ["provider", "profile", "export", "tavily", "--output", "json"], @@ -274,7 +274,7 @@ describe("CLI OpenShell provider adapter", () => { target: selectedOpenShellGateway(), profilePath: "/repo/profile.yaml", }), - ).resolves.toEqual({ ok: true, value: { state: "imported" } }); + ).resolves.toEqual({ ok: true }); expect(run.mock.calls.map(([args]) => args)).toEqual([ ["provider", "profile", "import", "--file", "/repo/profile.yaml"], ["provider", "profile", "export", "tavily", "--output", "json"], @@ -454,7 +454,7 @@ describe("CLI OpenShell provider adapter", () => { profilePath: "/repo/provider-profiles/openai.yaml", inferenceCapable: true, }), - ).resolves.toEqual({ ok: true, value: { state: "ready" } }); + ).resolves.toEqual({ ok: true }); expect(run.mock.calls.map(([args]) => args)).toEqual([ ["provider", "profile", "export", "openai", "--output", "json"], ["provider", "profile", "import", "--file", "/repo/provider-profiles/openai.yaml"], @@ -505,7 +505,7 @@ describe("CLI OpenShell provider adapter", () => { providerName: "search-prod", sandboxName: "alpha", }), - ).resolves.toEqual({ ok: true, value: { state: "detached" } }); + ).resolves.toEqual({ ok: true }); expect(run.mock.calls[1]?.[0]).toEqual([ "sandbox", "provider", @@ -546,7 +546,7 @@ describe("CLI OpenShell provider adapter", () => { providerName: "search-prod", sandboxName: "alpha", }), - ).resolves.toEqual({ ok: true, value: { state: "detached" } }); + ).resolves.toEqual({ ok: true }); expect(run).toHaveBeenCalledWith( ["sandbox", "provider", "detach", "-g", "nemoclaw-18080", "alpha", "search-prod"], expect.objectContaining({ ignoreError: true, timeout: 30_000 }), @@ -566,7 +566,7 @@ describe("CLI OpenShell provider adapter", () => { providerName: "search-prod", sandboxName: "alpha", }), - ).resolves.toEqual({ ok: true, value: { state: "absent" } }); + ).resolves.toEqual({ ok: true }); }, ); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 0950b5f8cf5..5368556a4f7 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -18,6 +18,7 @@ import { type InspectOpenShellProviderProfileRequest, type OpenShellProviderAdapter, type OpenShellProviderError, + type OpenShellProviderMutationResult, type OpenShellProviderRequest, type OpenShellProviderResult, } from "./provider-adapter"; @@ -62,6 +63,10 @@ function success(value: T): OpenShellProviderResult { return { ok: true, value }; } +function mutationSuccess(): OpenShellProviderMutationResult { + return { ok: true }; +} + function failure(error: OpenShellProviderError): OpenShellProviderResult { return { ok: false, error }; } @@ -276,7 +281,7 @@ export function createCliOpenShellProviderAdapter( message: "OpenShell could not create the provider from existing credentials.", }); } - return error ? failure(error) : success({ state: "created" }); + return error ? failure(error) : mutationSuccess(); }; const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async ( @@ -303,11 +308,8 @@ export function createCliOpenShellProviderAdapter( request, ); const error = commandError(result); - const state = - error?.kind === "command" && error.reason === "already_exists" - ? "already_present" - : "imported"; - if (error && state !== "already_present") return failure(error); + const alreadyPresent = error?.kind === "command" && error.reason === "already_exists"; + if (error && !alreadyPresent) return failure(error); const exported = invoke( ["provider", "profile", "export", contract.profileId, "--output", "json"], @@ -326,7 +328,7 @@ export function createCliOpenShellProviderAdapter( "The OpenShell provider profile does not match the checked-in credential boundary.", }); } - return success({ state }); + return mutationSuccess(); }; const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = @@ -338,7 +340,7 @@ export function createCliOpenShellProviderAdapter( runOpenshell: (args, options) => invoke(args, request, undefined, 2, options?.suppressOutput === true), }); - if (result.ok) return success({ state: "ready" }); + if (result.ok) return mutationSuccess(); const reason = result.reason === "export-failed" ? "profile_export_failed" @@ -377,7 +379,7 @@ export function createCliOpenShellProviderAdapter( ) => { const result = invoke(["provider", "delete", request.providerName], request); const error = commandError(result); - return error ? failure(error) : success({ state: "deleted" }); + return error ? failure(error) : mutationSuccess(); }; const detachProvider: OpenShellProviderAdapter["detachProvider"] = async ( @@ -391,10 +393,10 @@ export function createCliOpenShellProviderAdapter( ); const output = commandOutput(result); if (result.status !== 0 && TOLERATED_DETACH_OUTPUT_RE.test(output)) { - return success({ state: "absent" }); + return mutationSuccess(); } const error = commandError(result); - return error ? failure(error) : success({ state: "detached" }); + return error ? failure(error) : mutationSuccess(); }; return { diff --git a/src/lib/adapters/openshell/provider-adapter.ts b/src/lib/adapters/openshell/provider-adapter.ts index ee093341aec..a2051408d82 100644 --- a/src/lib/adapters/openshell/provider-adapter.ts +++ b/src/lib/adapters/openshell/provider-adapter.ts @@ -39,6 +39,10 @@ export type OpenShellProviderResult = | Readonly<{ ok: true; value: T }> | Readonly<{ ok: false; error: OpenShellProviderError }>; +export type OpenShellProviderMutationResult = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; error: OpenShellProviderError }>; + export type OpenShellProviderRequest = Readonly<{ target: OpenShellGatewayTarget; timeoutMs?: number; @@ -88,53 +92,27 @@ export type DetachOpenShellProviderRequest = DeleteOpenShellProviderRequest & sandboxName: string; }>; -export type OpenShellProviderProfileImport = Readonly<{ - state: "already_present" | "imported"; -}>; - -export type OpenShellEndpointlessProviderProfile = Readonly<{ - state: "ready"; -}>; - -export type OpenShellProviderCreate = Readonly<{ - state: "created"; -}>; - -export type OpenShellProviderDelete = Readonly<{ - state: "deleted"; -}>; - -export type OpenShellProviderDetach = Readonly<{ - state: "absent" | "detached"; -}>; - /** Transport-neutral provider capabilities used by NemoClaw credential actions. */ export interface OpenShellProviderAdapter { listProviders( request: OpenShellProviderRequest, ): Promise>; - createProvider( - request: CreateOpenShellProviderRequest, - ): Promise>; + createProvider(request: CreateOpenShellProviderRequest): Promise; importProviderProfile( request: ImportOpenShellProviderProfileRequest, - ): Promise>; + ): Promise; ensureEndpointlessProviderProfile( request: EnsureOpenShellEndpointlessProviderProfileRequest, - ): Promise>; + ): Promise; inspectProviderProfile( request: InspectOpenShellProviderProfileRequest, ): Promise>; - deleteProvider( - request: DeleteOpenShellProviderRequest, - ): Promise>; + deleteProvider(request: DeleteOpenShellProviderRequest): Promise; - detachProvider( - request: DetachOpenShellProviderRequest, - ): Promise>; + detachProvider(request: DetachOpenShellProviderRequest): Promise; } diff --git a/test/credentials/credentials-reset-outcome.test.ts b/test/credentials/credentials-reset-outcome.test.ts index 4768678a4fa..f3e7267d407 100644 --- a/test/credentials/credentials-reset-outcome.test.ts +++ b/test/credentials/credentials-reset-outcome.test.ts @@ -13,6 +13,7 @@ function result( ): CredentialsProviderDeleteWithRecoveryResult { return { ok: false, + detachedSandboxes: [], recoveryFailures: [], ...over, }; From 2ad8d5d04c39a70c316c031fea96158506b1cfbb Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 02:01:12 -0700 Subject: [PATCH 27/57] refactor(cli): reduce provider adapter surface --- src/lib/actions/credentials-add.ts | 17 +- .../credentials-provider-adapter.test.ts | 59 ------- .../openshell/provider-adapter-cli.test.ts | 34 ---- .../openshell/provider-adapter-cli.ts | 28 ---- .../adapters/openshell/provider-adapter.ts | 11 -- test/e2e/live/sandbox-operations.test.ts | 148 ++++++++---------- 6 files changed, 75 insertions(+), 222 deletions(-) diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 943a4302070..6be9f9e426b 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -7,8 +7,10 @@ import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provide import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; import { endpointlessProviderProfileFailureMessages, + ensureEndpointlessProviderProfile, OPENAI_GATEWAY_PROVIDER_TYPE, } from "../adapters/openshell/provider-profile"; +import { runOpenshellProviderCommand } from "../adapters/openshell/provider-command"; import { selectedOpenShellGateway } from "../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; @@ -116,21 +118,14 @@ async function ensureCredentialProviderProfile( if (type.toLowerCase() !== OPENAI_GATEWAY_PROVIDER_TYPE) { return ensureBundledProviderProfile(type, providerAdapter); } - const profile = await providerAdapter.ensureEndpointlessProviderProfile({ - target: selectedOpenShellGateway(), - profileType: OPENAI_GATEWAY_PROVIDER_TYPE, + const profile = ensureEndpointlessProviderProfile({ + profileId: OPENAI_GATEWAY_PROVIDER_TYPE, profilePath: bundledProviderProfilePath(OPENAI_GATEWAY_PROVIDER_TYPE), inferenceCapable: true, - timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + runOpenshell: runOpenshellProviderCommand, }); if (profile.ok) return null; - const reason = - profile.error.kind === "command" && profile.error.reason === "profile_import_failed" - ? "import-failed" - : profile.error.kind === "command" && profile.error.reason === "profile_incompatible" - ? "incompatible" - : "export-failed"; - return fail(endpointlessProviderProfileFailureMessages(reason)); + return fail(endpointlessProviderProfileFailureMessages(profile.reason)); } export async function runCredentialsAddAction( diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 918579851bc..881b35339cf 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -32,8 +32,6 @@ function providerAdapter( const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ ok: true, }); - const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = - async () => ({ ok: true }); const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async () => ({ ok: true, value: { credentialKeys: [] }, @@ -48,7 +46,6 @@ function providerAdapter( listProviders: vi.fn(listProviders), createProvider: vi.fn(createProvider), importProviderProfile: vi.fn(importProviderProfile), - ensureEndpointlessProviderProfile: vi.fn(ensureEndpointlessProviderProfile), inspectProviderProfile: vi.fn(inspectProviderProfile), deleteProvider: vi.fn(deleteProvider), detachProvider: vi.fn(detachProvider), @@ -99,62 +96,6 @@ describe("credential actions use typed OpenShell provider results", () => { expect(JSON.stringify(result)).not.toContain("credential-value"); }); - it("reconciles the OpenAI profile through the injected provider adapter (#9806)", async () => { - vi.stubEnv("OPENAI_API_KEY", "host-only-value"); - const adapter = providerAdapter(); - - const result = await runCredentialsAddAction( - { - provider: "openai-prod", - type: "openai", - credentials: ["OPENAI_API_KEY"], - configPairs: [], - fromExisting: false, - }, - { providerAdapter: adapter }, - ); - - expect(result.exitCode).toBe(0); - expect(adapter.ensureEndpointlessProviderProfile).toHaveBeenCalledWith({ - target: { kind: "selected" }, - profileType: "openai", - profilePath: expect.stringMatching(/provider-profiles\/openai\.yaml$/u), - inferenceCapable: true, - timeoutMs: 30_000, - }); - expect(adapter.createProvider).toHaveBeenCalledOnce(); - }); - - it("does not create an OpenAI provider after incompatible profile reconciliation (#9806)", async () => { - vi.stubEnv("OPENAI_API_KEY", "host-only-value"); - const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = - async () => ({ - ok: false, - error: { - kind: "command", - reason: "profile_incompatible", - message: "The installed endpointless profile does not match.", - }, - }); - const adapter = providerAdapter({ - ensureEndpointlessProviderProfile: vi.fn(ensureEndpointlessProviderProfile), - }); - - const result = await runCredentialsAddAction( - { - provider: "openai-prod", - type: "openai", - credentials: ["OPENAI_API_KEY"], - configPairs: [], - fromExisting: false, - }, - { providerAdapter: adapter }, - ); - - expect(result.exitCode).toBe(1); - expect(adapter.createProvider).not.toHaveBeenCalled(); - }); - it("does not create a provider from an incompatible bundled profile (#9806)", async () => { vi.stubEnv("TAVILY_API_KEY", "host-only-value"); const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 9e7d9eef3c8..cb8963ec468 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -428,40 +428,6 @@ describe("CLI OpenShell provider adapter", () => { ); }); - it("reconciles an endpointless profile inside the CLI adapter (#9806)", async () => { - const run = vi - .fn() - .mockReturnValueOnce(captured(1, "", "provider profile not found")) - .mockReturnValueOnce(captured(0)) - .mockReturnValueOnce( - captured( - 0, - JSON.stringify({ - id: "openai", - credentials: [], - endpoints: [], - binaries: [], - inference_capable: true, - }), - ), - ); - const adapter = createCliOpenShellProviderAdapter({ run }); - - await expect( - adapter.ensureEndpointlessProviderProfile({ - target: selectedOpenShellGateway(), - profileType: "openai", - profilePath: "/repo/provider-profiles/openai.yaml", - inferenceCapable: true, - }), - ).resolves.toEqual({ ok: true }); - expect(run.mock.calls.map(([args]) => args)).toEqual([ - ["provider", "profile", "export", "openai", "--output", "json"], - ["provider", "profile", "import", "--file", "/repo/provider-profiles/openai.yaml"], - ["provider", "profile", "export", "openai", "--output", "json"], - ]); - }); - it("returns a schema failure for an invalid provider profile (#9806)", async () => { const adapter = createCliOpenShellProviderAdapter({ run: () => captured(0, "not-json"), diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 5368556a4f7..ecd9307c461 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -13,7 +13,6 @@ import { type CreateOpenShellProviderRequest, type DeleteOpenShellProviderRequest, type DetachOpenShellProviderRequest, - type EnsureOpenShellEndpointlessProviderProfileRequest, type ImportOpenShellProviderProfileRequest, type InspectOpenShellProviderProfileRequest, type OpenShellProviderAdapter, @@ -24,7 +23,6 @@ import { } from "./provider-adapter"; import type { OpenShellGatewayTarget } from "./sandbox-observer"; import { - ensureEndpointlessProviderProfile as reconcileEndpointlessProviderProfile, exportedProviderProfileMatchesContract, parseCheckedInProviderProfileContract, } from "./provider-profile"; @@ -331,31 +329,6 @@ export function createCliOpenShellProviderAdapter( return mutationSuccess(); }; - const ensureEndpointlessProviderProfile: OpenShellProviderAdapter["ensureEndpointlessProviderProfile"] = - async (request: EnsureOpenShellEndpointlessProviderProfileRequest) => { - const result = reconcileEndpointlessProviderProfile({ - profileId: request.profileType, - inferenceCapable: request.inferenceCapable, - profilePath: request.profilePath, - runOpenshell: (args, options) => - invoke(args, request, undefined, 2, options?.suppressOutput === true), - }); - if (result.ok) return mutationSuccess(); - const reason = - result.reason === "export-failed" - ? "profile_export_failed" - : result.reason === "import-failed" - ? "profile_import_failed" - : "profile_incompatible"; - const message = - result.reason === "export-failed" - ? "OpenShell could not read the provider profile for validation." - : result.reason === "import-failed" - ? "OpenShell could not import the provider profile." - : "The existing OpenShell provider profile does not match the required contract."; - return failure({ kind: "command", reason, message }); - }; - const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async ( request: InspectOpenShellProviderProfileRequest, ) => { @@ -403,7 +376,6 @@ export function createCliOpenShellProviderAdapter( listProviders, createProvider, importProviderProfile, - ensureEndpointlessProviderProfile, inspectProviderProfile, deleteProvider, detachProvider, diff --git a/src/lib/adapters/openshell/provider-adapter.ts b/src/lib/adapters/openshell/provider-adapter.ts index a2051408d82..fbc2038121d 100644 --- a/src/lib/adapters/openshell/provider-adapter.ts +++ b/src/lib/adapters/openshell/provider-adapter.ts @@ -70,13 +70,6 @@ export type ImportOpenShellProviderProfileRequest = OpenShellProviderRequest & profilePath: string; }>; -export type EnsureOpenShellEndpointlessProviderProfileRequest = - ImportOpenShellProviderProfileRequest & - Readonly<{ - profileType: string; - inferenceCapable: boolean; - }>; - export type InspectOpenShellProviderProfileRequest = OpenShellProviderRequest & Readonly<{ profileType: string; @@ -104,10 +97,6 @@ export interface OpenShellProviderAdapter { request: ImportOpenShellProviderProfileRequest, ): Promise; - ensureEndpointlessProviderProfile( - request: EnsureOpenShellEndpointlessProviderProfileRequest, - ): Promise; - inspectProviderProfile( request: InspectOpenShellProviderProfileRequest, ): Promise>; diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index c8cc1b92bdd..a879d4340ba 100644 --- a/test/e2e/live/sandbox-operations.test.ts +++ b/test/e2e/live/sandbox-operations.test.ts @@ -168,82 +168,6 @@ async function resetCredentialProvider( return reset; } -async function assertCredentialProviderAdapterLifecycle( - host: HostCliClient, - sandbox: SandboxClient, - cleanup: CleanupRegistry, - hosted: HostedInferenceConfig, -): Promise { - await resetCredentialProvider(host, "tc-sbx-14-clear-stale-credential-provider"); - cleanup.add(`remove credential provider ${CREDENTIAL_PROVIDER}`, async () => { - await resetCredentialProvider(host, "cleanup-tc-sbx-14-credential-provider"); - }); - - const add = await host.nemoclaw( - [ - "credentials", - "add", - CREDENTIAL_PROVIDER, - "--type", - "tavily", - "--credential", - CREDENTIAL_ENV_NAME, - ], - { - artifactName: "tc-sbx-14-credentials-add", - env: { - ...buildAvailabilityProbeEnv(), - [CREDENTIAL_ENV_NAME]: CREDENTIAL_VALUE, - }, - redactionValues: [CREDENTIAL_VALUE], - timeoutMs: 3 * 60_000, - }, - ); - expectExitZero(add, `nemoclaw credentials add ${CREDENTIAL_PROVIDER}`); - expect(resultText(add)).toContain(`Registered provider '${CREDENTIAL_PROVIDER}'`); - expect(resultText(add)).not.toContain(CREDENTIAL_VALUE); - - const beforeRebuild = await host.nemoclaw(["credentials", "list"], { - artifactName: "tc-sbx-14-credentials-list-before-rebuild", - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, - }); - expectExitZero(beforeRebuild, "nemoclaw credentials list before rebuild"); - expect(resultText(beforeRebuild)).toContain(CREDENTIAL_PROVIDER); - - const rebuild = await host.nemoclaw([SANDBOX_A, "rebuild", "--yes"], { - artifactName: "tc-sbx-14-rebuild-with-credential-provider", - env: { - ...buildAvailabilityProbeEnv(), - ...hosted.env, - }, - redactionValues: [hosted.apiKey, CREDENTIAL_VALUE], - timeoutMs: 20 * 60_000, - }); - expectExitZero(rebuild, `nemoclaw ${SANDBOX_A} rebuild --yes`); - expect(resultText(rebuild)).not.toContain(CREDENTIAL_VALUE); - - await expectSandboxProviderAttachment(sandbox, SANDBOX_A, CREDENTIAL_PROVIDER, "present", { - artifactName: "tc-sbx-14-provider-attached-after-rebuild", - env: buildAvailabilityProbeEnv(), - }); - - const reset = await resetCredentialProvider(host, "tc-sbx-14-credentials-reset-attached"); - expect(resultText(reset)).toContain(`Removed provider '${CREDENTIAL_PROVIDER}'`); - await expectSandboxProviderAttachment(sandbox, SANDBOX_A, CREDENTIAL_PROVIDER, "absent", { - artifactName: "tc-sbx-14-provider-detached-after-reset", - env: buildAvailabilityProbeEnv(), - }); - - const afterReset = await host.nemoclaw(["credentials", "list"], { - artifactName: "tc-sbx-14-credentials-list-after-reset", - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, - }); - expectExitZero(afterReset, "nemoclaw credentials list after reset"); - expect(resultText(afterReset)).not.toContain(CREDENTIAL_PROVIDER); -} - async function expectListed(host: HostCliClient, sandboxName: string, artifactName: string) { const list = await host.nemoclaw(["list"], { artifactName, @@ -691,7 +615,7 @@ async function assertGatewayRecovery( } test( - "credentials reset detaches a rebuilt sandbox provider before deletion", + "credentials reset removes a provider attached during sandbox rebuild", { timeout: 45 * 60_000, meta: { @@ -709,7 +633,7 @@ test( id: "sandbox-operations", boundary: "repo-cli-openshell-provider-sandbox-attachment", contracts: [ - "TC-SBX-14 credentials add/list/reset crosses the real OpenShell provider boundary, attaches on rebuild, and detaches before deletion", + "TC-SBX-14 credentials add/list/reset crosses the real OpenShell provider boundary, attaches on rebuild, and removes the attachment and provider", ], }); @@ -721,12 +645,78 @@ test( timeoutMs: 5 * 60_000, }); await host.cleanupSandbox(SANDBOX_A); + await resetCredentialProvider(host, "tc-sbx-14-clear-stale-credential-provider"); + cleanup.add(`remove credential provider ${CREDENTIAL_PROVIDER}`, async () => { + await resetCredentialProvider(host, "cleanup-tc-sbx-14-credential-provider"); + }); progress.phase("onboard the credential lifecycle sandbox"); await onboardSandbox(host, cleanup, SANDBOX_A, "tc-sbx-14-onboard-sandbox", hosted); progress.phase("add, attach, reset, and remove the credential provider"); - await assertCredentialProviderAdapterLifecycle(host, sandbox, cleanup, hosted); + const add = await host.nemoclaw( + [ + "credentials", + "add", + CREDENTIAL_PROVIDER, + "--type", + "tavily", + "--credential", + CREDENTIAL_ENV_NAME, + ], + { + artifactName: "tc-sbx-14-credentials-add", + env: { + ...buildAvailabilityProbeEnv(), + [CREDENTIAL_ENV_NAME]: CREDENTIAL_VALUE, + }, + redactionValues: [CREDENTIAL_VALUE], + timeoutMs: 3 * 60_000, + }, + ); + expectExitZero(add, `nemoclaw credentials add ${CREDENTIAL_PROVIDER}`); + expect(resultText(add)).toContain(`Registered provider '${CREDENTIAL_PROVIDER}'`); + expect(resultText(add)).not.toContain(CREDENTIAL_VALUE); + + const beforeRebuild = await host.nemoclaw(["credentials", "list"], { + artifactName: "tc-sbx-14-credentials-list-before-rebuild", + env: buildAvailabilityProbeEnv(), + timeoutMs: 60_000, + }); + expectExitZero(beforeRebuild, "nemoclaw credentials list before rebuild"); + expect(resultText(beforeRebuild)).toContain(CREDENTIAL_PROVIDER); + + const rebuild = await host.nemoclaw([SANDBOX_A, "rebuild", "--yes"], { + artifactName: "tc-sbx-14-rebuild-with-credential-provider", + env: { + ...buildAvailabilityProbeEnv(), + ...hosted.env, + }, + redactionValues: [hosted.apiKey, CREDENTIAL_VALUE], + timeoutMs: 20 * 60_000, + }); + expectExitZero(rebuild, `nemoclaw ${SANDBOX_A} rebuild --yes`); + expect(resultText(rebuild)).not.toContain(CREDENTIAL_VALUE); + + await expectSandboxProviderAttachment(sandbox, SANDBOX_A, CREDENTIAL_PROVIDER, "present", { + artifactName: "tc-sbx-14-provider-attached-after-rebuild", + env: buildAvailabilityProbeEnv(), + }); + + const reset = await resetCredentialProvider(host, "tc-sbx-14-credentials-reset-attached"); + expect(resultText(reset)).toContain(`Removed provider '${CREDENTIAL_PROVIDER}'`); + await expectSandboxProviderAttachment(sandbox, SANDBOX_A, CREDENTIAL_PROVIDER, "absent", { + artifactName: "tc-sbx-14-provider-detached-after-reset", + env: buildAvailabilityProbeEnv(), + }); + + const afterReset = await host.nemoclaw(["credentials", "list"], { + artifactName: "tc-sbx-14-credentials-list-after-reset", + env: buildAvailabilityProbeEnv(), + timeoutMs: 60_000, + }); + expectExitZero(afterReset, "nemoclaw credentials list after reset"); + expect(resultText(afterReset)).not.toContain(CREDENTIAL_PROVIDER); }, ); From 20ed35ce70068df96aea327c26d36a76e5128e37 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 02:19:22 -0700 Subject: [PATCH 28/57] refactor(cli): unify provider profile imports --- src/commands/credentials.test.ts | 92 ++++++++++--------- src/lib/actions/credentials-add.ts | 25 +---- .../credentials-provider-adapter.test.ts | 54 +++++++++++ 3 files changed, 104 insertions(+), 67 deletions(-) diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index ba158d301a6..6644f44d612 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -228,17 +228,19 @@ describe("credentials oclif adapter source coverage", () => { it("rejects an incompatible OpenAI profile before provider creation", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); - mocks.runOpenshellProviderCommand.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ - id: "openai", - credentials: [], - endpoints: [{ name: "untrusted", url: "https://example.invalid" }], - binaries: [], - inference_capable: true, - }), - stderr: "", - }); + mocks.runOpenshellProviderCommand + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + id: "openai", + credentials: [], + endpoints: [{ name: "untrusted", url: "https://example.invalid" }], + binaries: [], + inference_capable: true, + }), + stderr: "", + }); const result = await runCredentialsAddAction({ provider: "openai-prod", @@ -250,25 +252,25 @@ describe("credentials oclif adapter source coverage", () => { expect(result.exitCode).toBe(1); expect(result.failureLines.join("\n")).toContain( - "does not match NemoClaw's endpointless inference contract", + "does not match NemoClaw's checked-in credential boundary", ); expect(result.failureLines.join("\n")).toContain("then retry this command"); expect(result.failureLines.join("\n")).not.toContain("onboarding"); expect(result.failureLines.join("\n")).not.toContain("host-only-secret"); - expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledTimes(1); - expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledWith( + expect(mocks.runOpenshellProviderCommand.mock.calls.map(([args]) => args)).toEqual([ + [ + "provider", + "profile", + "import", + "--file", + expect.stringMatching(/provider-profiles\/openai\.yaml$/u), + ], ["provider", "profile", "export", "openai", "--output", "json"], - { - ignoreError: true, - suppressOutput: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: 30_000, - }, - ); + ]); expect(mocks.recordExtraProvider).not.toHaveBeenCalled(); }); - it("stops before provider creation when OpenAI profile inspection times out", async () => { + it("stops before provider creation when OpenAI profile import times out", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); mocks.runOpenshellProviderCommand.mockReturnValueOnce({ status: null, @@ -285,15 +287,22 @@ describe("credentials oclif adapter source coverage", () => { }); expect(result.exitCode).toBe(1); - expect(result.failureLines.join("\n")).toContain("could not be read for validation"); - expect(result.failureLines.join("\n")).toContain("then retry this command"); + expect(result.failureLines.join("\n")).toContain( + "Could not import bundled provider profile 'openai'", + ); + expect(result.failureLines.join("\n")).toContain("operation timed out"); expect(result.failureLines.join("\n")).not.toContain("onboarding"); expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledOnce(); expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledWith( - ["provider", "profile", "export", "openai", "--output", "json"], + [ + "provider", + "profile", + "import", + "--file", + expect.stringMatching(/provider-profiles\/openai\.yaml$/u), + ], { ignoreError: true, - suppressOutput: true, stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, }, @@ -301,10 +310,9 @@ describe("credentials oclif adapter source coverage", () => { expect(mocks.recordExtraProvider).not.toHaveBeenCalled(); }); - it("imports a missing OpenAI profile before provider creation", async () => { + it("imports and verifies the OpenAI profile before provider creation", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); mocks.runOpenshellProviderCommand - .mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider profile not found" }) .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) .mockReturnValueOnce({ status: 0, @@ -329,7 +337,6 @@ describe("credentials oclif adapter source coverage", () => { expect(result.exitCode).toBe(0); expect(mocks.runOpenshellProviderCommand.mock.calls.map(([args]) => args)).toEqual([ - ["provider", "profile", "export", "openai", "--output", "json"], [ "provider", "profile", @@ -350,17 +357,10 @@ describe("credentials oclif adapter source coverage", () => { ], ]); expect( - mocks.runOpenshellProviderCommand.mock.calls.slice(0, 3).map(([, options]) => options), + mocks.runOpenshellProviderCommand.mock.calls.slice(0, 2).map(([, options]) => options), ).toEqual([ { ignoreError: true, - suppressOutput: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: 30_000, - }, - { - ignoreError: true, - suppressOutput: true, stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, }, @@ -375,9 +375,11 @@ describe("credentials oclif adapter source coverage", () => { it("reports caller-neutral guidance when OpenAI profile import fails", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); - mocks.runOpenshellProviderCommand - .mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider profile not found" }) - .mockReturnValueOnce({ status: 1, stdout: "", stderr: "import failed" }); + mocks.runOpenshellProviderCommand.mockReturnValueOnce({ + status: 1, + stdout: "", + stderr: "import failed", + }); const result = await runCredentialsAddAction({ provider: "openai-prod", @@ -388,10 +390,14 @@ describe("credentials oclif adapter source coverage", () => { }); expect(result.exitCode).toBe(1); - expect(result.failureLines.join("\n")).toContain("could not import the checked-in"); - expect(result.failureLines.join("\n")).toContain("then retry this command"); + expect(result.failureLines.join("\n")).toContain( + "Could not import bundled provider profile 'openai'", + ); + expect(result.failureLines.join("\n")).toContain( + "Update OpenShell with scripts/install-openshell.sh and retry", + ); expect(result.failureLines.join("\n")).not.toContain("onboarding"); - expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledTimes(2); + expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledOnce(); expect(mocks.recordExtraProvider).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 6be9f9e426b..9f0ae01bdc0 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -5,12 +5,6 @@ import fs from "node:fs"; import path from "node:path"; import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; -import { - endpointlessProviderProfileFailureMessages, - ensureEndpointlessProviderProfile, - OPENAI_GATEWAY_PROVIDER_TYPE, -} from "../adapters/openshell/provider-profile"; -import { runOpenshellProviderCommand } from "../adapters/openshell/provider-command"; import { selectedOpenShellGateway } from "../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; @@ -111,23 +105,6 @@ async function ensureBundledProviderProfile( ]); } -async function ensureCredentialProviderProfile( - type: string, - providerAdapter: OpenShellProviderAdapter, -): Promise { - if (type.toLowerCase() !== OPENAI_GATEWAY_PROVIDER_TYPE) { - return ensureBundledProviderProfile(type, providerAdapter); - } - const profile = ensureEndpointlessProviderProfile({ - profileId: OPENAI_GATEWAY_PROVIDER_TYPE, - profilePath: bundledProviderProfilePath(OPENAI_GATEWAY_PROVIDER_TYPE), - inferenceCapable: true, - runOpenshell: runOpenshellProviderCommand, - }); - if (profile.ok) return null; - return fail(endpointlessProviderProfileFailureMessages(profile.reason)); -} - export async function runCredentialsAddAction( input: CredentialsAddInput, deps: CredentialsAddDeps = {}, @@ -239,7 +216,7 @@ export async function runCredentialsAddAction( return fail(recoveryFailureLines); } - const providerProfileFailure = await ensureCredentialProviderProfile(type, providerAdapter); + const providerProfileFailure = await ensureBundledProviderProfile(type, providerAdapter); if (providerProfileFailure) return providerProfileFailure; let importedCredentialKeys: string[] | null = null; diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 881b35339cf..17a52c95709 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -96,6 +96,60 @@ describe("credential actions use typed OpenShell provider results", () => { expect(JSON.stringify(result)).not.toContain("credential-value"); }); + it("imports the bundled OpenAI profile through the provider adapter (#9806)", async () => { + vi.stubEnv("OPENAI_API_KEY", "host-only-value"); + const adapter = providerAdapter(); + + const result = await runCredentialsAddAction( + { + provider: "openai-prod", + type: "openai", + credentials: ["OPENAI_API_KEY"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(adapter.importProviderProfile).toHaveBeenCalledWith({ + target: { kind: "selected" }, + profilePath: expect.stringMatching(/provider-profiles\/openai\.yaml$/u), + timeoutMs: 30_000, + }); + expect(adapter.createProvider).toHaveBeenCalledOnce(); + }); + + it("does not create an OpenAI provider after profile import fails (#9806)", async () => { + vi.stubEnv("OPENAI_API_KEY", "host-only-value"); + const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ + ok: false, + error: { + kind: "command", + reason: "profile_incompatible", + message: "The OpenShell provider profile does not match the checked-in boundary.", + }, + }); + const adapter = providerAdapter({ importProviderProfile: vi.fn(importProviderProfile) }); + + const result = await runCredentialsAddAction( + { + provider: "openai-prod", + type: "openai", + credentials: ["OPENAI_API_KEY"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toContain( + " OpenShell provider profile 'openai' does not match NemoClaw's checked-in credential boundary.", + ); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }); + it("does not create a provider from an incompatible bundled profile (#9806)", async () => { vi.stubEnv("TAVILY_API_KEY", "host-only-value"); const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ From 9cee258331bc8710decc96421f41585ec6ee3f48 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 02:35:24 -0700 Subject: [PATCH 29/57] fix(cli): redact provider diagnostic URLs --- .../openshell/provider-adapter-cli.test.ts | 29 +++++++++++++++++++ .../openshell/provider-adapter-cli.ts | 4 +-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index cb8963ec468..238990c08e9 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -210,6 +210,35 @@ describe("CLI OpenShell provider adapter", () => { expect(JSON.stringify(result)).not.toContain(credentialValue); }); + it("removes URL userinfo from typed failures (#9806)", async () => { + const username = "upstream-user"; + const password = "upstream-password"; + const adapter = createCliOpenShellProviderAdapter({ + run: () => + captured(1, "", `provider rejected https://${username}:${password}@example.test/path`), + }); + + const result = await adapter.createProvider({ + target: selectedOpenShellGateway(), + name: "search-prod", + type: "tavily", + credentials: [{ name: "TAVILY_API_KEY", value: "unrelated-host-value" }], + config: [], + fromExisting: false, + }); + + expect(result).toEqual({ + ok: false, + error: { + kind: "command", + reason: "failed", + message: "provider rejected https://example.test/path", + }, + }); + expect(JSON.stringify(result)).not.toContain(username); + expect(JSON.stringify(result)).not.toContain(password); + }); + it("does not expose an imported credential value in a provider failure (#9806)", async () => { const storedCredentialValue = "arbitrary-stored-value"; const adapter = createCliOpenShellProviderAdapter({ diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index ecd9307c461..13f2a8eb435 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../../name-validation"; -import { redactFull } from "../../security/redact"; +import { redactFullWithUrls } from "../../security/redact"; import { OPENSHELL_OPERATION_TIMEOUT_MS, parseCliOpenShellProviderNames, @@ -85,7 +85,7 @@ function redactProviderDiagnostic(output: string, secrets: readonly string[]): s for (const secret of secrets) { if (secret) safe = safe.replaceAll(secret, ""); } - return redactFull(safe).trim(); + return redactFullWithUrls(safe).trim(); } function attachedSandboxNames(output: string): string[] | null { From ac8c1596683f42c73f1b48f9dcaffa10e33e3bd8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 03:10:49 -0700 Subject: [PATCH 30/57] test(cli): verify profiles after import --- .../mcp-bridge-provider-profile.test.ts | 14 +++- .../snapshot-managed-clone-providers.test.ts | 82 +++++++++--------- src/lib/hermes-provider-auth.test.ts | 2 + src/lib/messaging/provider-profile.test.ts | 38 ++++++--- src/lib/onboard/providers.test.ts | 13 ++- .../setup-inference-gateway-scope.test.ts | 14 +++- src/lib/onboard/setup-inference.test.ts | 25 +++++- .../cli/credentials-cli-command.test.ts | 83 +++++++++++++++---- ...nfig-rotate-token-provider-profile.test.ts | 16 +++- 9 files changed, 216 insertions(+), 71 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts b/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts index 3ba6261ee85..0cea3dc802e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-profile.test.ts @@ -30,12 +30,22 @@ describe("OpenShell MCP provider profile", () => { .fn() .mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider profile not found" }) .mockReturnValueOnce({ status: 0, stdout: "Imported", stderr: "" }) + .mockReturnValueOnce({ + status: 0, + stdout: exportedEndpointlessProfile("openai", true), + stderr: "", + }) .mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider profile not found" }) - .mockReturnValueOnce({ status: 0, stdout: "Imported", stderr: "" }); + .mockReturnValueOnce({ status: 0, stdout: "Imported", stderr: "" }) + .mockReturnValueOnce({ + status: 0, + stdout: exportedEndpointlessProfile(MCP_BRIDGE_PROVIDER_TYPE, false), + stderr: "", + }); setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshell as never }); expect(() => ensureMcpBridgeProviderProfile()).not.toThrow(); - expect(runOpenshell).toHaveBeenCalledTimes(4); + expect(runOpenshell).toHaveBeenCalledTimes(6); expect(runOpenshell).toHaveBeenCalledWith( ["provider", "profile", "import", "--file", expect.stringMatching(/openai\.yaml$/)], expect.any(Object), diff --git a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts index b9e285c6a86..734b97b556f 100644 --- a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts +++ b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts @@ -177,8 +177,14 @@ function providerRunner(initial: readonly LiveBinding[] = []) { const run = vi.fn((args: string[]) => { commands.push(args.join(" ")); switch (args.slice(0, 2).join(" ")) { - case "provider profile": - return args[2] === "import" ? profileImportResult : profileExportResult; + case "provider profile": { + const importing = args[2] === "import"; + profileExportResult = + importing && profileImportResult.status === 0 + ? EXACT_MESSAGING_PROFILE + : profileExportResult; + return importing ? profileImportResult : profileExportResult; + } case "provider get": { const name = args[2] ?? ""; const binding = live.get(name); @@ -264,26 +270,25 @@ function prepareWithBinding(input: { } describe("managed clone provider transaction", () => { - it.each([ - "openclaw", - "hermes", - "langchain-deepagents-code", - ] as const)("keeps the %s transaction provider-neutral, secret-free, and deeply frozen", (agent) => { - const { prepared } = prepareWithBinding({ agent }); - - expect(prepared).toMatchObject({ - providerId: "docker", - sourceSandboxName: "source", - destinationSandboxName: "destination", - snapshotRestoreAuthority: CONTENT_AUTHORITY, - providers: [{ binding: TOKEN_BINDING, action: "create" }], - }); - expect(JSON.stringify(prepared)).not.toContain("test-only-runtime-token"); - expect(Object.isFrozen(prepared)).toBe(true); - expect(Object.isFrozen(prepared.snapshotRestoreAuthority)).toBe(true); - expect(Object.isFrozen(prepared.sourceRegistryAuthority.workload)).toBe(true); - expect(Object.isFrozen(prepared.providers[0]?.binding)).toBe(true); - }); + it.each(["openclaw", "hermes", "langchain-deepagents-code"] as const)( + "keeps the %s transaction provider-neutral, secret-free, and deeply frozen", + (agent) => { + const { prepared } = prepareWithBinding({ agent }); + + expect(prepared).toMatchObject({ + providerId: "docker", + sourceSandboxName: "source", + destinationSandboxName: "destination", + snapshotRestoreAuthority: CONTENT_AUTHORITY, + providers: [{ binding: TOKEN_BINDING, action: "create" }], + }); + expect(JSON.stringify(prepared)).not.toContain("test-only-runtime-token"); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen(prepared.snapshotRestoreAuthority)).toBe(true); + expect(Object.isFrozen(prepared.sourceRegistryAuthority.workload)).toBe(true); + expect(Object.isFrozen(prepared.providers[0]?.binding)).toBe(true); + }, + ); it("resolves active messaging providers from the handoff", () => { const profile = managedStartupE2eProfile("openclaw"); @@ -711,21 +716,24 @@ describe("managed clone provider transaction", () => { 0, { ...TOKEN_BINDING, providerType: "other" } satisfies ManagedCloneProviderBinding, ], - ] as const)("preserves an unowned provider after an ambiguous create: %s", (_name, status, materialize) => { - const runner = providerRunner(); - runner.setCreateBehavior(() => ({ status, ...(materialize ? { materialize } : {}) })); - const { prepared, source } = prepareWithBinding({ runner }); - - expect(() => - provisionManagedCloneProviderTransaction(prepared, { - ...authorityDeps(source), - environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, - runOpenshell: runner.run, - }), - ).toThrow(/preserving the observed/u); - expect(runner.commands).not.toContain(`provider delete ${TOKEN_BINDING.providerName}`); - expect(runner.live.has(TOKEN_BINDING.providerName)).toBe(Boolean(materialize)); - }); + ] as const)( + "preserves an unowned provider after an ambiguous create: %s", + (_name, status, materialize) => { + const runner = providerRunner(); + runner.setCreateBehavior(() => ({ status, ...(materialize ? { materialize } : {}) })); + const { prepared, source } = prepareWithBinding({ runner }); + + expect(() => + provisionManagedCloneProviderTransaction(prepared, { + ...authorityDeps(source), + environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, + runOpenshell: runner.run, + }), + ).toThrow(/preserving the observed/u); + expect(runner.commands).not.toContain(`provider delete ${TOKEN_BINDING.providerName}`); + expect(runner.live.has(TOKEN_BINDING.providerName)).toBe(Boolean(materialize)); + }, + ); it("reconciles and preserves an exact provider when the create adapter throws", () => { const runner = providerRunner(); diff --git a/src/lib/hermes-provider-auth.test.ts b/src/lib/hermes-provider-auth.test.ts index 22b539b3251..9e4dc122b77 100644 --- a/src/lib/hermes-provider-auth.test.ts +++ b/src/lib/hermes-provider-auth.test.ts @@ -69,6 +69,7 @@ describe("Hermes provider OpenShell credential handoff", () => { .fn() .mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider profile not found" }) .mockReturnValueOnce({ status: 0, stdout: "Imported", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: EXACT_OPENAI_PROFILE, stderr: "" }) .mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider not found" }) .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); @@ -77,6 +78,7 @@ describe("Hermes provider OpenShell credential handoff", () => { expect(runOpenshell.mock.calls.map(([args]) => args)).toEqual([ ["provider", "profile", "export", "openai", "--output", "json"], ["provider", "profile", "import", "--file", expect.stringMatching(/openai\.yaml$/u)], + ["provider", "profile", "export", "openai", "--output", "json"], ["provider", "get", "hermes-provider"], expect.arrayContaining([ "provider", diff --git a/src/lib/messaging/provider-profile.test.ts b/src/lib/messaging/provider-profile.test.ts index e9e57a2b209..2f010d1f102 100644 --- a/src/lib/messaging/provider-profile.test.ts +++ b/src/lib/messaging/provider-profile.test.ts @@ -28,7 +28,17 @@ describe("messaging credential provider profile", () => { const runOpenshell = vi .fn() .mockReturnValueOnce({ status: 1, stderr: "provider profile not found" }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + id: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentials: [], + endpoints: [], + binaries: [], + inference_capable: false, + }), + }); ensureMessagingCredentialProviderProfile({ root: "/repo", runOpenshell }); @@ -48,6 +58,16 @@ describe("messaging credential provider profile", () => { stdio: ["ignore", "pipe", "pipe"], }, ); + expect(runOpenshell).toHaveBeenNthCalledWith( + 3, + ["provider", "profile", "export", MESSAGING_CREDENTIAL_PROVIDER_TYPE, "--output", "json"], + { + ignoreError: true, + suppressOutput: true, + timeout: 30_000, + stdio: ["ignore", "pipe", "pipe"], + }, + ); }); it("reports a fixed messaging import failure without command diagnostics (#9875)", () => { @@ -59,9 +79,9 @@ describe("messaging credential provider profile", () => { stderr: "request failed with discord-credential-must-not-leak", }); - expect(() => - ensureMessagingCredentialProviderProfile({ root: "/repo", runOpenshell }), - ).toThrow("Could not import the OpenShell messaging credential profile."); + expect(() => ensureMessagingCredentialProviderProfile({ root: "/repo", runOpenshell })).toThrow( + "Could not import the OpenShell messaging credential profile.", + ); }); it("reports a messaging-specific export failure (#10155)", () => { @@ -70,9 +90,7 @@ describe("messaging credential provider profile", () => { stderr: "gateway unavailable", }); - expect(() => - ensureMessagingCredentialProviderProfile({ root: "/repo", runOpenshell }), - ).toThrow( + expect(() => ensureMessagingCredentialProviderProfile({ root: "/repo", runOpenshell })).toThrow( `OpenShell provider profile '${MESSAGING_CREDENTIAL_PROVIDER_TYPE}' could not be exported for validation.`, ); }); @@ -89,8 +107,8 @@ describe("messaging credential provider profile", () => { }), }); - expect(() => - ensureMessagingCredentialProviderProfile({ root: "/repo", runOpenshell }), - ).toThrow(/does not match NemoClaw's endpointless messaging credential contract/u); + expect(() => ensureMessagingCredentialProviderProfile({ root: "/repo", runOpenshell })).toThrow( + /does not match NemoClaw's endpointless messaging credential contract/u, + ); }); }); diff --git a/src/lib/onboard/providers.test.ts b/src/lib/onboard/providers.test.ts index afa68083488..c55d79af38c 100644 --- a/src/lib/onboard/providers.test.ts +++ b/src/lib/onboard/providers.test.ts @@ -694,6 +694,7 @@ describe("onboard provider helpers", () => { const credential = "discord-credential-must-not-leak"; const calls: Array<{ command: string[]; env?: Record }> = []; let created = false; + let profileImported = false; const providers = upsertMessagingProviders( [ { @@ -707,9 +708,14 @@ describe("onboard provider helpers", () => { calls.push({ command, env: options?.env }); switch (command[1]) { case "profile": - return command.includes("export") - ? { status: 1, stdout: "", stderr: "provider profile not found" } + const exportingProfile = command.includes("export"); + const profileResult = exportingProfile + ? profileImported + ? { status: 0, stdout: MESSAGING_ENDPOINTLESS_PROFILE_EXPORT, stderr: "" } + : { status: 1, stdout: "", stderr: "provider profile not found" } : { status: 0, stdout: "", stderr: "" }; + profileImported ||= !exportingProfile; + return profileResult; case "get": return created ? { @@ -734,11 +740,12 @@ describe("onboard provider helpers", () => { "provider get alpha-discord-bridge", "provider profile export nemoclaw-mcp-v1 --output json", expect.stringMatching(/^provider profile import --file .*nemoclaw-mcp-v1\.yaml$/), + "provider profile export nemoclaw-mcp-v1 --output json", "provider get alpha-discord-bridge", "provider create --name alpha-discord-bridge --type nemoclaw-mcp-v1 --credential DISCORD_BOT_TOKEN", "provider get alpha-discord-bridge", ]); - expect(calls[4]?.env).toEqual({ DISCORD_BOT_TOKEN: credential }); + expect(calls[5]?.env).toEqual({ DISCORD_BOT_TOKEN: credential }); expect(calls.flatMap(({ command }) => command)).not.toContain(credential); }); diff --git a/src/lib/onboard/setup-inference-gateway-scope.test.ts b/src/lib/onboard/setup-inference-gateway-scope.test.ts index 97975cb9949..01bcead8c70 100644 --- a/src/lib/onboard/setup-inference-gateway-scope.test.ts +++ b/src/lib/onboard/setup-inference-gateway-scope.test.ts @@ -146,6 +146,17 @@ describe("gateway-scoped onboarding OpenShell commands", () => { stderr: "Error: status: 'NotFound', message: \"provider profile not found\"", }, { status: 0, stdout: "", stderr: "" }, + { + status: 0, + stdout: JSON.stringify({ + id: "openai", + credentials: [], + endpoints: [], + binaries: [], + inference_capable: true, + }), + stderr: "", + }, ]; const run = vi.fn((args: string[]) => { events.push(args.join(" ")); @@ -183,7 +194,8 @@ describe("gateway-scoped onboarding OpenShell commands", () => { expect(events[1]).toMatch( new RegExp(`^provider profile -g ${GATEWAY} import --file .*openai\\.yaml$`, "u"), ); - expect(events[2]).toBe("provider mutation"); + expect(events[2]).toBe(`provider profile -g ${GATEWAY} export openai --output json`); + expect(events[3]).toBe("provider mutation"); expect(upsert).toHaveBeenCalledWith( "nvidia-router", "openai", diff --git a/src/lib/onboard/setup-inference.test.ts b/src/lib/onboard/setup-inference.test.ts index d7a3deeba7b..2b569282a85 100644 --- a/src/lib/onboard/setup-inference.test.ts +++ b/src/lib/onboard/setup-inference.test.ts @@ -9,10 +9,21 @@ import { bindOpenAiProviderProfile, createProviderReviewDeps } from "./setup-inf describe("bindOpenAiProviderProfile", () => { it("imports the profile immediately before an OpenAI provider upsert", () => { const events: string[] = []; - const profileEvents = ["profile-export", "profile-import"]; + const profileEvents = ["profile-export", "profile-import", "profile-reexport"]; const profileResults = [ { status: 1, stdout: "", stderr: "provider profile not found" }, { status: 0, stdout: "", stderr: "" }, + { + status: 0, + stdout: JSON.stringify({ + id: "openai", + credentials: [], + endpoints: [], + binaries: [], + inference_capable: true, + }), + stderr: "", + }, ]; let profileIndex = 0; const runOpenshell = vi.fn(() => { @@ -42,7 +53,7 @@ describe("bindOpenAiProviderProfile", () => { ), ).toEqual({ ok: true }); - expect(events).toEqual(["profile-export", "profile-import", "upsert"]); + expect(events).toEqual(["profile-export", "profile-import", "profile-reexport", "upsert"]); expect(runOpenshell).toHaveBeenNthCalledWith( 1, ["provider", "profile", "export", "openai", "--output", "json"], @@ -63,6 +74,16 @@ describe("bindOpenAiProviderProfile", () => { stdio: ["ignore", "pipe", "pipe"], }, ); + expect(runOpenshell).toHaveBeenNthCalledWith( + 3, + ["provider", "profile", "export", "openai", "--output", "json"], + { + ignoreError: true, + suppressOutput: true, + timeout: 30_000, + stdio: ["ignore", "pipe", "pipe"], + }, + ); }); it("does not import the OpenAI profile for another provider type", () => { diff --git a/test/package-contract/cli/credentials-cli-command.test.ts b/test/package-contract/cli/credentials-cli-command.test.ts index 33c56d83edb..82a962683fc 100644 --- a/test/package-contract/cli/credentials-cli-command.test.ts +++ b/test/package-contract/cli/credentials-cli-command.test.ts @@ -66,6 +66,49 @@ type RuntimeBridge = { }; type OpenshellCall = { args: string[]; opts?: RuntimeBridgeRunOptions }; +const TAVILY_PROFILE_EXPORT = JSON.stringify({ + id: "tavily", + credentials: [ + { + name: "api_key", + env_vars: ["TAVILY_API_KEY"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", + }, + ], + endpoints: [ + { + host: "api.tavily.com", + port: 443, + protocol: "rest", + enforcement: "enforce", + request_body_credential_rewrite: true, + rules: [ + { allow: { method: "POST", path: "/search" } }, + { allow: { method: "POST", path: "/extract" } }, + ], + }, + ], + binaries: [ + "/opt/venv/bin/python3*", + "/usr/local/bin/node", + "/usr/bin/node", + "/usr/local/bin/curl", + "/usr/bin/curl", + ], + inference_capable: false, +}); + +function tavilyProfileCommandResult(args: string[]): SpawnLikeResult | null { + return args.includes("profile") + ? args.includes("export") + ? { status: 0, stdout: TAVILY_PROFILE_EXPORT } + : { status: 0, stdout: "" } + : null; +} + function loadCommands(): CredentialsCommandClasses { for (const modulePath of Object.values(COMMAND_PATHS)) { delete require.cache[modulePath]; @@ -351,7 +394,7 @@ describe("credentials oclif commands", () => { const calls = installRuntimeBridge({ runOpenshell: (args, opts) => { calls.push({ args, opts }); - return { status: 0, stdout: "" }; + return tavilyProfileCommandResult(args) ?? { status: 0, stdout: "" }; }, recordExtraProvider: (name) => { extraProviderCalls.push(name); @@ -382,6 +425,17 @@ describe("credentials oclif commands", () => { timeout: 30_000, }, }, + { + args: ["provider", "profile", "export", "tavily", "--output", "json"], + opts: { + env: expect.any(Object), + ignoreError: true, + replaceEnv: true, + stdio: ["ignore", "pipe", "pipe"], + suppressOutput: true, + timeout: 30_000, + }, + }, { args: [ "provider", @@ -403,9 +457,10 @@ describe("credentials oclif commands", () => { }, ]); expect(calls[0]?.opts?.env?.TAVILY_API_KEY).toBeUndefined(); - expect(calls[1]?.opts?.env?.UNRELATED_API_KEY).toBeUndefined(); - expect(calls[1]?.opts?.env?.TAVILY_API_KEY).toBe("tvly-test-12345"); - expect(calls[1]?.args).not.toContain("tvly-test-12345"); + expect(calls[1]?.opts?.env?.TAVILY_API_KEY).toBeUndefined(); + expect(calls[2]?.opts?.env?.UNRELATED_API_KEY).toBeUndefined(); + expect(calls[2]?.opts?.env?.TAVILY_API_KEY).toBe("tvly-test-12345"); + expect(calls[2]?.args).not.toContain("tvly-test-12345"); expect(extraProviderCalls).toEqual(["tavily-search"]); expect(output.stdout).toContain("Registered provider 'tavily-search'"); expect(output.stdout).toContain("rebuild"); @@ -425,8 +480,7 @@ describe("credentials oclif commands", () => { return { status: 1, stderr: "gateway unavailable" }; }; installRuntimeBridge({ - runOpenshell: (args) => - args.includes("profile") ? { status: 0, stdout: "" } : rejectGatewayCall(), + runOpenshell: (args) => tavilyProfileCommandResult(args) ?? rejectGatewayCall(), recordExtraProvider: (name) => { lifecycleCalls.push(`record:${name}`); const sizeBefore = extraProviders.size; @@ -471,12 +525,10 @@ describe("credentials oclif commands", () => { const leakedTavilyValue = `tvly-${"leaked-secret"}-9999`; installRuntimeBridge({ runOpenshell: (args) => - args.includes("profile") - ? { status: 0, stdout: "" } - : { - status: 1, - stderr: `auth failed: TAVILY_API_KEY=${leakedTavilyValue} rejected`, - }, + tavilyProfileCommandResult(args) ?? { + status: 1, + stderr: `auth failed: TAVILY_API_KEY=${leakedTavilyValue} rejected`, + }, }); const { CredentialsAddCommand } = loadCommands(); @@ -506,9 +558,10 @@ describe("credentials oclif commands", () => { process.env.TAVILY_API_KEY = "tvly-test-12345"; installRuntimeBridge({ runOpenshell: (args) => - args.includes("profile") - ? { status: 0, stdout: "" } - : { status: 1, stderr: "provider 'tavily-search' already exists" }, + tavilyProfileCommandResult(args) ?? { + status: 1, + stderr: "provider 'tavily-search' already exists", + }, }); const { CredentialsAddCommand } = loadCommands(); diff --git a/test/security/config-rotate-token-provider-profile.test.ts b/test/security/config-rotate-token-provider-profile.test.ts index f0dd5782f63..14b3e87255d 100644 --- a/test/security/config-rotate-token-provider-profile.test.ts +++ b/test/security/config-rotate-token-provider-profile.test.ts @@ -11,6 +11,13 @@ import { type CaptureResult = ReturnType; type RunResult = ReturnType; +const EXACT_OPENAI_PROFILE = JSON.stringify({ + id: "openai", + credentials: [], + endpoints: [], + binaries: [], + inference_capable: true, +}); function loadRotateTokenFixture(input: { providerType: string; captureResults: CaptureResult[] }) { const queuedCaptureResults = [...input.captureResults]; @@ -63,6 +70,12 @@ describe("config rotate-token OpenAI provider profile", () => { captureResults: [ { status: 1, output: "", stdout: "", stderr: "provider profile not found" }, { status: 0, output: "Imported", stdout: "Imported", stderr: "" }, + { + status: 0, + output: EXACT_OPENAI_PROFILE, + stdout: EXACT_OPENAI_PROFILE, + stderr: "", + }, ], }); @@ -71,13 +84,14 @@ describe("config rotate-token OpenAI provider profile", () => { expect(fixture.captureOpenshellCommand.mock.calls.map(([, args]) => args)).toEqual([ ["provider", "profile", "export", "openai", "--output", "json"], ["provider", "profile", "import", "--file", expect.stringMatching(/openai\.yaml$/u)], + ["provider", "profile", "export", "openai", "--output", "json"], ]); expect(fixture.captureOpenshellCommand.mock.calls[0]?.[2]).toMatchObject({ ignoreError: true, includeStreams: true, timeout: 30_000, }); - expect(fixture.captureOpenshellCommand.mock.invocationCallOrder[1]).toBeLessThan( + expect(fixture.captureOpenshellCommand.mock.invocationCallOrder[2]).toBeLessThan( fixture.saveCredential.mock.invocationCallOrder[0]!, ); expect(fixture.saveCredential.mock.invocationCallOrder[0]).toBeLessThan( From abd869e5773b5bd441795f2fb97448437eea3ea1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 03:23:50 -0700 Subject: [PATCH 31/57] fix(cli): reject truncated attachment names --- src/lib/actions/credentials-provider-adapter.test.ts | 2 +- src/lib/adapters/openshell/provider-adapter-cli.test.ts | 1 + src/lib/adapters/openshell/provider-adapter-cli.ts | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 17a52c95709..3d0cdf97309 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -540,7 +540,7 @@ describe("credential actions use typed OpenShell provider results", () => { kind: "command", reason: "attached", message: "provider remains attached", - attachedSandboxes: ["alpha", "invalid/name"], + attachedSandboxes: ["alpha", "team.alpha"], }, }); const detachProvider = vi.fn(); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 238990c08e9..22da95351fb 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -568,6 +568,7 @@ describe("CLI OpenShell provider adapter", () => { it.each([ "provider is attached to sandbox(es): alpha, invalid/name", "provider is attached to sandbox(es): --gateway, invalid/name", + "provider is attached to sandbox(es): team.alpha", "provider is attached to sandbox(es):", ])("does not return unvalidated attachment targets from %s (#9806)", async (diagnostic) => { const adapter = createCliOpenShellProviderAdapter({ diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 13f2a8eb435..7f52f41140c 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -53,7 +53,8 @@ export type CliOpenShellProviderAdapterDeps = Readonly<{ const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/u; const ANSI_RE = /\x1b\[[0-9;]*m/gu; -const ATTACHED_TO_SANDBOX_RE = /attached\s+to(?:\s|│)+sandbox\(\s*es?\s*\)?\s*:\s*([^".\n]+)/iu; +const ATTACHED_TO_SANDBOX_RE = + /attached\s+to(?:\s|│)+sandbox\(\s*es?\s*\)?\s*:\s*([^"\n]+?)(?=\.\s+[a-z]|["\n]|$)/iu; const TOLERATED_DETACH_OUTPUT_RE = /\bNotAttached\b|\bnot\s+attached\b|provider[^\n]{0,200}?(?:\bNotFound\b|\bnot\s+found\b)/iu; From aa9453141bf9818c46925121485b4fa4ef8a59c2 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 03:36:45 -0700 Subject: [PATCH 32/57] fix(cli): harden provider recovery output --- .../credentials-provider-adapter.test.ts | 35 +++++++++++++++++++ src/lib/actions/credentials/reset.ts | 14 +++++++- .../openshell/provider-adapter-cli.test.ts | 20 +++++++++++ .../openshell/provider-adapter-cli.ts | 10 ++++-- .../credentials-reset-outcome.test.ts | 15 ++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 3d0cdf97309..6e85a0fbddf 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -220,6 +220,37 @@ describe("credential actions use typed OpenShell provider results", () => { expect(JSON.stringify(result)).not.toContain(output); }); + it("does not render terminal control strings from provider failures (#9806)", async () => { + vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); + const adapter = createCliOpenShellProviderAdapter({ + run: () => ({ + status: 1, + stderr: "provider rejected \u001b]52;c;osc-payload\u0007\u001bP+dcs-payload\u001b\\request", + }), + }); + + const results = [ + await runCredentialsAddAction( + { + provider: "custom-provider", + type: "generic", + credentials: ["CUSTOM_TOKEN"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ), + await runCredentialsListAction("nemoclaw", { providerAdapter: adapter }), + await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ), + ]; + + expect(JSON.stringify(results)).not.toMatch(/[\u001B\u0090-\u009F]/u); + expect(JSON.stringify(results)).not.toContain("payload"); + }); + it.each([ [ "authentication", @@ -334,6 +365,10 @@ describe("credential actions use typed OpenShell provider results", () => { sandboxName: "alpha", timeoutMs: 30_000, }); + expect(result.outputLines).toContain( + " Provider 'custom-provider' was detached from sandbox(es): alpha during removal.", + ); + expect(result.outputLines).toContain(" nemoclaw alpha rebuild"); }); it("reports recovery for sandboxes detached before final deletion fails (#9806)", async () => { diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index 19599c864fe..002e61ee056 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -139,9 +139,21 @@ export function formatResetOutcome( ): { ok: boolean; lines: string[] } { const onboardHint = ` Re-run '${CLI_NAME} onboard' to enter a new value.`; if (recovery.ok) { + const detachedSandboxes = [...new Set(recovery.detachedSandboxes)]; return { ok: true, - lines: [` Removed provider '${key}' from the OpenShell gateway.`, onboardHint], + lines: [ + ` Removed provider '${key}' from the OpenShell gateway.`, + onboardHint, + ...(detachedSandboxes.length === 0 + ? [] + : [ + "", + ` Provider '${key}' was detached from sandbox(es): ${detachedSandboxes.join(", ")} during removal.`, + " After registering the replacement provider, rebuild each detached sandbox:", + ...detachedSandboxes.map((sandbox) => ` ${CLI_NAME} ${sandbox} rebuild`), + ]), + ], }; } diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 22da95351fb..0f9e99436fc 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -239,6 +239,26 @@ describe("CLI OpenShell provider adapter", () => { expect(JSON.stringify(result)).not.toContain(password); }); + it("removes terminal control strings from typed failures (#9806)", async () => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => + captured( + 1, + "", + "provider rejected \u001b]52;c;osc-payload\u0007\u001bP+dcs-payload\u001b\\request", + ), + }); + + const result = await adapter.listProviders({ target: selectedOpenShellGateway() }); + + expect(result).toEqual({ + ok: false, + error: { kind: "command", reason: "failed", message: "provider rejected request" }, + }); + expect(JSON.stringify(result)).not.toMatch(/[\u001B\u0090-\u009F]/u); + expect(JSON.stringify(result)).not.toContain("payload"); + }); + it("does not expose an imported credential value in a provider failure (#9806)", async () => { const storedCredentialValue = "arbitrary-stored-value"; const adapter = createCliOpenShellProviderAdapter({ diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 7f52f41140c..3e3ae338c6b 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -52,7 +52,10 @@ export type CliOpenShellProviderAdapterDeps = Readonly<{ }>; const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/u; -const ANSI_RE = /\x1b\[[0-9;]*m/gu; +const TERMINAL_OSC_RE = /(?:\x1B\]|\x9D)[\s\S]*?(?:\x07|\x1B\\|\x9C|$)/gu; +const TERMINAL_STRING_RE = /(?:\x1B[PX^_]|[\x90\x98\x9E\x9F])[\s\S]*?(?:\x1B\\|\x9C|$)/gu; +const TERMINAL_CSI_RE = /(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~]/gu; +const TERMINAL_CONTROL_RE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/gu; const ATTACHED_TO_SANDBOX_RE = /attached\s+to(?:\s|│)+sandbox\(\s*es?\s*\)?\s*:\s*([^"\n]+?)(?=\.\s+[a-z]|["\n]|$)/iu; const TOLERATED_DETACH_OUTPUT_RE = @@ -77,7 +80,10 @@ function bufferOrStringToText(value: string | Buffer | null | undefined): string function commandOutput(result: CapturedProviderCommandResult): string { return `${bufferOrStringToText(result.stderr)}\n${bufferOrStringToText(result.stdout)}` - .replace(ANSI_RE, "") + .replace(TERMINAL_OSC_RE, "") + .replace(TERMINAL_STRING_RE, "") + .replace(TERMINAL_CSI_RE, "") + .replace(TERMINAL_CONTROL_RE, "") .trim(); } diff --git a/test/credentials/credentials-reset-outcome.test.ts b/test/credentials/credentials-reset-outcome.test.ts index f3e7267d407..e7d64373a6e 100644 --- a/test/credentials/credentials-reset-outcome.test.ts +++ b/test/credentials/credentials-reset-outcome.test.ts @@ -25,6 +25,21 @@ describe("formatResetOutcome (#5560)", () => { expect(outcome.ok).toBe(true); expect(outcome.lines[0]).toContain("Removed provider 'my-assistant-brave-search'"); expect(outcome.lines.join("\n")).toContain("onboard"); + expect(outcome.lines.join("\n")).not.toContain("rebuild"); + }); + + it("reports every sandbox detached during a successful removal", () => { + const outcome = formatResetOutcome( + "my-assistant-brave-search", + result({ ok: true, detachedSandboxes: ["alpha", "beta", "alpha"] }), + ); + + expect(outcome.ok).toBe(true); + expect(outcome.lines).toContain( + " Provider 'my-assistant-brave-search' was detached from sandbox(es): alpha, beta during removal.", + ); + expect(outcome.lines).toContain(" nemoclaw alpha rebuild"); + expect(outcome.lines).toContain(" nemoclaw beta rebuild"); }); it("surfaces the still-attached sandboxes with a detach hint when recovery fails", () => { From 72c46452f4d37998892a15e863897273bece3db4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 04:13:31 -0700 Subject: [PATCH 33/57] fix(cli): verify inspected provider identity --- .../credentials-provider-adapter.test.ts | 30 +++++++++++++++++++ .../openshell/provider-adapter-cli.test.ts | 20 +++++++++++++ .../openshell/provider-adapter-cli.ts | 8 +++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 6e85a0fbddf..fbce04afae1 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -180,6 +180,36 @@ describe("credential actions use typed OpenShell provider results", () => { expect(adapter.createProvider).not.toHaveBeenCalled(); }); + it("does not create from existing credentials when profile identity is unverified (#9806)", async () => { + const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = + async () => ({ + ok: false, + error: { + kind: "schema", + message: "OpenShell returned an invalid provider profile.", + }, + }); + const adapter = providerAdapter({ inspectProviderProfile: vi.fn(inspectProviderProfile) }); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "generic", + credentials: [], + configPairs: [], + fromExisting: true, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toContain( + " Refusing --from-existing because the provider profile credential keys could not be compared with managed MCP reservations.", + ); + expect(result.failureLines).toContain(" OpenShell returned an invalid provider profile."); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }); + it("lists credentials separately from messaging bridge providers (#9806)", async () => { const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ ok: true, diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 0f9e99436fc..feb038d4cd5 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -456,6 +456,7 @@ describe("CLI OpenShell provider adapter", () => { captured( 0, JSON.stringify({ + id: "custom", credentials: [{ env_vars: ["ZETA_TOKEN", "ALPHA_TOKEN"] }, { env_vars: ["ALPHA_TOKEN"] }], }), ), @@ -493,6 +494,25 @@ describe("CLI OpenShell provider adapter", () => { }); }); + it.each([ + ["missing", { credentials: [{ env_vars: ["CUSTOM_TOKEN"] }] }], + ["mismatched", { id: "other", credentials: [{ env_vars: ["CUSTOM_TOKEN"] }] }], + ])("rejects a provider profile with a %s identity (#9806)", async (_case, profile) => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => captured(0, JSON.stringify(profile)), + }); + + await expect( + adapter.inspectProviderProfile({ + target: selectedOpenShellGateway(), + profileType: "custom", + }), + ).resolves.toEqual({ + ok: false, + error: { kind: "schema", message: "OpenShell returned an invalid provider profile." }, + }); + }); + it("returns typed attachment names and exact detach arguments (#9806)", async () => { const run = vi .fn() diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 3e3ae338c6b..b81aed11148 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -194,7 +194,7 @@ function scopedArgs( ]; } -function parseProfileCredentialKeys(output: string): string[] | null { +function parseProfileCredentialKeys(output: string, expectedProfileId: string): string[] | null { let profile: unknown; try { profile = JSON.parse(output); @@ -202,6 +202,7 @@ function parseProfileCredentialKeys(output: string): string[] | null { return null; } if (typeof profile !== "object" || profile === null || Array.isArray(profile)) return null; + if (Reflect.get(profile, "id") !== expectedProfileId) return null; const credentials = Reflect.get(profile, "credentials"); if (!Array.isArray(credentials)) return null; const keys = new Set(); @@ -345,7 +346,10 @@ export function createCliOpenShellProviderAdapter( ); const error = commandError(result); if (error) return failure(error); - const credentialKeys = parseProfileCredentialKeys(bufferOrStringToText(result.stdout)); + const credentialKeys = parseProfileCredentialKeys( + bufferOrStringToText(result.stdout), + request.profileType, + ); return credentialKeys ? success({ credentialKeys }) : failure({ From d8fd4acdba2c545c483fdf8e41aca9ce58b45d7d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 04:42:36 -0700 Subject: [PATCH 34/57] docs(cli): correct managed-image fallback guidance --- docs/get-started/quickstart.mdx | 2 +- .../credentials-provider-adapter.test.ts | 36 +++++++++++-------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index c5f97ed38b7..8bd08bcd435 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -51,7 +51,7 @@ Review the [Prerequisites](prerequisites) before you begin. Press Enter to accept the suggested `my-assistant` sandbox name. For a first run, skip optional web search and messaging setup, then accept the suggested network policy tier. With the OpenShell Docker driver, stock OpenClaw onboarding normally uses the release's exact managed-image digest. - If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. + If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. Invalid or inconsistent catalog evidence fails closed before sandbox creation. An explicit `nemoclaw onboard --from ` remains a separate custom-image path. diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index fbce04afae1..deee70801a8 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -19,6 +19,10 @@ vi.mock("../state/mcp-lifecycle-lock/credential-ownership", () => ({ withMcpCredentialOwnershipLock: (operation: () => Promise | T) => operation(), })); +vi.mock("../gateway-start-guidance", () => ({ + gatewayStartGuidance: () => "Start the gateway again with `nemoclaw onboard`.", +})); + function providerAdapter( overrides: Partial = {}, ): OpenShellProviderAdapter { @@ -282,26 +286,26 @@ describe("credential actions use typed OpenShell provider results", () => { }); it.each([ - [ - "authentication", - "OpenShell could not authenticate the provider operation.", - false, - undefined, - ], - ["schema", "The OpenShell CLI and gateway provider schemas do not match.", false, undefined], - ["timeout", "The OpenShell provider operation timed out.", false, undefined], - ["command", "OpenShell rejected the provider query.", false, undefined], - ["transport", "OpenShell could not start the provider operation.", false, "process_start"], + ["authentication", "OpenShell could not authenticate the provider operation.", null, undefined], + ["schema", "The OpenShell CLI and gateway provider schemas do not match.", null, undefined], + ["timeout", "The OpenShell provider operation timed out.", null, undefined], + ["command", "OpenShell rejected the provider query.", null, undefined], + ["transport", "OpenShell could not start the provider operation.", null, "process_start"], [ "transport", "The selected OpenShell gateway identity does not match the recorded identity.", - false, + null, "identity_mismatch", ], - ["transport", "OpenShell could not reach the selected gateway.", true, "unreachable"], + [ + "transport", + "OpenShell could not reach the selected gateway.", + "Start the gateway again with `nemoclaw onboard`.", + "unreachable", + ], ] as const)( "uses the typed %s provider-list failure for recovery guidance (#9806)", - async (kind, message, includesStartGuidance, reason) => { + async (kind, message, expectedGuidance, reason) => { const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ ok: false, error: @@ -318,7 +322,11 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.exitCode).toBe(1); expect(failure).toContain(message); - expect(result.failureLines).toHaveLength(includesStartGuidance ? 3 : 2); + expect(result.failureLines).toEqual([ + " Could not query OpenShell providers.", + ` ${message}`, + ...(expectedGuidance ? [` ${expectedGuidance}`] : []), + ]); }, ); From ce51776a7d995df1e08ef459c7d74cd901bf2879 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 04:56:31 -0700 Subject: [PATCH 35/57] fix(cli): preserve provider recovery guidance --- src/commands/credentials.test.ts | 4 +- src/lib/actions/credentials-add.ts | 37 +++++- .../credentials-provider-adapter.test.ts | 121 +++++++++++++++++- src/lib/actions/credentials/reset.ts | 23 ++-- 4 files changed, 170 insertions(+), 15 deletions(-) diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index 6644f44d612..46f2ec26127 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -373,7 +373,7 @@ describe("credentials oclif adapter source coverage", () => { ]); }); - it("reports caller-neutral guidance when OpenAI profile import fails", async () => { + it("reports profile recovery guidance when OpenAI profile import fails", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); mocks.runOpenshellProviderCommand.mockReturnValueOnce({ status: 1, @@ -394,7 +394,7 @@ describe("credentials oclif adapter source coverage", () => { "Could not import bundled provider profile 'openai'", ); expect(result.failureLines.join("\n")).toContain( - "Update OpenShell with scripts/install-openshell.sh and retry", + "Fix the reported OpenShell provider-profile error, then retry", ); expect(result.failureLines.join("\n")).not.toContain("onboarding"); expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledOnce(); diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 9f0ae01bdc0..0384974265e 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -4,7 +4,10 @@ import fs from "node:fs"; import path from "node:path"; import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; -import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; +import type { + OpenShellProviderAdapter, + OpenShellProviderError, +} from "../adapters/openshell/provider-adapter"; import { selectedOpenShellGateway } from "../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; @@ -12,6 +15,7 @@ import { isBridgeProviderName, recoverGatewayForCredentialMutationOrExit, } from "../credentials/command-support"; +import { gatewayStartGuidance } from "../gateway-start-guidance"; import { SECRET_PATTERNS } from "../security/secret-patterns"; import { withMcpCredentialOwnershipLock } from "../state/mcp-lifecycle-lock/credential-ownership"; import { ROOT } from "../state/paths"; @@ -79,6 +83,32 @@ function bundledProviderProfilePath(type: string): string { return path.join(ROOT, "nemoclaw-blueprint", "provider-profiles", `${type.toLowerCase()}.yaml`); } +function bundledProviderProfileRecoveryLines(error: OpenShellProviderError): string[] { + switch (error.kind) { + case "authentication": + return [" Restore OpenShell authentication for the selected gateway, then retry."]; + case "timeout": + return [" Confirm the selected OpenShell gateway is available, then retry."]; + case "schema": + return [" Update OpenShell with scripts/install-openshell.sh, then retry."]; + case "validation": + return [" Restore the bundled provider profile from this NemoClaw release, then retry."]; + case "transport": + switch (error.reason) { + case "unreachable": + return [` ${gatewayStartGuidance()}`, " Then retry this command."]; + case "identity_mismatch": + return [ + " Re-select the intended OpenShell gateway and restore its recorded identity, then retry.", + ]; + case "process_start": + return [" Repair OpenShell with scripts/install-openshell.sh, then retry."]; + } + case "command": + return [" Fix the reported OpenShell provider-profile error, then retry."]; + } +} + async function ensureBundledProviderProfile( type: string, providerAdapter: OpenShellProviderAdapter, @@ -96,12 +126,13 @@ async function ensureBundledProviderProfile( return fail([ ` OpenShell provider profile '${type}' does not match NemoClaw's checked-in credential boundary.`, " Remove the conflicting provider profile, then retry this command.", + ` ${result.error.message}`, ]); } return fail([ ` Could not import bundled provider profile '${type}'.`, - " Update OpenShell with scripts/install-openshell.sh and retry.", - ...(result.error.message ? [` ${result.error.message}`] : []), + ...bundledProviderProfileRecoveryLines(result.error), + ` ${result.error.message}`, ]); } diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index deee70801a8..c1c94fd9b0e 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -5,7 +5,10 @@ import readline from "node:readline"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; -import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter"; +import type { + OpenShellProviderAdapter, + OpenShellProviderError, +} from "../adapters/openshell/provider-adapter"; import { setGlobalCliActionRuntimeHooksForTest } from "./global"; import { runCredentialsAddAction } from "./credentials-add"; import { runCredentialsListAction } from "./credentials/list"; @@ -184,6 +187,77 @@ describe("credential actions use typed OpenShell provider results", () => { expect(adapter.createProvider).not.toHaveBeenCalled(); }); + it.each([ + [ + "authentication", + { + kind: "authentication", + message: "OpenShell could not authenticate the provider operation.", + }, + [" Restore OpenShell authentication for the selected gateway, then retry."], + ], + [ + "unreachable gateway", + { + kind: "transport", + reason: "unreachable", + message: "OpenShell could not reach the selected gateway.", + }, + [" Start the gateway again with `nemoclaw onboard`.", " Then retry this command."], + ], + [ + "timeout", + { kind: "timeout", message: "The OpenShell provider operation timed out." }, + [" Confirm the selected OpenShell gateway is available, then retry."], + ], + [ + "schema mismatch", + { + kind: "schema", + message: "The OpenShell CLI and gateway provider schemas do not match.", + }, + [" Update OpenShell with scripts/install-openshell.sh, then retry."], + ], + [ + "invalid bundled profile", + { + kind: "validation", + message: "The checked-in OpenShell provider profile is invalid or unreadable.", + }, + [" Restore the bundled provider profile from this NemoClaw release, then retry."], + ], + ] satisfies ReadonlyArray)( + "gives actionable recovery for a typed %s profile import failure (#9806)", + async (_case, error, recoveryLines) => { + vi.stubEnv("OPENAI_API_KEY", "host-only-value"); + const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = + async () => ({ + ok: false, + error, + }); + const adapter = providerAdapter({ importProviderProfile: vi.fn(importProviderProfile) }); + + const result = await runCredentialsAddAction( + { + provider: "openai-prod", + type: "openai", + credentials: ["OPENAI_API_KEY"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toEqual([ + " Could not import bundled provider profile 'openai'.", + ...recoveryLines, + ` ${error.message}`, + ]); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }, + ); + it("does not create from existing credentials when profile identity is unverified (#9806)", async () => { const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async () => ({ @@ -534,6 +608,51 @@ describe("credential actions use typed OpenShell provider results", () => { expect(forgetExtraProvider).toHaveBeenCalledWith("custom-provider"); }); + it("reports rebuild guidance when concurrent deletion follows a successful detach (#9806)", async () => { + const forgetExtraProvider = vi.fn(() => true); + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime: async () => ({ recovered: true }), + recordExtraProvider: () => true, + forgetExtraProvider, + listManagedMcpCredentialReservations: () => [], + }); + const deleteProvider = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha"], + }, + }) + .mockResolvedValueOnce({ + ok: false, + error: { kind: "command", reason: "not_found", message: "provider not found" }, + }); + const detachProvider = vi.fn(async () => ({ + ok: true, + })); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toContain( + " Provider 'custom-provider' is already absent from the OpenShell gateway. Local state was cleaned up.", + ); + expect(result.outputLines).toContain( + " Provider 'custom-provider' was detached from sandbox(es): alpha during removal.", + ); + expect(result.outputLines).toContain(" nemoclaw alpha rebuild"); + expect(detachProvider).toHaveBeenCalledOnce(); + expect(forgetExtraProvider).toHaveBeenCalledWith("custom-provider"); + }); + it("reports final attachments after successful detach recovery (#9806)", async () => { const deleteProvider = vi .fn() diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index 002e61ee056..687deff4741 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -73,6 +73,18 @@ function fail(failureLines: readonly string[]): CredentialsResetResult { return { exitCode: 1, outputLines: [], failureLines }; } +function detachedSandboxGuidance(key: string, sandboxes: readonly string[]): string[] { + const detachedSandboxes = [...new Set(sandboxes)]; + return detachedSandboxes.length === 0 + ? [] + : [ + "", + ` Provider '${key}' was detached from sandbox(es): ${detachedSandboxes.join(", ")} during removal.`, + " After registering the replacement provider, rebuild each detached sandbox:", + ...detachedSandboxes.map((sandbox) => ` ${CLI_NAME} ${sandbox} rebuild`), + ]; +} + export async function runCredentialsResetAction( input: CredentialsResetInput, deps: CredentialsResetDeps = {}, @@ -122,6 +134,7 @@ export async function runCredentialsResetAction( ? ` Provider '${key}' is already absent from the OpenShell gateway. Local state was cleaned up.` : ` Provider '${key}' is already absent from the OpenShell gateway.`, ` Re-run '${CLI_NAME} onboard' to enter a new value.`, + ...detachedSandboxGuidance(key, recovery.detachedSandboxes), ]); } @@ -139,20 +152,12 @@ export function formatResetOutcome( ): { ok: boolean; lines: string[] } { const onboardHint = ` Re-run '${CLI_NAME} onboard' to enter a new value.`; if (recovery.ok) { - const detachedSandboxes = [...new Set(recovery.detachedSandboxes)]; return { ok: true, lines: [ ` Removed provider '${key}' from the OpenShell gateway.`, onboardHint, - ...(detachedSandboxes.length === 0 - ? [] - : [ - "", - ` Provider '${key}' was detached from sandbox(es): ${detachedSandboxes.join(", ")} during removal.`, - " After registering the replacement provider, rebuild each detached sandbox:", - ...detachedSandboxes.map((sandbox) => ` ${CLI_NAME} ${sandbox} rebuild`), - ]), + ...detachedSandboxGuidance(key, recovery.detachedSandboxes), ], }; } From 3417f4667f76d261734b5179f5278b3d6407678b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 05:10:12 -0700 Subject: [PATCH 36/57] fix(cli): distinguish missing provider detach --- .../credentials-provider-adapter.test.ts | 44 +++++++++++++++++++ .../openshell/provider-adapter-cli.test.ts | 24 +++++++++- .../openshell/provider-adapter-cli.ts | 3 +- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index c1c94fd9b0e..e0bc9ad8b9c 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -653,6 +653,50 @@ describe("credential actions use typed OpenShell provider results", () => { expect(forgetExtraProvider).toHaveBeenCalledWith("custom-provider"); }); + it("does not report rebuild guidance when the provider disappears before detach (#9806)", async () => { + const forgetExtraProvider = vi.fn(() => true); + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime: async () => ({ recovered: true }), + recordExtraProvider: () => true, + forgetExtraProvider, + listManagedMcpCredentialReservations: () => [], + }); + const deleteProvider = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha"], + }, + }) + .mockResolvedValueOnce({ + ok: false, + error: { kind: "command", reason: "not_found", message: "provider not found" }, + }); + const detachProvider = vi.fn(async () => ({ + ok: false, + error: { kind: "command", reason: "not_found", message: "provider not found" }, + })); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toContain( + " Provider 'custom-provider' is already absent from the OpenShell gateway. Local state was cleaned up.", + ); + expect(result.outputLines.join("\n")).not.toContain("was detached from sandbox"); + expect(result.outputLines.join("\n")).not.toContain("nemoclaw alpha rebuild"); + expect(detachProvider).toHaveBeenCalledOnce(); + expect(forgetExtraProvider).toHaveBeenCalledWith("custom-provider"); + }); + it("reports final attachments after successful detach recovery (#9806)", async () => { const deleteProvider = vi .fn() diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index feb038d4cd5..16cb411367c 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -588,8 +588,8 @@ describe("CLI OpenShell provider adapter", () => { ); }); - it.each(["NotAttached", "provider search-prod NotFound", "provider search-prod not found"])( - "treats a stale detach result as already absent: %s (#9806)", + it.each(["NotAttached", "provider search-prod is not attached"])( + "treats an idempotent detach result as already detached: %s (#9806)", async (diagnostic) => { const adapter = createCliOpenShellProviderAdapter({ run: () => captured(1, "", diagnostic), @@ -605,6 +605,26 @@ describe("CLI OpenShell provider adapter", () => { }, ); + it.each(["provider search-prod NotFound", "provider search-prod not found"])( + "does not report a missing provider as detached: %s (#9806)", + async (diagnostic) => { + const adapter = createCliOpenShellProviderAdapter({ + run: () => captured(1, "", diagnostic), + }); + + await expect( + adapter.detachProvider({ + target: selectedOpenShellGateway(), + providerName: "search-prod", + sandboxName: "alpha", + }), + ).resolves.toEqual({ + ok: false, + error: { kind: "command", reason: "not_found", message: diagnostic }, + }); + }, + ); + it.each([ "provider is attached to sandbox(es): alpha, invalid/name", "provider is attached to sandbox(es): --gateway, invalid/name", diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index b81aed11148..c9c7f096fa8 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -58,8 +58,7 @@ const TERMINAL_CSI_RE = /(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~]/gu; const TERMINAL_CONTROL_RE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/gu; const ATTACHED_TO_SANDBOX_RE = /attached\s+to(?:\s|│)+sandbox\(\s*es?\s*\)?\s*:\s*([^"\n]+?)(?=\.\s+[a-z]|["\n]|$)/iu; -const TOLERATED_DETACH_OUTPUT_RE = - /\bNotAttached\b|\bnot\s+attached\b|provider[^\n]{0,200}?(?:\bNotFound\b|\bnot\s+found\b)/iu; +const TOLERATED_DETACH_OUTPUT_RE = /\bNotAttached\b|\bnot\s+attached\b/iu; function success(value: T): OpenShellProviderResult { return { ok: true, value }; From 167cd617778956d7ed1cee4b460596cc10166d0a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 05:21:48 -0700 Subject: [PATCH 37/57] fix(cli): bind provider mutations to gateway --- src/lib/actions/credentials-add.ts | 15 ++++++------- .../credentials-provider-adapter.test.ts | 21 ++++++++++++++++--- src/lib/actions/credentials/reset.ts | 11 +++++----- src/lib/credentials/command-support.ts | 15 ++++++++----- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 0384974265e..42f82276841 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -8,7 +8,7 @@ import type { OpenShellProviderAdapter, OpenShellProviderError, } from "../adapters/openshell/provider-adapter"; -import { selectedOpenShellGateway } from "../adapters/openshell/sandbox-observer"; +import type { OpenShellGatewayTarget } from "../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; import { @@ -111,13 +111,14 @@ function bundledProviderProfileRecoveryLines(error: OpenShellProviderError): str async function ensureBundledProviderProfile( type: string, + target: OpenShellGatewayTarget, providerAdapter: OpenShellProviderAdapter, ): Promise { const profilePath = bundledProviderProfilePath(type); if (!fs.existsSync(profilePath)) return null; const result = await providerAdapter.importProviderProfile({ - target: selectedOpenShellGateway(), + target, profilePath, timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); @@ -240,20 +241,20 @@ export async function runCredentialsAddAction( } const recoveryFailureLines: string[] = []; - const recovered = await recoverGatewayForCredentialMutationOrExit((lines) => { + const target = await recoverGatewayForCredentialMutationOrExit((lines) => { recoveryFailureLines.push(...lines); }); - if (!recovered) { + if (!target) { return fail(recoveryFailureLines); } - const providerProfileFailure = await ensureBundledProviderProfile(type, providerAdapter); + const providerProfileFailure = await ensureBundledProviderProfile(type, target, providerAdapter); if (providerProfileFailure) return providerProfileFailure; let importedCredentialKeys: string[] | null = null; if (fromExisting) { const inspection = await providerAdapter.inspectProviderProfile({ - target: selectedOpenShellGateway(), + target, profileType: type, timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); @@ -285,7 +286,7 @@ export async function runCredentialsAddAction( let keepReservation = false; try { const result = await providerAdapter.createProvider({ - target: selectedOpenShellGateway(), + target, name: provider, type, credentials: credentials.map((credential) => ({ diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index e0bc9ad8b9c..8ae69f47fe7 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -92,7 +92,7 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.exitCode).toBe(0); expect(adapter.createProvider).toHaveBeenCalledWith({ - target: { kind: "selected" }, + target: { kind: "named", gatewayName: "nemoclaw" }, name: "custom-provider", type: "generic", credentials: [{ name: "CUSTOM_TOKEN", value: "credential-value" }], @@ -120,7 +120,7 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.exitCode).toBe(0); expect(adapter.importProviderProfile).toHaveBeenCalledWith({ - target: { kind: "selected" }, + target: { kind: "named", gatewayName: "nemoclaw" }, profilePath: expect.stringMatching(/provider-profiles\/openai\.yaml$/u), timeoutMs: 30_000, }); @@ -285,6 +285,11 @@ describe("credential actions use typed OpenShell provider results", () => { " Refusing --from-existing because the provider profile credential keys could not be compared with managed MCP reservations.", ); expect(result.failureLines).toContain(" OpenShell returned an invalid provider profile."); + expect(adapter.inspectProviderProfile).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + profileType: "generic", + timeoutMs: 30_000, + }); expect(adapter.createProvider).not.toHaveBeenCalled(); }); @@ -472,11 +477,21 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.exitCode).toBe(0); expect(operations).toEqual(["delete:first", "detach:alpha", "delete:retry"]); expect(detachProvider).toHaveBeenCalledWith({ - target: { kind: "selected" }, + target: { kind: "named", gatewayName: "nemoclaw" }, providerName: "custom-provider", sandboxName: "alpha", timeoutMs: 30_000, }); + expect(deleteProvider).toHaveBeenNthCalledWith(1, { + target: { kind: "named", gatewayName: "nemoclaw" }, + providerName: "custom-provider", + timeoutMs: 30_000, + }); + expect(deleteProvider).toHaveBeenNthCalledWith(2, { + target: { kind: "named", gatewayName: "nemoclaw" }, + providerName: "custom-provider", + timeoutMs: 30_000, + }); expect(result.outputLines).toContain( " Provider 'custom-provider' was detached from sandbox(es): alpha during removal.", ); diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index 687deff4741..5057bfa5871 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -6,7 +6,7 @@ import type { OpenShellProviderAdapter, OpenShellProviderError, } from "../../adapters/openshell/provider-adapter"; -import { selectedOpenShellGateway } from "../../adapters/openshell/sandbox-observer"; +import type { OpenShellGatewayTarget } from "../../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import { NAME_MAX_LENGTH, @@ -114,13 +114,13 @@ export async function runCredentialsResetAction( } const recoveryFailureLines: string[] = []; - const recovered = await recoverGatewayForCredentialMutationOrExit((lines) => { + const target = await recoverGatewayForCredentialMutationOrExit((lines) => { recoveryFailureLines.push(...lines); }); - if (!recovered) return fail(recoveryFailureLines); + if (!target) return fail(recoveryFailureLines); const providerAdapter = deps.providerAdapter ?? createCliOpenShellProviderAdapter(); - const recovery = await deleteProviderWithRecovery(key, providerAdapter); + const recovery = await deleteProviderWithRecovery(key, target, providerAdapter); if ( !recovery.ok && @@ -207,10 +207,11 @@ export function formatResetOutcome( async function deleteProviderWithRecovery( providerName: string, + target: OpenShellGatewayTarget, providerAdapter: OpenShellProviderAdapter, ): Promise { const request = { - target: selectedOpenShellGateway(), + target, providerName, timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, } as const; diff --git a/src/lib/credentials/command-support.ts b/src/lib/credentials/command-support.ts index 70fcc3551d7..3d3a4208de2 100644 --- a/src/lib/credentials/command-support.ts +++ b/src/lib/credentials/command-support.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { recoverNamedGatewayRuntime } from "../actions/global"; +import { + namedOpenShellGateway, + type OpenShellGatewayTarget, +} from "../adapters/openshell/sandbox-observer"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../cli/branding"; import { GATEWAY_PORT } from "../core/ports"; import { gatewayStartGuidance } from "../gateway-start-guidance"; @@ -58,17 +62,18 @@ export async function recoverGatewayOrExit( export async function recoverGatewayForCredentialMutationOrExit( reportFailure: (lines: readonly string[]) => void = (lines) => lines.forEach((line) => console.error(line)), -): Promise { - if (!(await recoverGatewayOrExit("reach", reportFailure))) return false; +): Promise { + if (!(await recoverGatewayOrExit("reach", reportFailure))) return null; + const gatewayName = resolveGatewayName(GATEWAY_PORT); try { resolveGatewayCredentialMutationAuthority({ - gatewayName: resolveGatewayName(GATEWAY_PORT), + gatewayName, gatewayPort: GATEWAY_PORT, }); - return true; + return namedOpenShellGateway(gatewayName); } catch (error) { reportFailure(credentialsGatewayAuthorityFailureLines(error)); - return false; + return null; } } From a23190d1f3bc643a683c5ee4d69770f1b50b6726 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 05:45:25 -0700 Subject: [PATCH 38/57] fix(cli): reconcile provider creation outcomes --- src/lib/actions/credentials-add.ts | 86 ++++++++++++-- .../credentials-provider-adapter.test.ts | 105 ++++++++++++++++++ .../adapters/openshell/provider-adapter.ts | 2 - test/e2e/live/sandbox-operations.test.ts | 2 +- 4 files changed, 181 insertions(+), 14 deletions(-) diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 42f82276841..be61e4401c6 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -79,8 +79,15 @@ function managedMcpCollisionFailure( return null; } -function bundledProviderProfilePath(type: string): string { - return path.join(ROOT, "nemoclaw-blueprint", "provider-profiles", `${type.toLowerCase()}.yaml`); +function bundledProviderProfile(type: string): { profileType: string; profilePath: string } | null { + const profileType = type.toLowerCase(); + const profilePath = path.join( + ROOT, + "nemoclaw-blueprint", + "provider-profiles", + `${profileType}.yaml`, + ); + return fs.existsSync(profilePath) ? { profileType, profilePath } : null; } function bundledProviderProfileRecoveryLines(error: OpenShellProviderError): string[] { @@ -110,33 +117,78 @@ function bundledProviderProfileRecoveryLines(error: OpenShellProviderError): str } async function ensureBundledProviderProfile( - type: string, + profile: { profileType: string; profilePath: string } | null, target: OpenShellGatewayTarget, providerAdapter: OpenShellProviderAdapter, ): Promise { - const profilePath = bundledProviderProfilePath(type); - if (!fs.existsSync(profilePath)) return null; + if (!profile) return null; const result = await providerAdapter.importProviderProfile({ target, - profilePath, + profilePath: profile.profilePath, timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); if (result.ok) return null; if (result.error.kind === "command" && result.error.reason === "profile_incompatible") { return fail([ - ` OpenShell provider profile '${type}' does not match NemoClaw's checked-in credential boundary.`, + ` OpenShell provider profile '${profile.profileType}' does not match NemoClaw's checked-in credential boundary.`, " Remove the conflicting provider profile, then retry this command.", ` ${result.error.message}`, ]); } return fail([ - ` Could not import bundled provider profile '${type}'.`, + ` Could not import bundled provider profile '${profile.profileType}'.`, ...bundledProviderProfileRecoveryLines(result.error), ` ${result.error.message}`, ]); } +function isUncertainProviderCreateError(error: OpenShellProviderError): boolean { + return ( + error.kind === "timeout" || + (error.kind === "transport" && error.reason === "unreachable") + ); +} + +async function reconcileUncertainProviderCreate( + provider: string, + target: OpenShellGatewayTarget, + providerAdapter: OpenShellProviderAdapter, +): Promise<{ keepReservation: boolean; lines: string[] }> { + const inventory = await providerAdapter.listProviders({ + target, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + if (inventory.ok && inventory.value.names.includes(provider)) { + return { + keepReservation: true, + lines: [ + ` OpenShell reports provider '${provider}' is registered; local provider ownership was preserved.`, + ` Verify with '${CLI_NAME} credentials list'.`, + ` Rebuild the target sandbox (\`${CLI_NAME} rebuild\`) to attach the provider.`, + ], + }; + } + if (inventory.ok) { + return { + keepReservation: false, + lines: [ + ` OpenShell confirms provider '${provider}' is absent.`, + " It is safe to retry the credentials add command.", + ], + }; + } + return { + keepReservation: true, + lines: [ + ` Could not determine whether provider '${provider}' was registered; local provider ownership was preserved.`, + ` Run '${CLI_NAME} credentials list' to inspect the gateway before retrying.`, + ` If the provider exists, rebuild the target sandbox; otherwise run '${CLI_NAME} credentials reset ${provider} --yes' before retrying.`, + ` ${inventory.error.message}`, + ], + }; +} + export async function runCredentialsAddAction( input: CredentialsAddInput, deps: CredentialsAddDeps = {}, @@ -248,14 +300,20 @@ export async function runCredentialsAddAction( return fail(recoveryFailureLines); } - const providerProfileFailure = await ensureBundledProviderProfile(type, target, providerAdapter); + const profile = bundledProviderProfile(type); + const providerType = profile?.profileType ?? type; + const providerProfileFailure = await ensureBundledProviderProfile( + profile, + target, + providerAdapter, + ); if (providerProfileFailure) return providerProfileFailure; let importedCredentialKeys: string[] | null = null; if (fromExisting) { const inspection = await providerAdapter.inspectProviderProfile({ target, - profileType: type, + profileType: providerType, timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); if (!inspection.ok) { @@ -288,7 +346,7 @@ export async function runCredentialsAddAction( const result = await providerAdapter.createProvider({ target, name: provider, - type, + type: providerType, credentials: credentials.map((credential) => ({ name: credential, value: process.env[credential] ?? "", @@ -308,6 +366,12 @@ export async function runCredentialsAddAction( } const lines = [` Could not register provider '${provider}'.`]; + if (isUncertainProviderCreateError(result.error)) { + const recovery = await reconcileUncertainProviderCreate(provider, target, providerAdapter); + keepReservation = recovery.keepReservation; + lines.push(` ${result.error.message}`, ...recovery.lines); + return fail(lines); + } if (result.error.kind === "command" && result.error.reason === "already_exists") { lines.push( "", diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 8ae69f47fe7..10e26384926 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -127,6 +127,111 @@ describe("credential actions use typed OpenShell provider results", () => { expect(adapter.createProvider).toHaveBeenCalledOnce(); }); + it("canonicalizes a mixed-case bundled profile through provider creation (#9806)", async () => { + const adapter = providerAdapter(); + + const result = await runCredentialsAddAction( + { + provider: "openai-prod", + type: "OpenAI", + credentials: [], + configPairs: [], + fromExisting: true, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(adapter.importProviderProfile).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + profilePath: expect.stringMatching(/provider-profiles\/openai\.yaml$/u), + timeoutMs: 30_000, + }); + expect(adapter.inspectProviderProfile).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + profileType: "openai", + timeoutMs: 30_000, + }); + expect(adapter.createProvider).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "named", gatewayName: "nemoclaw" }, + type: "openai", + }), + ); + }); + + it.each([ + { + case: "confirmed present", + inventory: { ok: true, value: { names: ["custom-provider"] } } as const, + expectedLines: [ + " OpenShell reports provider 'custom-provider' is registered; local provider ownership was preserved.", + " Rebuild the target sandbox (`nemoclaw rebuild`) to attach the provider.", + ], + forgetCalls: 0, + }, + { + case: "confirmed absent", + inventory: { ok: true, value: { names: [] } } as const, + expectedLines: [" OpenShell confirms provider 'custom-provider' is absent."], + forgetCalls: 1, + }, + { + case: "still indeterminate", + inventory: { + ok: false, + error: { kind: "timeout", message: "The provider inventory query timed out." }, + } as const, + expectedLines: [ + " Could not determine whether provider 'custom-provider' was registered; local provider ownership was preserved.", + " Run 'nemoclaw credentials list' to inspect the gateway before retrying.", + " If the provider exists, rebuild the target sandbox; otherwise run 'nemoclaw credentials reset custom-provider --yes' before retrying.", + ], + forgetCalls: 0, + }, + ])( + "reconciles timed-out provider creation when the result is $case (#9806)", + async (testCase) => { + vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); + const forgetExtraProvider = vi.fn(() => true); + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime: async () => ({ recovered: true }), + recordExtraProvider: () => true, + forgetExtraProvider, + listManagedMcpCredentialReservations: () => [], + }); + const createProvider: OpenShellProviderAdapter["createProvider"] = async () => ({ + ok: false, + error: { kind: "timeout", message: "The OpenShell provider operation timed out." }, + }); + const listProviders: OpenShellProviderAdapter["listProviders"] = async () => + testCase.inventory; + const adapter = providerAdapter({ + createProvider: vi.fn(createProvider), + listProviders: vi.fn(listProviders), + }); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "generic", + credentials: ["CUSTOM_TOKEN"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(adapter.listProviders).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + timeoutMs: 30_000, + }); + expect(result.failureLines).toEqual(expect.arrayContaining(testCase.expectedLines)); + expect(forgetExtraProvider).toHaveBeenCalledTimes(testCase.forgetCalls); + }, + ); + it("does not create an OpenAI provider after profile import fails (#9806)", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-value"); const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ diff --git a/src/lib/adapters/openshell/provider-adapter.ts b/src/lib/adapters/openshell/provider-adapter.ts index fbc2038121d..b50177c5cc1 100644 --- a/src/lib/adapters/openshell/provider-adapter.ts +++ b/src/lib/adapters/openshell/provider-adapter.ts @@ -9,8 +9,6 @@ export type OpenShellProviderCommandReason = | "failed" | "invalid_request" | "not_found" - | "profile_export_failed" - | "profile_import_failed" | "profile_incompatible"; export type OpenShellProviderTransportReason = diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index a879d4340ba..7b5dbb38981 100644 --- a/test/e2e/live/sandbox-operations.test.ts +++ b/test/e2e/live/sandbox-operations.test.ts @@ -723,7 +723,7 @@ test( test( "sandbox operations preserve list/status/logs/recovery/multi-sandbox contracts", { - timeout: 50 * 60_000, + timeout: 45 * 60_000, meta: { e2ePhases: [ "confirm Docker and clear the sandbox operation fixtures", From 75e8d7b688f5f39e9dc1a500cdc0c2e65b7223d3 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 06:09:18 -0700 Subject: [PATCH 39/57] fix(cli): bind credential inventory to gateway --- src/commands/credentials.test.ts | 21 +++++++++--- src/lib/actions/credentials-add.ts | 4 +-- .../credentials-provider-adapter.test.ts | 6 +++- src/lib/actions/credentials/list.ts | 20 ++++++----- src/lib/actions/credentials/reset.ts | 4 +-- src/lib/credentials/command-support.ts | 27 ++++++++------- .../cli/credentials-cli-command.test.ts | 33 ++++++++++++++++--- 7 files changed, 81 insertions(+), 34 deletions(-) diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index 46f2ec26127..826f7558a88 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -82,9 +82,12 @@ describe("credentials oclif adapter source coverage", () => { await CredentialsListCommand.run([], rootDir); expect(mocks.recoverNamedGatewayRuntime).toHaveBeenCalledWith(); - expect(mocks.resolveGatewayCredentialMutationAuthority).not.toHaveBeenCalled(); + expect(mocks.resolveGatewayCredentialMutationAuthority).toHaveBeenCalledWith({ + gatewayName: "nemoclaw", + gatewayPort: 8080, + }); expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledWith( - ["provider", "list", "--names"], + ["provider", "list", "-g", "nemoclaw", "--names"], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"], @@ -106,7 +109,7 @@ describe("credentials oclif adapter source coverage", () => { expect(mocks.prompt).not.toHaveBeenCalled(); expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledWith( - ["provider", "delete", "nvidia-prod"], + ["provider", "delete", "-g", "nemoclaw", "nvidia-prod"], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"], @@ -261,11 +264,13 @@ describe("credentials oclif adapter source coverage", () => { [ "provider", "profile", + "-g", + "nemoclaw", "import", "--file", expect.stringMatching(/provider-profiles\/openai\.yaml$/u), ], - ["provider", "profile", "export", "openai", "--output", "json"], + ["provider", "profile", "-g", "nemoclaw", "export", "openai", "--output", "json"], ]); expect(mocks.recordExtraProvider).not.toHaveBeenCalled(); }); @@ -297,6 +302,8 @@ describe("credentials oclif adapter source coverage", () => { [ "provider", "profile", + "-g", + "nemoclaw", "import", "--file", expect.stringMatching(/provider-profiles\/openai\.yaml$/u), @@ -340,14 +347,18 @@ describe("credentials oclif adapter source coverage", () => { [ "provider", "profile", + "-g", + "nemoclaw", "import", "--file", expect.stringMatching(/provider-profiles\/openai\.yaml$/u), ], - ["provider", "profile", "export", "openai", "--output", "json"], + ["provider", "profile", "-g", "nemoclaw", "export", "openai", "--output", "json"], [ "provider", "create", + "-g", + "nemoclaw", "--name", "openai-prod", "--type", diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index be61e4401c6..c9d1edc0b28 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -13,7 +13,7 @@ import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; import { isBridgeProviderName, - recoverGatewayForCredentialMutationOrExit, + recoverCredentialGatewayTargetOrExit, } from "../credentials/command-support"; import { gatewayStartGuidance } from "../gateway-start-guidance"; import { SECRET_PATTERNS } from "../security/secret-patterns"; @@ -293,7 +293,7 @@ export async function runCredentialsAddAction( } const recoveryFailureLines: string[] = []; - const target = await recoverGatewayForCredentialMutationOrExit((lines) => { + const target = await recoverCredentialGatewayTargetOrExit("mutation", (lines) => { recoveryFailureLines.push(...lines); }); if (!target) { diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 10e26384926..6d639993b3c 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -410,6 +410,10 @@ describe("credential actions use typed OpenShell provider results", () => { const result = await runCredentialsListAction("nemoclaw", { providerAdapter: adapter }); expect(result.exitCode).toBe(0); + expect(adapter.listProviders).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + timeoutMs: 30_000, + }); expect(result.outputLines).toContain(" alpha"); expect(result.outputLines).toContain(" zeta"); expect(result.outputLines.join("\n")).not.toContain("alpha-telegram-bridge"); @@ -507,7 +511,7 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.exitCode).toBe(1); expect(failure).toContain(message); expect(result.failureLines).toEqual([ - " Could not query OpenShell providers.", + " Could not query OpenShell providers on gateway 'nemoclaw'.", ` ${message}`, ...(expectedGuidance ? [` ${expectedGuidance}`] : []), ]); diff --git a/src/lib/actions/credentials/list.ts b/src/lib/actions/credentials/list.ts index f5db5ed1d16..5a38d479133 100644 --- a/src/lib/actions/credentials/list.ts +++ b/src/lib/actions/credentials/list.ts @@ -3,9 +3,8 @@ import { createCliOpenShellProviderAdapter } from "../../adapters/openshell/provider-adapter-cli"; import type { OpenShellProviderAdapter } from "../../adapters/openshell/provider-adapter"; -import { selectedOpenShellGateway } from "../../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; -import { recoverGatewayOrExit } from "../../credentials/command-support"; +import { recoverCredentialGatewayTargetOrExit } from "../../credentials/command-support"; import { classifyGatewayProviderNames } from "../../credentials/provider-list"; import { gatewayStartGuidance } from "../../gateway-start-guidance"; @@ -28,18 +27,21 @@ export async function runCredentialsListAction( deps: CredentialsListDeps = {}, ): Promise { const recoveryFailureLines: string[] = []; - const recovered = await recoverGatewayOrExit("query", (lines) => { + const target = await recoverCredentialGatewayTargetOrExit("query", (lines) => { recoveryFailureLines.push(...lines); }); - if (!recovered) return fail(recoveryFailureLines); + if (!target) return fail(recoveryFailureLines); const providerAdapter = deps.providerAdapter ?? createCliOpenShellProviderAdapter(); const result = await providerAdapter.listProviders({ - target: selectedOpenShellGateway(), + target, timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); if (!result.ok) { - const failureLines = [" Could not query OpenShell providers.", ` ${result.error.message}`]; + const failureLines = [ + ` Could not query OpenShell providers on gateway '${target.gatewayName}'.`, + ` ${result.error.message}`, + ]; if (result.error.kind === "transport" && result.error.reason === "unreachable") { failureLines.push(` ${gatewayStartGuidance()}`); } @@ -49,9 +51,11 @@ export async function runCredentialsListAction( const { bridgeNames, credentialNames } = classifyGatewayProviderNames(result.value.names); const outputLines: string[] = []; if (credentialNames.length === 0) { - outputLines.push(" No provider credentials registered."); + outputLines.push( + ` No provider credentials registered with OpenShell gateway '${target.gatewayName}'.`, + ); } else { - outputLines.push(" Providers registered with the OpenShell gateway:"); + outputLines.push(` Providers registered with OpenShell gateway '${target.gatewayName}':`); outputLines.push(...credentialNames.map((name) => ` ${name}`)); } if (bridgeNames.length > 0) { diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index 5057bfa5871..4a1a051a3a5 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -16,7 +16,7 @@ import { import { CLI_NAME } from "../../cli/branding"; import { isBridgeProviderName, - recoverGatewayForCredentialMutationOrExit, + recoverCredentialGatewayTargetOrExit, } from "../../credentials/command-support"; import { prompt as askPrompt, KNOWN_CREDENTIAL_ENV_KEYS } from "../../credentials/store"; import { forgetExtraProvider } from "../global"; @@ -114,7 +114,7 @@ export async function runCredentialsResetAction( } const recoveryFailureLines: string[] = []; - const target = await recoverGatewayForCredentialMutationOrExit((lines) => { + const target = await recoverCredentialGatewayTargetOrExit("mutation", (lines) => { recoveryFailureLines.push(...lines); }); if (!target) return fail(recoveryFailureLines); diff --git a/src/lib/credentials/command-support.ts b/src/lib/credentials/command-support.ts index 3d3a4208de2..1ce535a1fe7 100644 --- a/src/lib/credentials/command-support.ts +++ b/src/lib/credentials/command-support.ts @@ -2,10 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { recoverNamedGatewayRuntime } from "../actions/global"; -import { - namedOpenShellGateway, - type OpenShellGatewayTarget, -} from "../adapters/openshell/sandbox-observer"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../cli/branding"; import { GATEWAY_PORT } from "../core/ports"; import { gatewayStartGuidance } from "../gateway-start-guidance"; @@ -38,10 +34,14 @@ export function credentialsGatewayRecoveryFailureLines(kind: "query" | "reach"): ]; } -export function credentialsGatewayAuthorityFailureLines(error: unknown): string[] { +export function credentialsGatewayAuthorityFailureLines( + error: unknown, + operation: "mutation" | "query" = "mutation", +): string[] { const detail = error instanceof Error ? error.message : String(error); + const action = operation === "query" ? "query" : "change"; return [ - " Refusing to change provider credentials because the gateway lifecycle authority could not be revalidated.", + ` Refusing to ${action} provider credentials because the gateway lifecycle authority could not be revalidated.`, ` ${detail}`, ` Run '${CLI_NAME} onboard' to bind the current gateway authority before retrying.`, ]; @@ -59,11 +59,16 @@ export async function recoverGatewayOrExit( return false; } -export async function recoverGatewayForCredentialMutationOrExit( +export type CredentialGatewayTarget = Readonly<{ kind: "named"; gatewayName: string }>; + +export async function recoverCredentialGatewayTargetOrExit( + operation: "mutation" | "query", reportFailure: (lines: readonly string[]) => void = (lines) => lines.forEach((line) => console.error(line)), -): Promise { - if (!(await recoverGatewayOrExit("reach", reportFailure))) return null; +): Promise { + if (!(await recoverGatewayOrExit(operation === "query" ? "query" : "reach", reportFailure))) { + return null; + } const gatewayName = resolveGatewayName(GATEWAY_PORT); try { @@ -71,9 +76,9 @@ export async function recoverGatewayForCredentialMutationOrExit( gatewayName, gatewayPort: GATEWAY_PORT, }); - return namedOpenShellGateway(gatewayName); + return { kind: "named", gatewayName }; } catch (error) { - reportFailure(credentialsGatewayAuthorityFailureLines(error)); + reportFailure(credentialsGatewayAuthorityFailureLines(error, operation)); return null; } } diff --git a/test/package-contract/cli/credentials-cli-command.test.ts b/test/package-contract/cli/credentials-cli-command.test.ts index 82a962683fc..b2127250b4b 100644 --- a/test/package-contract/cli/credentials-cli-command.test.ts +++ b/test/package-contract/cli/credentials-cli-command.test.ts @@ -246,7 +246,7 @@ describe("credentials oclif commands", () => { expect(calls).toEqual([ { - args: ["provider", "list", "--names"], + args: ["provider", "list", "-g", "nemoclaw", "--names"], opts: { env: expect.any(Object), ignoreError: true, @@ -273,7 +273,9 @@ describe("credentials oclif commands", () => { const output = await captureOutput(() => CredentialsListCommand.run([])); - expect(output.stdout).toContain("No provider credentials registered."); + expect(output.stdout).toContain( + "No provider credentials registered with OpenShell gateway 'nemoclaw'.", + ); expect(output.stdout).toContain("2 per-sandbox messaging bridge(s)"); }); @@ -321,7 +323,7 @@ describe("credentials oclif commands", () => { expect(calls).toEqual([ { - args: ["provider", "delete", "nvidia-prod"], + args: ["provider", "delete", "-g", "nemoclaw", "nvidia-prod"], opts: { env: expect.any(Object), ignoreError: true, @@ -416,7 +418,15 @@ describe("credentials oclif commands", () => { expect(calls).toEqual([ { - args: ["provider", "profile", "import", "--file", TAVILY_PROFILE_PATH], + args: [ + "provider", + "profile", + "-g", + "nemoclaw", + "import", + "--file", + TAVILY_PROFILE_PATH, + ], opts: { env: expect.any(Object), ignoreError: true, @@ -426,7 +436,16 @@ describe("credentials oclif commands", () => { }, }, { - args: ["provider", "profile", "export", "tavily", "--output", "json"], + args: [ + "provider", + "profile", + "-g", + "nemoclaw", + "export", + "tavily", + "--output", + "json", + ], opts: { env: expect.any(Object), ignoreError: true, @@ -440,6 +459,8 @@ describe("credentials oclif commands", () => { args: [ "provider", "create", + "-g", + "nemoclaw", "--name", "tavily-search", "--type", @@ -618,6 +639,8 @@ describe("credentials oclif commands", () => { expect(calls[0]?.args).toEqual([ "provider", "profile", + "-g", + "nemoclaw", "import", "--file", TAVILY_PROFILE_PATH, From 1b6147a9c74b00e409855444ae1d1166c72201f1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 06:35:03 -0700 Subject: [PATCH 40/57] fix(cli): close provider recovery boundaries --- docs/reference/commands.mdx | 2 +- src/commands/credentials/add.ts | 3 +- src/lib/actions/credentials-add.ts | 50 ++++- .../credentials-provider-adapter.test.ts | 176 +++++++++++++----- src/lib/actions/credentials/reset.ts | 11 +- src/lib/adapters/openshell/gateway-scope.ts | 53 ++++++ .../openshell/provider-adapter-cli.test.ts | 6 + .../openshell/provider-adapter-cli.ts | 20 +- .../adapters/openshell/provider-adapter.ts | 3 +- .../setup-inference-gateway-scope.test.ts | 2 +- src/lib/onboard/setup-inference.ts | 39 +--- .../credentials-reset-outcome.test.ts | 14 +- 12 files changed, 270 insertions(+), 109 deletions(-) create mode 100644 src/lib/adapters/openshell/gateway-scope.ts diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index e4a06bc872f..36d08902fcc 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3207,7 +3207,7 @@ $$nemoclaw credentials add tavily-search --type tavily --credential TAVILY_API_K | --- | --- | | `--type ` | Provider type (e.g. `tavily`, `nvidia`, `openai`, `anthropic`, `generic`) | | `--credential ` | Env variable name whose value holds the credential. Repeatable | -| `--config ` | Provider configuration pair. Repeatable | +| `--config ` | Typed non-secret provider configuration. Supported: `OPENAI_BASE_URL=` with `--type openai`. URLs with credentials, query parameters, or fragments are rejected. Repeatable | | `--from-existing` | Load credentials and config from existing local state when no managed MCP server reserves credential keys | ### `$$nemoclaw credentials reset ` diff --git a/src/commands/credentials/add.ts b/src/commands/credentials/add.ts index ac826542282..3ef1d62c8fd 100644 --- a/src/commands/credentials/add.ts +++ b/src/commands/credentials/add.ts @@ -39,7 +39,8 @@ export default class CredentialsAddCommand extends NemoClawCommand { multiple: true, }), config: Flags.string({ - description: "Provider configuration pair (KEY=VALUE). Repeatable.", + description: + "Typed non-secret provider configuration. Supported: OPENAI_BASE_URL= with --type openai. Repeatable.", multiple: true, }), "from-existing": Flags.boolean({ diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index c9d1edc0b28..db894f4effb 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -50,6 +50,7 @@ const CONFIG_KEY_DENYLIST = const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,127}$/i; const PROVIDER_TYPE_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i; const MAX_CONFIG_ENTRY_LENGTH = 4096; +const MAX_PROVIDER_BASE_URL_LENGTH = 2048; function ok(successLines: readonly string[]): CredentialsAddResult { return { exitCode: 0, successLines, failureLines: [] }; @@ -79,6 +80,38 @@ function managedMcpCollisionFailure( return null; } +function typedProviderConfigFailure(type: string, key: string, value: string): string[] | null { + if (type.toLowerCase() !== "openai" || key !== "OPENAI_BASE_URL") { + return [ + ` --config '${key}' is not a supported non-secret setting for provider type '${type}'.`, + " Supported: --type openai with --config OPENAI_BASE_URL=.", + " Use --from-existing for provider configuration already stored by OpenShell.", + ]; + } + let baseUrl: URL; + try { + baseUrl = new URL(value); + } catch { + return [ + " --config 'OPENAI_BASE_URL' must be an absolute HTTP(S) URL without credentials, query parameters, or a fragment.", + ]; + } + if ( + value !== value.trim() || + value.length > MAX_PROVIDER_BASE_URL_LENGTH || + (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") || + baseUrl.username !== "" || + baseUrl.password !== "" || + baseUrl.search !== "" || + baseUrl.hash !== "" + ) { + return [ + " --config 'OPENAI_BASE_URL' must be an absolute HTTP(S) URL without credentials, query parameters, or a fragment.", + ]; + } + return null; +} + function bundledProviderProfile(type: string): { profileType: string; profilePath: string } | null { const profileType = type.toLowerCase(); const profilePath = path.join( @@ -146,7 +179,8 @@ async function ensureBundledProviderProfile( function isUncertainProviderCreateError(error: OpenShellProviderError): boolean { return ( error.kind === "timeout" || - (error.kind === "transport" && error.reason === "unreachable") + (error.kind === "transport" && error.reason === "unreachable") || + (error.kind === "command" && error.reason === "uncertain") ); } @@ -244,6 +278,8 @@ export async function runCredentialsAddAction( } } + const config: Array<{ key: string; value: string }> = []; + const configKeys = new Set(); for (const entry of configPairs) { if (entry.length > MAX_CONFIG_ENTRY_LENGTH) { return fail([` --config entry exceeds ${MAX_CONFIG_ENTRY_LENGTH} characters.`]); @@ -274,6 +310,13 @@ export async function runCredentialsAddAction( ]); } } + if (configKeys.has(key)) { + return fail([` --config '${key}' may be provided only once.`]); + } + const typedConfigFailure = typedProviderConfigFailure(type, key, value); + if (typedConfigFailure) return fail(typedConfigFailure); + configKeys.add(key); + config.push({ key, value }); } const managedMcpReservations = listManagedMcpCredentialReservations(); @@ -326,11 +369,6 @@ export async function runCredentialsAddAction( importedCredentialKeys = [...inspection.value.credentialKeys]; } - const config = configPairs.map((configPair) => { - const separator = configPair.indexOf("="); - return { key: configPair.slice(0, separator), value: configPair.slice(separator + 1) }; - }); - return withMcpCredentialOwnershipLock(async () => { const providerCredentialKeys = importedCredentialKeys ?? credentials; const collision = managedMcpCollisionFailure( diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 6d639993b3c..47a2149bc68 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -82,9 +82,9 @@ describe("credential actions use typed OpenShell provider results", () => { const result = await runCredentialsAddAction( { provider: "custom-provider", - type: "generic", + type: "openai", credentials: ["CUSTOM_TOKEN"], - configPairs: ["region=us-west"], + configPairs: ["OPENAI_BASE_URL=https://api.openai.com/v1"], fromExisting: false, }, { providerAdapter: adapter }, @@ -94,15 +94,69 @@ describe("credential actions use typed OpenShell provider results", () => { expect(adapter.createProvider).toHaveBeenCalledWith({ target: { kind: "named", gatewayName: "nemoclaw" }, name: "custom-provider", - type: "generic", + type: "openai", credentials: [{ name: "CUSTOM_TOKEN", value: "credential-value" }], - config: [{ key: "region", value: "us-west" }], + config: [{ key: "OPENAI_BASE_URL", value: "https://api.openai.com/v1" }], fromExisting: false, timeoutMs: 30_000, }); expect(JSON.stringify(result)).not.toContain("credential-value"); }); + it("rejects an untyped config value before provider creation (#9806)", async () => { + vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); + const adapter = providerAdapter(); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "generic", + credentials: ["CUSTOM_TOKEN"], + configPairs: ["region=ordinary-auth-value"], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toContain( + " --config 'region' is not a supported non-secret setting for provider type 'generic'.", + ); + expect(JSON.stringify(result)).not.toContain("ordinary-auth-value"); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }); + + it.each([ + ["userinfo", "https://user:password-value@example.test/v1"], + ["query parameters", "https://example.test/v1?region=west"], + ["a fragment", "https://example.test/v1#fragment-value"], + ["a non-HTTP scheme", "file:///tmp/provider-value"], + ])( + "rejects an OpenAI base URL containing %s before provider creation (#9806)", + async (_case, baseUrl) => { + vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); + const adapter = providerAdapter(); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "openai", + credentials: ["CUSTOM_TOKEN"], + configPairs: [`OPENAI_BASE_URL=${baseUrl}`], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toContain( + " --config 'OPENAI_BASE_URL' must be an absolute HTTP(S) URL without credentials, query parameters, or a fragment.", + ); + expect(JSON.stringify(result)).not.toContain(baseUrl); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }, + ); + it("imports the bundled OpenAI profile through the provider adapter (#9806)", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-value"); const adapter = providerAdapter(); @@ -162,7 +216,11 @@ describe("credential actions use typed OpenShell provider results", () => { it.each([ { - case: "confirmed present", + case: "timed out and is confirmed present", + createError: { + kind: "timeout", + message: "The OpenShell provider operation timed out.", + } as const, inventory: { ok: true, value: { names: ["custom-provider"] } } as const, expectedLines: [ " OpenShell reports provider 'custom-provider' is registered; local provider ownership was preserved.", @@ -171,13 +229,37 @@ describe("credential actions use typed OpenShell provider results", () => { forgetCalls: 0, }, { - case: "confirmed absent", + case: "has no status and is confirmed present", + createError: { + kind: "command", + reason: "uncertain", + message: "OpenShell did not report whether the provider operation completed.", + } as const, + inventory: { ok: true, value: { names: ["custom-provider"] } } as const, + expectedLines: [ + " OpenShell reports provider 'custom-provider' is registered; local provider ownership was preserved.", + " Rebuild the target sandbox (`nemoclaw rebuild`) to attach the provider.", + ], + forgetCalls: 0, + }, + { + case: "has no status and is confirmed absent", + createError: { + kind: "command", + reason: "uncertain", + message: "OpenShell did not report whether the provider operation completed.", + } as const, inventory: { ok: true, value: { names: [] } } as const, expectedLines: [" OpenShell confirms provider 'custom-provider' is absent."], forgetCalls: 1, }, { - case: "still indeterminate", + case: "has no status and remains indeterminate", + createError: { + kind: "command", + reason: "uncertain", + message: "OpenShell did not report whether the provider operation completed.", + } as const, inventory: { ok: false, error: { kind: "timeout", message: "The provider inventory query timed out." }, @@ -189,48 +271,44 @@ describe("credential actions use typed OpenShell provider results", () => { ], forgetCalls: 0, }, - ])( - "reconciles timed-out provider creation when the result is $case (#9806)", - async (testCase) => { - vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); - const forgetExtraProvider = vi.fn(() => true); - setGlobalCliActionRuntimeHooksForTest({ - recoverNamedGatewayRuntime: async () => ({ recovered: true }), - recordExtraProvider: () => true, - forgetExtraProvider, - listManagedMcpCredentialReservations: () => [], - }); - const createProvider: OpenShellProviderAdapter["createProvider"] = async () => ({ - ok: false, - error: { kind: "timeout", message: "The OpenShell provider operation timed out." }, - }); - const listProviders: OpenShellProviderAdapter["listProviders"] = async () => - testCase.inventory; - const adapter = providerAdapter({ - createProvider: vi.fn(createProvider), - listProviders: vi.fn(listProviders), - }); + ])("reconciles provider creation when the result $case (#9806)", async (testCase) => { + vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); + const forgetExtraProvider = vi.fn(() => true); + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime: async () => ({ recovered: true }), + recordExtraProvider: () => true, + forgetExtraProvider, + listManagedMcpCredentialReservations: () => [], + }); + const createProvider: OpenShellProviderAdapter["createProvider"] = async () => ({ + ok: false, + error: testCase.createError, + }); + const listProviders: OpenShellProviderAdapter["listProviders"] = async () => testCase.inventory; + const adapter = providerAdapter({ + createProvider: vi.fn(createProvider), + listProviders: vi.fn(listProviders), + }); - const result = await runCredentialsAddAction( - { - provider: "custom-provider", - type: "generic", - credentials: ["CUSTOM_TOKEN"], - configPairs: [], - fromExisting: false, - }, - { providerAdapter: adapter }, - ); + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "generic", + credentials: ["CUSTOM_TOKEN"], + configPairs: [], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); - expect(result.exitCode).toBe(1); - expect(adapter.listProviders).toHaveBeenCalledWith({ - target: { kind: "named", gatewayName: "nemoclaw" }, - timeoutMs: 30_000, - }); - expect(result.failureLines).toEqual(expect.arrayContaining(testCase.expectedLines)); - expect(forgetExtraProvider).toHaveBeenCalledTimes(testCase.forgetCalls); - }, - ); + expect(result.exitCode).toBe(1); + expect(adapter.listProviders).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + timeoutMs: 30_000, + }); + expect(result.failureLines).toEqual(expect.arrayContaining(testCase.expectedLines)); + expect(forgetExtraProvider).toHaveBeenCalledTimes(testCase.forgetCalls); + }); it("does not create an OpenAI provider after profile import fails (#9806)", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-value"); @@ -857,10 +935,10 @@ describe("credential actions use typed OpenShell provider results", () => { " 'custom-provider' is still attached to sandbox(es): beta.", ); expect(result.failureLines).toContain( - " Detach it with 'openshell sandbox provider detach custom-provider'", + " openshell sandbox provider detach -g nemoclaw beta custom-provider", ); expect(result.failureLines).toContain( - " for each, then re-run 'nemoclaw credentials reset custom-provider'.", + " Then re-run 'nemoclaw credentials reset custom-provider'.", ); }); diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index 4a1a051a3a5..f90cf58fa52 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -138,7 +138,7 @@ export async function runCredentialsResetAction( ]); } - const outcome = formatResetOutcome(key, recovery); + const outcome = formatResetOutcome(key, recovery, target.gatewayName); if (!outcome.ok) return fail(outcome.lines); forgetExtraProvider(key); @@ -149,6 +149,7 @@ export async function runCredentialsResetAction( export function formatResetOutcome( key: string, recovery: CredentialsProviderDeleteWithRecoveryResult, + gatewayName: string, ): { ok: boolean; lines: string[] } { const onboardHint = ` Re-run '${CLI_NAME} onboard' to enter a new value.`; if (recovery.ok) { @@ -187,8 +188,12 @@ export function formatResetOutcome( (failure) => ` Could not detach provider '${key}' from sandbox '${failure.sandbox}': ${failure.error.message}`, ), - ` Detach it with 'openshell sandbox provider detach ${key}'`, - ` for each, then re-run '${CLI_NAME} credentials reset ${key}'.`, + " Detach the provider from each remaining sandbox:", + ...stuckSandboxes.map( + (sandbox) => + ` openshell sandbox provider detach -g ${gatewayName} ${sandbox} ${key}`, + ), + ` Then re-run '${CLI_NAME} credentials reset ${key}'.`, ); } const detachedSandboxes = [...new Set(recovery.detachedSandboxes)]; diff --git a/src/lib/adapters/openshell/gateway-scope.ts b/src/lib/adapters/openshell/gateway-scope.ts new file mode 100644 index 00000000000..06d2f3360a1 --- /dev/null +++ b/src/lib/adapters/openshell/gateway-scope.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + assertNoExplicitOpenShellGatewayEndpoint, + assertNoOpenShellGatewayEndpointOverride, + type OpenShellGatewayEndpointEnvironment, +} from "../../openshell-gateway-endpoint-guard"; + +export { assertNoOpenShellGatewayEndpointOverride, type OpenShellGatewayEndpointEnvironment }; + +function inferredGatewayFlagIndex(args: readonly string[]): number | null { + if (args[0] === "inference" || args[0] === "provider") return 2; + if (args[0] !== "sandbox" || typeof args[1] !== "string") return null; + return args[1] === "provider" ? 3 : 2; +} + +/** Bind one OpenShell command to one named gateway without accepting a competing target. */ +export function scopeGatewayOpenshellArgs( + args: readonly string[], + gatewayName: string, + explicitGatewayFlagIndex?: number, +): string[] { + if (!gatewayName) throw new Error("OpenShell gateway name is required."); + assertNoExplicitOpenShellGatewayEndpoint(args); + if (args[0] === "gateway" && args[1] === "select") { + throw new Error("Gateway-scoped OpenShell operations must not change the selected gateway."); + } + + const gatewayFlagIndex = explicitGatewayFlagIndex ?? inferredGatewayFlagIndex(args); + if (gatewayFlagIndex === null) return [...args]; + + const separatorIndex = args.indexOf("--"); + const optionEnd = separatorIndex === -1 ? args.length : separatorIndex; + const gatewayTargets = args.slice(0, optionEnd).flatMap((value, index) => { + if (index < gatewayFlagIndex) return []; + if (value === "-g" || value === "--gateway") return [args[index + 1] ?? ""]; + return value.startsWith("--gateway=") ? [value.slice("--gateway=".length)] : []; + }); + if (gatewayTargets.length > 1) { + throw new Error("OpenShell command contains multiple gateway targets."); + } + const existingGatewayName = gatewayTargets[0]; + if (existingGatewayName !== undefined) { + if (existingGatewayName !== gatewayName) { + throw new Error( + `OpenShell command targets gateway '${existingGatewayName}' instead of '${gatewayName}'.`, + ); + } + return [...args]; + } + return [...args.slice(0, gatewayFlagIndex), "-g", gatewayName, ...args.slice(gatewayFlagIndex)]; +} diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 16cb411367c..d8d6389a4b7 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -690,6 +690,12 @@ describe("CLI OpenShell provider adapter", () => { "OpenShell could not start the provider operation.", "process_start", ], + [ + "command", + captured(null, "", "credential-value"), + "OpenShell did not report whether the provider operation completed.", + "uncertain", + ], ])( "maps %s failures without returning CLI diagnostics (#9806)", async (kind, result, message, reason) => { diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index c9c7f096fa8..e2cedf9cfb1 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -22,6 +22,7 @@ import { type OpenShellProviderResult, } from "./provider-adapter"; import type { OpenShellGatewayTarget } from "./sandbox-observer"; +import { scopeGatewayOpenshellArgs } from "./gateway-scope"; import { exportedProviderProfileMatchesContract, parseCheckedInProviderProfileContract, @@ -118,7 +119,7 @@ function commandError( const output = commandOutput(result); const message = redactProviderDiagnostic(output, secrets); const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code; - if (errorCode === "ETIMEDOUT") { + if (errorCode === "ETIMEDOUT" || /\boperation timed out\b/iu.test(output)) { return { kind: "timeout", message: "The OpenShell provider operation timed out." }; } if (errorCode === "ENOENT" || errorCode === "EACCES") { @@ -128,6 +129,13 @@ function commandError( message: "OpenShell could not start the provider operation.", }; } + if (result.status === null) { + return { + kind: "command", + reason: "uncertain", + message: "OpenShell did not report whether the provider operation completed.", + }; + } if (/invalid wire type|proto(?:buf)?(?: decode| schema| wire)/iu.test(output)) { return { kind: "schema", @@ -184,13 +192,9 @@ function scopedArgs( target: OpenShellGatewayTarget, gatewayFlagIndex = 2, ): string[] { - if (target.kind === "selected") return args; - return [ - ...args.slice(0, gatewayFlagIndex), - "-g", - target.gatewayName, - ...args.slice(gatewayFlagIndex), - ]; + return target.kind === "selected" + ? [...args] + : scopeGatewayOpenshellArgs(args, target.gatewayName, gatewayFlagIndex); } function parseProfileCredentialKeys(output: string, expectedProfileId: string): string[] | null { diff --git a/src/lib/adapters/openshell/provider-adapter.ts b/src/lib/adapters/openshell/provider-adapter.ts index b50177c5cc1..3d11665197f 100644 --- a/src/lib/adapters/openshell/provider-adapter.ts +++ b/src/lib/adapters/openshell/provider-adapter.ts @@ -9,7 +9,8 @@ export type OpenShellProviderCommandReason = | "failed" | "invalid_request" | "not_found" - | "profile_incompatible"; + | "profile_incompatible" + | "uncertain"; export type OpenShellProviderTransportReason = | "identity_mismatch" diff --git a/src/lib/onboard/setup-inference-gateway-scope.test.ts b/src/lib/onboard/setup-inference-gateway-scope.test.ts index 01bcead8c70..25908ab6d8d 100644 --- a/src/lib/onboard/setup-inference-gateway-scope.test.ts +++ b/src/lib/onboard/setup-inference-gateway-scope.test.ts @@ -2,12 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; +import { scopeGatewayOpenshellArgs } from "../adapters/openshell/gateway-scope"; import { createInferenceRouteHelpers } from "./inference-route"; import { bindGatewayUpsertProvider, createRoutedResumeProviderUpsert, createGatewayScopedOpenshellRunner, - scopeGatewayOpenshellArgs, selectGatewayForFollowupOrExit, } from "./setup-inference"; diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 60e40e1a96a..1e5bb36aa2b 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -27,10 +27,10 @@ import { withOllamaModelOwnershipLock, } from "../inference/ollama/proxy"; import { - assertNoExplicitOpenShellGatewayEndpoint, assertNoOpenShellGatewayEndpointOverride, + scopeGatewayOpenshellArgs, type OpenShellGatewayEndpointEnvironment, -} from "../openshell-gateway-endpoint-guard"; +} from "../adapters/openshell/gateway-scope"; import { withSandboxMutationLock } from "../state/mcp-lifecycle-lock"; import type { Session } from "../state/onboard-session"; import { createSandboxHostLocalInferenceProvenance } from "../state/registry/host-local-inference"; @@ -41,7 +41,7 @@ import { upsertRoutedProvider as upsertRoutedInferenceProvider, } from "./routed-inference"; -export { assertNoOpenShellGatewayEndpointOverride }; +export { assertNoOpenShellGatewayEndpointOverride, scopeGatewayOpenshellArgs }; export function createProviderReviewDeps( updateSession: (mutator: (session: Session) => Session | void) => Session | Promise, @@ -240,39 +240,6 @@ export type SetupInferenceDeps = ProviderBranchDeps & { exitProcess: (code: number) => never; }; -export function scopeGatewayOpenshellArgs(args: string[], gatewayName: string): string[] { - if (!gatewayName) throw new Error("OpenShell gateway name is required."); - assertNoExplicitOpenShellGatewayEndpoint(args); - if (args[0] === "gateway" && args[1] === "select") { - throw new Error("Gateway-scoped OpenShell operations must not change the selected gateway."); - } - const providerCommand = args[0] === "inference" || args[0] === "provider"; - const sandboxCommand = args[0] === "sandbox" && typeof args[1] === "string"; - const sandboxProviderCommand = sandboxCommand && args[1] === "provider"; - if (!providerCommand && !sandboxCommand) return [...args]; - const gatewayFlagIndex = sandboxProviderCommand ? 3 : 2; - const separatorIndex = args.indexOf("--"); - const optionEnd = separatorIndex === -1 ? args.length : separatorIndex; - const gatewayTargets = args.slice(0, optionEnd).flatMap((value, index) => { - if (index < gatewayFlagIndex) return []; - if (value === "-g" || value === "--gateway") return [args[index + 1] ?? ""]; - return value.startsWith("--gateway=") ? [value.slice("--gateway=".length)] : []; - }); - if (gatewayTargets.length > 1) { - throw new Error("OpenShell command contains multiple gateway targets."); - } - const existingGatewayName = gatewayTargets[0]; - if (existingGatewayName !== undefined) { - if (existingGatewayName !== gatewayName) { - throw new Error( - `OpenShell command targets gateway '${existingGatewayName}' instead of '${gatewayName}'.`, - ); - } - return [...args]; - } - return [...args.slice(0, gatewayFlagIndex), "-g", gatewayName, ...args.slice(gatewayFlagIndex)]; -} - export function createGatewayScopedOpenshellRunner( runOpenshell: (args: string[], ...rest: Rest) => Result, gatewayName: string, diff --git a/test/credentials/credentials-reset-outcome.test.ts b/test/credentials/credentials-reset-outcome.test.ts index e7d64373a6e..1ba3f64af30 100644 --- a/test/credentials/credentials-reset-outcome.test.ts +++ b/test/credentials/credentials-reset-outcome.test.ts @@ -21,7 +21,11 @@ function result( describe("formatResetOutcome (#5560)", () => { it("reports a clean removal when no detach was needed", () => { - const outcome = formatResetOutcome("my-assistant-brave-search", result({ ok: true })); + const outcome = formatResetOutcome( + "my-assistant-brave-search", + result({ ok: true }), + "nemoclaw", + ); expect(outcome.ok).toBe(true); expect(outcome.lines[0]).toContain("Removed provider 'my-assistant-brave-search'"); expect(outcome.lines.join("\n")).toContain("onboard"); @@ -32,6 +36,7 @@ describe("formatResetOutcome (#5560)", () => { const outcome = formatResetOutcome( "my-assistant-brave-search", result({ ok: true, detachedSandboxes: ["alpha", "beta", "alpha"] }), + "nemoclaw", ); expect(outcome.ok).toBe(true); @@ -60,16 +65,19 @@ describe("formatResetOutcome (#5560)", () => { }, ], }), + "nemoclaw-18080", ); expect(outcome.ok).toBe(false); const text = outcome.lines.join("\n"); expect(text).toContain("still attached to sandbox(es): my-assistant"); - expect(text).toContain("openshell sandbox provider detach my-assistant-brave-search"); + expect(text).toContain( + "openshell sandbox provider detach -g nemoclaw-18080 my-assistant my-assistant-brave-search", + ); expect(text).toContain("FailedPrecondition"); }); it("hints when the argument looks like an env var name instead of a provider", () => { - const outcome = formatResetOutcome("BRAVE_API_KEY", result({ ok: false })); + const outcome = formatResetOutcome("BRAVE_API_KEY", result({ ok: false }), "nemoclaw"); expect(outcome.ok).toBe(false); expect(outcome.lines.join("\n")).toContain("looks like a credential env variable name"); }); From c6de468f49626aa99adbcc1406a1860b6338207c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 07:04:26 -0700 Subject: [PATCH 41/57] fix(cli): reject ambient provider gateway overrides --- docs/reference/commands.mdx | 2 +- src/commands/credentials.test.ts | 29 ++++++++++++ src/lib/adapters/openshell/gateway-scope.ts | 7 ++- .../openshell/provider-adapter-cli.test.ts | 46 +++++++++++++++++++ .../openshell/provider-adapter-cli.ts | 35 +++++++++++++- 5 files changed, 116 insertions(+), 3 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 36d08902fcc..fe53f0c14c3 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -736,7 +736,7 @@ The Docker-driver gateway and the portable experimental profile's Podman-driver #### `--from ` -Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent also fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. +Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent still fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. The supplied Dockerfile defines the complete sandbox image, and NemoClaw does not layer it on top of the stock managed runtime. The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. When the supplied path is the selected agent's own managed Dockerfile (for example, `agents/hermes/Dockerfile` in the NemoClaw checkout the CLI runs from), NemoClaw applies one exception and stages the repository root as the build context, exactly as the managed build does, because that Dockerfile copies repository-root paths. This lets you edit the managed Dockerfile in place (for example to add Python packages) and rebuild from it with `--from`. For this managed exception, onboarding applies the `.dockerignore` from the repository root. For every other `--from` path, onboarding applies a `.dockerignore` from the Dockerfile's parent directory while calculating the context size and staging files for Docker. NemoClaw also applies additional secret-safety exclusions that override `.dockerignore` negation rules: credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key` are still skipped even if `.dockerignore` tries to include them. Without a `.dockerignore`, onboarding still skips common large or local-only directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context. Other build outputs such as `dist/`, `target/`, or `build/` are included unless your `.dockerignore` excludes them. If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts. Move the Dockerfile into a smaller dedicated directory or add `.dockerignore` entries for generated artifacts to shrink the context. If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory. diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index 826f7558a88..2d3678b0cd2 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -41,6 +41,8 @@ vi.mock("../lib/onboard/gateway-teardown-authority", () => ({ })); import { runCredentialsAddAction } from "../lib/actions/credentials-add"; +import { runCredentialsListAction } from "../lib/actions/credentials/list"; +import { runCredentialsResetAction } from "../lib/actions/credentials/reset"; import CredentialsCommand from "./credentials"; import CredentialsListCommand from "./credentials/list"; import CredentialsResetCommand from "./credentials/reset"; @@ -149,6 +151,33 @@ describe("credentials oclif adapter source coverage", () => { ); }); + it("rejects an ambient gateway endpoint before credential provider operations (#9806)", async () => { + const credentialValue = "host-only-secret"; + vi.stubEnv("CUSTOM_TOKEN", credentialValue); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://untrusted.example.test"); + + const add = await runCredentialsAddAction({ + provider: "custom-provider", + type: "generic", + credentials: ["CUSTOM_TOKEN"], + configPairs: [], + fromExisting: false, + }); + const list = await runCredentialsListAction("nemoclaw"); + const reset = await runCredentialsResetAction({ + provider: "custom-provider", + confirmed: true, + }); + + expect(add.exitCode).toBe(1); + expect(list.exitCode).toBe(1); + expect(reset.exitCode).toBe(1); + const diagnostics = JSON.stringify([add, list, reset]); + expect(diagnostics.match(/OPENSHELL_GATEWAY_ENDPOINT is set/gu)).toHaveLength(3); + expect(diagnostics).not.toContain(credentialValue); + expect(mocks.runOpenshellProviderCommand).not.toHaveBeenCalled(); + }); + it("rejects a provider credential reserved by managed MCP before gateway mutation (#9388)", async () => { vi.stubEnv("MAAS_GLEAN_TOKEN", "qa-secret-value"); mocks.listManagedMcpCredentialReservations.mockReturnValue([ diff --git a/src/lib/adapters/openshell/gateway-scope.ts b/src/lib/adapters/openshell/gateway-scope.ts index 06d2f3360a1..deb097a8524 100644 --- a/src/lib/adapters/openshell/gateway-scope.ts +++ b/src/lib/adapters/openshell/gateway-scope.ts @@ -4,10 +4,15 @@ import { assertNoExplicitOpenShellGatewayEndpoint, assertNoOpenShellGatewayEndpointOverride, + OpenShellGatewayEndpointOverrideError, type OpenShellGatewayEndpointEnvironment, } from "../../openshell-gateway-endpoint-guard"; -export { assertNoOpenShellGatewayEndpointOverride, type OpenShellGatewayEndpointEnvironment }; +export { + assertNoOpenShellGatewayEndpointOverride, + OpenShellGatewayEndpointOverrideError, + type OpenShellGatewayEndpointEnvironment, +}; function inferredGatewayFlagIndex(args: readonly string[]): number | null { if (args[0] === "inference" || args[0] === "provider") return 2; diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index d8d6389a4b7..486293bb33e 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -73,6 +73,52 @@ inference_capable: false `; describe("CLI OpenShell provider adapter", () => { + it("rejects an ambient endpoint before every named-gateway operation (#9806)", async () => { + const run = vi.fn(() => captured(0)); + const adapter = createCliOpenShellProviderAdapter({ + run, + environment: { OPENSHELL_GATEWAY_ENDPOINT: "https://untrusted.example.test" }, + }); + const target = namedOpenShellGateway("nemoclaw-18080"); + const credentialValue = "host-only-value"; + const operations = [ + adapter.listProviders({ target }), + adapter.createProvider({ + target, + name: "search-prod", + type: "tavily", + credentials: [{ name: "TAVILY_API_KEY", value: credentialValue }], + config: [], + fromExisting: false, + }), + adapter.importProviderProfile({ target, profilePath: "/unused/profile.yaml" }), + adapter.inspectProviderProfile({ target, profileType: "tavily" }), + adapter.deleteProvider({ target, providerName: "search-prod" }), + adapter.detachProvider({ target, providerName: "search-prod", sandboxName: "alpha" }), + ]; + + const results = await Promise.all(operations); + + const expectedFailure = { + ok: false, + error: { + kind: "validation", + message: + "OPENSHELL_GATEWAY_ENDPOINT is set, so OpenShell may bypass the gateway recorded for this sandbox. Unset OPENSHELL_GATEWAY_ENDPOINT and retry.", + }, + }; + expect(results).toEqual([ + expectedFailure, + expectedFailure, + expectedFailure, + expectedFailure, + expectedFailure, + expectedFailure, + ]); + expect(JSON.stringify(results)).not.toContain(credentialValue); + expect(run).not.toHaveBeenCalled(); + }); + it("targets a named gateway and returns provider names (#9806)", async () => { const run = vi.fn(() => captured(0, "zeta\nalpha\n")); const adapter = createCliOpenShellProviderAdapter({ run }); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index e2cedf9cfb1..d070c273620 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -22,7 +22,12 @@ import { type OpenShellProviderResult, } from "./provider-adapter"; import type { OpenShellGatewayTarget } from "./sandbox-observer"; -import { scopeGatewayOpenshellArgs } from "./gateway-scope"; +import { + assertNoOpenShellGatewayEndpointOverride, + OpenShellGatewayEndpointOverrideError, + scopeGatewayOpenshellArgs, + type OpenShellGatewayEndpointEnvironment, +} from "./gateway-scope"; import { exportedProviderProfileMatchesContract, parseCheckedInProviderProfileContract, @@ -50,6 +55,7 @@ export type CliOpenShellProviderAdapterDeps = Readonly<{ run?: RunProviderCommand; defaultTimeoutMs?: number; readProfileFile?: (profilePath: string) => string; + environment?: OpenShellGatewayEndpointEnvironment; }>; const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/u; @@ -197,6 +203,20 @@ function scopedArgs( : scopeGatewayOpenshellArgs(args, target.gatewayName, gatewayFlagIndex); } +function namedGatewayEndpointOverrideError( + target: OpenShellGatewayTarget, + environment: OpenShellGatewayEndpointEnvironment, +): OpenShellProviderError | null { + if (target.kind !== "named") return null; + try { + assertNoOpenShellGatewayEndpointOverride(environment); + return null; + } catch (error) { + if (!(error instanceof OpenShellGatewayEndpointOverrideError)) throw error; + return { kind: "validation", message: error.message }; + } +} + function parseProfileCredentialKeys(output: string, expectedProfileId: string): string[] | null { let profile: unknown; try { @@ -227,6 +247,7 @@ export function createCliOpenShellProviderAdapter( deps: CliOpenShellProviderAdapterDeps = {}, ): OpenShellProviderAdapter { const run = deps.run ?? runOpenshellProviderCommand; + const environment = deps.environment ?? process.env; const timeoutFor = (request: OpenShellProviderRequest) => request.timeoutMs ?? deps.defaultTimeoutMs ?? OPENSHELL_OPERATION_TIMEOUT_MS; const invoke = ( @@ -245,6 +266,8 @@ export function createCliOpenShellProviderAdapter( }); const listProviders: OpenShellProviderAdapter["listProviders"] = async (request) => { + const targetError = namedGatewayEndpointOverrideError(request.target, environment); + if (targetError) return failure(targetError); const result = invoke(["provider", "list", "--names"], request); const error = commandError(result); if (error) return failure(error); @@ -259,6 +282,8 @@ export function createCliOpenShellProviderAdapter( }; const createProvider: OpenShellProviderAdapter["createProvider"] = async (request) => { + const targetError = namedGatewayEndpointOverrideError(request.target, environment); + if (targetError) return failure(targetError); if ( (!request.fromExisting && request.credentials.length === 0) || (request.fromExisting && request.credentials.length > 0) || @@ -296,6 +321,8 @@ export function createCliOpenShellProviderAdapter( const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async ( request: ImportOpenShellProviderProfileRequest, ) => { + const targetError = namedGatewayEndpointOverrideError(request.target, environment); + if (targetError) return failure(targetError); const readProfileFile = deps.readProfileFile ?? ((file: string) => fs.readFileSync(file, "utf8")); const contract = (() => { @@ -343,6 +370,8 @@ export function createCliOpenShellProviderAdapter( const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async ( request: InspectOpenShellProviderProfileRequest, ) => { + const targetError = namedGatewayEndpointOverrideError(request.target, environment); + if (targetError) return failure(targetError); const result = invoke( ["provider", "profile", "export", request.profileType, "--output", "json"], request, @@ -364,6 +393,8 @@ export function createCliOpenShellProviderAdapter( const deleteProvider: OpenShellProviderAdapter["deleteProvider"] = async ( request: DeleteOpenShellProviderRequest, ) => { + const targetError = namedGatewayEndpointOverrideError(request.target, environment); + if (targetError) return failure(targetError); const result = invoke(["provider", "delete", request.providerName], request); const error = commandError(result); return error ? failure(error) : mutationSuccess(); @@ -372,6 +403,8 @@ export function createCliOpenShellProviderAdapter( const detachProvider: OpenShellProviderAdapter["detachProvider"] = async ( request: DetachOpenShellProviderRequest, ) => { + const targetError = namedGatewayEndpointOverrideError(request.target, environment); + if (targetError) return failure(targetError); const result = invoke( ["sandbox", "provider", "detach", request.sandboxName, request.providerName], request, From 1ba1afc38687cda0e245ea56edf7b39e430aa1ed Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 07:23:38 -0700 Subject: [PATCH 42/57] fix(cli): scope provider failure diagnostics --- docs/reference/commands.mdx | 2 +- .../inference-set-failure-handling.test.ts | 2 +- ...inference-set-provider-diagnostics.test.ts | 34 +++++++++++++++---- .../inference-set-provider-diagnostics.ts | 14 ++++++-- src/lib/actions/inference-set.ts | 7 +++- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index fe53f0c14c3..a0d066eceb2 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3197,7 +3197,7 @@ $$nemoclaw credentials list Register a provider credential with the OpenShell gateway by name and type. Each `--credential` takes the env variable name whose value the gateway should read; export the value first so it is not placed in argv. Pass either repeatable `--credential ` or `--from-existing`, but do not combine them. `--from-existing` is available only when no managed MCP server reserves credential keys. The command fails before gateway work when a reservation exists because `--from-existing` does not expose credential keys before provider creation. Rerun with explicit `--credential ` input, or remove every managed MCP server that reserves credential keys before retrying. After the gateway accepts the provider, rebuild the target sandbox so the new provider is attached. -Registered providers attach to every sandbox you build or rebuild after the call (the gateway is one process serving all sandboxes). If you want a provider available to only some sandboxes, scope it with `nemoclaw credentials reset ` once those sandboxes finish using it. +Registered providers are gateway-wide and attach to every sandbox you build or rebuild after the call. `nemoclaw credentials reset ` removes the provider from the gateway and detaches it from every sandbox currently using it; it cannot limit the provider to selected sandboxes. To replace a credential, reset the provider, register the replacement, and rebuild each detached sandbox. ```bash $$nemoclaw credentials add tavily-search --type tavily --credential TAVILY_API_KEY diff --git a/src/lib/actions/inference-set-failure-handling.test.ts b/src/lib/actions/inference-set-failure-handling.test.ts index d4c80a517bf..461d6d7cc0a 100644 --- a/src/lib/actions/inference-set-failure-handling.test.ts +++ b/src/lib/actions/inference-set-failure-handling.test.ts @@ -187,7 +187,7 @@ describe("runInferenceSet failure handling", () => { expect(message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); expect(deps.calls.captureOpenshell).toHaveBeenNthCalledWith( 2, - ["provider", "list", "--names"], + ["provider", "list", "-g", "nemoclaw", "--names"], { ignoreError: true, maxBuffer: 64 * 1024, timeout: 5_000 }, ); expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); diff --git a/src/lib/actions/inference-set-provider-diagnostics.test.ts b/src/lib/actions/inference-set-provider-diagnostics.test.ts index 0822baf8761..8d587e609f1 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.test.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.test.ts @@ -16,18 +16,38 @@ describe("inference set provider diagnostics", () => { })); const log = vi.fn(); - expect(queryRegisteredGatewayProviders({ captureOpenshell, log })).toEqual([ + expect(queryRegisteredGatewayProviders("nemoclaw", { captureOpenshell, log })).toEqual([ "anthropic-prod", "nvidia-prod", ]); - expect(captureOpenshell).toHaveBeenCalledWith(["provider", "list", "--names"], { - ignoreError: true, - maxBuffer: 64 * 1024, - timeout: 5_000, - }); + expect(captureOpenshell).toHaveBeenCalledWith( + ["provider", "list", "-g", "nemoclaw", "--names"], + { + ignoreError: true, + maxBuffer: 64 * 1024, + timeout: 5_000, + }, + ); expect(log).not.toHaveBeenCalled(); }); + it("omits inventory without invoking OpenShell when an endpoint override is set (#9806)", () => { + const captureOpenshell = vi.fn(() => ({ status: 0, output: "ambient-provider" })); + const log = vi.fn(); + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://untrusted.example.test"); + + try { + expect( + queryRegisteredGatewayProviders("nemoclaw", { captureOpenshell, log }), + ).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + + expect(captureOpenshell).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(STATIC_WARNING); + }); + it("partitions empty and messaging-only provider output", () => { expect(classifyGatewayProviderNames([])).toEqual({ bridgeNames: [], @@ -76,7 +96,7 @@ describe("inference set provider diagnostics", () => { const captureOpenshell = vi.fn(capture); const log = vi.fn(); - expect(queryRegisteredGatewayProviders({ captureOpenshell, log })).toBeUndefined(); + expect(queryRegisteredGatewayProviders("nemoclaw", { captureOpenshell, log })).toBeUndefined(); expect(log).toHaveBeenCalledWith(STATIC_WARNING); expect(log).not.toHaveBeenCalledWith(expect.stringContaining("query-secret")); }); diff --git a/src/lib/actions/inference-set-provider-diagnostics.ts b/src/lib/actions/inference-set-provider-diagnostics.ts index 7234e966815..40b778640eb 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { CaptureOpenshellOptions, CaptureOpenshellResult } from "../adapters/openshell/client"; +import { + assertNoOpenShellGatewayEndpointOverride, + scopeGatewayOpenshellArgs, +} from "../adapters/openshell/gateway-scope"; import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-command"; import { classifyGatewayProviderNames } from "../credentials/provider-list"; import { @@ -21,10 +25,13 @@ interface ProviderDiagnosticDeps { } export function queryRegisteredGatewayProviders( + gatewayName: string, deps: ProviderDiagnosticDeps, ): string[] | undefined { try { - const result = deps.captureOpenshell(["provider", "list", "--names"], { + assertNoOpenShellGatewayEndpointOverride(); + const args = scopeGatewayOpenshellArgs(["provider", "list", "--names"], gatewayName); + const result = deps.captureOpenshell(args, { ignoreError: true, maxBuffer: OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER, timeout: OPEN_SHELL_DIAGNOSTIC_TIMEOUT_MS, @@ -47,6 +54,7 @@ export function queryRegisteredGatewayProviders( export function buildInferenceSetFailure( setResult: CaptureOpenshellResult, provider: string, + gatewayName: string, deps: ProviderDiagnosticDeps, ): { exitCode: number; message: string } { const stderr = typeof setResult.stderr === "string" ? setResult.stderr : ""; @@ -58,7 +66,9 @@ export function buildInferenceSetFailure( message: buildOpenshellInferenceSetFailureMessage({ exitCode, providerNotFound, - registeredProviders: providerNotFound ? queryRegisteredGatewayProviders(deps) : undefined, + registeredProviders: providerNotFound + ? queryRegisteredGatewayProviders(gatewayName, deps) + : undefined, stderr, stdout, }), diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index edb26b9056f..58349f85eeb 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -1148,7 +1148,12 @@ async function runInferenceSetWithoutHostLock( setResult = setInferenceRoute(); } if (setResult.status !== 0) { - const failure = buildInferenceSetFailure(setResult, provider, deps); + const failure = buildInferenceSetFailure( + setResult, + provider, + preparedRoute.gatewayName, + deps, + ); throw new InferenceSetError(failure.message, failure.exitCode); } appliedInferenceSelection = true; From c56698cbae160adac79efc682cb156405447fc6a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 07:40:54 -0700 Subject: [PATCH 43/57] fix(cli): preflight provider base URLs --- docs/reference/commands.mdx | 2 +- src/lib/actions/credentials-add.ts | 25 ++++++++ .../credentials-provider-adapter.test.ts | 64 ++++++++++++++++++- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index a0d066eceb2..f4f2c0da264 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3207,7 +3207,7 @@ $$nemoclaw credentials add tavily-search --type tavily --credential TAVILY_API_K | --- | --- | | `--type ` | Provider type (e.g. `tavily`, `nvidia`, `openai`, `anthropic`, `generic`) | | `--credential ` | Env variable name whose value holds the credential. Repeatable | -| `--config ` | Typed non-secret provider configuration. Supported: `OPENAI_BASE_URL=` with `--type openai`. URLs with credentials, query parameters, or fragments are rejected. Repeatable | +| `--config ` | Typed non-secret provider configuration. Supported: `OPENAI_BASE_URL=` with `--type openai`. URLs with credentials, query parameters, fragments, loopback, link-local, private, internal, or private DNS destinations are rejected. Configure trusted private inference endpoints through onboarding so NemoClaw can preserve their trust and address pins. Repeatable | | `--from-existing` | Load credentials and config from existing local state when no managed MCP server reserves credential keys | ### `$$nemoclaw credentials reset ` diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index db894f4effb..4c05109945b 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -16,6 +16,10 @@ import { recoverCredentialGatewayTargetOrExit, } from "../credentials/command-support"; import { gatewayStartGuidance } from "../gateway-start-guidance"; +import { + assertEndpointResolvesPublic, + type EndpointDnsLookupFn, +} from "../security/trusted-private-endpoint"; import { SECRET_PATTERNS } from "../security/secret-patterns"; import { withMcpCredentialOwnershipLock } from "../state/mcp-lifecycle-lock/credential-ownership"; import { ROOT } from "../state/paths"; @@ -41,6 +45,7 @@ export type CredentialsAddResult = { export type CredentialsAddDeps = Readonly<{ providerAdapter?: OpenShellProviderAdapter; + resolveEndpointHost?: EndpointDnsLookupFn; }>; const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/; @@ -112,6 +117,23 @@ function typedProviderConfigFailure(type: string, key: string, value: string): s return null; } +async function providerConfigEndpointFailure( + config: readonly { key: string; value: string }[], + resolveEndpointHost?: EndpointDnsLookupFn, +): Promise { + const baseUrl = config.find((entry) => entry.key === "OPENAI_BASE_URL")?.value; + if (!baseUrl) return null; + + const preflight = await assertEndpointResolvesPublic(baseUrl, resolveEndpointHost); + if (preflight.ok) return null; + + return [ + " --config 'OPENAI_BASE_URL' failed endpoint security validation.", + ` ${preflight.reason ?? "The endpoint is not safe to use."}`, + ` Use a routable public endpoint, or configure a trusted private inference endpoint through '${CLI_NAME} onboard' so NemoClaw can preserve its trust and address pins.`, + ]; +} + function bundledProviderProfile(type: string): { profileType: string; profilePath: string } | null { const profileType = type.toLowerCase(); const profilePath = path.join( @@ -319,6 +341,9 @@ export async function runCredentialsAddAction( config.push({ key, value }); } + const endpointFailure = await providerConfigEndpointFailure(config, deps.resolveEndpointHost); + if (endpointFailure) return fail(endpointFailure); + const managedMcpReservations = listManagedMcpCredentialReservations(); const explicitCollision = managedMcpCollisionFailure( provider, diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 47a2149bc68..3f1e8cdb6b7 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -87,7 +87,10 @@ describe("credential actions use typed OpenShell provider results", () => { configPairs: ["OPENAI_BASE_URL=https://api.openai.com/v1"], fromExisting: false, }, - { providerAdapter: adapter }, + { + providerAdapter: adapter, + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], + }, ); expect(result.exitCode).toBe(0); @@ -103,6 +106,65 @@ describe("credential actions use typed OpenShell provider results", () => { expect(JSON.stringify(result)).not.toContain("credential-value"); }); + it.each([ + ["loopback IP literal", "http://127.0.0.1/v1"], + ["link-local metadata IP literal", "http://169.254.169.254/latest"], + ])("rejects an OpenAI base URL targeting a %s (#9806)", async (_case, baseUrl) => { + vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); + const adapter = providerAdapter(); + const resolveEndpointHost = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "openai", + credentials: ["CUSTOM_TOKEN"], + configPairs: [`OPENAI_BASE_URL=${baseUrl}`], + fromExisting: false, + }, + { providerAdapter: adapter, resolveEndpointHost }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines[0]).toBe( + " --config 'OPENAI_BASE_URL' failed endpoint security validation.", + ); + expect(result.failureLines.join("\n")).toMatch(/private\/internal address/u); + expect(JSON.stringify(result)).not.toContain("host-only-value"); + expect(adapter.importProviderProfile).not.toHaveBeenCalled(); + expect(adapter.createProvider).not.toHaveBeenCalled(); + expect(resolveEndpointHost).not.toHaveBeenCalled(); + }); + + it("rejects an OpenAI base URL whose hostname resolves to a private address (#9806)", async () => { + vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); + const adapter = providerAdapter(); + const resolveEndpointHost = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "openai", + credentials: ["CUSTOM_TOKEN"], + configPairs: ["OPENAI_BASE_URL=https://public-looking.example/v1"], + fromExisting: false, + }, + { providerAdapter: adapter, resolveEndpointHost }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines[0]).toBe( + " --config 'OPENAI_BASE_URL' failed endpoint security validation.", + ); + expect(result.failureLines.join("\n")).toContain( + 'endpoint host "public-looking.example" resolves to private/internal address "10.0.0.8"', + ); + expect(JSON.stringify(result)).not.toContain("host-only-value"); + expect(resolveEndpointHost).toHaveBeenCalledWith("public-looking.example", { all: true }); + expect(adapter.importProviderProfile).not.toHaveBeenCalled(); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }); + it("rejects an untyped config value before provider creation (#9806)", async () => { vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); const adapter = providerAdapter(); From df493a3328f4119b620b02cad6a6ad168b7dacce Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 08:03:30 -0700 Subject: [PATCH 44/57] fix(cli): close provider DNS rebinding --- docs/reference/commands.mdx | 2 +- src/commands/credentials/add.ts | 2 +- src/lib/actions/credentials-add.ts | 25 ++++++++++++------- .../credentials-provider-adapter.test.ts | 25 +++++++------------ .../uninstall/hermes-portable-uninstall.ts | 2 +- src/lib/onboard/setup-inference.ts | 14 +++-------- 6 files changed, 31 insertions(+), 39 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index f4f2c0da264..945a0606b14 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3207,7 +3207,7 @@ $$nemoclaw credentials add tavily-search --type tavily --credential TAVILY_API_K | --- | --- | | `--type ` | Provider type (e.g. `tavily`, `nvidia`, `openai`, `anthropic`, `generic`) | | `--credential ` | Env variable name whose value holds the credential. Repeatable | -| `--config ` | Typed non-secret provider configuration. Supported: `OPENAI_BASE_URL=` with `--type openai`. URLs with credentials, query parameters, fragments, loopback, link-local, private, internal, or private DNS destinations are rejected. Configure trusted private inference endpoints through onboarding so NemoClaw can preserve their trust and address pins. Repeatable | +| `--config ` | Typed non-secret provider configuration. Supported: `OPENAI_BASE_URL=` with `--type openai`. DNS hostnames are rejected because this gateway-wide credential path cannot enforce admission-time address pins. URLs with credentials, query parameters, fragments, loopback, link-local, private, internal, or reserved IP destinations are also rejected. Configure hostname-based and trusted private inference endpoints through onboarding so NemoClaw can preserve their trust and address pins. Repeatable | | `--from-existing` | Load credentials and config from existing local state when no managed MCP server reserves credential keys | ### `$$nemoclaw credentials reset ` diff --git a/src/commands/credentials/add.ts b/src/commands/credentials/add.ts index 3ef1d62c8fd..cc37fb41950 100644 --- a/src/commands/credentials/add.ts +++ b/src/commands/credentials/add.ts @@ -40,7 +40,7 @@ export default class CredentialsAddCommand extends NemoClawCommand { }), config: Flags.string({ description: - "Typed non-secret provider configuration. Supported: OPENAI_BASE_URL= with --type openai. Repeatable.", + "Typed non-secret provider configuration. Supported: OPENAI_BASE_URL= with --type openai. Use onboarding for hostname-based endpoints. Repeatable.", multiple: true, }), "from-existing": Flags.boolean({ diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 4c05109945b..6351e9d4599 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import { isIP } from "node:net"; import path from "node:path"; import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; import type { @@ -16,11 +17,8 @@ import { recoverCredentialGatewayTargetOrExit, } from "../credentials/command-support"; import { gatewayStartGuidance } from "../gateway-start-guidance"; -import { - assertEndpointResolvesPublic, - type EndpointDnsLookupFn, -} from "../security/trusted-private-endpoint"; import { SECRET_PATTERNS } from "../security/secret-patterns"; +import { assertEndpointResolvesPublic } from "../security/trusted-private-endpoint"; import { withMcpCredentialOwnershipLock } from "../state/mcp-lifecycle-lock/credential-ownership"; import { ROOT } from "../state/paths"; import { @@ -45,7 +43,6 @@ export type CredentialsAddResult = { export type CredentialsAddDeps = Readonly<{ providerAdapter?: OpenShellProviderAdapter; - resolveEndpointHost?: EndpointDnsLookupFn; }>; const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/; @@ -89,7 +86,7 @@ function typedProviderConfigFailure(type: string, key: string, value: string): s if (type.toLowerCase() !== "openai" || key !== "OPENAI_BASE_URL") { return [ ` --config '${key}' is not a supported non-secret setting for provider type '${type}'.`, - " Supported: --type openai with --config OPENAI_BASE_URL=.", + " Supported: --type openai with --config OPENAI_BASE_URL=.", " Use --from-existing for provider configuration already stored by OpenShell.", ]; } @@ -119,12 +116,22 @@ function typedProviderConfigFailure(type: string, key: string, value: string): s async function providerConfigEndpointFailure( config: readonly { key: string; value: string }[], - resolveEndpointHost?: EndpointDnsLookupFn, ): Promise { const baseUrl = config.find((entry) => entry.key === "OPENAI_BASE_URL")?.value; if (!baseUrl) return null; - const preflight = await assertEndpointResolvesPublic(baseUrl, resolveEndpointHost); + const hostname = new URL(baseUrl).hostname; + const bareHostname = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + if (isIP(bareHostname) === 0) { + return [ + " --config 'OPENAI_BASE_URL' accepts only a public IP-literal URL.", + " DNS hostnames are not supported because OpenShell cannot enforce admission-time address pins for this credential-bearing path.", + ` Configure a hostname-based endpoint through '${CLI_NAME} onboard' so NemoClaw can preserve its address pins.`, + ]; + } + + const preflight = await assertEndpointResolvesPublic(baseUrl); if (preflight.ok) return null; return [ @@ -341,7 +348,7 @@ export async function runCredentialsAddAction( config.push({ key, value }); } - const endpointFailure = await providerConfigEndpointFailure(config, deps.resolveEndpointHost); + const endpointFailure = await providerConfigEndpointFailure(config); if (endpointFailure) return fail(endpointFailure); const managedMcpReservations = listManagedMcpCredentialReservations(); diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 3f1e8cdb6b7..b6eab24a111 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -84,13 +84,10 @@ describe("credential actions use typed OpenShell provider results", () => { provider: "custom-provider", type: "openai", credentials: ["CUSTOM_TOKEN"], - configPairs: ["OPENAI_BASE_URL=https://api.openai.com/v1"], + configPairs: ["OPENAI_BASE_URL=https://93.184.216.34/v1"], fromExisting: false, }, - { - providerAdapter: adapter, - resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], - }, + { providerAdapter: adapter }, ); expect(result.exitCode).toBe(0); @@ -99,7 +96,7 @@ describe("credential actions use typed OpenShell provider results", () => { name: "custom-provider", type: "openai", credentials: [{ name: "CUSTOM_TOKEN", value: "credential-value" }], - config: [{ key: "OPENAI_BASE_URL", value: "https://api.openai.com/v1" }], + config: [{ key: "OPENAI_BASE_URL", value: "https://93.184.216.34/v1" }], fromExisting: false, timeoutMs: 30_000, }); @@ -112,7 +109,6 @@ describe("credential actions use typed OpenShell provider results", () => { ])("rejects an OpenAI base URL targeting a %s (#9806)", async (_case, baseUrl) => { vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); const adapter = providerAdapter(); - const resolveEndpointHost = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]); const result = await runCredentialsAddAction( { @@ -122,7 +118,7 @@ describe("credential actions use typed OpenShell provider results", () => { configPairs: [`OPENAI_BASE_URL=${baseUrl}`], fromExisting: false, }, - { providerAdapter: adapter, resolveEndpointHost }, + { providerAdapter: adapter }, ); expect(result.exitCode).toBe(1); @@ -133,13 +129,11 @@ describe("credential actions use typed OpenShell provider results", () => { expect(JSON.stringify(result)).not.toContain("host-only-value"); expect(adapter.importProviderProfile).not.toHaveBeenCalled(); expect(adapter.createProvider).not.toHaveBeenCalled(); - expect(resolveEndpointHost).not.toHaveBeenCalled(); }); - it("rejects an OpenAI base URL whose hostname resolves to a private address (#9806)", async () => { + it("rejects a DNS OpenAI base URL before a later resolution can rebind (#9806)", async () => { vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); const adapter = providerAdapter(); - const resolveEndpointHost = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]); const result = await runCredentialsAddAction( { @@ -149,18 +143,17 @@ describe("credential actions use typed OpenShell provider results", () => { configPairs: ["OPENAI_BASE_URL=https://public-looking.example/v1"], fromExisting: false, }, - { providerAdapter: adapter, resolveEndpointHost }, + { providerAdapter: adapter }, ); expect(result.exitCode).toBe(1); expect(result.failureLines[0]).toBe( - " --config 'OPENAI_BASE_URL' failed endpoint security validation.", + " --config 'OPENAI_BASE_URL' accepts only a public IP-literal URL.", ); - expect(result.failureLines.join("\n")).toContain( - 'endpoint host "public-looking.example" resolves to private/internal address "10.0.0.8"', + expect(result.failureLines).toContain( + " DNS hostnames are not supported because OpenShell cannot enforce admission-time address pins for this credential-bearing path.", ); expect(JSON.stringify(result)).not.toContain("host-only-value"); - expect(resolveEndpointHost).toHaveBeenCalledWith("public-looking.example", { all: true }); expect(adapter.importProviderProfile).not.toHaveBeenCalled(); expect(adapter.createProvider).not.toHaveBeenCalled(); }); diff --git a/src/lib/actions/uninstall/hermes-portable-uninstall.ts b/src/lib/actions/uninstall/hermes-portable-uninstall.ts index f429f772499..6dd0c3a3466 100644 --- a/src/lib/actions/uninstall/hermes-portable-uninstall.ts +++ b/src/lib/actions/uninstall/hermes-portable-uninstall.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { isDeepStrictEqual } from "node:util"; +import { scopeGatewayOpenshellArgs } from "../../adapters/openshell/gateway-scope"; import { runHermesPortableUninstallOpenShell } from "../../adapters/openshell/hermes-portable-uninstall"; import { OLLAMA_LOCAL_CREDENTIAL_ENV } from "../../inference/ollama/contract"; import type { GatewayRegistryDocument } from "../../state/gateway-registry"; @@ -47,7 +48,6 @@ import { readHermesPortableLifecycleReceipt, type HermesPortableConfiguredReceipt, } from "../../onboard/experimental/hermes-portable-receipt"; -import { scopeGatewayOpenshellArgs } from "../../onboard/setup-inference"; import { assertPreparedHostLocalInferenceRuntimePresent, inspectPreparedHostLocalInferenceSharingAuthority, diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 1e5bb36aa2b..86efa3b5dda 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -41,7 +41,7 @@ import { upsertRoutedProvider as upsertRoutedInferenceProvider, } from "./routed-inference"; -export { assertNoOpenShellGatewayEndpointOverride, scopeGatewayOpenshellArgs }; +export { assertNoOpenShellGatewayEndpointOverride }; export function createProviderReviewDeps( updateSession: (mutator: (session: Session) => Session | void) => Session | Promise, @@ -558,9 +558,7 @@ export function createSetupInference( hermesToolGateways: string[] = [], options: ProviderInferenceSetupOptions = {}, ): Promise { - const revalidateSandboxIdentity = sandboxName - ? options.revalidateSandboxIdentity - : undefined; + const revalidateSandboxIdentity = sandboxName ? options.revalidateSandboxIdentity : undefined; const gatewayName = options.gatewayName ?? deps.getGatewayName(); const endpointSource = options.endpointSource === undefined ? "onboard" : options.endpointSource; @@ -1127,13 +1125,7 @@ export function createSetupInference( /* An unreadable registry skips GPU release; it must not fail onboarding. */ } const result = await mutateGatewayRoute(); - releaseSupersededOllamaModel( - previousSandbox, - model, - result, - deps, - revalidateSandboxIdentity, - ); + releaseSupersededOllamaModel(previousSandbox, model, result, deps, revalidateSandboxIdentity); if (shouldLogSuccessfulRoute && "ok" in result) { deps.log(` ✓ Inference route set: ${provider} / ${model}`); } From 8ebc72343b30193595a06617cd5b04f4d0a13afe Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:44:08 -0700 Subject: [PATCH 45/57] fix(cli): preserve provider recovery guidance Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/get-started/quickstart.mdx | 2 +- docs/reference/commands.mdx | 2 +- src/lib/actions/credentials-provider-adapter.test.ts | 4 ++-- src/lib/actions/credentials/reset.ts | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 8bd08bcd435..c5f97ed38b7 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -51,7 +51,7 @@ Review the [Prerequisites](prerequisites) before you begin. Press Enter to accept the suggested `my-assistant` sandbox name. For a first run, skip optional web search and messaging setup, then accept the suggested network policy tier. With the OpenShell Docker driver, stock OpenClaw onboarding normally uses the release's exact managed-image digest. - If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. + If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Invalid or inconsistent catalog evidence fails closed before sandbox creation. An explicit `nemoclaw onboard --from ` remains a separate custom-image path. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 945a0606b14..fe1113fcad1 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -736,7 +736,7 @@ The Docker-driver gateway and the portable experimental profile's Podman-driver #### `--from ` -Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent still fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. +Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent also fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. The supplied Dockerfile defines the complete sandbox image, and NemoClaw does not layer it on top of the stock managed runtime. The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. When the supplied path is the selected agent's own managed Dockerfile (for example, `agents/hermes/Dockerfile` in the NemoClaw checkout the CLI runs from), NemoClaw applies one exception and stages the repository root as the build context, exactly as the managed build does, because that Dockerfile copies repository-root paths. This lets you edit the managed Dockerfile in place (for example to add Python packages) and rebuild from it with `--from`. For this managed exception, onboarding applies the `.dockerignore` from the repository root. For every other `--from` path, onboarding applies a `.dockerignore` from the Dockerfile's parent directory while calculating the context size and staging files for Docker. NemoClaw also applies additional secret-safety exclusions that override `.dockerignore` negation rules: credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key` are still skipped even if `.dockerignore` tries to include them. Without a `.dockerignore`, onboarding still skips common large or local-only directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context. Other build outputs such as `dist/`, `target/`, or `build/` are included unless your `.dockerignore` excludes them. If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts. Move the Dockerfile into a smaller dedicated directory or add `.dockerignore` entries for generated artifacts to shrink the context. If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory. diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index b6eab24a111..c32cf98b4b9 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -776,7 +776,7 @@ describe("credential actions use typed OpenShell provider results", () => { "Provider 'custom-provider' was detached from sandbox(es): alpha, beta, but provider removal was not confirmed.", ); expect(failure).toContain( - "Re-run 'nemoclaw credentials reset custom-provider' to complete provider removal.", + "Rerun 'nemoclaw credentials reset custom-provider' to complete provider removal.", ); expect(failure).toContain("nemoclaw alpha rebuild"); expect(failure).toContain("nemoclaw beta rebuild"); @@ -993,7 +993,7 @@ describe("credential actions use typed OpenShell provider results", () => { " openshell sandbox provider detach -g nemoclaw beta custom-provider", ); expect(result.failureLines).toContain( - " Then re-run 'nemoclaw credentials reset custom-provider'.", + " Then rerun 'nemoclaw credentials reset custom-provider'.", ); }); diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index f90cf58fa52..fa5bd96fbff 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -133,7 +133,7 @@ export async function runCredentialsResetAction( removedLocal ? ` Provider '${key}' is already absent from the OpenShell gateway. Local state was cleaned up.` : ` Provider '${key}' is already absent from the OpenShell gateway.`, - ` Re-run '${CLI_NAME} onboard' to enter a new value.`, + ` Rerun '${CLI_NAME} onboard' to enter a new value.`, ...detachedSandboxGuidance(key, recovery.detachedSandboxes), ]); } @@ -151,7 +151,7 @@ export function formatResetOutcome( recovery: CredentialsProviderDeleteWithRecoveryResult, gatewayName: string, ): { ok: boolean; lines: string[] } { - const onboardHint = ` Re-run '${CLI_NAME} onboard' to enter a new value.`; + const onboardHint = ` Rerun '${CLI_NAME} onboard' to enter a new value.`; if (recovery.ok) { return { ok: true, @@ -193,7 +193,7 @@ export function formatResetOutcome( (sandbox) => ` openshell sandbox provider detach -g ${gatewayName} ${sandbox} ${key}`, ), - ` Then re-run '${CLI_NAME} credentials reset ${key}'.`, + ` Then rerun '${CLI_NAME} credentials reset ${key}'.`, ); } const detachedSandboxes = [...new Set(recovery.detachedSandboxes)]; @@ -201,7 +201,7 @@ export function formatResetOutcome( lines.push( "", ` Provider '${key}' was detached from sandbox(es): ${detachedSandboxes.join(", ")}, but provider removal was not confirmed.`, - ` Re-run '${CLI_NAME} credentials reset ${key}' to complete provider removal.`, + ` Rerun '${CLI_NAME} credentials reset ${key}' to complete provider removal.`, " If the provider remains registered, restore it by rebuilding the detached sandbox(es):", ...detachedSandboxes.map((sandbox) => ` ${CLI_NAME} ${sandbox} rebuild`), ); From 7202ebdd53870a51033ba5f7bb0ce2ddbcee0514 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:01:19 -0700 Subject: [PATCH 46/57] test(cli): close provider adapter review findings Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/commands/credentials.test.ts | 6 ++-- src/lib/actions/credentials-add.ts | 4 +-- .../credentials-provider-adapter.test.ts | 22 ++++++++++++ .../credentials-reset-outcome.test.ts | 2 +- .../cli/credentials-cli-command.test.ts | 34 +++++++++++++++++-- 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index 2d3678b0cd2..377eb1044a5 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -304,7 +304,7 @@ describe("credentials oclif adapter source coverage", () => { expect(mocks.recordExtraProvider).not.toHaveBeenCalled(); }); - it("stops before provider creation when OpenAI profile import times out", async () => { + it("stops before provider creation when OpenAI profile import times out (#9806)", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); mocks.runOpenshellProviderCommand.mockReturnValueOnce({ status: null, @@ -346,7 +346,7 @@ describe("credentials oclif adapter source coverage", () => { expect(mocks.recordExtraProvider).not.toHaveBeenCalled(); }); - it("imports and verifies the OpenAI profile before provider creation", async () => { + it("imports and verifies the OpenAI profile before provider creation (#9806)", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); mocks.runOpenshellProviderCommand .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) @@ -413,7 +413,7 @@ describe("credentials oclif adapter source coverage", () => { ]); }); - it("reports profile recovery guidance when OpenAI profile import fails", async () => { + it("reports profile recovery guidance when OpenAI profile import fails (#9806)", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); mocks.runOpenshellProviderCommand.mockReturnValueOnce({ status: 1, diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 6351e9d4599..55ac9257142 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -290,7 +290,7 @@ export async function runCredentialsAddAction( return fail([ ` --credential expects an env variable name, not 'KEY=VALUE'.`, ` Export the value first (e.g. \`export ${credential.split("=", 1)[0]}=...\`)`, - ` and re-run with \`--credential ${credential.split("=", 1)[0]}\`.`, + ` and rerun with \`--credential ${credential.split("=", 1)[0]}\`.`, ]); } if (!ENV_NAME_PATTERN.test(credential)) { @@ -320,7 +320,7 @@ export async function runCredentialsAddAction( const key = entry.slice(0, eq); if (!CONFIG_KEY_PATTERN.test(key)) { return fail([ - " --config key must be alphanumeric / underscore (e.g. `--config region=us-east-1`).", + " --config key must be alphanumeric / underscore (e.g. `--config OPENAI_BASE_URL=https://93.184.216.34/v1`).", ]); } if (CONFIG_KEY_DENYLIST.test(key)) { diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index c32cf98b4b9..d4f2c47a79e 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -103,6 +103,28 @@ describe("credential actions use typed OpenShell provider results", () => { expect(JSON.stringify(result)).not.toContain("credential-value"); }); + it("recommends a supported OpenAI base URL after rejecting a config key (#9806)", async () => { + vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); + const adapter = providerAdapter(); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "openai", + credentials: ["CUSTOM_TOKEN"], + configPairs: ["OPENAI-BASE-URL=https://93.184.216.34/v1"], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines).toEqual([ + " --config key must be alphanumeric / underscore (e.g. `--config OPENAI_BASE_URL=https://93.184.216.34/v1`).", + ]); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }); + it.each([ ["loopback IP literal", "http://127.0.0.1/v1"], ["link-local metadata IP literal", "http://169.254.169.254/latest"], diff --git a/test/credentials/credentials-reset-outcome.test.ts b/test/credentials/credentials-reset-outcome.test.ts index 1ba3f64af30..f5bf2c997aa 100644 --- a/test/credentials/credentials-reset-outcome.test.ts +++ b/test/credentials/credentials-reset-outcome.test.ts @@ -32,7 +32,7 @@ describe("formatResetOutcome (#5560)", () => { expect(outcome.lines.join("\n")).not.toContain("rebuild"); }); - it("reports every sandbox detached during a successful removal", () => { + it("reports every sandbox detached during a successful removal (#9806)", () => { const outcome = formatResetOutcome( "my-assistant-brave-search", result({ ok: true, detachedSandboxes: ["alpha", "beta", "alpha"] }), diff --git a/test/package-contract/cli/credentials-cli-command.test.ts b/test/package-contract/cli/credentials-cli-command.test.ts index b2127250b4b..cc6b4c36eb5 100644 --- a/test/package-contract/cli/credentials-cli-command.test.ts +++ b/test/package-contract/cli/credentials-cli-command.test.ts @@ -1,9 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; import { createRequire } from "node:module"; +import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const require = createRequire(import.meta.url); const REPO_ROOT = path.join(import.meta.dirname, "../../.."); @@ -32,6 +34,8 @@ const PROVIDER_COMMAND_PATH = path.join( "openshell", "provider-command.js", ); +let authorityFixtureRoot = ""; +let authorityDeclarationPath = ""; type CredentialsCommandClasses = { CredentialsCommand: typeof import("../../../src/commands/credentials.js").default; CredentialsAddCommand: typeof import("../../../src/commands/credentials/add.js").default; @@ -200,6 +204,25 @@ async function expectExitCode(action: () => Promise, expectedCode: numb } } +beforeAll(() => { + authorityFixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-credentials-cli-")); + authorityDeclarationPath = path.join(authorityFixtureRoot, "gateway-management.json"); + fs.writeFileSync( + authorityDeclarationPath, + JSON.stringify({ + version: 1, + mode: "nemoclaw-managed", + supervisor: null, + requiredCapabilities: [], + }), + ); +}); + +beforeEach(() => { + vi.stubEnv("HOME", authorityFixtureRoot); + vi.stubEnv("NEMOCLAW_GATEWAY_MANAGEMENT", authorityDeclarationPath); +}); + afterEach(() => { for (const modulePath of Object.values(COMMAND_PATHS)) { delete require.cache[modulePath]; @@ -211,6 +234,11 @@ afterEach(() => { }) => void; }; providerCommands.setProviderCommandRuntimeHooksForTest({}); + vi.unstubAllEnvs(); +}); + +afterAll(() => { + fs.rmSync(authorityFixtureRoot, { recursive: true, force: true }); }); describe("credentials oclif commands", () => { @@ -334,7 +362,7 @@ describe("credentials oclif commands", () => { }, ]); expect(output.stdout).toContain("Removed provider 'nvidia-prod'"); - expect(output.stdout).toContain("Re-run 'nemoclaw onboard'"); + expect(output.stdout).toContain("Rerun 'nemoclaw onboard'"); }); it("rejects per-sandbox messaging bridge names for credential reset", async () => { @@ -848,7 +876,7 @@ describe("credentials oclif commands", () => { expect(output.stderr).toContain("delete failed"); }); - it("credentials reset rejects invalid provider names before gateway mutation", async () => { + it("credentials reset rejects invalid provider names before gateway mutation (#9806)", async () => { const gatewayRecoveries: string[] = []; const openshellCalls = installRuntimeBridge({ recoverNamedGatewayRuntime: async () => { From 9450a4438da0ae6dfe5ef6219216e8a3223f78d8 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:20:49 -0700 Subject: [PATCH 47/57] fix(cli): clarify provider rebuild scope --- docs/reference/commands.mdx | 2 +- src/lib/actions/credentials-add.ts | 6 +++--- src/lib/actions/credentials-provider-adapter.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index fe1113fcad1..07e7cc8c86c 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3195,7 +3195,7 @@ $$nemoclaw credentials list ### `$$nemoclaw credentials add ` -Register a provider credential with the OpenShell gateway by name and type. Each `--credential` takes the env variable name whose value the gateway should read; export the value first so it is not placed in argv. Pass either repeatable `--credential ` or `--from-existing`, but do not combine them. `--from-existing` is available only when no managed MCP server reserves credential keys. The command fails before gateway work when a reservation exists because `--from-existing` does not expose credential keys before provider creation. Rerun with explicit `--credential ` input, or remove every managed MCP server that reserves credential keys before retrying. After the gateway accepts the provider, rebuild the target sandbox so the new provider is attached. +Register a provider credential with the OpenShell gateway by name and type. Each `--credential` takes the env variable name whose value the gateway should read; export the value first so it is not placed in argv. Pass either repeatable `--credential ` or `--from-existing`, but do not combine them. `--from-existing` is available only when no managed MCP server reserves credential keys. The command fails before gateway work when a reservation exists because `--from-existing` does not expose credential keys before provider creation. Rerun with explicit `--credential ` input, or remove every managed MCP server that reserves credential keys before retrying. After the gateway accepts the provider, rebuild each sandbox that should use it. Registered providers are gateway-wide and attach to every sandbox you build or rebuild after the call. `nemoclaw credentials reset ` removes the provider from the gateway and detaches it from every sandbox currently using it; it cannot limit the provider to selected sandboxes. To replace a credential, reset the provider, register the replacement, and rebuild each detached sandbox. diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 55ac9257142..f66b45419fb 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -228,7 +228,7 @@ async function reconcileUncertainProviderCreate( lines: [ ` OpenShell reports provider '${provider}' is registered; local provider ownership was preserved.`, ` Verify with '${CLI_NAME} credentials list'.`, - ` Rebuild the target sandbox (\`${CLI_NAME} rebuild\`) to attach the provider.`, + ` Rebuild each sandbox that should use '${provider}' (\`${CLI_NAME} rebuild\`).`, ], }; } @@ -246,7 +246,7 @@ async function reconcileUncertainProviderCreate( lines: [ ` Could not determine whether provider '${provider}' was registered; local provider ownership was preserved.`, ` Run '${CLI_NAME} credentials list' to inspect the gateway before retrying.`, - ` If the provider exists, rebuild the target sandbox; otherwise run '${CLI_NAME} credentials reset ${provider} --yes' before retrying.`, + ` If the provider exists, rebuild each sandbox that should use '${provider}'; otherwise run '${CLI_NAME} credentials reset ${provider} --yes' before retrying.`, ` ${inventory.error.message}`, ], }; @@ -431,7 +431,7 @@ export async function runCredentialsAddAction( return ok([ ` Registered provider '${provider}' with the OpenShell gateway.`, ` Verify with '${CLI_NAME} credentials list'.`, - ` Rebuild the target sandbox (\`${CLI_NAME} rebuild\`) to attach the new provider.`, + ` Rebuild each sandbox that should use '${provider}' (\`${CLI_NAME} rebuild\`).`, ]); } diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index d4f2c47a79e..d03d935e201 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -301,7 +301,7 @@ describe("credential actions use typed OpenShell provider results", () => { inventory: { ok: true, value: { names: ["custom-provider"] } } as const, expectedLines: [ " OpenShell reports provider 'custom-provider' is registered; local provider ownership was preserved.", - " Rebuild the target sandbox (`nemoclaw rebuild`) to attach the provider.", + " Rebuild each sandbox that should use 'custom-provider' (`nemoclaw rebuild`).", ], forgetCalls: 0, }, @@ -315,7 +315,7 @@ describe("credential actions use typed OpenShell provider results", () => { inventory: { ok: true, value: { names: ["custom-provider"] } } as const, expectedLines: [ " OpenShell reports provider 'custom-provider' is registered; local provider ownership was preserved.", - " Rebuild the target sandbox (`nemoclaw rebuild`) to attach the provider.", + " Rebuild each sandbox that should use 'custom-provider' (`nemoclaw rebuild`).", ], forgetCalls: 0, }, @@ -344,7 +344,7 @@ describe("credential actions use typed OpenShell provider results", () => { expectedLines: [ " Could not determine whether provider 'custom-provider' was registered; local provider ownership was preserved.", " Run 'nemoclaw credentials list' to inspect the gateway before retrying.", - " If the provider exists, rebuild the target sandbox; otherwise run 'nemoclaw credentials reset custom-provider --yes' before retrying.", + " If the provider exists, rebuild each sandbox that should use 'custom-provider'; otherwise run 'nemoclaw credentials reset custom-provider --yes' before retrying.", ], forgetCalls: 0, }, From f8edf6c437e13adb5ca5a1b008cdb41b33bacb91 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 09:32:26 -0700 Subject: [PATCH 48/57] docs(cli): restore managed image fallback guidance Signed-off-by: Prekshi Vyas --- docs/get-started/quickstart.mdx | 2 +- docs/reference/commands.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 2c1228a39b3..c708470db22 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -51,7 +51,7 @@ Review the [Prerequisites](prerequisites) before you begin. Press Enter to accept the suggested `my-assistant` sandbox name. For a first run, skip optional web search and messaging setup, then accept the suggested network policy tier. With the OpenShell Docker driver, stock OpenClaw onboarding normally uses the release's exact managed-image digest. - If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. + If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. Invalid or inconsistent catalog evidence fails closed before sandbox creation. An explicit `nemoclaw onboard --from ` remains a separate custom-image path. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 07e7cc8c86c..93589fbde7c 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -736,7 +736,7 @@ The Docker-driver gateway and the portable experimental profile's Podman-driver #### `--from ` -Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent also fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. +Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent still fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. The supplied Dockerfile defines the complete sandbox image, and NemoClaw does not layer it on top of the stock managed runtime. The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. When the supplied path is the selected agent's own managed Dockerfile (for example, `agents/hermes/Dockerfile` in the NemoClaw checkout the CLI runs from), NemoClaw applies one exception and stages the repository root as the build context, exactly as the managed build does, because that Dockerfile copies repository-root paths. This lets you edit the managed Dockerfile in place (for example to add Python packages) and rebuild from it with `--from`. For this managed exception, onboarding applies the `.dockerignore` from the repository root. For every other `--from` path, onboarding applies a `.dockerignore` from the Dockerfile's parent directory while calculating the context size and staging files for Docker. NemoClaw also applies additional secret-safety exclusions that override `.dockerignore` negation rules: credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key` are still skipped even if `.dockerignore` tries to include them. Without a `.dockerignore`, onboarding still skips common large or local-only directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context. Other build outputs such as `dist/`, `target/`, or `build/` are included unless your `.dockerignore` excludes them. If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts. Move the Dockerfile into a smaller dedicated directory or add `.dockerignore` entries for generated artifacts to shrink the context. If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory. From 0cc804801bc4d069acd5585684a86031b49555eb Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 09:47:03 -0700 Subject: [PATCH 49/57] refactor(cli): reuse provider profile validator Signed-off-by: Prekshi Vyas --- src/lib/onboard/messaging-bridge-provider.ts | 74 +++++--------------- 1 file changed, 17 insertions(+), 57 deletions(-) diff --git a/src/lib/onboard/messaging-bridge-provider.ts b/src/lib/onboard/messaging-bridge-provider.ts index 73b2f7adf54..628ce03806f 100644 --- a/src/lib/onboard/messaging-bridge-provider.ts +++ b/src/lib/onboard/messaging-bridge-provider.ts @@ -17,9 +17,12 @@ import fs from "node:fs"; import path from "node:path"; -import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; +import { + exportedProviderProfileMatchesContract, + parseCheckedInProviderProfileContract, +} from "../adapters/openshell/provider-profile"; import { compactText } from "../core/url-utils"; import { createBuiltInChannelManifestRegistry } from "../messaging/channels"; import type { @@ -145,63 +148,20 @@ function bufferOrStringToText(value: string | Buffer | null | undefined): string return ""; } -function credentialBoundary(doc: Record): Record | null { - if ( - typeof doc.id !== "string" || - !Array.isArray(doc.credentials) || - !Array.isArray(doc.endpoints) || - !Array.isArray(doc.binaries) || - typeof doc.inference_capable !== "boolean" - ) { - return null; - } - const credentials = doc.credentials.map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; - const credential = entry as Record; - return { - name: credential.name, - env_vars: credential.env_vars, - required: credential.required, - auth_style: credential.auth_style, - header_name: credential.header_name, - query_param: credential.query_param, - refresh: credential.refresh ?? null, - }; - }); - if (credentials.some((entry) => entry === null)) return null; - return { - id: doc.id, - credentials, - endpoints: doc.endpoints, - binaries: doc.binaries, - inference_capable: doc.inference_capable, - }; -} - -function staticProfileMatchesCheckedInBoundary( +function registeredStaticProfileMatchesCheckedInContract( profile: MessagingBridgeProfile, exported: string, readFileSync: (file: string) => string, ): boolean { - try { - const actual = JSON.parse(exported) as Record; - const expected = YAML.parse(readFileSync(profile.profilePath)) as Record; - const actualBoundary = credentialBoundary(actual); - const expectedBoundary = credentialBoundary(expected); - return ( - actualBoundary !== null && - expectedBoundary !== null && - expectedBoundary.id === profile.profileId && - Array.isArray(expectedBoundary.endpoints) && - expectedBoundary.endpoints.length === 0 && - Array.isArray(expectedBoundary.binaries) && - expectedBoundary.binaries.length === 0 && - expectedBoundary.inference_capable === false && - isDeepStrictEqual(actualBoundary, expectedBoundary) - ); - } catch { - return false; - } + const expected = parseCheckedInProviderProfileContract(readFileSync(profile.profilePath)); + return ( + expected !== null && + expected.profileId === profile.profileId && + expected.boundary.endpoints.length === 0 && + expected.boundary.binaries.length === 0 && + expected.boundary.inference_capable === false && + exportedProviderProfileMatchesContract(exported, expected) + ); } /** Compare a registered static profile with its checked-in credential boundary. */ @@ -218,7 +178,7 @@ export function matchesRegisteredStaticMessagingProfile( { ignoreError: true, suppressOutput: true, stdio: ["ignore", "pipe", "pipe"] }, ); if (exported.status !== 0) return false; - return staticProfileMatchesCheckedInBoundary( + return registeredStaticProfileMatchesCheckedInContract( profile, bufferOrStringToText(exported.stdout), deps.readFileSync ?? ((file: string) => fs.readFileSync(file, "utf-8")), @@ -486,7 +446,7 @@ export function ensureMessagingBridgeProfiles( if (alreadyRegistered.status === 0) { if ( profile.strategy === null && - !staticProfileMatchesCheckedInBoundary( + !registeredStaticProfileMatchesCheckedInContract( profile, bufferOrStringToText(alreadyRegistered.stdout), readFileSync, @@ -524,7 +484,7 @@ export function ensureMessagingBridgeProfiles( ); if ( racedProfile.status !== 0 || - !staticProfileMatchesCheckedInBoundary( + !registeredStaticProfileMatchesCheckedInContract( profile, bufferOrStringToText(racedProfile.stdout), readFileSync, From bcf2503221f27c3f043c86fbdc41d02d61a91cdc Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 09:48:22 -0700 Subject: [PATCH 50/57] docs(cli): remove stale provider detach workflow Signed-off-by: Prekshi Vyas --- docs/get-started/quickstart-langchain-deepagents-code.mdx | 2 +- docs/manage-sandboxes/run-pi.mdx | 2 +- docs/reference/pi-support.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 3880965337f..e6ef48d39b5 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -209,7 +209,7 @@ Remove the target sandbox's managed-Python opt-in when it is no longer needed. nemo-deepagents policy remove tavily --yes ``` -This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, destroy those sandboxes or detach it from each one with `openshell sandbox provider detach tavily-search`, then remove it globally with `nemo-deepagents credentials reset tavily-search --yes`. OpenShell rejects provider deletion while any sandbox still has it attached. +This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, run `nemo-deepagents credentials reset tavily-search --yes`. The command detaches the provider from each sandbox currently using it, removes the gateway-held credential, and prints a rebuild command for every detached sandbox. Run each printed rebuild command to remove the provider from that sandbox's configuration. To preserve the provider for other sandboxes, detach only the target sandbox with `openshell sandbox provider detach tavily-search` instead of resetting it globally. diff --git a/docs/manage-sandboxes/run-pi.mdx b/docs/manage-sandboxes/run-pi.mdx index be9c6b66bf4..2ec9a22a4ab 100644 --- a/docs/manage-sandboxes/run-pi.mdx +++ b/docs/manage-sandboxes/run-pi.mdx @@ -113,7 +113,7 @@ After a NemoClaw update, rebuild the sandbox to consume a newer reviewed Pi pack ## Stop, Start, and Destroy -Destroy removes the registered sandbox and its managed runtime resources. It does not remove gateway-held provider credentials. Create a snapshot before destroy when you need declared user state, and reset a provider credential separately after every dependent sandbox is gone. +Destroy removes the registered sandbox and its managed runtime resources. It does not remove gateway-held provider credentials. Create a snapshot before destroy when you need declared user state, and reset a provider credential separately when no dependent sandbox needs it. Reset detaches the provider from sandboxes currently using it, removes the gateway-held credential, and prints a rebuild command for every detached sandbox. Run each printed rebuild command to remove the provider from that sandbox's configuration. ```bash nemoclaw stop diff --git a/docs/reference/pi-support.mdx b/docs/reference/pi-support.mdx index 1791d3d58bf..b5351d18b87 100644 --- a/docs/reference/pi-support.mdx +++ b/docs/reference/pi-support.mdx @@ -56,7 +56,7 @@ Final activation must publish both platform digests in the same complete cohort. Pi reads a generated `/sandbox/.pi/agent/models.json` file. The file contains the model, managed route, API family, and a non-secret route placeholder. It does not contain the upstream provider credential. -The upstream credential remains in OpenShell provider state. It survives rebuild and sandbox destruction until an operator runs `nemoclaw credentials reset --yes` after all dependent sandboxes are gone. Pi reaches the provider through `inference.local`; direct provider access is denied. NemoClaw rejects an unsupported API family, empty model, credential-bearing base URL, or malformed model tuning before Pi starts. +The upstream credential remains in OpenShell provider state. It survives rebuild and sandbox destruction until an operator runs `nemoclaw credentials reset --yes`. Reset detaches the provider from sandboxes currently using it, removes the gateway-held credential, and prints a rebuild command for every detached sandbox. Run each printed rebuild command to remove the provider from that sandbox's configuration. Pi reaches the provider through `inference.local`; direct provider access is denied. NemoClaw rejects an unsupported API family, empty model, credential-bearing base URL, or malformed model tuning before Pi starts. The initial qualification requires streaming, a structured `read` tool call, a successful tool result, and an independently checked final response with `nvidia/nemotron-3-super-120b-a12b`. From 91c1b5906902f10c83d2313829ca2f56f29ea5ed Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 10:12:54 -0700 Subject: [PATCH 51/57] fix(cli): validate messaging provider contracts --- .../quickstart-langchain-deepagents-code.mdx | 6 +- .../onboard/messaging-bridge-provider.test.ts | 208 +++++++++++++++++- src/lib/onboard/messaging-bridge-provider.ts | 103 ++++----- 3 files changed, 254 insertions(+), 63 deletions(-) diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index e6ef48d39b5..24e31cec080 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -67,8 +67,8 @@ If you use the coding-agent prompt in the preceding section, you can skip this p If the installer does not offer Express setup, or if you enter `n` at the Express prompt on a supported non-N1x host, choose an inference provider and model, then provide its credential when prompted. For that interactive path, accept the suggested network policy tier on a first run. With the OpenShell Docker driver, stock Deep Agents Code onboarding normally uses the release's exact managed-image digest. - If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. - Invalid or inconsistent catalog evidence fails closed before sandbox creation. + If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. + Catalog evidence that is incomplete, mutable, for the wrong platform, or identity-inconsistent fails closed before sandbox creation. An explicit `nemo-deepagents onboard --from ` remains a separate custom-image path. @@ -209,7 +209,7 @@ Remove the target sandbox's managed-Python opt-in when it is no longer needed. nemo-deepagents policy remove tavily --yes ``` -This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, run `nemo-deepagents credentials reset tavily-search --yes`. The command detaches the provider from each sandbox currently using it, removes the gateway-held credential, and prints a rebuild command for every detached sandbox. Run each printed rebuild command to remove the provider from that sandbox's configuration. To preserve the provider for other sandboxes, detach only the target sandbox with `openshell sandbox provider detach tavily-search` instead of resetting it globally. +This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, run `nemo-deepagents credentials reset tavily-search --yes`. The command detaches the provider from each sandbox currently using it, removes the gateway-held credential, and prints a rebuild command for every detached sandbox. Run each printed rebuild command to remove the provider from that sandbox's configuration. To preserve the provider for other sandboxes, first run `nemo-deepagents status` so NemoClaw selects the sandbox's recorded gateway, then run `openshell status` and use its `Gateway:` value in `openshell sandbox provider detach -g tavily-search` instead of resetting the provider globally. diff --git a/src/lib/onboard/messaging-bridge-provider.test.ts b/src/lib/onboard/messaging-bridge-provider.test.ts index 7ad51053a1c..12a2f1010fc 100644 --- a/src/lib/onboard/messaging-bridge-provider.test.ts +++ b/src/lib/onboard/messaging-bridge-provider.test.ts @@ -93,6 +93,33 @@ const DISCORD_PROFILE_DOC = { inference_capable: false, }; +function refreshingProfileDoc(profile: MessagingBridgeProfile) { + return { + id: profile.profileId, + credentials: [ + { + name: "access_token", + env_vars: [profile.credentialKey], + required: true, + auth_style: "bearer", + header_name: "Authorization", + query_param: "", + refresh: { + strategy: profile.strategy, + scopes: profile.scopes, + material: [ + { name: "client_email", required: true }, + { name: "private_key", required: true, secret: true }, + ], + }, + }, + ], + endpoints: [{ host: "chat.googleapis.com", port: 443 }], + binaries: [profile.agent === "hermes" ? "/opt/hermes/.venv/bin/python" : "/usr/local/bin/node"], + inference_capable: false, + }; +} + const STATIC_DEF = { name: "sbx-discord-bridge", providerType: DISCORD_PROFILE.profileId, @@ -434,9 +461,9 @@ describe("configureMessagingBridgeRefreshes", () => { expect(result.ok).toBe(false); // Six probes at a minute each cross the five-minute deadline well before // the fifty-attempt cap. - expect( - runOpenshell.mock.calls.filter((call) => call[0][2] === "status").length, - ).toBeLessThan(10); + expect(runOpenshell.mock.calls.filter((call) => call[0][2] === "status").length).toBeLessThan( + 10, + ); }); it("bounds each status probe with a command timeout", () => { @@ -489,11 +516,19 @@ describe("ensureMessagingBridgeProfiles", () => { }); it("imports the profile from its co-located path when not yet registered", () => { - const runOpenshell = vi.fn((args: string[], _opts: unknown) => - args.includes("export") ? { status: 1 } : { status: 0 }, - ); + const profileDoc = refreshingProfileDoc(GC_PROFILE); + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 1 }) + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 0, stdout: JSON.stringify(profileDoc) }); const exit = vi.fn(() => undefined as never); - ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); + ensureMessagingBridgeProfiles([BRIDGE_DEF], { + ...baseDeps(), + readFileSync: () => YAML.stringify(profileDoc), + runOpenshell, + exit, + }); const importCall = runOpenshell.mock.calls.find((call) => call[0].includes("import")); expect(importCall?.[0].slice(0, 4)).toEqual(["provider", "profile", "import", "--file"]); expect(importCall?.[0]).toContain(GC_PROFILE.profilePath); @@ -503,9 +538,18 @@ describe("ensureMessagingBridgeProfiles", () => { it("skips the import when the profile is already registered", () => { // A fresh onboard registers bridge providers twice; the second pass must not // re-import and trigger OpenShell's "already exists / import failed" output. - const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ status: 0 })); + const profileDoc = refreshingProfileDoc(GC_PROFILE); + const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ + status: 0, + stdout: JSON.stringify(profileDoc), + })); const exit = vi.fn(() => undefined as never); - ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); + ensureMessagingBridgeProfiles([BRIDGE_DEF], { + ...baseDeps(), + readFileSync: () => YAML.stringify(profileDoc), + runOpenshell, + exit, + }); expect(runOpenshell.mock.calls.some((call) => call[0].includes("import"))).toBe(false); const exportCall = runOpenshell.mock.calls.find((call) => call[0].includes("export")); expect(exportCall?.[0]).toEqual([ @@ -604,12 +648,154 @@ describe("ensureMessagingBridgeProfiles", () => { }); it("tolerates an already-registered profile without exiting", () => { - const runOpenshell = vi.fn(() => ({ status: 1, stderr: "profile already exists" })); + const profileDoc = refreshingProfileDoc(GC_PROFILE); + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 1 }) + .mockReturnValueOnce({ status: 1, stderr: "profile already exists" }) + .mockReturnValueOnce({ status: 0, stdout: JSON.stringify(profileDoc) }); const exit = vi.fn(() => undefined as never); - ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); + ensureMessagingBridgeProfiles([BRIDGE_DEF], { + ...baseDeps(), + readFileSync: () => YAML.stringify(profileDoc), + runOpenshell, + exit, + }); expect(exit).not.toHaveBeenCalled(); }); + const refreshingProfileMismatchCases = [GC_PROFILE, GC_HERMES_PROFILE].flatMap((profile) => { + const expected = refreshingProfileDoc(profile); + return [ + [ + "additional endpoint", + profile.profileId, + profile, + expected, + { ...expected, endpoints: [...expected.endpoints, { host: "example.test", port: 443 }] }, + ], + [ + "additional binary", + profile.profileId, + profile, + expected, + { ...expected, binaries: [...expected.binaries, "/usr/bin/curl"] }, + ], + [ + "additional credential", + profile.profileId, + profile, + expected, + { + ...expected, + credentials: [ + ...expected.credentials, + { + name: "unexpected", + env_vars: ["UNEXPECTED_TOKEN"], + required: true, + auth_style: "bearer", + header_name: "Authorization", + query_param: "", + }, + ], + }, + ], + [ + "modified refresh configuration", + profile.profileId, + profile, + expected, + { + ...expected, + credentials: [ + { + ...expected.credentials[0], + refresh: { + ...expected.credentials[0].refresh, + strategy: "client-credentials", + }, + }, + ], + }, + ], + ] as const; + }); + + it.each(refreshingProfileMismatchCases)( + "rejects %s in the existing %s profile before provider or refresh setup", + (_drift, _profileId, profile, expected, exported) => { + const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ + status: 0, + stdout: JSON.stringify(exported), + })); + const exit = vi.fn(() => undefined as never); + + ensureMessagingBridgeProfiles( + [ + { + name: `sbx-${profile.profileId}`, + providerType: profile.profileId, + token: MESSAGING_BRIDGE_PENDING_VALUE, + }, + ], + { + ...baseDeps(), + profiles: [profile], + readFileSync: () => YAML.stringify(expected), + runOpenshell, + exit, + }, + ); + + expect(exit).toHaveBeenCalledWith(1); + expect(runOpenshell.mock.calls.some((call) => call[0].includes("import"))).toBe(false); + expect( + runOpenshell.mock.calls.some( + (call) => call[0][0] === "provider" && ["create", "refresh"].includes(call[0][1]), + ), + ).toBe(false); + }, + ); + + it("rejects an unreadable registered profile after a successful import", () => { + const profileDoc = refreshingProfileDoc(GC_PROFILE); + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 1 }) + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 1, stderr: "profile export unavailable" }); + const exit = vi.fn(() => undefined as never); + + ensureMessagingBridgeProfiles([BRIDGE_DEF], { + ...baseDeps(), + readFileSync: () => YAML.stringify(profileDoc), + runOpenshell, + exit, + }); + + expect(exit).toHaveBeenCalledWith(1); + expect(runOpenshell).toHaveBeenCalledTimes(3); + }); + + it("rejects a malformed checked-in profile before provider or refresh setup", () => { + const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ + status: 0, + stdout: JSON.stringify(refreshingProfileDoc(GC_PROFILE)), + })); + const exit = vi.fn(() => undefined as never); + + ensureMessagingBridgeProfiles([BRIDGE_DEF], { + ...baseDeps(), + readFileSync: () => "not: [valid", + runOpenshell, + exit, + }); + + expect(exit).toHaveBeenCalledWith(1); + expect(runOpenshell).toHaveBeenCalledTimes(1); + }); + it("exits when profile import fails for another reason", () => { const runOpenshell = vi.fn(() => ({ status: 1, stderr: "connection refused" })); const exit = vi.fn(() => undefined as never); diff --git a/src/lib/onboard/messaging-bridge-provider.ts b/src/lib/onboard/messaging-bridge-provider.ts index 628ce03806f..47d4d016e8b 100644 --- a/src/lib/onboard/messaging-bridge-provider.ts +++ b/src/lib/onboard/messaging-bridge-provider.ts @@ -148,20 +148,25 @@ function bufferOrStringToText(value: string | Buffer | null | undefined): string return ""; } -function registeredStaticProfileMatchesCheckedInContract( +function registeredMessagingProfileMatchesCheckedInContract( profile: MessagingBridgeProfile, exported: string, readFileSync: (file: string) => string, ): boolean { - const expected = parseCheckedInProviderProfileContract(readFileSync(profile.profilePath)); - return ( - expected !== null && - expected.profileId === profile.profileId && - expected.boundary.endpoints.length === 0 && - expected.boundary.binaries.length === 0 && - expected.boundary.inference_capable === false && - exportedProviderProfileMatchesContract(exported, expected) - ); + try { + const expected = parseCheckedInProviderProfileContract(readFileSync(profile.profilePath)); + return ( + expected !== null && + expected.profileId === profile.profileId && + (profile.strategy !== null || + (expected.boundary.endpoints.length === 0 && + expected.boundary.binaries.length === 0 && + expected.boundary.inference_capable === false)) && + exportedProviderProfileMatchesContract(exported, expected) + ); + } catch { + return false; + } } /** Compare a registered static profile with its checked-in credential boundary. */ @@ -178,7 +183,7 @@ export function matchesRegisteredStaticMessagingProfile( { ignoreError: true, suppressOutput: true, stdio: ["ignore", "pipe", "pipe"] }, ); if (exported.status !== 0) return false; - return registeredStaticProfileMatchesCheckedInContract( + return registeredMessagingProfileMatchesCheckedInContract( profile, bufferOrStringToText(exported.stdout), deps.readFileSync ?? ((file: string) => fs.readFileSync(file, "utf-8")), @@ -424,14 +429,41 @@ export function ensureMessagingBridgeProfiles( const exit = deps.exit ?? ((code?: number) => process.exit(code)); const readFileSync = deps.readFileSync ?? ((file: string) => fs.readFileSync(file, "utf-8")); - const rejectMismatchedStaticProfile = (profile: MessagingBridgeProfile): void => { + const rejectUntrustedProfile = (profile: MessagingBridgeProfile): void => { errorLog( - `\n ✗ OpenShell provider profile '${profile.profileId}' does not match NemoClaw's endpointless ${profile.channelId} credential contract.`, + `\n ✗ OpenShell provider profile '${profile.profileId}' could not be read or does not exactly match NemoClaw's checked-in ${profile.channelId} credential contract.`, + ); + errorLog( + " Confirm the checked-in profile is readable; remove a conflicting gateway profile, then re-run onboarding.", ); - errorLog(" Remove the conflicting profile and re-run onboarding."); exit(1); }; + const exportProfile = (profile: MessagingBridgeProfile) => + deps.runOpenshell(["provider", "profile", "export", profile.profileId, "--output", "json"], { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + const acceptRegisteredProfile = ( + profile: MessagingBridgeProfile, + exported: ReturnType, + ): boolean => { + if ( + exported.status === 0 && + registeredMessagingProfileMatchesCheckedInContract( + profile, + bufferOrStringToText(exported.stdout), + readFileSync, + ) + ) { + return true; + } + rejectUntrustedProfile(profile); + return false; + }; + for (const profile of active) { // Onboard registers each bridge provider twice: once up front so an // interrupted run can resume, then again during create-plan materialization. @@ -439,22 +471,9 @@ export function ensureMessagingBridgeProfiles( // "already exists" error. A fresh gateway answers the probe with a harmless // "not found" that suppressOutput hides — only the exit status says whether // the profile already exists. - const alreadyRegistered = deps.runOpenshell( - ["provider", "profile", "export", profile.profileId, "--output", "json"], - { ignoreError: true, suppressOutput: true, stdio: ["ignore", "pipe", "pipe"] }, - ); + const alreadyRegistered = exportProfile(profile); if (alreadyRegistered.status === 0) { - if ( - profile.strategy === null && - !registeredStaticProfileMatchesCheckedInContract( - profile, - bufferOrStringToText(alreadyRegistered.stdout), - readFileSync, - ) - ) { - rejectMismatchedStaticProfile(profile); - return; - } + if (!acceptRegisteredProfile(profile, alreadyRegistered)) return; continue; } // Probe failed for something other than "not found" (gateway down, auth, …): @@ -472,34 +491,20 @@ export function ensureMessagingBridgeProfiles( ["provider", "profile", "import", "--file", profile.profilePath], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, ); - if (result.status === 0) continue; + if (result.status === 0) { + if (!acceptRegisteredProfile(profile, exportProfile(profile))) return; + continue; + } // Reconcile a lost race: the probe saw no profile but a concurrent import made it. const rawDiagnostic = `${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`; if (/already exists/i.test(rawDiagnostic)) { - if (profile.strategy !== null) continue; - const racedProfile = deps.runOpenshell( - ["provider", "profile", "export", profile.profileId, "--output", "json"], - { ignoreError: true, suppressOutput: true, stdio: ["ignore", "pipe", "pipe"] }, - ); - if ( - racedProfile.status !== 0 || - !registeredStaticProfileMatchesCheckedInContract( - profile, - bufferOrStringToText(racedProfile.stdout), - readFileSync, - ) - ) { - rejectMismatchedStaticProfile(profile); - return; - } + if (!acceptRegisteredProfile(profile, exportProfile(profile))) return; continue; } const diagnostic = compactText(deps.redact(rawDiagnostic)); - errorLog( - `\n ✗ Failed to register the ${profile.channelId} provider profile with OpenShell.`, - ); + errorLog(`\n ✗ Failed to register the ${profile.channelId} provider profile with OpenShell.`); if (diagnostic) errorLog(` ${diagnostic.slice(0, 500)}`); errorLog(" Update OpenShell with scripts/install-openshell.sh and re-run onboarding."); exit(result.status || 1); From 28436b04ccf3bf718ebb9c65913b2b33623ac82b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 10:45:33 -0700 Subject: [PATCH 52/57] test(e2e): verify credential sandbox boundary --- test/e2e/live/sandbox-operations.test.ts | 78 +++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index 7b5dbb38981..671758ea392 100644 --- a/test/e2e/live/sandbox-operations.test.ts +++ b/test/e2e/live/sandbox-operations.test.ts @@ -193,6 +193,81 @@ async function execInSandbox( }); } +function credentialBoundaryProbeScript(): string { + const encodedFixture = Buffer.from(CREDENTIAL_VALUE, "utf8").toString("base64"); + return `python3 - ${shellQuote(encodedFixture)} <<'PY' +from pathlib import Path +import base64 +import os +import sys + +secret = base64.b64decode(sys.argv[1], validate=True) + +def contains_secret(path): + try: + return secret in Path(path).read_bytes() + except OSError: + return False + +environment = b"\\0".join( + f"{key}={value}".encode("utf-8", errors="surrogateescape") + for key, value in os.environ.items() +) +if secret in environment: + raise SystemExit(98) + +managed_config_files = 0 +for root_text in ("/sandbox/.openclaw", "/etc/nemoclaw", "/tmp"): + root = Path(root_text) + if not root.exists(): + continue + for path in root.rglob("*"): + try: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 1024 * 1024: + continue + except OSError: + continue + if root_text != "/tmp": + managed_config_files += 1 + if contains_secret(path): + raise SystemExit(98) + +agent_environment_inspected = False +for process in Path("/proc").iterdir(): + if not process.name.isdigit(): + continue + try: + command = (process / "cmdline").read_bytes() + except OSError: + continue + if b"openclaw" not in command.lower(): + continue + try: + agent_environment = (process / "environ").read_bytes() + except OSError: + continue + agent_environment_inspected = True + if secret in command or secret in agent_environment: + raise SystemExit(98) + +if managed_config_files == 0 or not agent_environment_inspected: + raise SystemExit(97) +PY`; +} + +async function assertCredentialRemainsOutsideSandbox(sandbox: SandboxClient): Promise { + const probe = await execInSandbox( + sandbox, + SANDBOX_A, + credentialBoundaryProbeScript(), + "tc-sbx-14-sandbox-credential-boundary", + ); + expect( + probe.exitCode, + "credential fixture must remain absent from sandbox environment and managed runtime configuration", + ).toBe(0); +} + async function assertAgentCanAnswer( host: HostCliClient, sandboxName: string, @@ -633,7 +708,7 @@ test( id: "sandbox-operations", boundary: "repo-cli-openshell-provider-sandbox-attachment", contracts: [ - "TC-SBX-14 credentials add/list/reset crosses the real OpenShell provider boundary, attaches on rebuild, and removes the attachment and provider", + "TC-SBX-14 credentials add/list/reset crosses the real OpenShell provider boundary, keeps the credential outside the rebuilt sandbox, and removes the attachment and provider", ], }); @@ -702,6 +777,7 @@ test( artifactName: "tc-sbx-14-provider-attached-after-rebuild", env: buildAvailabilityProbeEnv(), }); + await assertCredentialRemainsOutsideSandbox(sandbox); const reset = await resetCredentialProvider(host, "tc-sbx-14-credentials-reset-attached"); expect(resultText(reset)).toContain(`Removed provider '${CREDENTIAL_PROVIDER}'`); From 05693f8b9493beaf66dacd364211b5e5d7ed6428 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 11:24:43 -0700 Subject: [PATCH 53/57] refactor(cli): restore credential-only PR scope --- .../quickstart-langchain-deepagents-code.mdx | 6 +- docs/get-started/quickstart.mdx | 2 +- docs/manage-sandboxes/run-pi.mdx | 2 +- docs/reference/commands.mdx | 2 +- docs/reference/pi-support.mdx | 2 +- .../onboard/messaging-bridge-provider.test.ts | 208 +----------------- src/lib/onboard/messaging-bridge-provider.ts | 139 +++++++----- 7 files changed, 105 insertions(+), 256 deletions(-) diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 24e31cec080..3880965337f 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -67,8 +67,8 @@ If you use the coding-agent prompt in the preceding section, you can skip this p If the installer does not offer Express setup, or if you enter `n` at the Express prompt on a supported non-N1x host, choose an inference provider and model, then provide its credential when prompted. For that interactive path, accept the suggested network policy tier on a first run. With the OpenShell Docker driver, stock Deep Agents Code onboarding normally uses the release's exact managed-image digest. - If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. - Catalog evidence that is incomplete, mutable, for the wrong platform, or identity-inconsistent fails closed before sandbox creation. + If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. + Invalid or inconsistent catalog evidence fails closed before sandbox creation. An explicit `nemo-deepagents onboard --from ` remains a separate custom-image path. @@ -209,7 +209,7 @@ Remove the target sandbox's managed-Python opt-in when it is no longer needed. nemo-deepagents policy remove tavily --yes ``` -This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, run `nemo-deepagents credentials reset tavily-search --yes`. The command detaches the provider from each sandbox currently using it, removes the gateway-held credential, and prints a rebuild command for every detached sandbox. Run each printed rebuild command to remove the provider from that sandbox's configuration. To preserve the provider for other sandboxes, first run `nemo-deepagents status` so NemoClaw selects the sandbox's recorded gateway, then run `openshell status` and use its `Gateway:` value in `openshell sandbox provider detach -g tavily-search` instead of resetting the provider globally. +This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, destroy those sandboxes or detach it from each one with `openshell sandbox provider detach tavily-search`, then remove it globally with `nemo-deepagents credentials reset tavily-search --yes`. OpenShell rejects provider deletion while any sandbox still has it attached. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index c708470db22..2c1228a39b3 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -51,7 +51,7 @@ Review the [Prerequisites](prerequisites) before you begin. Press Enter to accept the suggested `my-assistant` sandbox name. For a first run, skip optional web search and messaging setup, then accept the suggested network policy tier. With the OpenShell Docker driver, stock OpenClaw onboarding normally uses the release's exact managed-image digest. - If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. + If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Invalid or inconsistent catalog evidence fails closed before sandbox creation. An explicit `nemoclaw onboard --from ` remains a separate custom-image path. diff --git a/docs/manage-sandboxes/run-pi.mdx b/docs/manage-sandboxes/run-pi.mdx index 2ec9a22a4ab..be9c6b66bf4 100644 --- a/docs/manage-sandboxes/run-pi.mdx +++ b/docs/manage-sandboxes/run-pi.mdx @@ -113,7 +113,7 @@ After a NemoClaw update, rebuild the sandbox to consume a newer reviewed Pi pack ## Stop, Start, and Destroy -Destroy removes the registered sandbox and its managed runtime resources. It does not remove gateway-held provider credentials. Create a snapshot before destroy when you need declared user state, and reset a provider credential separately when no dependent sandbox needs it. Reset detaches the provider from sandboxes currently using it, removes the gateway-held credential, and prints a rebuild command for every detached sandbox. Run each printed rebuild command to remove the provider from that sandbox's configuration. +Destroy removes the registered sandbox and its managed runtime resources. It does not remove gateway-held provider credentials. Create a snapshot before destroy when you need declared user state, and reset a provider credential separately after every dependent sandbox is gone. ```bash nemoclaw stop diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 93589fbde7c..07e7cc8c86c 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -736,7 +736,7 @@ The Docker-driver gateway and the portable experimental profile's Podman-driver #### `--from ` -Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If the managed-image catalog is unavailable, stock onboarding uses the trusted Dockerfile recipe. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent still fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. +Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent also fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. The supplied Dockerfile defines the complete sandbox image, and NemoClaw does not layer it on top of the stock managed runtime. The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. When the supplied path is the selected agent's own managed Dockerfile (for example, `agents/hermes/Dockerfile` in the NemoClaw checkout the CLI runs from), NemoClaw applies one exception and stages the repository root as the build context, exactly as the managed build does, because that Dockerfile copies repository-root paths. This lets you edit the managed Dockerfile in place (for example to add Python packages) and rebuild from it with `--from`. For this managed exception, onboarding applies the `.dockerignore` from the repository root. For every other `--from` path, onboarding applies a `.dockerignore` from the Dockerfile's parent directory while calculating the context size and staging files for Docker. NemoClaw also applies additional secret-safety exclusions that override `.dockerignore` negation rules: credential-style files and directories such as `.env*`, `.ssh/`, `.aws/`, `.netrc`, `.npmrc`, `secrets/`, `*.pem`, and `*.key` are still skipped even if `.dockerignore` tries to include them. Without a `.dockerignore`, onboarding still skips common large or local-only directories (`node_modules`, `.git`, `.venv`, and `__pycache__`) while staging this context. Other build outputs such as `dist/`, `target/`, or `build/` are included unless your `.dockerignore` excludes them. If the staged context is larger than 100 MB, onboarding prints a warning before the Docker build starts. Move the Dockerfile into a smaller dedicated directory or add `.dockerignore` entries for generated artifacts to shrink the context. If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory. diff --git a/docs/reference/pi-support.mdx b/docs/reference/pi-support.mdx index b5351d18b87..1791d3d58bf 100644 --- a/docs/reference/pi-support.mdx +++ b/docs/reference/pi-support.mdx @@ -56,7 +56,7 @@ Final activation must publish both platform digests in the same complete cohort. Pi reads a generated `/sandbox/.pi/agent/models.json` file. The file contains the model, managed route, API family, and a non-secret route placeholder. It does not contain the upstream provider credential. -The upstream credential remains in OpenShell provider state. It survives rebuild and sandbox destruction until an operator runs `nemoclaw credentials reset --yes`. Reset detaches the provider from sandboxes currently using it, removes the gateway-held credential, and prints a rebuild command for every detached sandbox. Run each printed rebuild command to remove the provider from that sandbox's configuration. Pi reaches the provider through `inference.local`; direct provider access is denied. NemoClaw rejects an unsupported API family, empty model, credential-bearing base URL, or malformed model tuning before Pi starts. +The upstream credential remains in OpenShell provider state. It survives rebuild and sandbox destruction until an operator runs `nemoclaw credentials reset --yes` after all dependent sandboxes are gone. Pi reaches the provider through `inference.local`; direct provider access is denied. NemoClaw rejects an unsupported API family, empty model, credential-bearing base URL, or malformed model tuning before Pi starts. The initial qualification requires streaming, a structured `read` tool call, a successful tool result, and an independently checked final response with `nvidia/nemotron-3-super-120b-a12b`. diff --git a/src/lib/onboard/messaging-bridge-provider.test.ts b/src/lib/onboard/messaging-bridge-provider.test.ts index 12a2f1010fc..7ad51053a1c 100644 --- a/src/lib/onboard/messaging-bridge-provider.test.ts +++ b/src/lib/onboard/messaging-bridge-provider.test.ts @@ -93,33 +93,6 @@ const DISCORD_PROFILE_DOC = { inference_capable: false, }; -function refreshingProfileDoc(profile: MessagingBridgeProfile) { - return { - id: profile.profileId, - credentials: [ - { - name: "access_token", - env_vars: [profile.credentialKey], - required: true, - auth_style: "bearer", - header_name: "Authorization", - query_param: "", - refresh: { - strategy: profile.strategy, - scopes: profile.scopes, - material: [ - { name: "client_email", required: true }, - { name: "private_key", required: true, secret: true }, - ], - }, - }, - ], - endpoints: [{ host: "chat.googleapis.com", port: 443 }], - binaries: [profile.agent === "hermes" ? "/opt/hermes/.venv/bin/python" : "/usr/local/bin/node"], - inference_capable: false, - }; -} - const STATIC_DEF = { name: "sbx-discord-bridge", providerType: DISCORD_PROFILE.profileId, @@ -461,9 +434,9 @@ describe("configureMessagingBridgeRefreshes", () => { expect(result.ok).toBe(false); // Six probes at a minute each cross the five-minute deadline well before // the fifty-attempt cap. - expect(runOpenshell.mock.calls.filter((call) => call[0][2] === "status").length).toBeLessThan( - 10, - ); + expect( + runOpenshell.mock.calls.filter((call) => call[0][2] === "status").length, + ).toBeLessThan(10); }); it("bounds each status probe with a command timeout", () => { @@ -516,19 +489,11 @@ describe("ensureMessagingBridgeProfiles", () => { }); it("imports the profile from its co-located path when not yet registered", () => { - const profileDoc = refreshingProfileDoc(GC_PROFILE); - const runOpenshell = vi - .fn() - .mockReturnValueOnce({ status: 1 }) - .mockReturnValueOnce({ status: 0 }) - .mockReturnValueOnce({ status: 0, stdout: JSON.stringify(profileDoc) }); + const runOpenshell = vi.fn((args: string[], _opts: unknown) => + args.includes("export") ? { status: 1 } : { status: 0 }, + ); const exit = vi.fn(() => undefined as never); - ensureMessagingBridgeProfiles([BRIDGE_DEF], { - ...baseDeps(), - readFileSync: () => YAML.stringify(profileDoc), - runOpenshell, - exit, - }); + ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); const importCall = runOpenshell.mock.calls.find((call) => call[0].includes("import")); expect(importCall?.[0].slice(0, 4)).toEqual(["provider", "profile", "import", "--file"]); expect(importCall?.[0]).toContain(GC_PROFILE.profilePath); @@ -538,18 +503,9 @@ describe("ensureMessagingBridgeProfiles", () => { it("skips the import when the profile is already registered", () => { // A fresh onboard registers bridge providers twice; the second pass must not // re-import and trigger OpenShell's "already exists / import failed" output. - const profileDoc = refreshingProfileDoc(GC_PROFILE); - const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ - status: 0, - stdout: JSON.stringify(profileDoc), - })); + const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ status: 0 })); const exit = vi.fn(() => undefined as never); - ensureMessagingBridgeProfiles([BRIDGE_DEF], { - ...baseDeps(), - readFileSync: () => YAML.stringify(profileDoc), - runOpenshell, - exit, - }); + ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); expect(runOpenshell.mock.calls.some((call) => call[0].includes("import"))).toBe(false); const exportCall = runOpenshell.mock.calls.find((call) => call[0].includes("export")); expect(exportCall?.[0]).toEqual([ @@ -648,154 +604,12 @@ describe("ensureMessagingBridgeProfiles", () => { }); it("tolerates an already-registered profile without exiting", () => { - const profileDoc = refreshingProfileDoc(GC_PROFILE); - const runOpenshell = vi - .fn() - .mockReturnValueOnce({ status: 1 }) - .mockReturnValueOnce({ status: 1, stderr: "profile already exists" }) - .mockReturnValueOnce({ status: 0, stdout: JSON.stringify(profileDoc) }); + const runOpenshell = vi.fn(() => ({ status: 1, stderr: "profile already exists" })); const exit = vi.fn(() => undefined as never); - ensureMessagingBridgeProfiles([BRIDGE_DEF], { - ...baseDeps(), - readFileSync: () => YAML.stringify(profileDoc), - runOpenshell, - exit, - }); + ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit }); expect(exit).not.toHaveBeenCalled(); }); - const refreshingProfileMismatchCases = [GC_PROFILE, GC_HERMES_PROFILE].flatMap((profile) => { - const expected = refreshingProfileDoc(profile); - return [ - [ - "additional endpoint", - profile.profileId, - profile, - expected, - { ...expected, endpoints: [...expected.endpoints, { host: "example.test", port: 443 }] }, - ], - [ - "additional binary", - profile.profileId, - profile, - expected, - { ...expected, binaries: [...expected.binaries, "/usr/bin/curl"] }, - ], - [ - "additional credential", - profile.profileId, - profile, - expected, - { - ...expected, - credentials: [ - ...expected.credentials, - { - name: "unexpected", - env_vars: ["UNEXPECTED_TOKEN"], - required: true, - auth_style: "bearer", - header_name: "Authorization", - query_param: "", - }, - ], - }, - ], - [ - "modified refresh configuration", - profile.profileId, - profile, - expected, - { - ...expected, - credentials: [ - { - ...expected.credentials[0], - refresh: { - ...expected.credentials[0].refresh, - strategy: "client-credentials", - }, - }, - ], - }, - ], - ] as const; - }); - - it.each(refreshingProfileMismatchCases)( - "rejects %s in the existing %s profile before provider or refresh setup", - (_drift, _profileId, profile, expected, exported) => { - const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ - status: 0, - stdout: JSON.stringify(exported), - })); - const exit = vi.fn(() => undefined as never); - - ensureMessagingBridgeProfiles( - [ - { - name: `sbx-${profile.profileId}`, - providerType: profile.profileId, - token: MESSAGING_BRIDGE_PENDING_VALUE, - }, - ], - { - ...baseDeps(), - profiles: [profile], - readFileSync: () => YAML.stringify(expected), - runOpenshell, - exit, - }, - ); - - expect(exit).toHaveBeenCalledWith(1); - expect(runOpenshell.mock.calls.some((call) => call[0].includes("import"))).toBe(false); - expect( - runOpenshell.mock.calls.some( - (call) => call[0][0] === "provider" && ["create", "refresh"].includes(call[0][1]), - ), - ).toBe(false); - }, - ); - - it("rejects an unreadable registered profile after a successful import", () => { - const profileDoc = refreshingProfileDoc(GC_PROFILE); - const runOpenshell = vi - .fn() - .mockReturnValueOnce({ status: 1 }) - .mockReturnValueOnce({ status: 0 }) - .mockReturnValueOnce({ status: 1, stderr: "profile export unavailable" }); - const exit = vi.fn(() => undefined as never); - - ensureMessagingBridgeProfiles([BRIDGE_DEF], { - ...baseDeps(), - readFileSync: () => YAML.stringify(profileDoc), - runOpenshell, - exit, - }); - - expect(exit).toHaveBeenCalledWith(1); - expect(runOpenshell).toHaveBeenCalledTimes(3); - }); - - it("rejects a malformed checked-in profile before provider or refresh setup", () => { - const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ - status: 0, - stdout: JSON.stringify(refreshingProfileDoc(GC_PROFILE)), - })); - const exit = vi.fn(() => undefined as never); - - ensureMessagingBridgeProfiles([BRIDGE_DEF], { - ...baseDeps(), - readFileSync: () => "not: [valid", - runOpenshell, - exit, - }); - - expect(exit).toHaveBeenCalledWith(1); - expect(runOpenshell).toHaveBeenCalledTimes(1); - }); - it("exits when profile import fails for another reason", () => { const runOpenshell = vi.fn(() => ({ status: 1, stderr: "connection refused" })); const exit = vi.fn(() => undefined as never); diff --git a/src/lib/onboard/messaging-bridge-provider.ts b/src/lib/onboard/messaging-bridge-provider.ts index 47d4d016e8b..73b2f7adf54 100644 --- a/src/lib/onboard/messaging-bridge-provider.ts +++ b/src/lib/onboard/messaging-bridge-provider.ts @@ -17,12 +17,9 @@ import fs from "node:fs"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; -import { - exportedProviderProfileMatchesContract, - parseCheckedInProviderProfileContract, -} from "../adapters/openshell/provider-profile"; import { compactText } from "../core/url-utils"; import { createBuiltInChannelManifestRegistry } from "../messaging/channels"; import type { @@ -148,21 +145,59 @@ function bufferOrStringToText(value: string | Buffer | null | undefined): string return ""; } -function registeredMessagingProfileMatchesCheckedInContract( +function credentialBoundary(doc: Record): Record | null { + if ( + typeof doc.id !== "string" || + !Array.isArray(doc.credentials) || + !Array.isArray(doc.endpoints) || + !Array.isArray(doc.binaries) || + typeof doc.inference_capable !== "boolean" + ) { + return null; + } + const credentials = doc.credentials.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + const credential = entry as Record; + return { + name: credential.name, + env_vars: credential.env_vars, + required: credential.required, + auth_style: credential.auth_style, + header_name: credential.header_name, + query_param: credential.query_param, + refresh: credential.refresh ?? null, + }; + }); + if (credentials.some((entry) => entry === null)) return null; + return { + id: doc.id, + credentials, + endpoints: doc.endpoints, + binaries: doc.binaries, + inference_capable: doc.inference_capable, + }; +} + +function staticProfileMatchesCheckedInBoundary( profile: MessagingBridgeProfile, exported: string, readFileSync: (file: string) => string, ): boolean { try { - const expected = parseCheckedInProviderProfileContract(readFileSync(profile.profilePath)); + const actual = JSON.parse(exported) as Record; + const expected = YAML.parse(readFileSync(profile.profilePath)) as Record; + const actualBoundary = credentialBoundary(actual); + const expectedBoundary = credentialBoundary(expected); return ( - expected !== null && - expected.profileId === profile.profileId && - (profile.strategy !== null || - (expected.boundary.endpoints.length === 0 && - expected.boundary.binaries.length === 0 && - expected.boundary.inference_capable === false)) && - exportedProviderProfileMatchesContract(exported, expected) + actualBoundary !== null && + expectedBoundary !== null && + expectedBoundary.id === profile.profileId && + Array.isArray(expectedBoundary.endpoints) && + expectedBoundary.endpoints.length === 0 && + Array.isArray(expectedBoundary.binaries) && + expectedBoundary.binaries.length === 0 && + expectedBoundary.inference_capable === false && + isDeepStrictEqual(actualBoundary, expectedBoundary) ); } catch { return false; @@ -183,7 +218,7 @@ export function matchesRegisteredStaticMessagingProfile( { ignoreError: true, suppressOutput: true, stdio: ["ignore", "pipe", "pipe"] }, ); if (exported.status !== 0) return false; - return registeredMessagingProfileMatchesCheckedInContract( + return staticProfileMatchesCheckedInBoundary( profile, bufferOrStringToText(exported.stdout), deps.readFileSync ?? ((file: string) => fs.readFileSync(file, "utf-8")), @@ -429,41 +464,14 @@ export function ensureMessagingBridgeProfiles( const exit = deps.exit ?? ((code?: number) => process.exit(code)); const readFileSync = deps.readFileSync ?? ((file: string) => fs.readFileSync(file, "utf-8")); - const rejectUntrustedProfile = (profile: MessagingBridgeProfile): void => { - errorLog( - `\n ✗ OpenShell provider profile '${profile.profileId}' could not be read or does not exactly match NemoClaw's checked-in ${profile.channelId} credential contract.`, - ); + const rejectMismatchedStaticProfile = (profile: MessagingBridgeProfile): void => { errorLog( - " Confirm the checked-in profile is readable; remove a conflicting gateway profile, then re-run onboarding.", + `\n ✗ OpenShell provider profile '${profile.profileId}' does not match NemoClaw's endpointless ${profile.channelId} credential contract.`, ); + errorLog(" Remove the conflicting profile and re-run onboarding."); exit(1); }; - const exportProfile = (profile: MessagingBridgeProfile) => - deps.runOpenshell(["provider", "profile", "export", profile.profileId, "--output", "json"], { - ignoreError: true, - suppressOutput: true, - stdio: ["ignore", "pipe", "pipe"], - }); - - const acceptRegisteredProfile = ( - profile: MessagingBridgeProfile, - exported: ReturnType, - ): boolean => { - if ( - exported.status === 0 && - registeredMessagingProfileMatchesCheckedInContract( - profile, - bufferOrStringToText(exported.stdout), - readFileSync, - ) - ) { - return true; - } - rejectUntrustedProfile(profile); - return false; - }; - for (const profile of active) { // Onboard registers each bridge provider twice: once up front so an // interrupted run can resume, then again during create-plan materialization. @@ -471,9 +479,22 @@ export function ensureMessagingBridgeProfiles( // "already exists" error. A fresh gateway answers the probe with a harmless // "not found" that suppressOutput hides — only the exit status says whether // the profile already exists. - const alreadyRegistered = exportProfile(profile); + const alreadyRegistered = deps.runOpenshell( + ["provider", "profile", "export", profile.profileId, "--output", "json"], + { ignoreError: true, suppressOutput: true, stdio: ["ignore", "pipe", "pipe"] }, + ); if (alreadyRegistered.status === 0) { - if (!acceptRegisteredProfile(profile, alreadyRegistered)) return; + if ( + profile.strategy === null && + !staticProfileMatchesCheckedInBoundary( + profile, + bufferOrStringToText(alreadyRegistered.stdout), + readFileSync, + ) + ) { + rejectMismatchedStaticProfile(profile); + return; + } continue; } // Probe failed for something other than "not found" (gateway down, auth, …): @@ -491,20 +512,34 @@ export function ensureMessagingBridgeProfiles( ["provider", "profile", "import", "--file", profile.profilePath], { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, ); - if (result.status === 0) { - if (!acceptRegisteredProfile(profile, exportProfile(profile))) return; - continue; - } + if (result.status === 0) continue; // Reconcile a lost race: the probe saw no profile but a concurrent import made it. const rawDiagnostic = `${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`; if (/already exists/i.test(rawDiagnostic)) { - if (!acceptRegisteredProfile(profile, exportProfile(profile))) return; + if (profile.strategy !== null) continue; + const racedProfile = deps.runOpenshell( + ["provider", "profile", "export", profile.profileId, "--output", "json"], + { ignoreError: true, suppressOutput: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + if ( + racedProfile.status !== 0 || + !staticProfileMatchesCheckedInBoundary( + profile, + bufferOrStringToText(racedProfile.stdout), + readFileSync, + ) + ) { + rejectMismatchedStaticProfile(profile); + return; + } continue; } const diagnostic = compactText(deps.redact(rawDiagnostic)); - errorLog(`\n ✗ Failed to register the ${profile.channelId} provider profile with OpenShell.`); + errorLog( + `\n ✗ Failed to register the ${profile.channelId} provider profile with OpenShell.`, + ); if (diagnostic) errorLog(` ${diagnostic.slice(0, 500)}`); errorLog(" Update OpenShell with scripts/install-openshell.sh and re-run onboarding."); exit(result.status || 1); From 2b55927f7975bc4afe30d50b3ebf6b9c875e7953 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 11:46:40 -0700 Subject: [PATCH 54/57] refactor(cli): defer inference provider migration --- .../inference-set-failure-handling.test.ts | 2 +- ...inference-set-provider-diagnostics.test.ts | 47 ++++--------------- .../inference-set-provider-diagnostics.ts | 26 ++-------- src/lib/actions/inference-set.ts | 7 +-- src/lib/credentials/provider-list.ts | 12 +++++ 5 files changed, 29 insertions(+), 65 deletions(-) diff --git a/src/lib/actions/inference-set-failure-handling.test.ts b/src/lib/actions/inference-set-failure-handling.test.ts index 461d6d7cc0a..d4c80a517bf 100644 --- a/src/lib/actions/inference-set-failure-handling.test.ts +++ b/src/lib/actions/inference-set-failure-handling.test.ts @@ -187,7 +187,7 @@ describe("runInferenceSet failure handling", () => { expect(message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); expect(deps.calls.captureOpenshell).toHaveBeenNthCalledWith( 2, - ["provider", "list", "-g", "nemoclaw", "--names"], + ["provider", "list", "--names"], { ignoreError: true, maxBuffer: 64 * 1024, timeout: 5_000 }, ); expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); diff --git a/src/lib/actions/inference-set-provider-diagnostics.test.ts b/src/lib/actions/inference-set-provider-diagnostics.test.ts index 8d587e609f1..adcbb068076 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.test.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; -import { classifyGatewayProviderNames, isBridgeProviderName } from "../credentials/provider-list"; +import { isBridgeProviderName, parseGatewayProviderNames } from "../credentials/provider-list"; import { queryRegisteredGatewayProviders } from "./inference-set-provider-diagnostics"; const STATIC_WARNING = @@ -16,44 +16,21 @@ describe("inference set provider diagnostics", () => { })); const log = vi.fn(); - expect(queryRegisteredGatewayProviders("nemoclaw", { captureOpenshell, log })).toEqual([ + expect(queryRegisteredGatewayProviders({ captureOpenshell, log })).toEqual([ "anthropic-prod", "nvidia-prod", ]); - expect(captureOpenshell).toHaveBeenCalledWith( - ["provider", "list", "-g", "nemoclaw", "--names"], - { - ignoreError: true, - maxBuffer: 64 * 1024, - timeout: 5_000, - }, - ); + expect(captureOpenshell).toHaveBeenCalledWith(["provider", "list", "--names"], { + ignoreError: true, + maxBuffer: 64 * 1024, + timeout: 5_000, + }); expect(log).not.toHaveBeenCalled(); }); - it("omits inventory without invoking OpenShell when an endpoint override is set (#9806)", () => { - const captureOpenshell = vi.fn(() => ({ status: 0, output: "ambient-provider" })); - const log = vi.fn(); - vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://untrusted.example.test"); - - try { - expect( - queryRegisteredGatewayProviders("nemoclaw", { captureOpenshell, log }), - ).toBeUndefined(); - } finally { - vi.unstubAllEnvs(); - } - - expect(captureOpenshell).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith(STATIC_WARNING); - }); - it("partitions empty and messaging-only provider output", () => { - expect(classifyGatewayProviderNames([])).toEqual({ - bridgeNames: [], - credentialNames: [], - }); - expect(classifyGatewayProviderNames(["alpha-telegram-bridge", "alpha-slack-app"])).toEqual({ + expect(parseGatewayProviderNames("")).toEqual({ bridgeNames: [], credentialNames: [] }); + expect(parseGatewayProviderNames("alpha-telegram-bridge\nalpha-slack-app\n")).toEqual({ bridgeNames: ["alpha-telegram-bridge", "alpha-slack-app"], credentialNames: [], }); @@ -88,15 +65,11 @@ describe("inference set provider diagnostics", () => { name: "nonzero status", capture: () => ({ status: 17, output: "query-secret" }), }, - { - name: "unsafe provider name", - capture: () => ({ status: 0, output: "alpha\n\u001b]52;c;YXR0YWNr\u0007" }), - }, ])("uses the static fallback for $name", ({ capture }) => { const captureOpenshell = vi.fn(capture); const log = vi.fn(); - expect(queryRegisteredGatewayProviders("nemoclaw", { captureOpenshell, log })).toBeUndefined(); + expect(queryRegisteredGatewayProviders({ captureOpenshell, log })).toBeUndefined(); expect(log).toHaveBeenCalledWith(STATIC_WARNING); expect(log).not.toHaveBeenCalledWith(expect.stringContaining("query-secret")); }); diff --git a/src/lib/actions/inference-set-provider-diagnostics.ts b/src/lib/actions/inference-set-provider-diagnostics.ts index 40b778640eb..7c3aee69ceb 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.ts @@ -2,12 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { CaptureOpenshellOptions, CaptureOpenshellResult } from "../adapters/openshell/client"; -import { - assertNoOpenShellGatewayEndpointOverride, - scopeGatewayOpenshellArgs, -} from "../adapters/openshell/gateway-scope"; -import { parseCliOpenShellProviderNames } from "../adapters/openshell/provider-command"; -import { classifyGatewayProviderNames } from "../credentials/provider-list"; +import { parseGatewayProviderNames } from "../credentials/provider-list"; import { buildOpenshellInferenceSetFailureMessage, OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER, @@ -24,23 +19,15 @@ interface ProviderDiagnosticDeps { log: (message: string) => void; } -export function queryRegisteredGatewayProviders( - gatewayName: string, - deps: ProviderDiagnosticDeps, -): string[] | undefined { +export function queryRegisteredGatewayProviders(deps: ProviderDiagnosticDeps): string[] | undefined { try { - assertNoOpenShellGatewayEndpointOverride(); - const args = scopeGatewayOpenshellArgs(["provider", "list", "--names"], gatewayName); - const result = deps.captureOpenshell(args, { + const result = deps.captureOpenshell(["provider", "list", "--names"], { ignoreError: true, maxBuffer: OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER, timeout: OPEN_SHELL_DIAGNOSTIC_TIMEOUT_MS, }); if (result.status === 0) { - const providerNames = parseCliOpenShellProviderNames(result.output); - if (providerNames) { - return classifyGatewayProviderNames(providerNames).credentialNames; - } + return parseGatewayProviderNames(result.output).credentialNames; } } catch (_error: unknown) { // #5924: intentionally treat every thrown query or parsing error identically. @@ -54,7 +41,6 @@ export function queryRegisteredGatewayProviders( export function buildInferenceSetFailure( setResult: CaptureOpenshellResult, provider: string, - gatewayName: string, deps: ProviderDiagnosticDeps, ): { exitCode: number; message: string } { const stderr = typeof setResult.stderr === "string" ? setResult.stderr : ""; @@ -66,9 +52,7 @@ export function buildInferenceSetFailure( message: buildOpenshellInferenceSetFailureMessage({ exitCode, providerNotFound, - registeredProviders: providerNotFound - ? queryRegisteredGatewayProviders(gatewayName, deps) - : undefined, + registeredProviders: providerNotFound ? queryRegisteredGatewayProviders(deps) : undefined, stderr, stdout, }), diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 58349f85eeb..edb26b9056f 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -1148,12 +1148,7 @@ async function runInferenceSetWithoutHostLock( setResult = setInferenceRoute(); } if (setResult.status !== 0) { - const failure = buildInferenceSetFailure( - setResult, - provider, - preparedRoute.gatewayName, - deps, - ); + const failure = buildInferenceSetFailure(setResult, provider, deps); throw new InferenceSetError(failure.message, failure.exitCode); } appliedInferenceSelection = true; diff --git a/src/lib/credentials/provider-list.ts b/src/lib/credentials/provider-list.ts index ce70520d6c6..5465de437c3 100644 --- a/src/lib/credentials/provider-list.ts +++ b/src/lib/credentials/provider-list.ts @@ -18,3 +18,15 @@ export function classifyGatewayProviderNames(names: readonly string[]): { credentialNames: names.filter((name) => !isBridgeProviderName(name)).sort(), }; } + +export function parseGatewayProviderNames(output: unknown): { + bridgeNames: string[]; + credentialNames: string[]; +} { + return classifyGatewayProviderNames( + String(output ?? "") + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0), + ); +} From c5d5ec202e86c5d6cf150009e84d173a01f56f7b Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:59:36 -0700 Subject: [PATCH 55/57] test(cli): tag provider lifecycle cases Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../actions/sandbox/snapshot-managed-clone-providers.test.ts | 2 +- src/lib/adapters/openshell/provider-profile.test.ts | 2 +- test/e2e/live/sandbox-operations.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts index 734b97b556f..54af019d796 100644 --- a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts +++ b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts @@ -271,7 +271,7 @@ function prepareWithBinding(input: { describe("managed clone provider transaction", () => { it.each(["openclaw", "hermes", "langchain-deepagents-code"] as const)( - "keeps the %s transaction provider-neutral, secret-free, and deeply frozen", + "keeps the %s transaction provider-neutral, secret-free, and deeply frozen (#8931)", (agent) => { const { prepared } = prepareWithBinding({ agent }); diff --git a/src/lib/adapters/openshell/provider-profile.test.ts b/src/lib/adapters/openshell/provider-profile.test.ts index c6c0ccc925d..9ab6f9d5cbc 100644 --- a/src/lib/adapters/openshell/provider-profile.test.ts +++ b/src/lib/adapters/openshell/provider-profile.test.ts @@ -47,7 +47,7 @@ describe("OpenShell endpointless provider profiles", () => { `\n ✗ OpenShell provider profile '${PROFILE_ID}' already exists but does not match NemoClaw's endpointless inference contract.`, " Remove the conflicting profile, then retry this command.", ], - ] as const)("returns recovery guidance for a %s profile result", (reason, summary, action) => { + ] as const)("returns recovery guidance for a %s profile result (#9806)", (reason, summary, action) => { expect(endpointlessProviderProfileFailureMessages(reason)).toEqual([summary, action]); }); diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index 671758ea392..cd3d74c0ad0 100644 --- a/test/e2e/live/sandbox-operations.test.ts +++ b/test/e2e/live/sandbox-operations.test.ts @@ -690,7 +690,7 @@ async function assertGatewayRecovery( } test( - "credentials reset removes a provider attached during sandbox rebuild", + "credentials reset removes a provider attached during sandbox rebuild (#9806)", { timeout: 45 * 60_000, meta: { From 641a5b92659f58b0d86a5ee408c224f906842480 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 13:09:03 -0700 Subject: [PATCH 56/57] fix(cli): close credential review findings --- .../quickstart-langchain-deepagents-code.mdx | 2 +- src/lib/actions/credentials-add.ts | 15 ++++----- .../credentials-provider-adapter.test.ts | 24 +++++++------- test/e2e/live/sandbox-operations.test.ts | 32 +++++++++++++++---- 4 files changed, 46 insertions(+), 27 deletions(-) diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 3880965337f..31a6f0459bf 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -209,7 +209,7 @@ Remove the target sandbox's managed-Python opt-in when it is no longer needed. nemo-deepagents policy remove tavily --yes ``` -This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, destroy those sandboxes or detach it from each one with `openshell sandbox provider detach tavily-search`, then remove it globally with `nemo-deepagents credentials reset tavily-search --yes`. OpenShell rejects provider deletion while any sandbox still has it attached. +This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. When no sandbox needs the provider, remove it globally with `nemo-deepagents credentials reset tavily-search --yes`. The command detaches current sandbox attachments, removes the gateway provider, and reports every detached sandbox that you must rebuild after registering a replacement. diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index f66b45419fb..050c7837552 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -224,11 +224,11 @@ async function reconcileUncertainProviderCreate( }); if (inventory.ok && inventory.value.names.includes(provider)) { return { - keepReservation: true, + keepReservation: false, lines: [ - ` OpenShell reports provider '${provider}' is registered; local provider ownership was preserved.`, - ` Verify with '${CLI_NAME} credentials list'.`, - ` Rebuild each sandbox that should use '${provider}' (\`${CLI_NAME} rebuild\`).`, + ` OpenShell reports a provider named '${provider}', but a name-only inventory cannot verify that this command created it.`, + " Local provider ownership was not recorded.", + " Do not rebuild a sandbox from this result. Resolve the provider through a verified gateway operation, then retry.", ], }; } @@ -242,11 +242,10 @@ async function reconcileUncertainProviderCreate( }; } return { - keepReservation: true, + keepReservation: false, lines: [ - ` Could not determine whether provider '${provider}' was registered; local provider ownership was preserved.`, - ` Run '${CLI_NAME} credentials list' to inspect the gateway before retrying.`, - ` If the provider exists, rebuild each sandbox that should use '${provider}'; otherwise run '${CLI_NAME} credentials reset ${provider} --yes' before retrying.`, + ` Could not determine whether provider '${provider}' was registered; local provider ownership was not recorded.`, + " Do not rebuild a sandbox from this result. Resolve the provider through a verified gateway operation, then retry.", ` ${inventory.error.message}`, ], }; diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index d03d935e201..6411c91fc07 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -293,17 +293,18 @@ describe("credential actions use typed OpenShell provider results", () => { it.each([ { - case: "timed out and is confirmed present", + case: "timed out and a name-only inventory finds the provider", createError: { kind: "timeout", message: "The OpenShell provider operation timed out.", } as const, inventory: { ok: true, value: { names: ["custom-provider"] } } as const, expectedLines: [ - " OpenShell reports provider 'custom-provider' is registered; local provider ownership was preserved.", - " Rebuild each sandbox that should use 'custom-provider' (`nemoclaw rebuild`).", + " OpenShell reports a provider named 'custom-provider', but a name-only inventory cannot verify that this command created it.", + " Local provider ownership was not recorded.", + " Do not rebuild a sandbox from this result. Resolve the provider through a verified gateway operation, then retry.", ], - forgetCalls: 0, + forgetCalls: 1, }, { case: "has no status and is confirmed present", @@ -314,10 +315,11 @@ describe("credential actions use typed OpenShell provider results", () => { } as const, inventory: { ok: true, value: { names: ["custom-provider"] } } as const, expectedLines: [ - " OpenShell reports provider 'custom-provider' is registered; local provider ownership was preserved.", - " Rebuild each sandbox that should use 'custom-provider' (`nemoclaw rebuild`).", + " OpenShell reports a provider named 'custom-provider', but a name-only inventory cannot verify that this command created it.", + " Local provider ownership was not recorded.", + " Do not rebuild a sandbox from this result. Resolve the provider through a verified gateway operation, then retry.", ], - forgetCalls: 0, + forgetCalls: 1, }, { case: "has no status and is confirmed absent", @@ -342,11 +344,10 @@ describe("credential actions use typed OpenShell provider results", () => { error: { kind: "timeout", message: "The provider inventory query timed out." }, } as const, expectedLines: [ - " Could not determine whether provider 'custom-provider' was registered; local provider ownership was preserved.", - " Run 'nemoclaw credentials list' to inspect the gateway before retrying.", - " If the provider exists, rebuild each sandbox that should use 'custom-provider'; otherwise run 'nemoclaw credentials reset custom-provider --yes' before retrying.", + " Could not determine whether provider 'custom-provider' was registered; local provider ownership was not recorded.", + " Do not rebuild a sandbox from this result. Resolve the provider through a verified gateway operation, then retry.", ], - forgetCalls: 0, + forgetCalls: 1, }, ])("reconciles provider creation when the result $case (#9806)", async (testCase) => { vi.stubEnv("CUSTOM_TOKEN", "host-only-value"); @@ -384,6 +385,7 @@ describe("credential actions use typed OpenShell provider results", () => { timeoutMs: 30_000, }); expect(result.failureLines).toEqual(expect.arrayContaining(testCase.expectedLines)); + expect(result.failureLines.join("\n")).not.toContain(" rebuild"); expect(forgetExtraProvider).toHaveBeenCalledTimes(testCase.forgetCalls); }); diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index cd3d74c0ad0..a42cf7cd619 100644 --- a/test/e2e/live/sandbox-operations.test.ts +++ b/test/e2e/live/sandbox-operations.test.ts @@ -8,6 +8,7 @@ * and gateway recovery — without introducing another target framework. */ +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -194,26 +195,43 @@ async function execInSandbox( } function credentialBoundaryProbeScript(): string { - const encodedFixture = Buffer.from(CREDENTIAL_VALUE, "utf8").toString("base64"); - return `python3 - ${shellQuote(encodedFixture)} <<'PY' + const fixtureDigest = createHash("sha256").update(CREDENTIAL_VALUE, "utf8").digest("hex"); + return `python3 - ${shellQuote(fixtureDigest)} ${CREDENTIAL_VALUE.length} <<'PY' from pathlib import Path -import base64 +import hashlib import os import sys -secret = base64.b64decode(sys.argv[1], validate=True) +secret_digest = bytes.fromhex(sys.argv[1]) +secret_length = int(sys.argv[2]) def contains_secret(path): try: - return secret in Path(path).read_bytes() + content = Path(path).read_bytes() except OSError: return False + if len(content) < secret_length: + return False + view = memoryview(content) + return any( + hashlib.sha256(view[offset:offset + secret_length]).digest() == secret_digest + for offset in range(len(content) - secret_length + 1) + ) + +def contains_secret_bytes(content): + if len(content) < secret_length: + return False + view = memoryview(content) + return any( + hashlib.sha256(view[offset:offset + secret_length]).digest() == secret_digest + for offset in range(len(content) - secret_length + 1) + ) environment = b"\\0".join( f"{key}={value}".encode("utf-8", errors="surrogateescape") for key, value in os.environ.items() ) -if secret in environment: +if contains_secret_bytes(environment): raise SystemExit(98) managed_config_files = 0 @@ -247,7 +265,7 @@ for process in Path("/proc").iterdir(): except OSError: continue agent_environment_inspected = True - if secret in command or secret in agent_environment: + if contains_secret_bytes(command) or contains_secret_bytes(agent_environment): raise SystemExit(98) if managed_config_files == 0 or not agent_environment_inspected: From 9e43b0daa60c7d04298c0dc96a6c99f686bb055a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 14:18:17 -0700 Subject: [PATCH 57/57] fix(cli): close provider lifecycle review gaps --- docs/reference/commands.mdx | 4 +- src/commands/credentials.test.ts | 31 ++++-- src/lib/actions/credentials-add.ts | 8 -- .../credentials-provider-adapter.test.ts | 103 ++++++++++++++++++ src/lib/actions/credentials/reset.ts | 5 +- src/lib/messaging/provider-profile.test.ts | 6 - 6 files changed, 129 insertions(+), 28 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 340f32d5aef..de3a33321eb 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3207,7 +3207,7 @@ $$nemoclaw credentials list ### `$$nemoclaw credentials add ` -Register a provider credential with the OpenShell gateway by name and type. Each `--credential` takes the env variable name whose value the gateway should read; export the value first so it is not placed in argv. Pass either repeatable `--credential ` or `--from-existing`, but do not combine them. `--from-existing` is available only when no managed MCP server reserves credential keys. The command fails before gateway work when a reservation exists because `--from-existing` does not expose credential keys before provider creation. Rerun with explicit `--credential ` input, or remove every managed MCP server that reserves credential keys before retrying. After the gateway accepts the provider, rebuild each sandbox that should use it. +Register a provider credential with the OpenShell gateway by name and type. Each `--credential` takes the env variable name whose value the gateway should read; export the value first so it is not placed in argv. Pass either repeatable `--credential ` or `--from-existing`, but do not combine them. With `--from-existing`, NemoClaw inspects the provider profile and rejects registration when one of its credential keys is reserved by a managed MCP server. After the gateway accepts the provider, rebuild each sandbox that should use it. Registered providers are gateway-wide and attach to every sandbox you build or rebuild after the call. `nemoclaw credentials reset ` removes the provider from the gateway and detaches it from every sandbox currently using it; it cannot limit the provider to selected sandboxes. To replace a credential, reset the provider, register the replacement, and rebuild each detached sandbox. @@ -3220,7 +3220,7 @@ $$nemoclaw credentials add tavily-search --type tavily --credential TAVILY_API_K | `--type ` | Provider type (e.g. `tavily`, `nvidia`, `openai`, `anthropic`, `generic`) | | `--credential ` | Env variable name whose value holds the credential. Repeatable | | `--config ` | Typed non-secret provider configuration. Supported: `OPENAI_BASE_URL=` with `--type openai`. DNS hostnames are rejected because this gateway-wide credential path cannot enforce admission-time address pins. URLs with credentials, query parameters, fragments, loopback, link-local, private, internal, or reserved IP destinations are also rejected. Configure hostname-based and trusted private inference endpoints through onboarding so NemoClaw can preserve their trust and address pins. Repeatable | -| `--from-existing` | Load credentials and config from existing local state when no managed MCP server reserves credential keys | +| `--from-existing` | Load credentials and config from existing local state after checking the provider profile for credential keys reserved by managed MCP servers | ### `$$nemoclaw credentials reset ` diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index 377eb1044a5..6d5bc624e93 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -206,7 +206,7 @@ describe("credentials oclif adapter source coverage", () => { expect(mocks.recordExtraProvider).not.toHaveBeenCalled(); }); - it("rejects --from-existing before gateway work when managed MCP reserves credentials (#9388)", async () => { + it("allows --from-existing after inspecting disjoint managed MCP credential keys (#9388)", async () => { mocks.listManagedMcpCredentialReservations.mockReturnValue([ { sandboxName: "hermes", @@ -214,23 +214,36 @@ describe("credentials oclif adapter source coverage", () => { credentialKeys: ["MAAS_GLEAN_TOKEN"], }, ]); + mocks.runOpenshellProviderCommand + .mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + id: "generic", + credentials: [{ env_vars: ["CUSTOM_TOKEN"] }], + }), + }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); const result = await runCredentialsAddAction({ - provider: "maas-glean", + provider: "custom-provider", type: "generic", credentials: [], configPairs: [], fromExisting: true, }); - expect(result.exitCode).toBe(1); - expect(result.failureLines.join("\n")).toContain( - "Cannot compare imported provider credentials with keys reserved by managed MCP servers.", + expect(result.exitCode).toBe(0); + expect(mocks.runOpenshellProviderCommand).toHaveBeenNthCalledWith( + 1, + ["provider", "profile", "-g", "nemoclaw", "export", "generic", "--output", "json"], + expect.any(Object), ); - expect(mocks.runOpenshellProviderCommand).not.toHaveBeenCalled(); - expect(mocks.recoverNamedGatewayRuntime).not.toHaveBeenCalled(); - expect(mocks.resolveGatewayCredentialMutationAuthority).not.toHaveBeenCalled(); - expect(mocks.recordExtraProvider).not.toHaveBeenCalled(); + expect(mocks.runOpenshellProviderCommand).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(["provider", "create", "custom-provider", "--from-existing"]), + expect.any(Object), + ); + expect(mocks.recordExtraProvider).toHaveBeenCalledWith("custom-provider"); }); it("releases a provider reservation when credential registration fails (#9388)", async () => { diff --git a/src/lib/actions/credentials-add.ts b/src/lib/actions/credentials-add.ts index 050c7837552..c5da13ee028 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -358,14 +358,6 @@ export async function runCredentialsAddAction( ); if (explicitCollision) return explicitCollision; - if (fromExisting && managedMcpReservations.length > 0) { - return fail([ - " --from-existing does not expose credential keys before provider creation.", - " Cannot compare imported provider credentials with keys reserved by managed MCP servers.", - " Rerun with explicit --credential input, or remove every managed MCP server that reserves credential keys before retrying.", - ]); - } - const recoveryFailureLines: string[] = []; const target = await recoverCredentialGatewayTargetOrExit("mutation", (lines) => { recoveryFailureLines.push(...lines); diff --git a/src/lib/actions/credentials-provider-adapter.test.ts b/src/lib/actions/credentials-provider-adapter.test.ts index 6411c91fc07..b8f5d830bae 100644 --- a/src/lib/actions/credentials-provider-adapter.test.ts +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -555,6 +555,77 @@ describe("credential actions use typed OpenShell provider results", () => { expect(adapter.createProvider).not.toHaveBeenCalled(); }); + it("creates from existing credentials when inspected keys do not overlap managed MCP reservations (#9806)", async () => { + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime: async () => ({ recovered: true }), + recordExtraProvider: () => true, + forgetExtraProvider: () => true, + listManagedMcpCredentialReservations: () => [ + { + sandboxName: "hermes", + server: "maas-glean", + credentialKeys: ["MAAS_GLEAN_TOKEN"], + }, + ], + }); + const inspectProviderProfile = vi.fn( + async () => ({ ok: true, value: { credentialKeys: ["CUSTOM_TOKEN"] } }), + ); + const adapter = providerAdapter({ inspectProviderProfile }); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "generic", + credentials: [], + configPairs: [], + fromExisting: true, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(adapter.inspectProviderProfile).toHaveBeenCalledOnce(); + expect(adapter.createProvider).toHaveBeenCalledOnce(); + }); + + it("rejects existing credentials whose inspected key overlaps a managed MCP reservation (#9806)", async () => { + setGlobalCliActionRuntimeHooksForTest({ + recoverNamedGatewayRuntime: async () => ({ recovered: true }), + recordExtraProvider: () => true, + forgetExtraProvider: () => true, + listManagedMcpCredentialReservations: () => [ + { + sandboxName: "hermes", + server: "maas-glean", + credentialKeys: ["MAAS_GLEAN_TOKEN"], + }, + ], + }); + const inspectProviderProfile = vi.fn( + async () => ({ ok: true, value: { credentialKeys: ["MAAS_GLEAN_TOKEN"] } }), + ); + const adapter = providerAdapter({ inspectProviderProfile }); + + const result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "generic", + credentials: [], + configPairs: [], + fromExisting: true, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines.join("\n")).toContain( + "Credential key 'MAAS_GLEAN_TOKEN' is reserved by managed MCP server 'maas-glean' on sandbox 'hermes'", + ); + expect(adapter.inspectProviderProfile).toHaveBeenCalledOnce(); + expect(adapter.createProvider).not.toHaveBeenCalled(); + }); + it("lists credentials separately from messaging bridge providers (#9806)", async () => { const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ ok: true, @@ -843,6 +914,38 @@ describe("credential actions use typed OpenShell provider results", () => { expect(result.failureLines).toContain(" provider deletion failed"); }); + it("reports rebuild guidance when final deletion succeeds after a concurrent detach (#9806)", async () => { + const deleteProvider = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: { + kind: "command", + reason: "attached", + message: "provider remains attached", + attachedSandboxes: ["alpha"], + }, + }) + .mockResolvedValueOnce({ ok: true }); + const detachProvider = vi.fn(async () => ({ + ok: false, + error: { kind: "command", reason: "failed", message: "detach raced" }, + })); + const adapter = providerAdapter({ deleteProvider, detachProvider }); + + const result = await runCredentialsResetAction( + { provider: "custom-provider", confirmed: true }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toContain( + " Provider 'custom-provider' was detached from sandbox(es): alpha during removal.", + ); + expect(result.outputLines).toContain(" nemoclaw alpha rebuild"); + expect(detachProvider).toHaveBeenCalledOnce(); + }); + it("cleans local state when concurrent deletion settles detach recovery (#9806)", async () => { const forgetExtraProvider = vi.fn(() => true); setGlobalCliActionRuntimeHooksForTest({ diff --git a/src/lib/actions/credentials/reset.ts b/src/lib/actions/credentials/reset.ts index fa5bd96fbff..d8fd32202ab 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -190,8 +190,7 @@ export function formatResetOutcome( ), " Detach the provider from each remaining sandbox:", ...stuckSandboxes.map( - (sandbox) => - ` openshell sandbox provider detach -g ${gatewayName} ${sandbox} ${key}`, + (sandbox) => ` openshell sandbox provider detach -g ${gatewayName} ${sandbox} ${key}`, ), ` Then rerun '${CLI_NAME} credentials reset ${key}'.`, ); @@ -241,6 +240,6 @@ async function deleteProviderWithRecovery( } result = await providerAdapter.deleteProvider(request); return result.ok - ? { ok: true, detachedSandboxes, recoveryFailures } + ? { ok: true, detachedSandboxes: attachedSandboxes, recoveryFailures } : { ok: false, error: result.error, detachedSandboxes, recoveryFailures }; } diff --git a/src/lib/messaging/provider-profile.test.ts b/src/lib/messaging/provider-profile.test.ts index 2f010d1f102..5536f9c14e0 100644 --- a/src/lib/messaging/provider-profile.test.ts +++ b/src/lib/messaging/provider-profile.test.ts @@ -10,14 +10,8 @@ import { MESSAGING_CREDENTIAL_PROVIDER_TYPE, messagingCredentialProviderProfilePath, } from "./provider-profile"; -import * as providerProfileModule from "./provider-profile"; describe("messaging credential provider profile", () => { - it("exports only the messaging-specific profile surface (#10155)", () => { - expect(providerProfileModule).not.toHaveProperty("endpointlessProviderProfilePath"); - expect(providerProfileModule).not.toHaveProperty("ensureEndpointlessProviderProfile"); - }); - it("resolves the checked-in profile from the source repository root (#9875)", () => { expect(messagingCredentialProviderProfilePath(REPOSITORY_ROOT)).toBe( path.join(REPOSITORY_ROOT, "nemoclaw-blueprint", "provider-profiles", "nemoclaw-mcp-v1.yaml"),