diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index bd297984820..eeeeb16a16c 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": 54, "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, 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/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index f1781b99741..2c1228a39b3 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/docs/reference/commands.mdx b/docs/reference/commands.mdx index ed7c80707e8..de3a33321eb 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3207,9 +3207,9 @@ $$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. 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 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 @@ -3219,8 +3219,8 @@ $$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 | -| `--from-existing` | Load credentials and config from existing local state when no managed MCP server reserves credential keys | +| `--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 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 320a5542b88..6d5bc624e93 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -27,15 +27,22 @@ 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, })); 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"; @@ -77,9 +84,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"], @@ -101,7 +111,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"], @@ -141,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([ @@ -169,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", @@ -177,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 () => { @@ -223,17 +273,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", @@ -245,25 +297,27 @@ 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( - ["provider", "profile", "export", "openai", "--output", "json"], - { - ignoreError: true, - suppressOutput: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: 30_000, - }, - ); + expect(mocks.runOpenshellProviderCommand.mock.calls.map(([args]) => args)).toEqual([ + [ + "provider", + "profile", + "-g", + "nemoclaw", + "import", + "--file", + expect.stringMatching(/provider-profiles\/openai\.yaml$/u), + ], + ["provider", "profile", "-g", "nemoclaw", "export", "openai", "--output", "json"], + ]); 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 (#9806)", async () => { vi.stubEnv("OPENAI_API_KEY", "host-only-secret"); mocks.runOpenshellProviderCommand.mockReturnValueOnce({ status: null, @@ -280,15 +334,24 @@ 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", + "-g", + "nemoclaw", + "import", + "--file", + expect.stringMatching(/provider-profiles\/openai\.yaml$/u), + ], { ignoreError: true, - suppressOutput: true, stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, }, @@ -296,11 +359,21 @@ 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 (#9806)", 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, + stdout: JSON.stringify({ + id: "openai", + credentials: [], + endpoints: [], + binaries: [], + inference_capable: true, + }), + stderr: "", + }) .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); const result = await runCredentialsAddAction({ @@ -313,17 +386,21 @@ 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", + "-g", + "nemoclaw", "import", "--file", expect.stringMatching(/provider-profiles\/openai\.yaml$/u), ], + ["provider", "profile", "-g", "nemoclaw", "export", "openai", "--output", "json"], [ "provider", "create", + "-g", + "nemoclaw", "--name", "openai-prod", "--type", @@ -337,7 +414,6 @@ describe("credentials oclif adapter source coverage", () => { ).toEqual([ { ignoreError: true, - suppressOutput: true, stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, }, @@ -350,11 +426,13 @@ 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 (#9806)", 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", @@ -365,10 +443,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( + "Fix the reported OpenShell provider-profile error, then 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/commands/credentials/add.ts b/src/commands/credentials/add.ts index ac826542282..cc37fb41950 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. 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 5c2d93acbbf..c5da13ee028 100644 --- a/src/lib/actions/credentials-add.ts +++ b/src/lib/actions/credentials-add.ts @@ -2,20 +2,23 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import { isIP } from "node:net"; import path from "node:path"; -import { runOpenshellProviderCommand } from "../adapters/openshell/provider-command"; -import { - checkOpenAiInferenceProviderProfile, - OPENAI_GATEWAY_PROVIDER_TYPE, -} from "../adapters/openshell/provider-profile"; +import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; +import type { + OpenShellProviderAdapter, + OpenShellProviderError, +} from "../adapters/openshell/provider-adapter"; +import type { OpenShellGatewayTarget } from "../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { CLI_NAME } from "../cli/branding"; import { isBridgeProviderName, - recoverGatewayForCredentialMutationOrExit, + recoverCredentialGatewayTargetOrExit, } from "../credentials/command-support"; -import { redact } from "../security/redact"; +import { gatewayStartGuidance } from "../gateway-start-guidance"; 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 { @@ -38,6 +41,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 = @@ -45,6 +52,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: [] }; @@ -74,96 +82,181 @@ 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; +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 { - profile = JSON.parse(output); + baseUrl = new URL(value); } catch { - return null; + return [ + " --config 'OPENAI_BASE_URL' must be an absolute HTTP(S) URL without credentials, query parameters, or a fragment.", + ]; } - 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); - } + 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 [...keys].sort(); + return null; } -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(), - }; -} +async function providerConfigEndpointFailure( + config: readonly { key: string; value: string }[], +): Promise { + const baseUrl = config.find((entry) => entry.key === "OPENAI_BASE_URL")?.value; + if (!baseUrl) return null; + + 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; -function bundledProviderProfilePath(type: string): string { - return path.join(ROOT, "nemoclaw-blueprint", "provider-profiles", `${type.toLowerCase()}.yaml`); + 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 ensureBundledProviderProfile(type: string): CredentialsAddResult | null { - 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, - }, +function bundledProviderProfile(type: string): { profileType: string; profilePath: string } | null { + const profileType = type.toLowerCase(); + const profilePath = path.join( + ROOT, + "nemoclaw-blueprint", + "provider-profiles", + `${profileType}.yaml`, ); - if (result.status === 0) return null; + return fs.existsSync(profilePath) ? { profileType, profilePath } : null; +} - const rawDiagnostic = `${String(result.stderr || "")} ${String(result.stdout || "")}`; - if (/already exists/i.test(rawDiagnostic)) return null; +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."]; + } +} - const redactedDiagnostic = redact(rawDiagnostic).trim(); +async function ensureBundledProviderProfile( + profile: { profileType: string; profilePath: string } | null, + target: OpenShellGatewayTarget, + providerAdapter: OpenShellProviderAdapter, +): Promise { + if (!profile) return null; + + const result = await providerAdapter.importProviderProfile({ + target, + 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 '${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}'.`, - " Update OpenShell with scripts/install-openshell.sh and retry.", - ...(redactedDiagnostic ? [` ${redactedDiagnostic}`] : []), + ` Could not import bundled provider profile '${profile.profileType}'.`, + ...bundledProviderProfileRecoveryLines(result.error), + ` ${result.error.message}`, ]); } -function ensureCredentialProviderProfile(type: string): CredentialsAddResult | null { - if (type.toLowerCase() !== OPENAI_GATEWAY_PROVIDER_TYPE) { - return ensureBundledProviderProfile(type); - } - const profile = checkOpenAiInferenceProviderProfile({ - runOpenshell: (args, options) => - runOpenshellProviderCommand(args, { - ...options, - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }), +function isUncertainProviderCreateError(error: OpenShellProviderError): boolean { + return ( + error.kind === "timeout" || + (error.kind === "transport" && error.reason === "unreachable") || + (error.kind === "command" && error.reason === "uncertain") + ); +} + +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, }); - return profile.ok ? null : fail(profile.messages); + if (inventory.ok && inventory.value.names.includes(provider)) { + return { + keepReservation: false, + lines: [ + ` 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.", + ], + }; + } + if (inventory.ok) { + return { + keepReservation: false, + lines: [ + ` OpenShell confirms provider '${provider}' is absent.`, + " It is safe to retry the credentials add command.", + ], + }; + } + return { + keepReservation: false, + lines: [ + ` 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}`, + ], + }; } 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([ @@ -196,7 +289,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)) { @@ -213,6 +306,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.`]); @@ -224,7 +319,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)) { @@ -243,8 +338,18 @@ 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 endpointFailure = await providerConfigEndpointFailure(config); + if (endpointFailure) return fail(endpointFailure); + const managedMcpReservations = listManagedMcpCredentialReservations(); const explicitCollision = managedMcpCollisionFailure( provider, @@ -253,51 +358,41 @@ 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 recovered = await recoverGatewayForCredentialMutationOrExit((lines) => { + const target = await recoverCredentialGatewayTargetOrExit("mutation", (lines) => { recoveryFailureLines.push(...lines); }); - if (!recovered) { + if (!target) { return fail(recoveryFailureLines); } - const providerProfileFailure = ensureCredentialProviderProfile(type); + 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 = inspectProviderProfileCredentialKeys(type); - if (!inspection.credentialKeys) { + const inspection = await providerAdapter.inspectProviderProfile({ + target, + profileType: providerType, + 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; - } - - 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); + importedCredentialKeys = [...inspection.value.credentialKeys]; } - return withMcpCredentialOwnershipLock(() => { + return withMcpCredentialOwnershipLock(async () => { const providerCredentialKeys = importedCredentialKeys ?? credentials; const collision = managedMcpCollisionFailure( provider, @@ -309,35 +404,43 @@ 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, + name: provider, + type: providerType, + 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.`, ` 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\`).`, ]); } - const rawStderr = String(result.stderr || "").trim(); - const redactedStderr = redact(rawStderr); const lines = [` Could not register provider '${provider}'.`]; - if (/already exists/i.test(rawStderr)) { + 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( "", ` '${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..b8f5d830bae --- /dev/null +++ b/src/lib/actions/credentials-provider-adapter.test.ts @@ -0,0 +1,1178 @@ +// 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 { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli"; +import type { + OpenShellProviderAdapter, + OpenShellProviderError, +} 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(), +})); + +vi.mock("../gateway-start-guidance", () => ({ + gatewayStartGuidance: () => "Start the gateway again with `nemoclaw onboard`.", +})); + +function providerAdapter( + overrides: Partial = {}, +): OpenShellProviderAdapter { + const listProviders: OpenShellProviderAdapter["listProviders"] = async () => ({ + ok: true, + value: { names: [] }, + }); + const createProvider: OpenShellProviderAdapter["createProvider"] = async () => ({ + ok: true, + }); + const importProviderProfile: OpenShellProviderAdapter["importProviderProfile"] = async () => ({ + ok: true, + }); + const inspectProviderProfile: OpenShellProviderAdapter["inspectProviderProfile"] = async () => ({ + ok: true, + value: { credentialKeys: [] }, + }); + const deleteProvider: OpenShellProviderAdapter["deleteProvider"] = async () => ({ + ok: true, + }); + const detachProvider: OpenShellProviderAdapter["detachProvider"] = async () => ({ + ok: true, + }); + 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: "openai", + credentials: ["CUSTOM_TOKEN"], + configPairs: ["OPENAI_BASE_URL=https://93.184.216.34/v1"], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(0); + expect(adapter.createProvider).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + name: "custom-provider", + type: "openai", + credentials: [{ name: "CUSTOM_TOKEN", value: "credential-value" }], + config: [{ key: "OPENAI_BASE_URL", value: "https://93.184.216.34/v1" }], + fromExisting: false, + timeoutMs: 30_000, + }); + 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"], + ])("rejects an OpenAI base URL targeting a %s (#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[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(); + }); + + 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 result = await runCredentialsAddAction( + { + provider: "custom-provider", + type: "openai", + credentials: ["CUSTOM_TOKEN"], + configPairs: ["OPENAI_BASE_URL=https://public-looking.example/v1"], + fromExisting: false, + }, + { providerAdapter: adapter }, + ); + + expect(result.exitCode).toBe(1); + expect(result.failureLines[0]).toBe( + " --config 'OPENAI_BASE_URL' accepts only a public IP-literal URL.", + ); + 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(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(); + + 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(); + + 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: "named", gatewayName: "nemoclaw" }, + profilePath: expect.stringMatching(/provider-profiles\/openai\.yaml$/u), + timeoutMs: 30_000, + }); + 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: "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 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: 1, + }, + { + 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 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: 1, + }, + { + 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: "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." }, + } as const, + expectedLines: [ + " 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: 1, + }, + ])("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 }, + ); + + 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(result.failureLines.join("\n")).not.toContain(" rebuild"); + 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 () => ({ + 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 () => ({ + 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.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 () => ({ + 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.inspectProviderProfile).toHaveBeenCalledWith({ + target: { kind: "named", gatewayName: "nemoclaw" }, + profileType: "generic", + timeoutMs: 30_000, + }); + 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, + value: { names: ["alpha-telegram-bridge", "zeta", "alpha"] }, + }); + const adapter = providerAdapter({ + listProviders: vi.fn(listProviders), + }); + + 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"); + 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([ + ["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("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", "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.", + null, + "identity_mismatch", + ], + [ + "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, expectedGuidance, 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).toEqual([ + " Could not query OpenShell providers on gateway 'nemoclaw'.", + ` ${message}`, + ...(expectedGuidance ? [` ${expectedGuidance}`] : []), + ]); + }, + ); + + 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 + .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 }; + }); + const detachProvider = vi.fn(async () => { + operations.push("detach:alpha"); + return { ok: true }; + }); + 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: "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.", + ); + expect(result.outputLines).toContain(" nemoclaw alpha rebuild"); + }); + + 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( + "Rerun '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() + .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("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({ + 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 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("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() + .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, + })); + 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( + " openshell sandbox provider detach -g nemoclaw beta custom-provider", + ); + expect(result.failureLines).toContain( + " Then rerun 'nemoclaw credentials reset custom-provider'.", + ); + }); + + 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).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", "team.alpha"], + }, + }); + 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/list.ts b/src/lib/actions/credentials/list.ts index ee556edb696..5a38d479133 100644 --- a/src/lib/actions/credentials/list.ts +++ b/src/lib/actions/credentials/list.ts @@ -1,10 +1,11 @@ // 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 { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; -import { recoverGatewayOrExit } from "../../credentials/command-support"; -import { parseGatewayProviderNames } from "../../credentials/provider-list"; +import { recoverCredentialGatewayTargetOrExit } from "../../credentials/command-support"; +import { classifyGatewayProviderNames } from "../../credentials/provider-list"; import { gatewayStartGuidance } from "../../gateway-start-guidance"; export type CredentialsListResult = { @@ -13,42 +14,57 @@ 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(cliName: string): Promise { +export async function runCredentialsListAction( + cliName: string, + 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 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, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, }); - if (result.status !== 0) { - return fail([ - " Could not query OpenShell gateway. Is it running?", - ` ${gatewayStartGuidance()}`, - ]); + if (!result.ok) { + 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()}`); + } + return fail(failureLines); } - 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."); + 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) { 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 d240da6ad3b..d8fd32202ab 100644 --- a/src/lib/actions/credentials/reset.ts +++ b/src/lib/actions/credentials/reset.ts @@ -1,19 +1,24 @@ // 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 type { OpenShellGatewayTarget } from "../../adapters/openshell/sandbox-observer"; import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; +import { + NAME_MAX_LENGTH, + NAME_VALID_PATTERN, + PROVIDER_NAME_VALID_PATTERN, +} from "../../name-validation"; 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 { - deleteProviderWithRecovery, - type ProviderDeleteWithRecoveryResult, -} from "../../onboard/sandbox-provider-cleanup"; -import { redact } from "../../security/redact"; import { forgetExtraProvider } from "../global"; export type CredentialsResetInput = { @@ -27,8 +32,39 @@ export type CredentialsResetResult = { failureLines: readonly string[]; }; +export type CredentialsResetDeps = Readonly<{ + providerAdapter?: OpenShellProviderAdapter; +}>; + +export type CredentialsProviderDeleteWithRecoveryResult = Readonly<{ + ok: boolean; + error?: OpenShellProviderError; + detachedSandboxes: readonly string[]; + recoveryFailures: readonly Readonly<{ + sandbox: string; + error: OpenShellProviderError; + }>[]; +}>; + 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: [] }; } @@ -37,16 +73,34 @@ 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 = {}, ): 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.`, ` 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.`, ]); } @@ -60,38 +114,31 @@ export async function runCredentialsResetAction( } const recoveryFailureLines: string[] = []; - const recovered = await recoverGatewayForCredentialMutationOrExit((lines) => { + const target = await recoverCredentialGatewayTargetOrExit("mutation", (lines) => { recoveryFailureLines.push(...lines); }); - 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, - }), - }); + if (!target) return fail(recoveryFailureLines); + + const providerAdapter = deps.providerAdapter ?? createCliOpenShellProviderAdapter(); + const recovery = await deleteProviderWithRecovery(key, target, 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([ 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), ]); } - const outcome = formatResetOutcome(key, recovery); + const outcome = formatResetOutcome(key, recovery, target.gatewayName); if (!outcome.ok) return fail(outcome.lines); forgetExtraProvider(key); @@ -101,13 +148,18 @@ export async function runCredentialsResetAction( /** Build the user-facing result after a provider delete attempt. */ export function formatResetOutcome( key: string, - recovery: ProviderDeleteWithRecoveryResult, + 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, - lines: [` Removed provider '${key}' from the OpenShell gateway.`, onboardHint], + lines: [ + ` Removed provider '${key}' from the OpenShell gateway.`, + onboardHint, + ...detachedSandboxGuidance(key, recovery.detachedSandboxes), + ], }; } @@ -121,16 +173,73 @@ 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}.`, - ` Detach it with 'openshell sandbox provider detach ${key}'`, - ` for each, then re-run '${CLI_NAME} credentials reset ${key}'.`, + ...recovery.recoveryFailures.map( + (failure) => + ` Could not detach provider '${key}' from sandbox '${failure.sandbox}': ${failure.error.message}`, + ), + " Detach the provider from each remaining sandbox:", + ...stuckSandboxes.map( + (sandbox) => ` openshell sandbox provider detach -g ${gatewayName} ${sandbox} ${key}`, + ), + ` Then rerun '${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.`, + ` 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`), ); } - 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, + target: OpenShellGatewayTarget, + providerAdapter: OpenShellProviderAdapter, +): Promise { + const request = { + target, + providerName, + 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, detachedSandboxes, recoveryFailures } + : { ok: false, error: result.error, detachedSandboxes, recoveryFailures }; + } + + const attachedSandboxes = validatedAttachedSandboxes(result.error); + if (attachedSandboxes.length === 0) { + return { ok: false, error: result.error, detachedSandboxes, recoveryFailures }; + } + + for (const sandbox of attachedSandboxes) { + const detach = await providerAdapter.detachProvider({ ...request, sandboxName: sandbox }); + if (detach.ok) detachedSandboxes.push(sandbox); + else recoveryFailures.push({ sandbox, error: detach.error }); + } + result = await providerAdapter.deleteProvider(request); + return result.ok + ? { ok: true, detachedSandboxes: attachedSandboxes, recoveryFailures } + : { ok: false, error: result.error, detachedSandboxes, recoveryFailures }; +} diff --git a/src/lib/actions/inference-set-provider-diagnostics.ts b/src/lib/actions/inference-set-provider-diagnostics.ts index eef83de9bf2..7c3aee69ceb 100644 --- a/src/lib/actions/inference-set-provider-diagnostics.ts +++ b/src/lib/actions/inference-set-provider-diagnostics.ts @@ -19,9 +19,7 @@ interface ProviderDiagnosticDeps { log: (message: string) => void; } -export function queryRegisteredGatewayProviders( - deps: ProviderDiagnosticDeps, -): string[] | undefined { +export function queryRegisteredGatewayProviders(deps: ProviderDiagnosticDeps): string[] | undefined { try { const result = deps.captureOpenshell(["provider", "list", "--names"], { ignoreError: true, 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..54af019d796 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 (#8931)", + (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/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/adapters/openshell/gateway-scope.ts b/src/lib/adapters/openshell/gateway-scope.ts new file mode 100644 index 00000000000..deb097a8524 --- /dev/null +++ b/src/lib/adapters/openshell/gateway-scope.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + assertNoExplicitOpenShellGatewayEndpoint, + assertNoOpenShellGatewayEndpointOverride, + OpenShellGatewayEndpointOverrideError, + type OpenShellGatewayEndpointEnvironment, +} from "../../openshell-gateway-endpoint-guard"; + +export { + assertNoOpenShellGatewayEndpointOverride, + OpenShellGatewayEndpointOverrideError, + 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 new file mode 100644 index 00000000000..486293bb33e --- /dev/null +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -0,0 +1,759 @@ +// 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 } : {}) }; +} + +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("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 }); + + 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.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 }); + 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 }); + + 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("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("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({ + 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("treats an existing provider profile as already present (#9806)", async () => { + 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({ + target: selectedOpenShellGateway(), + profilePath: "/repo/profile.yaml", + }), + ).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"], + ]); + 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 }); + 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 () => { + const run = vi.fn(() => + captured( + 0, + JSON.stringify({ + id: "custom", + credentials: [{ env_vars: ["ZETA_TOKEN", "ALPHA_TOKEN"] }, { env_vars: ["ALPHA_TOKEN"] }], + }), + ), + ); + const adapter = createCliOpenShellProviderAdapter({ run }); + + await expect( + adapter.inspectProviderProfile({ + target: selectedOpenShellGateway(), + profileType: "custom", + }), + ).resolves.toEqual({ + ok: true, + value: { credentialKeys: ["ALPHA_TOKEN", "ZETA_TOKEN"] }, + }); + expect(run).toHaveBeenCalledWith( + ["provider", "profile", "export", "custom", "--output", "json"], + expect.any(Object), + ); + }); + + 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.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() + .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 }); + expect(run.mock.calls[1]?.[0]).toEqual([ + "sandbox", + "provider", + "detach", + "alpha", + "search-prod", + ]); + }); + + 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 }); + + await expect( + adapter.detachProvider({ + target: namedOpenShellGateway("nemoclaw-18080"), + providerName: "search-prod", + sandboxName: "alpha", + }), + ).resolves.toEqual({ ok: true }); + expect(run).toHaveBeenCalledWith( + ["sandbox", "provider", "detach", "-g", "nemoclaw-18080", "alpha", "search-prod"], + expect.objectContaining({ ignoreError: true, timeout: 30_000 }), + ); + }); + + 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), + }); + + await expect( + adapter.detachProvider({ + target: selectedOpenShellGateway(), + providerName: "search-prod", + sandboxName: "alpha", + }), + ).resolves.toEqual({ ok: true }); + }, + ); + + 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", + "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({ + 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, "", "handshake verification failed"), + "The selected OpenShell gateway identity does not match the recorded identity.", + "identity_mismatch", + ], + [ + "transport", + captured(1, "", "client error (Connect): connection refused"), + "OpenShell could not reach the selected gateway.", + "unreachable", + ], + [ + "timeout", + captured( + null, + "", + "credential-value", + Object.assign(new Error("provider create credential-value timed out"), { + code: "ETIMEDOUT", + }), + ), + "The OpenShell provider operation timed out.", + undefined, + ], + [ + "transport", + captured( + null, + "", + "credential-value", + Object.assign(new Error("spawn openshell credential-value"), { code: "ENOENT" }), + ), + "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) => { + const adapter = createCliOpenShellProviderAdapter({ run: () => result }); + + const mapped = await adapter.listProviders({ target: selectedOpenShellGateway() }); + + 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 new file mode 100644 index 00000000000..d070c273620 --- /dev/null +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -0,0 +1,430 @@ +// 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 { redactFullWithUrls } from "../../security/redact"; +import { + OPENSHELL_OPERATION_TIMEOUT_MS, + parseCliOpenShellProviderNames, + runOpenshellProviderCommand, +} from "./provider-command"; +import { + type CreateOpenShellProviderRequest, + type DeleteOpenShellProviderRequest, + type DetachOpenShellProviderRequest, + type ImportOpenShellProviderProfileRequest, + type InspectOpenShellProviderProfileRequest, + type OpenShellProviderAdapter, + type OpenShellProviderError, + type OpenShellProviderMutationResult, + type OpenShellProviderRequest, + type OpenShellProviderResult, +} from "./provider-adapter"; +import type { OpenShellGatewayTarget } from "./sandbox-observer"; +import { + assertNoOpenShellGatewayEndpointOverride, + OpenShellGatewayEndpointOverrideError, + scopeGatewayOpenshellArgs, + type OpenShellGatewayEndpointEnvironment, +} from "./gateway-scope"; +import { + exportedProviderProfileMatchesContract, + parseCheckedInProviderProfileContract, +} from "./provider-profile"; + +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"]; + suppressOutput?: boolean; + timeout: number; + }, +) => CapturedProviderCommandResult; + +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; +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 = /\bNotAttached\b|\bnot\s+attached\b/iu; + +function success(value: T): OpenShellProviderResult { + return { ok: true, value }; +} + +function mutationSuccess(): OpenShellProviderMutationResult { + return { ok: true }; +} + +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(TERMINAL_OSC_RE, "") + .replace(TERMINAL_STRING_RE, "") + .replace(TERMINAL_CSI_RE, "") + .replace(TERMINAL_CONTROL_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 redactFullWithUrls(safe).trim(); +} + +function attachedSandboxNames(output: string): string[] | null { + const match = ATTACHED_TO_SANDBOX_RE.exec(output); + if (!match?.[1]) return null; + const names = match[1] + .split(/[,\s]+/u) + .map((name) => name.trim().replace(/[.'"`]+$/u, "")) + .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( + 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" || /\boperation timed out\b/iu.test(output)) { + return { kind: "timeout", message: "The OpenShell provider operation timed out." }; + } + if (errorCode === "ENOENT" || errorCode === "EACCES") { + return { + kind: "transport", + reason: "process_start", + 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", + 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 (/\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)\b/iu.test( + output, + ) + ) { + 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 }; + } + const attachedSandboxes = attachedSandboxNames(output); + 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)) { + 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, + gatewayFlagIndex = 2, +): string[] { + return target.kind === "selected" + ? [...args] + : 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 { + profile = JSON.parse(output); + } catch { + 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(); + 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 environment = deps.environment ?? process.env; + const timeoutFor = (request: OpenShellProviderRequest) => + request.timeoutMs ?? deps.defaultTimeoutMs ?? OPENSHELL_OPERATION_TIMEOUT_MS; + const invoke = ( + args: string[], + 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), + }); + + 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); + 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) => { + const targetError = namedGatewayEndpointOverrideError(request.target, environment); + if (targetError) return failure(targetError); + 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)); + 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) : mutationSuccess(); + }; + + 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 = (() => { + 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); + 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"], + 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 mutationSuccess(); + }; + + 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, + ); + const error = commandError(result); + if (error) return failure(error); + const credentialKeys = parseProfileCredentialKeys( + bufferOrStringToText(result.stdout), + request.profileType, + ); + return credentialKeys + ? success({ credentialKeys }) + : failure({ + kind: "schema", + message: "OpenShell returned an invalid provider profile.", + }); + }; + + 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(); + }; + + 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, + undefined, + 3, + ); + const output = commandOutput(result); + if (result.status !== 0 && TOLERATED_DETACH_OUTPUT_RE.test(output)) { + return mutationSuccess(); + } + const error = commandError(result); + return error ? failure(error) : mutationSuccess(); + }; + + 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..3d11665197f --- /dev/null +++ b/src/lib/adapters/openshell/provider-adapter.ts @@ -0,0 +1,106 @@ +// 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" + | "profile_incompatible" + | "uncertain"; + +export type OpenShellProviderTransportReason = + | "identity_mismatch" + | "process_start" + | "unreachable"; + +export type OpenShellProviderError = + | Readonly<{ + kind: "authentication" | "schema" | "timeout" | "validation"; + message: string; + }> + | Readonly<{ + kind: "transport"; + reason: OpenShellProviderTransportReason; + 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 OpenShellProviderMutationResult = + | Readonly<{ ok: true }> + | 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; + }>; + +/** 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/adapters/openshell/provider-command.test.ts b/src/lib/adapters/openshell/provider-command.test.ts index 871e2d70eeb..0fd080c95c3 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,20 @@ describe("OpenShell provider command runtime", () => { ); expect(result).toEqual({ status: 0 }); }); + + 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 e332155a8d0..62c0cad7d45 100644 --- a/src/lib/adapters/openshell/provider-command.ts +++ b/src/lib/adapters/openshell/provider-command.ts @@ -3,6 +3,7 @@ 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"; @@ -12,6 +13,7 @@ export type ProviderCommandOptions = { env?: Record; ignoreError?: boolean; stdio?: StdioOptions; + suppressOutput?: boolean; timeout?: number; }; @@ -25,6 +27,22 @@ export function setProviderCommandRuntimeHooksForTest(hooks: ProviderCommandRunt runtimeHooks = hooks; } +export function parseCliOpenShellProviderNames(output: unknown): string[] | null { + const text = + 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) { const explicitEnv = Object.fromEntries( Object.entries(opts?.env ?? {}).filter( diff --git a/src/lib/adapters/openshell/provider-profile.test.ts b/src/lib/adapters/openshell/provider-profile.test.ts index e5658de01c1..9ab6f9d5cbc 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 (#9806)", (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"), @@ -40,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( @@ -63,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)", () => { @@ -76,10 +108,15 @@ 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).toHaveBeenCalledTimes(2); + 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("imports after OpenShell wraps the missing-profile message (#10155)", () => { @@ -90,10 +127,39 @@ 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).toHaveBeenCalledTimes(2); + 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"], + ]); }); 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 a891b4480be..01a300d3382 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, "") @@ -88,11 +183,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 +202,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; @@ -116,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)) { @@ -144,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. */ @@ -181,30 +293,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) }; } diff --git a/src/lib/credentials/command-support.ts b/src/lib/credentials/command-support.ts index 70fcc3551d7..1ce535a1fe7 100644 --- a/src/lib/credentials/command-support.ts +++ b/src/lib/credentials/command-support.ts @@ -34,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.`, ]; @@ -55,20 +59,26 @@ 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 false; +): Promise { + if (!(await recoverGatewayOrExit(operation === "query" ? "query" : "reach", reportFailure))) { + return null; + } + const gatewayName = resolveGatewayName(GATEWAY_PORT); try { resolveGatewayCredentialMutationAuthority({ - gatewayName: resolveGatewayName(GATEWAY_PORT), + gatewayName, gatewayPort: GATEWAY_PORT, }); - return true; + return { kind: "named", gatewayName }; } catch (error) { - reportFailure(credentialsGatewayAuthorityFailureLines(error)); - return false; + reportFailure(credentialsGatewayAuthorityFailureLines(error, operation)); + return null; } } diff --git a/src/lib/credentials/provider-list.ts b/src/lib/credentials/provider-list.ts index 7f7d8854799..5465de437c3 100644 --- a/src/lib/credentials/provider-list.ts +++ b/src/lib/credentials/provider-list.ts @@ -9,16 +9,24 @@ export function isBridgeProviderName(name: string): boolean { return BRIDGE_PROVIDER_SUFFIXES.some((suffix) => name.endsWith(suffix)); } -export function parseGatewayProviderNames(output: unknown): { +export function classifyGatewayProviderNames(names: readonly string[]): { bridgeNames: string[]; credentialNames: string[]; } { - const allNames = String(output ?? "") - .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(), + bridgeNames: names.filter((name) => isBridgeProviderName(name)), + 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), + ); +} 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..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"), @@ -28,7 +22,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 +52,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 +73,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 +84,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 +101,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/dashboard.ts b/src/lib/onboard/dashboard.ts index b403ed76b8f..66e2326cd33 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -767,7 +767,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/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..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"; @@ -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/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 60e40e1a96a..86efa3b5dda 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"; @@ -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, @@ -591,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; @@ -1160,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}`); } diff --git a/test/credentials/credentials-reset-outcome.test.ts b/test/credentials/credentials-reset-outcome.test.ts index b935303931f..f5bf2c997aa 100644 --- a/test/credentials/credentials-reset-outcome.test.ts +++ b/test/credentials/credentials-reset-outcome.test.ts @@ -3,15 +3,17 @@ 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: "", + detachedSandboxes: [], recoveryFailures: [], ...over, }; @@ -21,11 +23,28 @@ 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 }), + 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"); + expect(outcome.lines.join("\n")).not.toContain("rebuild"); + }); + + it("reports every sandbox detached during a successful removal (#9806)", () => { + const outcome = formatResetOutcome( + "my-assistant-brave-search", + result({ ok: true, detachedSandboxes: ["alpha", "beta", "alpha"] }), + "nemoclaw", + ); + + 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", () => { @@ -33,19 +52,32 @@ 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" }, + }, + ], }), + "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, status: 1 })); + 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"); }); diff --git a/test/e2e/live/sandbox-operations.test.ts b/test/e2e/live/sandbox-operations.test.ts index 801b8fc9922..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"; @@ -27,6 +28,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 +41,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 +154,21 @@ 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 expectListed(host: HostCliClient, sandboxName: string, artifactName: string) { const list = await host.nemoclaw(["list"], { artifactName, @@ -174,6 +194,97 @@ async function execInSandbox( }); } +function credentialBoundaryProbeScript(): string { + const fixtureDigest = createHash("sha256").update(CREDENTIAL_VALUE, "utf8").digest("hex"); + return `python3 - ${shellQuote(fixtureDigest)} ${CREDENTIAL_VALUE.length} <<'PY' +from pathlib import Path +import hashlib +import os +import sys + +secret_digest = bytes.fromhex(sys.argv[1]) +secret_length = int(sys.argv[2]) + +def contains_secret(path): + try: + 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 contains_secret_bytes(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 contains_secret_bytes(command) or contains_secret_bytes(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, @@ -596,6 +707,113 @@ async function assertGatewayRecovery( return recoveryOutcome; } +test( + "credentials reset removes a provider attached during sandbox rebuild (#9806)", + { + 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, keeps the credential outside the rebuilt sandbox, and removes the attachment and provider", + ], + }); + + 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); + 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"); + 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(), + }); + await assertCredentialRemainsOutsideSandbox(sandbox); + + 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); + }, +); + test( "sandbox operations preserve list/status/logs/recovery/multi-sandbox contracts", { 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..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; @@ -66,6 +70,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]; @@ -157,12 +204,41 @@ 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]; } 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({}); + vi.unstubAllEnvs(); +}); + +afterAll(() => { + fs.rmSync(authorityFixtureRoot, { recursive: true, force: true }); }); describe("credentials oclif commands", () => { @@ -198,7 +274,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, @@ -211,7 +287,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"); }); @@ -223,7 +301,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)"); }); @@ -237,8 +317,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 () => { @@ -270,7 +351,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, @@ -281,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 () => { @@ -343,7 +424,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); @@ -365,12 +446,40 @@ 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, + replaceEnv: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: 30_000, + }, + }, + { + args: [ + "provider", + "profile", + "-g", + "nemoclaw", + "export", + "tavily", + "--output", + "json", + ], opts: { env: expect.any(Object), ignoreError: true, replaceEnv: true, stdio: ["ignore", "pipe", "pipe"], + suppressOutput: true, timeout: 30_000, }, }, @@ -378,6 +487,8 @@ describe("credentials oclif commands", () => { args: [ "provider", "create", + "-g", + "nemoclaw", "--name", "tavily-search", "--type", @@ -395,9 +506,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"); @@ -417,8 +529,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; @@ -463,12 +574,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(); @@ -498,9 +607,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(); @@ -557,6 +667,8 @@ describe("credentials oclif commands", () => { expect(calls[0]?.args).toEqual([ "provider", "profile", + "-g", + "nemoclaw", "import", "--file", TAVILY_PROFILE_PATH, @@ -764,6 +876,25 @@ describe("credentials oclif commands", () => { expect(output.stderr).toContain("delete failed"); }); + it("credentials reset rejects invalid provider names before gateway mutation (#9806)", 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(); 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( diff --git a/tools/e2e/target-catalogue.mts b/tools/e2e/target-catalogue.mts index 332c871b973..8068b5d56c5 100644 --- a/tools/e2e/target-catalogue.mts +++ b/tools/e2e/target-catalogue.mts @@ -1341,7 +1341,7 @@ export const E2E_TARGET_CATALOGUE: readonly E2eCatalogueTarget[] = [ agentRuntime: "openclaw", environmentOrInferenceEndpoint: "Ubuntu; NVIDIA hosted inference", profile: "nvidia-inference", - timeoutMinutes: 60, + timeoutMinutes: 120, installMode: "credential-free", installNonInteractive: true, restoreCli: true,