diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 9b50dc8ec98..de144e2f12d 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -8,9 +8,9 @@ "src/lib/actions/sandbox/process-recovery.ts": 26, "src/lib/adapters/docker/index.ts": 42, "src/lib/adapters/openshell/client.ts": 18, - "src/lib/adapters/openshell/command-argv.ts": 23, + "src/lib/adapters/openshell/command-argv.ts": 22, "src/lib/adapters/openshell/resolve.ts": 25, - "src/lib/adapters/openshell/runtime.ts": 53, + "src/lib/adapters/openshell/runtime.ts": 52, "src/lib/adapters/openshell/timeouts.ts": 32, "src/lib/agent/defs.ts": 32, "src/lib/cli/branding.ts": 80, diff --git a/src/lib/actions/sandbox/rebuild-credential-preflight.ts b/src/lib/actions/sandbox/rebuild-credential-preflight.ts index d3dcf3371cb..547409b4882 100644 --- a/src/lib/actions/sandbox/rebuild-credential-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-credential-preflight.ts @@ -156,7 +156,9 @@ export async function preflightRebuildCredentials( const rebuildProvider = sb.provider; if (rebuildProvider === hermesProviderAuth.HERMES_PROVIDER_NAME) { - if (!(await preflightHermesProviderCredentials(sb.hermesAuthMethod, rebuildCredentialEnv, log))) { + if ( + !(await preflightHermesProviderCredentials(sb.hermesAuthMethod, rebuildCredentialEnv, log)) + ) { bail("Missing Hermes Provider credentials"); return false; } @@ -164,7 +166,9 @@ export async function preflightRebuildCredentials( } if (!rebuildCredentialEnv) { - if (!checkRebuildGatewayProviderOrBail(rebuildProvider, rebuildCredentialEnv, log, bail)) { + if ( + !(await checkRebuildGatewayProviderOrBail(rebuildProvider, rebuildCredentialEnv, log, bail)) + ) { return false; } log( @@ -178,11 +182,11 @@ export async function preflightRebuildCredentials( `Preflight credential check: ${rebuildCredentialEnv} → ${credentialValue ? "present" : "MISSING"}`, ); if ( - !checkRebuildGatewayProviderOrBail(rebuildProvider, rebuildCredentialEnv, log, bail, { + !(await checkRebuildGatewayProviderOrBail(rebuildProvider, rebuildCredentialEnv, log, bail, { allowProviderReconfigure: options.allowMissingGatewayProviderWithHostCredential, hostCredentialAvailable: Boolean(credentialValue), onProviderReconfigureRequired: options.onGatewayProviderReconfigureRequired, - }) + })) ) { return false; } diff --git a/src/lib/actions/sandbox/rebuild-dcode-recovered-provider.test.ts b/src/lib/actions/sandbox/rebuild-dcode-recovered-provider.test.ts index 59eb593197a..3a09fb92320 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-recovered-provider.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-recovered-provider.test.ts @@ -72,7 +72,7 @@ describe("rebuildSandbox DCode recovered provider", () => { configureDcodeSession(harness); setGatewayProviderMetadata( harness, - "Name: compatible-endpoint\nType: openai\nCredential keys: COMPATIBLE_API_KEY\n", + "Name: compatible-endpoint\nType: openai\nCredential keys: COMPATIBLE_API_KEY\nConfig keys: \n", ); await expect( diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index 717f1e1f009..e6d4062d032 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -734,7 +734,7 @@ async function rebuildSandboxUnlocked( }; } const providerRegistration = providerReconfigure - ? inspectRebuildGatewayProviderRegistration( + ? await inspectRebuildGatewayProviderRegistration( providerReconfigure.provider, log, "Delete-edge", diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index 970d7d8a3c4..37acb0db299 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -326,13 +326,13 @@ export async function runRebuildPreflightPhase( resumeConfig.credentialEnv && hydrateCredentialEnv(resumeConfig.credentialEnv), ); if ( - !checkRebuildGatewayCredentialReuseOrBail( + !(await checkRebuildGatewayCredentialReuseOrBail( sandboxName, resumeConfig, hostCredentialAvailable, log, bail, - ) + )) ) { return null; } diff --git a/src/lib/actions/sandbox/rebuild-provider-preflight.test.ts b/src/lib/actions/sandbox/rebuild-provider-preflight.test.ts index 1850c971f57..699e88d53d8 100644 --- a/src/lib/actions/sandbox/rebuild-provider-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-provider-preflight.test.ts @@ -8,7 +8,6 @@ import { canRecreateMissingRebuildGatewayProvider, checkRebuildGatewayCredentialReuseOrBail, checkRebuildGatewayProviderOrBail, - classifyRebuildGatewayProviderRegistration, inspectRebuildGatewayProviderRegistration, shouldVerifyRebuildGatewayProvider, } from "./rebuild-provider-preflight"; @@ -55,7 +54,7 @@ afterEach(() => { }); describe("shouldVerifyRebuildGatewayProvider", () => { - it("requires remote registrations while allowing reconstructible local registrations", () => { + it("requires remote registrations while allowing reconstructible local registrations", async () => { expect(shouldVerifyRebuildGatewayProvider("nvidia-prod")).toBe(true); expect(shouldVerifyRebuildGatewayProvider("ollama-local")).toBe(false); expect(shouldVerifyRebuildGatewayProvider("vllm-local")).toBe(false); @@ -64,14 +63,16 @@ describe("shouldVerifyRebuildGatewayProvider", () => { const bail = vi.fn(() => { throw new Error("local provider must not require an existing gateway registration"); }); - expect(checkRebuildGatewayProviderOrBail("ollama-local", null, log, bail)).toBe(true); + await expect(checkRebuildGatewayProviderOrBail("ollama-local", null, log, bail)).resolves.toBe( + true, + ); expect(log).not.toHaveBeenCalled(); expect(bail).not.toHaveBeenCalled(); }); }); describe("canRecreateMissingRebuildGatewayProvider", () => { - it("requires a canonical provider and its exact credential binding (#6114)", () => { + it("requires a canonical provider and its exact credential binding (#6114)", async () => { expect( canRecreateMissingRebuildGatewayProvider("compatible-endpoint", "COMPATIBLE_API_KEY"), ).toBe(true); @@ -88,115 +89,8 @@ describe("canRecreateMissingRebuildGatewayProvider", () => { }); }); -describe("classifyRebuildGatewayProviderRegistration", () => { - it("distinguishes explicit absence from an indeterminate lookup failure (#6114)", () => { - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 1, - stderr: "Error: provider 'compatible-endpoint' not found", - }, - "compatible-endpoint", - ), - ).toBe("missing"); - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 1, - stderr: - "Error: × code: 'Some requested entity was not found', message: \"provider not found\"", - }, - "compatible-endpoint", - ), - ).toBe("missing"); - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 1, - stderr: - 'Error: status: NotFound, message: "provider not found", details: [], metadata: MetadataMap { headers: {} }', - }, - "compatible-endpoint", - ), - ).toBe("missing"); - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 7, - stderr: "gateway transport unavailable", - }, - "compatible-endpoint", - ), - ).toBe("indeterminate"); - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 7, - stderr: "provider lookup failed because gateway was not found", - }, - "compatible-endpoint", - ), - ).toBe("indeterminate"); - expect( - classifyRebuildGatewayProviderRegistration( - { status: 1, stderr: "provider lookup not found" }, - "compatible-endpoint", - ), - ).toBe("indeterminate"); - expect( - classifyRebuildGatewayProviderRegistration( - { status: 1, stderr: "provider 'other-provider' not found" }, - "compatible-endpoint", - ), - ).toBe("indeterminate"); - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 7, - stderr: 'Error: status: Unavailable, message: "provider not found"', - }, - "compatible-endpoint", - ), - ).toBe("indeterminate"); - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 1, - stderr: 'Error: status: NotFound, message: "gateway not found"', - }, - "compatible-endpoint", - ), - ).toBe("indeterminate"); - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 1, - stderr: [ - 'Error: status: NotFound, message: "gateway not found"', - 'Error: status: Unavailable, message: "provider not found"', - ].join("\n"), - }, - "compatible-endpoint", - ), - ).toBe("indeterminate"); - expect( - classifyRebuildGatewayProviderRegistration( - { - status: 1, - stderr: - 'Error: status: NotFound, message: "gateway not found"; status: Unavailable, message: "provider not found"', - }, - "compatible-endpoint", - ), - ).toBe("indeterminate"); - expect(classifyRebuildGatewayProviderRegistration({ status: 0 }, "compatible-endpoint")).toBe( - "registered", - ); - }); -}); - describe("inspectRebuildGatewayProviderRegistration", () => { - it("pins the delete-edge lookup to the frozen target under hostile ambient selectors (#10514)", () => { + it("pins the delete-edge lookup to the frozen target under hostile ambient selectors (#10514)", async () => { vi.stubEnv("OPENSHELL_GATEWAY", "hostile-gateway"); vi.stubEnv("OPENSHELL_WORKSPACE", "hostile-workspace"); vi.stubEnv("OPENSHELL_LOCAL_TLS_DIR", "/hostile/tls"); @@ -212,14 +106,14 @@ describe("inspectRebuildGatewayProviderRegistration", () => { localTlsDir: "/authority/tls", }; - expect( + await expect( inspectRebuildGatewayProviderRegistration( "compatible-endpoint", vi.fn(), "Delete-edge", runtimeSelection, ), - ).toBe("missing"); + ).resolves.toBe("missing"); expect(runOpenshell).toHaveBeenCalledWith( ["provider", "get", "compatible-endpoint"], @@ -238,27 +132,27 @@ describe("inspectRebuildGatewayProviderRegistration", () => { }); describe("checkRebuildGatewayCredentialReuseOrBail", () => { - it("accepts an exact complete registry route and gateway provider identity", () => { - expect( + it("accepts an exact complete registry route and gateway provider identity", async () => { + await expect( checkRebuildGatewayCredentialReuseOrBail("alpha", config(), false, vi.fn(), throwingBail, { - readGatewayProviderMetadata: () => exactGatewayProvider, + readGatewayProviderMetadata: async () => exactGatewayProvider, readRecordedProviderEndpoints: () => [], }), - ).toBe(true); + ).resolves.toBe(true); }); - it("preserves normal host-key validation without reading gateway recovery metadata", () => { + it("preserves normal host-key validation without reading gateway recovery metadata", async () => { const readGatewayProviderMetadata = vi.fn(); - expect( + await expect( checkRebuildGatewayCredentialReuseOrBail("alpha", config(), true, vi.fn(), throwingBail, { readGatewayProviderMetadata, readRecordedProviderEndpoints: vi.fn(), }), - ).toBe(true); + ).resolves.toBe(true); expect(readGatewayProviderMetadata).not.toHaveBeenCalled(); }); - it("preserves Bedrock Runtime rebuilds with explicit AWS authentication", () => { + it("preserves Bedrock Runtime rebuilds with explicit AWS authentication", async () => { const readGatewayProviderMetadata = vi.fn(); const bedrock = config({ provider: "compatible-anthropic-endpoint", @@ -273,17 +167,17 @@ describe("checkRebuildGatewayCredentialReuseOrBail", () => { }, }); - expect( + await expect( checkRebuildGatewayCredentialReuseOrBail("alpha", bedrock, false, vi.fn(), throwingBail, { hasBedrockRuntimeAwsAuth: () => true, readGatewayProviderMetadata, readRecordedProviderEndpoints: vi.fn(), }), - ).toBe(true); + ).resolves.toBe(true); expect(readGatewayProviderMetadata).not.toHaveBeenCalled(); }); - it("rejects Bedrock Runtime before deletion when neither AWS nor compatible auth exists", () => { + it("rejects Bedrock Runtime before deletion when neither AWS nor compatible auth exists", async () => { const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); const bedrock = config({ provider: "compatible-anthropic-endpoint", @@ -298,10 +192,10 @@ describe("checkRebuildGatewayCredentialReuseOrBail", () => { }, }); - expect(() => + await expect( checkRebuildGatewayCredentialReuseOrBail("alpha", bedrock, false, vi.fn(), throwingBail, { hasBedrockRuntimeAwsAuth: () => false, - readGatewayProviderMetadata: () => ({ + readGatewayProviderMetadata: async () => ({ name: "compatible-anthropic-endpoint", type: "openai", credentialKeys: ["NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_TOKEN"], @@ -309,7 +203,7 @@ describe("checkRebuildGatewayCredentialReuseOrBail", () => { }), readRecordedProviderEndpoints: () => [], }), - ).toThrow("Missing Bedrock Runtime authentication"); + ).rejects.toThrow("Missing Bedrock Runtime authentication"); const diagnostics = errors.mock.calls.flat().join(" "); expect(diagnostics).toContain("AWS_BEARER_TOKEN_BEDROCK"); @@ -340,9 +234,9 @@ describe("checkRebuildGatewayCredentialReuseOrBail", () => { }, }), ], - ])("rejects %s before destructive rebuild work", (_label, unsafeConfig) => { + ])("rejects %s before destructive rebuild work", async (_label, unsafeConfig) => { vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(() => + await expect( checkRebuildGatewayCredentialReuseOrBail( "alpha", unsafeConfig, @@ -350,37 +244,37 @@ describe("checkRebuildGatewayCredentialReuseOrBail", () => { vi.fn(), throwingBail, { - readGatewayProviderMetadata: () => exactGatewayProvider, + readGatewayProviderMetadata: async () => exactGatewayProvider, readRecordedProviderEndpoints: () => [], }, ), - ).toThrow("Unsafe gateway credential reuse"); + ).rejects.toThrow("Unsafe gateway credential reuse"); }); - it("rejects spoofed gateway bindings", () => { + it("rejects spoofed gateway bindings", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const spoofedProvider = { ...exactGatewayProvider, credentialKeys: ["ATTACKER_KEY"], }; - expect(() => + await expect( checkRebuildGatewayCredentialReuseOrBail("alpha", config(), false, vi.fn(), throwingBail, { - readGatewayProviderMetadata: () => spoofedProvider, + readGatewayProviderMetadata: async () => spoofedProvider, readRecordedProviderEndpoints: () => [], }), - ).toThrow("no compatible non-secret identity"); + ).rejects.toThrow("no compatible non-secret identity"); }); - it("rejects a custom endpoint recorded by another sandbox", () => { + it("rejects a custom endpoint recorded by another sandbox", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const readRecordedProviderEndpoints = vi.fn(() => ["https://other.example.test/v1"]); - expect(() => + await expect( checkRebuildGatewayCredentialReuseOrBail("alpha", config(), false, vi.fn(), throwingBail, { - readGatewayProviderMetadata: () => exactGatewayProvider, + readGatewayProviderMetadata: async () => exactGatewayProvider, readRecordedProviderEndpoints, }), - ).toThrow("recovered endpoint identity is missing or incompatible"); + ).rejects.toThrow("recovered endpoint identity is missing or incompatible"); expect(readRecordedProviderEndpoints).toHaveBeenCalledWith("compatible-endpoint", "alpha"); }); }); diff --git a/src/lib/actions/sandbox/rebuild-provider-preflight.ts b/src/lib/actions/sandbox/rebuild-provider-preflight.ts index 14c4b0053c2..bf05df7c0fb 100644 --- a/src/lib/actions/sandbox/rebuild-provider-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-provider-preflight.ts @@ -1,8 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { runOpenshell } from "../../adapters/openshell/runtime"; -import { buildSelectedOpenShellSubprocessEnv } from "../../adapters/openshell/command-argv"; +import type { OpenShellProviderAdapter } from "../../adapters/openshell/provider-adapter"; +import type { RunProviderCommand } from "../../adapters/openshell/provider-adapter-cli"; +import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import { + createManagedProviderAdapter, + managedProviderGatewayTarget, +} from "../../adapters/openshell/managed-provider-adapter"; import type { OpenShellRuntimeSelection } from "../../adapters/openshell/runtime-selection"; import { RD as _RD, R } from "../../cli/terminal-style"; import { @@ -15,91 +20,55 @@ import { isRecoveredProviderCredentialReuseSelectionKey, } from "../../onboard/recovered-provider-reuse"; import * as registry from "../../state/registry"; -import { - OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER, - openshellReportsProviderNotFound, -} from "../inference-set-error"; import type { RebuildResumeConfig } from "./rebuild-resume-config"; import { isLocalInferenceProvider } from "./rebuild-resume-config"; const hermesProviderAuth = require("../../hermes-provider-auth") as { HERMES_PROVIDER_NAME: string; }; -const { readGatewayProviderMetadata, REMOTE_PROVIDER_CONFIG } = - require("../../onboard/providers") as { - readGatewayProviderMetadata: ( - name: string, - runOpenshellFn: typeof runOpenshell, - ) => GatewayProviderMetadata | null; - REMOTE_PROVIDER_CONFIG: Record< - string, - { - providerName: string; - providerType: string; - credentialEnv: string | null; - } - >; - }; +const { REMOTE_PROVIDER_CONFIG } = require("../../onboard/providers") as { + REMOTE_PROVIDER_CONFIG: Record< + string, + { + providerName: string; + providerType: string; + credentialEnv: string | null; + } + >; +}; export type RebuildGatewayProviderRegistration = "registered" | "missing" | "indeterminate"; -/** Match OpenShell's rendered gRPC absence without accepting transport failures. */ -function openshellReportsStructuredProviderNotFound(detail: string): boolean { - const bounded = detail.slice(0, OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER); - return bounded - .split(/\r?\n/) - .some((line) => - /\b(?:status:\s*NotFound|code:\s*["']Some requested entity was not found["'])\s*,\s*message:\s*["']provider not found["'](?:\s*,|$)/i.test( - line, - ), - ); -} - -export function classifyRebuildGatewayProviderRegistration( - result: { - status: number | null; - stdout?: unknown; - stderr?: unknown; - output?: unknown; - }, - provider: string, -): RebuildGatewayProviderRegistration { - if (result.status === 0) return "registered"; - const detail = [result.stderr, result.stdout, result.output] - .filter((value) => value !== undefined && value !== null) - .map(String) - .join("\n"); - const explicitMissing = - openshellReportsProviderNotFound(detail, provider) || - openshellReportsStructuredProviderNotFound(detail) || - detail - .slice(0, OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER) - .split(/\r?\n/) - .some((line) => - /^(?:error:\s*)?provider\s+(?:(?:was|is)\s+)?not found(?:\s+in\s+(?:the\s+)?gateway)?[.!]?\s*$/i.test( - line.trim(), - ), - ); - return explicitMissing ? "missing" : "indeterminate"; +function rebuildProviderAdapter( + runtimeSelection?: OpenShellRuntimeSelection, +): OpenShellProviderAdapter { + return createManagedProviderAdapter( + runtimeSelection + ? (((args, options) => + runOpenshellProviderCommand(args, { + ...options, + runtimeSelection, + })) as RunProviderCommand) + : undefined, + ); } -export function inspectRebuildGatewayProviderRegistration( +export async function inspectRebuildGatewayProviderRegistration( provider: string, log: (msg: string) => void, phase = "Preflight", runtimeSelection?: OpenShellRuntimeSelection, -): RebuildGatewayProviderRegistration { - const result = runOpenshell(["provider", "get", provider], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - ...(runtimeSelection - ? { - env: buildSelectedOpenShellSubprocessEnv(runtimeSelection), - replaceEnv: true, - } - : {}), + providerAdapter = rebuildProviderAdapter(runtimeSelection), +): Promise { + const result = await providerAdapter.getProvider({ + providerName: provider, + target: managedProviderGatewayTarget, }); - const registration = classifyRebuildGatewayProviderRegistration(result, provider); + const registration = result.ok + ? "registered" + : result.error.kind === "command" && result.error.reason === "not_found" + ? "missing" + : "indeterminate"; log( `${phase} gateway provider check: provider '${provider}' is ${ registration === "registered" @@ -114,7 +83,7 @@ export function inspectRebuildGatewayProviderRegistration( type GatewayCredentialReusePreflightDeps = { hasBedrockRuntimeAwsAuth?(): boolean; - readGatewayProviderMetadata(provider: string): GatewayProviderMetadata | null; + readGatewayProviderMetadata(provider: string): Promise; readRecordedProviderEndpoints(provider: string, excludeSandboxName: string): string[] | null; }; @@ -152,8 +121,8 @@ export function shouldVerifyRebuildGatewayProvider( // upsert the local provider with locally available credentials. return Boolean( provider && - !isLocalInferenceProvider(provider) && - provider !== hermesProviderAuth.HERMES_PROVIDER_NAME, + !isLocalInferenceProvider(provider) && + provider !== hermesProviderAuth.HERMES_PROVIDER_NAME, ); } @@ -170,7 +139,7 @@ export function canRecreateMissingRebuildGatewayProvider( return config?.credentialEnv === credentialEnv; } -export function checkRebuildGatewayProviderOrBail( +export async function checkRebuildGatewayProviderOrBail( provider: string | null | undefined, credentialEnv: string | null, log: (msg: string) => void, @@ -180,10 +149,10 @@ export function checkRebuildGatewayProviderOrBail( hostCredentialAvailable?: boolean; onProviderReconfigureRequired?: (provider: string, credentialEnv: string) => void; } = {}, -): boolean { +): Promise { if (!shouldVerifyRebuildGatewayProvider(provider)) return true; - const registration = inspectRebuildGatewayProviderRegistration(provider, log); + const registration = await inspectRebuildGatewayProviderRegistration(provider, log); if (registration === "registered") return true; if ( registration === "missing" && @@ -211,8 +180,15 @@ export function checkRebuildGatewayProviderOrBail( } function defaultGatewayCredentialReusePreflightDeps(): GatewayCredentialReusePreflightDeps { + const providerAdapter = rebuildProviderAdapter(); return { - readGatewayProviderMetadata: (provider) => readGatewayProviderMetadata(provider, runOpenshell), + readGatewayProviderMetadata: async (provider) => { + const result = await providerAdapter.getProvider({ + providerName: provider, + target: managedProviderGatewayTarget, + }); + return result.ok ? result.value : null; + }, readRecordedProviderEndpoints: (provider, excludeSandboxName) => { try { return registry @@ -229,14 +205,14 @@ function defaultGatewayCredentialReusePreflightDeps(): GatewayCredentialReusePre } /** Validate keyless gateway-provider reuse before a rebuild deletes the sandbox. */ -export function checkRebuildGatewayCredentialReuseOrBail( +export async function checkRebuildGatewayCredentialReuseOrBail( sandboxName: string, config: RebuildResumeConfig, hostCredentialAvailable: boolean, log: (msg: string) => void, bail: (msg: string, code?: number) => never, deps: GatewayCredentialReusePreflightDeps = defaultGatewayCredentialReusePreflightDeps(), -): boolean { +): Promise { if (hostCredentialAvailable || !config.provider || !config.credentialEnv) return true; const isBedrockRuntime = config.provider === "compatible-anthropic-endpoint" && @@ -282,7 +258,7 @@ export function checkRebuildGatewayCredentialReuseOrBail( recoveredPreferredInferenceApi: route?.preferredInferenceApi, expectedProviderType: remoteConfig.providerType, expectedCredentialEnv: config.credentialEnv, - gatewayProvider: deps.readGatewayProviderMetadata(config.provider), + gatewayProvider: await deps.readGatewayProviderMetadata(config.provider), endpointIdentity: endpointFlavor ? { flavor: endpointFlavor, diff --git a/src/lib/actions/sandbox/snapshot-hermes-managed-clone-broker.test.ts b/src/lib/actions/sandbox/snapshot-hermes-managed-clone-broker.test.ts index df17e76ddf3..af1d745b80c 100644 --- a/src/lib/actions/sandbox/snapshot-hermes-managed-clone-broker.test.ts +++ b/src/lib/actions/sandbox/snapshot-hermes-managed-clone-broker.test.ts @@ -259,13 +259,13 @@ describe("Hermes managed clone broker transaction", () => { expect(runDeviceCodeFlow).toHaveBeenCalledOnce(); }); - it("stages one secret-free provider-neutral transaction and activates after exact creation", () => { + it("stages one secret-free provider-neutral transaction and activates after exact creation", async () => { const profile = hermesProfile(); const source = sourceEntry(profile); const preparedHandoff = handoff(profile); const runner = providerRunner(); const hostBroker = broker(); - const prepared = prepareHermesManagedCloneBrokerTransaction({ + const prepared = await prepareHermesManagedCloneBrokerTransaction({ handoff: preparedHandoff, destination: null, environment: environment(), @@ -281,7 +281,7 @@ describe("Hermes managed clone broker transaction", () => { ]); expect(JSON.stringify(prepared)).not.toContain("test-only-refresh-token"); - const receipt = provisionHermesManagedCloneBrokerTransaction(prepared, { + const receipt = await provisionHermesManagedCloneBrokerTransaction(prepared, { ...authority(source), environment: environment(), runOpenshell: runner.run, @@ -303,12 +303,12 @@ describe("Hermes managed clone broker transaction", () => { expect(receipt.phase).toBe("activated"); }); - it("imports the OpenAI profile before provider creation and broker activation (#10155)", () => { + it("imports the OpenAI profile before provider creation and broker activation (#10155)", async () => { const profile = hermesProfile(); const source = sourceEntry(profile); const runner = providerRunner("missing"); const hostBroker = broker(); - const prepared = prepareHermesManagedCloneBrokerTransaction({ + const prepared = await prepareHermesManagedCloneBrokerTransaction({ handoff: handoff(profile), destination: null, environment: environment(), @@ -317,7 +317,7 @@ describe("Hermes managed clone broker transaction", () => { transactionId: "4".repeat(32), }); - provisionHermesManagedCloneBrokerTransaction(prepared, { + await provisionHermesManagedCloneBrokerTransaction(prepared, { ...authority(source), environment: environment(), runOpenshell: runner.run, @@ -339,17 +339,17 @@ describe("Hermes managed clone broker transaction", () => { expect(profileExportIndex).toBeGreaterThanOrEqual(0); expect(profileImportIndex).toBeGreaterThan(profileExportIndex); expect(inferenceCreateIndex).toBeGreaterThan(profileImportIndex); - expect( - runner.run.mock.invocationCallOrder[inferenceCreateIndex], - ).toBeLessThan(hostBroker.activateHermesToolGatewayCloneBinding.mock.invocationCallOrder[0]); + expect(runner.run.mock.invocationCallOrder[inferenceCreateIndex]).toBeLessThan( + hostBroker.activateHermesToolGatewayCloneBinding.mock.invocationCallOrder[0], + ); }); - it("blocks providers and broker mutation when the OpenAI profile import fails (#10155)", () => { + it("blocks providers and broker mutation when the OpenAI profile import fails (#10155)", async () => { const profile = hermesProfile(); const source = sourceEntry(profile); const runner = providerRunner("import-failed"); const hostBroker = broker(); - const prepared = prepareHermesManagedCloneBrokerTransaction({ + const prepared = await prepareHermesManagedCloneBrokerTransaction({ handoff: handoff(profile), destination: null, environment: environment(), @@ -358,14 +358,14 @@ describe("Hermes managed clone broker transaction", () => { transactionId: "5".repeat(32), }); - expect(() => + await expect( provisionHermesManagedCloneBrokerTransaction(prepared, { ...authority(source), environment: environment(), runOpenshell: runner.run, broker: hostBroker, }), - ).toThrow("could not import the checked-in 'openai' inference provider profile"); + ).rejects.toThrow("could not import the checked-in 'openai' inference provider profile"); expect( runner.run.mock.calls.some(([args]) => args.slice(0, 2).join(" ") === "provider create"), ).toBe(false); @@ -373,7 +373,7 @@ describe("Hermes managed clone broker transaction", () => { expect(hostBroker.activateHermesToolGatewayCloneBinding).not.toHaveBeenCalled(); }); - it("preserves exact providers when activation outcome is unknown", () => { + it("preserves exact providers when activation outcome is unknown", async () => { const profile = hermesProfile(); const source = sourceEntry(profile); const runner = providerRunner(); @@ -383,7 +383,7 @@ describe("Hermes managed clone broker transaction", () => { code: "hermes_clone_activation_outcome_unknown", }); }); - const prepared = prepareHermesManagedCloneBrokerTransaction({ + const prepared = await prepareHermesManagedCloneBrokerTransaction({ handoff: handoff(profile), destination: null, environment: environment(), @@ -394,7 +394,7 @@ describe("Hermes managed clone broker transaction", () => { let thrown: unknown; try { - provisionHermesManagedCloneBrokerTransaction(prepared, { + await provisionHermesManagedCloneBrokerTransaction(prepared, { ...authority(source), environment: environment(), runOpenshell: runner.run, @@ -409,7 +409,7 @@ describe("Hermes managed clone broker transaction", () => { expect(hostBroker.discardHermesToolGatewayCloneBinding).not.toHaveBeenCalled(); }); - it("rolls back only its provider receipt when activation fails definitively", () => { + it("rolls back only its provider receipt when activation fails definitively", async () => { const profile = hermesProfile(); const source = sourceEntry(profile); const runner = providerRunner(); @@ -417,7 +417,7 @@ describe("Hermes managed clone broker transaction", () => { hostBroker.activateHermesToolGatewayCloneBinding.mockImplementation(() => { throw new Error("activation rejected"); }); - const prepared = prepareHermesManagedCloneBrokerTransaction({ + const prepared = await prepareHermesManagedCloneBrokerTransaction({ handoff: handoff(profile), destination: null, environment: environment(), @@ -426,14 +426,14 @@ describe("Hermes managed clone broker transaction", () => { transactionId: "3".repeat(32), }); - expect(() => + await expect( provisionHermesManagedCloneBrokerTransaction(prepared, { ...authority(source), environment: environment(), runOpenshell: runner.run, broker: hostBroker, }), - ).toThrow("activation rejected"); + ).rejects.toThrow("activation rejected"); expect(runner.live.size).toBe(0); expect(hostBroker.discardHermesToolGatewayCloneBinding).toHaveBeenCalledOnce(); }); 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 54af019d796..164b5a02dc1 100644 --- a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts +++ b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { managedStartupE2eProfile } from "../../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import type { OpenShellProviderAdapter } from "../../adapters/openshell/provider-adapter"; import { REPOSITORY_ROOT } from "../../core/repository-root"; import type { SandboxMessagingPlan } from "../../messaging/manifest"; import { @@ -247,22 +248,24 @@ function authorityDeps( }; } -function prepareWithBinding(input: { +async function prepareWithBinding(input: { readonly agent?: ManagedStartupAgent; readonly binding?: ManagedCloneProviderBinding; readonly destination?: SandboxEntry | null; readonly environment?: NodeJS.ProcessEnv; + readonly providerAdapter?: OpenShellProviderAdapter; readonly runner?: ReturnType; }) { const profile = managedStartupE2eProfile(input.agent ?? "openclaw"); const source = entry("source", profile); const runner = input.runner ?? providerRunner(); const destination = input.destination ?? null; - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source), destination, additionalBindings: [input.binding ?? TOKEN_BINDING], environment: input.environment ?? { RUNTIME_TOKEN: "test-only-runtime-token" }, + providerAdapter: input.providerAdapter, runOpenshell: runner.run, transactionId: "1".repeat(32), }); @@ -272,8 +275,8 @@ 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 (#8931)", - (agent) => { - const { prepared } = prepareWithBinding({ agent }); + async (agent) => { + const { prepared } = await prepareWithBinding({ agent }); expect(prepared).toMatchObject({ providerId: "docker", @@ -290,13 +293,43 @@ describe("managed clone provider transaction", () => { }, ); - it("resolves active messaging providers from the handoff", () => { + it("routes preparation inspection through the injected provider adapter", async () => { + const runner = providerRunner(); + const getProvider: OpenShellProviderAdapter["getProvider"] = vi.fn(async () => ({ + ok: false, + error: { kind: "command", reason: "not_found", message: "Provider was not found." }, + }) as const); + + const { prepared } = await prepareWithBinding({ + providerAdapter: { getProvider } as OpenShellProviderAdapter, + runner, + }); + + expect(prepared.providers[0]?.action).toBe("create"); + expect(getProvider).toHaveBeenCalledWith({ + providerName: TOKEN_BINDING.providerName, + target: { kind: "selected" }, + timeoutMs: 5_000, + }); + expect(runner.commands.some((command) => command.startsWith("provider get"))).toBe(false); + }); + + it("rejects credential keys outside the provider adapter contract during preflight", async () => { + await expect( + prepareWithBinding({ + binding: { ...TOKEN_BINDING, providerEnvKey: "_RUNTIME_TOKEN" }, + environment: { _RUNTIME_TOKEN: "test-only-runtime-token" }, + }), + ).rejects.toThrow(/invalid credential binding/u); + }); + + it("resolves active messaging providers from the handoff", async () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const runner = providerRunner(); const plan = messagingPlan("destination"); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source, plan), destination: null, environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, @@ -317,7 +350,7 @@ describe("managed clone provider transaction", () => { ]); }); - it("imports the endpointless profile before creating a cloned messaging provider (#9875)", () => { + it("imports the endpointless profile before creating a cloned messaging provider (#9875)", async () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const runner = providerRunner(); @@ -326,7 +359,7 @@ describe("managed clone provider transaction", () => { stdout: "", stderr: "provider profile not found", }); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source, messagingPlan("destination")), destination: null, environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, @@ -334,7 +367,7 @@ describe("managed clone provider transaction", () => { transactionId: "9".repeat(32), }); - provisionManagedCloneProviderTransaction(prepared, { + await provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, runOpenshell: runner.run, @@ -358,11 +391,11 @@ describe("managed clone provider transaction", () => { expect(createIndex).toBeGreaterThan(importIndex); }); - it("rejects stale clone authority before importing the messaging profile (#9875)", () => { + it("rejects stale clone authority before importing the messaging profile (#9875)", async () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const runner = providerRunner(); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source, messagingPlan("destination")), destination: null, environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, @@ -370,7 +403,7 @@ describe("managed clone provider transaction", () => { transactionId: "8".repeat(32), }); - expect(() => + await expect( provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source, null, { ...CONTENT_AUTHORITY, @@ -379,7 +412,7 @@ describe("managed clone provider transaction", () => { environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, runOpenshell: runner.run, }), - ).toThrow(/snapshot content changed before mutation/u); + ).rejects.toThrow(/snapshot content changed before mutation/u); expect( runner.commands.some((command) => command.startsWith("provider profile import --file ")), ).toBe(false); @@ -388,7 +421,7 @@ describe("managed clone provider transaction", () => { ); }); - it("does not create a cloned messaging provider after profile import fails (#9875)", () => { + it("does not create a cloned messaging provider after profile import fails (#9875)", async () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const runner = providerRunner(); @@ -398,7 +431,7 @@ describe("managed clone provider transaction", () => { stderr: "provider profile not found", }); runner.setProfileImportResult({ status: 1, stdout: "", stderr: "gateway unavailable" }); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source, messagingPlan("destination")), destination: null, environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, @@ -406,17 +439,17 @@ describe("managed clone provider transaction", () => { transactionId: "7".repeat(32), }); - expect(() => + await expect( provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, runOpenshell: runner.run, }), - ).toThrow(/Could not import the OpenShell messaging credential profile/); + ).rejects.toThrow(/Could not import the OpenShell messaging credential profile/); expect(runner.commands.some((command) => command.startsWith("provider create"))).toBe(false); }); - it("reuses an exact provider only with exact destination registry ownership", () => { + it("reuses an exact provider only with exact destination registry ownership", async () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const plan = messagingPlan("destination"); @@ -427,7 +460,7 @@ describe("managed clone provider transaction", () => { providerEnvKey: "TELEGRAM_BOT_TOKEN", }; const runner = providerRunner([liveBinding]); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source, plan), destination, environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, @@ -436,7 +469,7 @@ describe("managed clone provider transaction", () => { }); expect(prepared.providers[0]?.action).toBe("reuse-destination-owned"); - const receipt = provisionManagedCloneProviderTransaction(prepared, { + const receipt = await provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source, destination), environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, runOpenshell: runner.run, @@ -451,7 +484,7 @@ describe("managed clone provider transaction", () => { }); }); - it("rejects clone reuse backed by an incompatible global messaging profile (#9875)", () => { + it("rejects clone reuse backed by an incompatible global messaging profile (#9875)", async () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const plan = messagingPlan("destination"); @@ -474,7 +507,7 @@ describe("managed clone provider transaction", () => { }), stderr: "", }); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source, plan), destination, environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, @@ -482,34 +515,36 @@ describe("managed clone provider transaction", () => { transactionId: "4".repeat(32), }); - expect(() => + await expect( provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source, destination), environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, runOpenshell: runner.run, }), - ).toThrow(/does not match NemoClaw's endpointless messaging credential contract/u); + ).rejects.toThrow(/does not match NemoClaw's endpointless messaging credential contract/u); expect( runner.commands.some((command) => /provider (create|delete|update)/u.test(command)), ).toBe(false); }); - it("rejects an exact same-name provider without destination ownership", () => { + it("rejects an exact same-name provider without destination ownership", async () => { const runner = providerRunner([TOKEN_BINDING]); - expect(() => prepareWithBinding({ runner })).toThrow(/without exact destination ownership/u); + await expect(prepareWithBinding({ runner })).rejects.toThrow( + /without exact destination ownership/u, + ); expect( runner.commands.some((command) => /provider (create|delete|update)/u.test(command)), ).toBe(false); }); - it("rejects a destination registered under another runtime provider", () => { + it("rejects a destination registered under another runtime provider", async () => { const profile = managedStartupE2eProfile("langchain-deepagents-code"); const source = entry("source", profile); const destination = entry("destination", profile, { openshellDriver: "mxc" }); const runner = providerRunner(); - expect(() => + await expect( prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source), destination, @@ -517,11 +552,11 @@ describe("managed clone provider transaction", () => { runOpenshell: runner.run, transactionId: "8".repeat(32), }), - ).toThrow(/destination registry authority uses a different runtime provider/u); + ).rejects.toThrow(/destination registry authority uses a different runtime provider/u); expect(runner.run).not.toHaveBeenCalled(); }); - it("fails closed on indeterminate provider inspection with bounded diagnostics", () => { + it("fails closed on indeterminate provider inspection with bounded diagnostics", async () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const runOpenshell = vi.fn(() => ({ @@ -530,7 +565,7 @@ describe("managed clone provider transaction", () => { stderr: "gateway transport unavailable", })); - expect(() => + await expect( prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source), destination: null, @@ -539,7 +574,7 @@ describe("managed clone provider transaction", () => { runOpenshell, transactionId: "7".repeat(32), }), - ).toThrow(/could not prove whether provider/u); + ).rejects.toThrow(/could not prove whether provider/u); expect(runOpenshell).toHaveBeenCalledWith( ["provider", "get", TOKEN_BINDING.providerName], expect.objectContaining({ @@ -551,26 +586,26 @@ describe("managed clone provider transaction", () => { expect(runOpenshell).toHaveBeenCalledOnce(); }); - it("rejects an incompatible provider collision during read-only preflight", () => { + it("rejects an incompatible provider collision during read-only preflight", async () => { const runner = providerRunner([{ ...TOKEN_BINDING, providerType: "other" }]); - expect(() => prepareWithBinding({ runner })).toThrow(/incompatible live binding/u); + await expect(prepareWithBinding({ runner })).rejects.toThrow(/incompatible live binding/u); expect( runner.commands.some((command) => /provider (create|delete|update)/u.test(command)), ).toBe(false); }); - it("revalidates snapshot, source, and destination authority before provider mutation", () => { - const { prepared, runner, source } = prepareWithBinding({}); + it("revalidates snapshot, source, and destination authority before provider mutation", async () => { + const { prepared, runner, source } = await prepareWithBinding({}); const changedContent = { ...CONTENT_AUTHORITY, contentSha256: "d".repeat(64) }; - expect(() => + await expect( provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source, null, changedContent), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, }), - ).toThrow(/snapshot content changed before mutation/u); + ).rejects.toThrow(/snapshot content changed before mutation/u); expect(runner.commands.some((command) => command.startsWith("provider create"))).toBe(false); expect(() => @@ -585,11 +620,11 @@ describe("managed clone provider transaction", () => { ).toThrow(/destination appeared after clone preflight/u); }); - it("revalidates content authority for an agent with no credential providers", () => { + it("revalidates content authority for an agent with no credential providers", async () => { const profile = managedStartupE2eProfile("langchain-deepagents-code"); const source = entry("source", profile); const runner = providerRunner(); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source), destination: null, environment: {}, @@ -598,7 +633,7 @@ describe("managed clone provider transaction", () => { }); expect(prepared.providers).toEqual([]); - expect(() => + await expect( provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source, null, { ...CONTENT_AUTHORITY, @@ -607,13 +642,13 @@ describe("managed clone provider transaction", () => { environment: {}, runOpenshell: runner.run, }), - ).toThrow(/snapshot content changed before mutation/u); + ).rejects.toThrow(/snapshot content changed before mutation/u); expect(runner.commands).toEqual([]); }); - it("creates with an exact receipt and makes cleanup idempotent against name reuse", () => { - const { prepared, runner, source } = prepareWithBinding({}); - const receipt = provisionManagedCloneProviderTransaction(prepared, { + it("creates with an exact receipt and makes cleanup idempotent against name reuse", async () => { + const { prepared, runner, source } = await prepareWithBinding({}); + const receipt = await provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, @@ -639,10 +674,10 @@ describe("managed clone provider transaction", () => { expect(runner.live.get(TOKEN_BINDING.providerName)?.providerType).toBe("other"); }); - it("bounds provider creation before exact-result reconciliation", () => { - const { prepared, runner, source } = prepareWithBinding({}); + it("bounds provider creation before exact-result reconciliation", async () => { + const { prepared, runner, source } = await prepareWithBinding({}); - provisionManagedCloneProviderTransaction(prepared, { + await provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, @@ -667,7 +702,7 @@ describe("managed clone provider transaction", () => { ); }); - it("rolls back confirmed providers when a later credential disappears", () => { + it("rolls back confirmed providers when a later credential disappears", async () => { const first = { ...TOKEN_BINDING, providerName: "destination-first-token" }; const second = { ...TOKEN_BINDING, @@ -677,7 +712,7 @@ describe("managed clone provider transaction", () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const runner = providerRunner(); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source), destination: null, additionalBindings: [first, second], @@ -691,7 +726,7 @@ describe("managed clone provider transaction", () => { let failure: ManagedCloneProviderTransactionError | null = null; try { - provisionManagedCloneProviderTransaction(prepared, { + await provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, @@ -718,45 +753,45 @@ describe("managed clone provider transaction", () => { ], ] as const)( "preserves an unowned provider after an ambiguous create: %s", - (_name, status, materialize) => { + async (_name, status, materialize) => { const runner = providerRunner(); runner.setCreateBehavior(() => ({ status, ...(materialize ? { materialize } : {}) })); - const { prepared, source } = prepareWithBinding({ runner }); + const { prepared, source } = await prepareWithBinding({ runner }); - expect(() => + await expect( provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, }), - ).toThrow(/preserving the observed/u); + ).rejects.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", () => { + it("reconciles and preserves an exact provider when the create adapter throws", async () => { const runner = providerRunner(); runner.setCreateBehavior((binding) => { runner.live.set(binding.providerName, binding); throw new Error("synthetic child-process transport loss"); }); - const { prepared, source } = prepareWithBinding({ runner }); + const { prepared, source } = await prepareWithBinding({ runner }); - expect(() => + await expect( provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, }), - ).toThrow(/exact but unowned/u); + ).rejects.toThrow(/exact but unowned/u); expect(runner.commands).not.toContain(`provider delete ${TOKEN_BINDING.providerName}`); expect(runner.live.has(TOKEN_BINDING.providerName)).toBe(true); }); - it("reports cleanup failure without discarding its exact retry receipt", () => { - const { prepared, runner, source } = prepareWithBinding({}); - const receipt = provisionManagedCloneProviderTransaction(prepared, { + it("reports cleanup failure without discarding its exact retry receipt", async () => { + const { prepared, runner, source } = await prepareWithBinding({}); + const receipt = await provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, @@ -770,9 +805,9 @@ describe("managed clone provider transaction", () => { expect(runner.live.has(TOKEN_BINDING.providerName)).toBe(true); }); - it("rejects a cloned or fabricated cleanup receipt", () => { - const { prepared, runner, source } = prepareWithBinding({}); - const receipt = provisionManagedCloneProviderTransaction(prepared, { + it("rejects a cloned or fabricated cleanup receipt", async () => { + const { prepared, runner, source } = await prepareWithBinding({}); + const receipt = await provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, @@ -784,12 +819,12 @@ describe("managed clone provider transaction", () => { expect(runner.live.has(TOKEN_BINDING.providerName)).toBe(true); }); - it("fails a force-replace transaction when destination authority becomes stale", () => { + it("fails a force-replace transaction when destination authority becomes stale", async () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); const destination = entry("destination", profile); const runner = providerRunner(); - const prepared = prepareManagedCloneProviderTransaction({ + const prepared = await prepareManagedCloneProviderTransaction({ handoff: handoff(profile, source), destination, additionalBindings: [TOKEN_BINDING], @@ -799,20 +834,20 @@ describe("managed clone provider transaction", () => { }); const staleDestination = { ...destination, model: "changed-model" }; - expect(() => + await expect( provisionManagedCloneProviderTransaction(prepared, { ...authorityDeps(source, staleDestination), environment: { RUNTIME_TOKEN: "test-only-runtime-token" }, runOpenshell: runner.run, }), - ).toThrow(/destination registry authority changed/u); + ).rejects.toThrow(/destination registry authority changed/u); expect(runner.commands.some((command) => command.startsWith("provider create"))).toBe(false); }); - it("captures the complete destination row in the reuse authority receipt", () => { + it("captures the complete destination row in the reuse authority receipt", async () => { const profile = managedStartupE2eProfile("openclaw"); const destination = entry("destination", profile); - const { prepared } = prepareWithBinding({ destination }); + const { prepared } = await prepareWithBinding({ destination }); expect(prepared.destinationRegistryAuthority).toEqual( captureSandboxRebuildAuthority(destination, "docker") as SandboxRebuildAuthority, diff --git a/src/lib/actions/sandbox/snapshot/hermes-managed-clone-broker.ts b/src/lib/actions/sandbox/snapshot/hermes-managed-clone-broker.ts index bb41e9a69d1..a216106bad8 100644 --- a/src/lib/actions/sandbox/snapshot/hermes-managed-clone-broker.ts +++ b/src/lib/actions/sandbox/snapshot/hermes-managed-clone-broker.ts @@ -3,8 +3,18 @@ import { randomBytes } from "node:crypto"; -import { checkOpenAiInferenceProviderProfile } from "../../../adapters/openshell/provider-profile-registration"; +import type { OpenShellProviderAdapter } from "../../../adapters/openshell/provider-adapter"; +import { endpointlessProviderProfilePath } from "../../../adapters/openshell/provider-profile"; +import { + createManagedProviderAdapter, + managedProviderGatewayTarget, +} from "../../../adapters/openshell/managed-provider-adapter"; +import { + endpointlessProviderProfileFailureMessages, + OPENAI_GATEWAY_PROVIDER_TYPE, +} from "../../../adapters/openshell/provider-profile-registration"; import { cloneAndDeepFreeze } from "../../../core/immutable"; +import { REPOSITORY_ROOT } from "../../../core/repository-root"; import { getHermesToolGatewayCloneBroker, type HermesToolGatewayCloneBroker, @@ -156,14 +166,15 @@ function destinationHermesBindings( return hermesBindings(destination.name, broker); } -export function prepareHermesManagedCloneBrokerTransaction(input: { +export async function prepareHermesManagedCloneBrokerTransaction(input: { readonly handoff: HermesCloneHandoff; readonly destination: SandboxEntry | null; readonly environment?: NodeJS.ProcessEnv; + readonly providerAdapter?: OpenShellProviderAdapter; readonly runOpenshell: ManagedCloneProviderRunner; readonly broker?: HermesToolGatewayCloneBroker; readonly transactionId?: string; -}): PreparedHermesManagedCloneBrokerTransaction { +}): Promise { if (!hermesEnabled(input.handoff)) { throw new Error("Hermes managed-tool broker preparation requires an enabled Hermes gateway"); } @@ -171,13 +182,14 @@ export function prepareHermesManagedCloneBrokerTransaction(input: { const destinationSandboxName = input.handoff.destinationSandboxName; broker.preflightHermesToolGatewayCloneBinding(destinationSandboxName); const bindings = hermesBindings(destinationSandboxName, broker); - const providerTransaction = prepareManagedCloneProviderTransaction({ + const providerTransaction = await prepareManagedCloneProviderTransaction({ handoff: input.handoff, destination: input.destination, additionalBindings: bindings, resolveAdditionalDestinationOwnedBindings: (destination) => destinationHermesBindings(destination, broker), environment: input.environment, + providerAdapter: input.providerAdapter, runOpenshell: input.runOpenshell, transactionId: input.transactionId, }); @@ -199,28 +211,37 @@ function isUnknownActivationOutcome(error: unknown): boolean { ); } -function ensureHermesCloneInferenceProviderProfile(runOpenshell: ManagedCloneProviderRunner): void { - const profile = checkOpenAiInferenceProviderProfile({ - runOpenshell: (args, options) => - runOpenshell(args, { - ...options, - timeout: MANAGED_CLONE_PROVIDER_CREATE_TIMEOUT_MS, - }), +async function ensureHermesCloneInferenceProviderProfile( + providerAdapter: OpenShellProviderAdapter, +): Promise { + const profile = await providerAdapter.importProviderProfile({ + profilePath: endpointlessProviderProfilePath(REPOSITORY_ROOT, OPENAI_GATEWAY_PROVIDER_TYPE), + target: managedProviderGatewayTarget, + timeoutMs: MANAGED_CLONE_PROVIDER_CREATE_TIMEOUT_MS, }); if (profile.ok) return; - throw new HermesManagedCloneBrokerTransactionError(profile.messages.join("\n")); + const reason = + profile.error.kind === "command" && profile.error.reason === "profile_incompatible" + ? "incompatible" + : profile.operation === "import" + ? "import-failed" + : "export-failed"; + throw new HermesManagedCloneBrokerTransactionError( + endpointlessProviderProfileFailureMessages(reason).join("\n"), + ); } -export function provisionHermesManagedCloneBrokerTransaction( +export async function provisionHermesManagedCloneBrokerTransaction( prepared: PreparedHermesManagedCloneBrokerTransaction, input: { readonly environment?: NodeJS.ProcessEnv; + readonly providerAdapter?: OpenShellProviderAdapter; readonly runOpenshell: ManagedCloneProviderRunner; readonly readSandbox: ReadSandbox; readonly captureSnapshotRestoreAuthority?: CaptureSnapshotRestoreAuthority; readonly broker?: HermesToolGatewayCloneBroker; }, -): HermesManagedCloneBrokerReceipt { +): Promise { const broker = input.broker ?? getHermesToolGatewayCloneBroker(); const environment = input.environment ?? process.env; const refreshToken = environment[HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV] @@ -232,8 +253,9 @@ export function provisionHermesManagedCloneBrokerTransaction( ); } + const providerAdapter = input.providerAdapter ?? createManagedProviderAdapter(input.runOpenshell); revalidateManagedCloneMutationAuthority(prepared.providerTransaction, input); - ensureHermesCloneInferenceProviderProfile(input.runOpenshell); + await ensureHermesCloneInferenceProviderProfile(providerAdapter); let staged: ReturnType; try { staged = broker.stageHermesToolGatewayCloneBinding( @@ -250,9 +272,10 @@ export function provisionHermesManagedCloneBrokerTransaction( } let providerReceipt: ManagedCloneProviderTransactionReceipt | undefined; try { - providerReceipt = provisionManagedCloneProviderTransaction(prepared.providerTransaction, { + providerReceipt = await provisionManagedCloneProviderTransaction(prepared.providerTransaction, { ...input, environment, + providerAdapter, resolveCredential: (binding, applyEnvironment) => binding.providerName === prepared.gatewayProviderName ? staged.brokerToken diff --git a/src/lib/actions/sandbox/snapshot/managed-clone-providers.ts b/src/lib/actions/sandbox/snapshot/managed-clone-providers.ts index 8d0f1f17ff9..98d87c8b7e3 100644 --- a/src/lib/actions/sandbox/snapshot/managed-clone-providers.ts +++ b/src/lib/actions/sandbox/snapshot/managed-clone-providers.ts @@ -4,13 +4,17 @@ import { randomBytes } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; +import type { OpenShellProviderAdapter } from "../../../adapters/openshell/provider-adapter"; +import { isValidOpenShellProviderCredentialName } from "../../../adapters/openshell/provider-adapter-cli"; +import { endpointlessProviderProfilePath } from "../../../adapters/openshell/provider-profile"; +import { + createManagedProviderAdapter, + managedProviderGatewayTarget, +} from "../../../adapters/openshell/managed-provider-adapter"; import { cloneAndDeepFreeze } from "../../../core/immutable"; import { REPOSITORY_ROOT } from "../../../core/repository-root"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; -import { - ensureMessagingCredentialProviderProfile, - MESSAGING_CREDENTIAL_PROVIDER_TYPE, -} from "../../../messaging/provider-profile"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; import { isValidName, isValidProviderName } from "../../../name-validation"; import { reportsExactProviderNotFound } from "../../../adapters/openshell/provider-diagnostic-cli"; import { @@ -33,15 +37,14 @@ const PROVIDER_PROBE_DIAGNOSTIC_LIMIT = 64 * 1024; export const MANAGED_CLONE_PROVIDER_CREATE_TIMEOUT_MS = 30_000; const PROVIDER_PROBE_TIMEOUT_MS = 5_000; const PROVIDER_TYPE_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/u; -const PROVIDER_ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,127}$/u; const TRANSACTION_ID_PATTERN = /^[a-f0-9]{32}$/u; export type ManagedCloneProviderCommandResult = { readonly status: number | null; readonly stdout?: string | Buffer | null; readonly stderr?: string | Buffer | null; - readonly error?: unknown; - readonly signal?: NodeJS.Signals | string | null; + readonly error?: Error; + readonly signal?: NodeJS.Signals | null; }; export type ManagedCloneProviderRunner = ( @@ -154,7 +157,12 @@ type ProviderInspection = | { readonly kind: "exact" } | { readonly kind: "missing" }; -function inspectProvider( +/** + * TODO(#9806, Slice 8): retire this raw-runner bridge when cleanup inspection and deletion use + * the typed provider adapter. Exit when both cleanup call sites below use typed get/delete + * operations and this helper has no callers. + */ +function inspectProviderForCleanup( binding: ManagedCloneProviderBinding, runOpenshell: ManagedCloneProviderRunner, ): ProviderInspection { @@ -193,6 +201,33 @@ function inspectProvider( : { kind: "collision" }; } +async function inspectProvider( + binding: ManagedCloneProviderBinding, + providerAdapter: OpenShellProviderAdapter, +): Promise { + const result = await providerAdapter.getProvider({ + providerName: binding.providerName, + target: managedProviderGatewayTarget, + timeoutMs: PROVIDER_PROBE_TIMEOUT_MS, + }); + if (!result.ok) { + if (result.error.kind === "command" && result.error.reason === "not_found") { + return { kind: "missing" }; + } + fail( + `could not prove whether provider '${binding.providerName}' exists; ` + + "refusing destination mutation", + ); + } + return matchesGatewayCredentialOnlyProviderBinding(result.value, { + name: binding.providerName, + type: binding.providerType, + credentialKey: binding.providerEnvKey, + }) + ? { kind: "exact" } + : { kind: "collision" }; +} + function validatedBinding(binding: ManagedCloneProviderBinding): ManagedCloneProviderBinding { if (!isValidProviderName(binding.providerName)) { fail(`provider name '${binding.providerName}' is invalid`); @@ -200,7 +235,10 @@ function validatedBinding(binding: ManagedCloneProviderBinding): ManagedClonePro if (!PROVIDER_TYPE_PATTERN.test(binding.providerType)) { fail(`provider '${binding.providerName}' has an invalid type`); } - if (!PROVIDER_ENV_KEY_PATTERN.test(binding.providerEnvKey)) { + if ( + !isValidOpenShellProviderCredentialName(binding.providerEnvKey) || + binding.providerEnvKey.length > 128 + ) { fail(`provider '${binding.providerName}' has an invalid credential binding`); } if ( @@ -356,7 +394,7 @@ type CloneProviderHandoff = Pick< * credential rotation remains a separate explicit operation with its own * recovery contract. */ -export function prepareManagedCloneProviderTransaction(input: { +export async function prepareManagedCloneProviderTransaction(input: { readonly handoff: CloneProviderHandoff; readonly destination: SandboxEntry | null; readonly additionalBindings?: readonly ManagedCloneProviderBinding[]; @@ -364,9 +402,10 @@ export function prepareManagedCloneProviderTransaction(input: { destination: Readonly, ) => readonly ManagedCloneProviderBinding[]; readonly environment?: NodeJS.ProcessEnv; + readonly providerAdapter?: OpenShellProviderAdapter; readonly runOpenshell: ManagedCloneProviderRunner; readonly transactionId?: string; -}): PreparedManagedCloneProviderTransaction { +}): Promise { const destinationSandboxName = input.handoff.destinationSandboxName; if ( !isValidName(input.handoff.sourceSandboxName) || @@ -405,6 +444,7 @@ export function prepareManagedCloneProviderTransaction(input: { ]) : []; const environment = input.environment ?? process.env; + const providerAdapter = input.providerAdapter ?? createManagedProviderAdapter(input.runOpenshell); const providers: PreparedManagedCloneProvider[] = []; for (const binding of desired) { if (!hasCredential(environment, binding.providerEnvKey)) { @@ -413,7 +453,7 @@ export function prepareManagedCloneProviderTransaction(input: { `credential in ${binding.providerEnvKey}`, ); } - const inspection = inspectProvider(binding, input.runOpenshell); + const inspection = await inspectProvider(binding, providerAdapter); if (inspection.kind === "collision") { fail(`provider '${binding.providerName}' has an incompatible live binding`); } @@ -514,10 +554,11 @@ function issueReceipt( * A non-zero create followed by an exact provider is explicitly ambiguous: * it is preserved and never claimed by this transaction. */ -export function provisionManagedCloneProviderTransaction( +export async function provisionManagedCloneProviderTransaction( prepared: PreparedManagedCloneProviderTransaction, input: { readonly environment?: NodeJS.ProcessEnv; + readonly providerAdapter?: OpenShellProviderAdapter; readonly runOpenshell: ManagedCloneProviderRunner; readonly readSandbox: ReadSandbox; readonly captureSnapshotRestoreAuthority?: CaptureSnapshotRestoreAuthority; @@ -527,8 +568,9 @@ export function provisionManagedCloneProviderTransaction( environment: NodeJS.ProcessEnv, ) => string | null | undefined; }, -): ManagedCloneProviderTransactionReceipt { +): Promise { const environment = input.environment ?? process.env; + const providerAdapter = input.providerAdapter ?? createManagedProviderAdapter(input.runOpenshell); const confirmed: ManagedCloneProviderOwnershipReceipt[] = []; try { // Fence every shared gateway mutation, including provider profile import. @@ -538,14 +580,31 @@ export function provisionManagedCloneProviderTransaction( (provider) => provider.binding.providerType === MESSAGING_CREDENTIAL_PROVIDER_TYPE, ) ) { - ensureMessagingCredentialProviderProfile({ - root: REPOSITORY_ROOT, - runOpenshell: input.runOpenshell, + const profile = await providerAdapter.importProviderProfile({ + profilePath: endpointlessProviderProfilePath( + REPOSITORY_ROOT, + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + ), + target: managedProviderGatewayTarget, + timeoutMs: MANAGED_CLONE_PROVIDER_CREATE_TIMEOUT_MS, }); + if (!profile.ok) { + if (profile.operation === "import") { + fail("Could not import the OpenShell messaging credential profile."); + } + if (profile.error.kind === "command" && profile.error.reason === "profile_incompatible") { + fail( + `OpenShell provider profile '${MESSAGING_CREDENTIAL_PROVIDER_TYPE}' already exists but does not match NemoClaw's endpointless messaging credential contract.`, + ); + } + fail( + `OpenShell provider profile '${MESSAGING_CREDENTIAL_PROVIDER_TYPE}' could not be exported for validation.`, + ); + } } for (const provider of prepared.providers) { revalidateManagedCloneMutationAuthority(prepared, input); - const current = inspectProvider(provider.binding, input.runOpenshell); + const current = await inspectProvider(provider.binding, providerAdapter); if (provider.action === "reuse-destination-owned") { if (current.kind !== "exact") { fail(`destination-owned provider '${provider.binding.providerName}' changed before use`); @@ -568,35 +627,17 @@ export function provisionManagedCloneProviderTransaction( if (!credential) { fail(`credential ${provider.binding.providerEnvKey} disappeared before provider creation`); } - let result: ManagedCloneProviderCommandResult; - try { - result = input.runOpenshell( - [ - "provider", - "create", - "--name", - provider.binding.providerName, - "--type", - provider.binding.providerType, - "--credential", - provider.binding.providerEnvKey, - ], - { - ignoreError: true, - env: { [provider.binding.providerEnvKey]: credential }, - maxBuffer: PROVIDER_PROBE_DIAGNOSTIC_LIMIT, - stdio: ["ignore", "pipe", "pipe"], - suppressOutput: true, - timeout: MANAGED_CLONE_PROVIDER_CREATE_TIMEOUT_MS, - }, - ); - } catch (error) { - // A thrown child-process adapter can still mean the gateway committed - // the create. Reconcile by exact metadata and preserve it as unowned. - result = { status: null, error }; - } - const reconciled = inspectProvider(provider.binding, input.runOpenshell); - if (result.status !== 0 || result.error || result.signal) { + const result = await providerAdapter.createProvider({ + config: [], + credentials: [{ name: provider.binding.providerEnvKey, value: credential }], + fromExisting: false, + name: provider.binding.providerName, + target: managedProviderGatewayTarget, + timeoutMs: MANAGED_CLONE_PROVIDER_CREATE_TIMEOUT_MS, + type: provider.binding.providerType, + }); + const reconciled = await inspectProvider(provider.binding, providerAdapter); + if (!result.ok) { const state = reconciled.kind === "exact" ? "exact but unowned" : reconciled.kind; fail( `create for provider '${provider.binding.providerName}' had an ambiguous result ` + @@ -654,7 +695,7 @@ export function cleanupManagedCloneProviderTransaction( } let inspection: ProviderInspection; try { - inspection = inspectProvider(provider.binding, runOpenshell); + inspection = inspectProviderForCleanup(provider.binding, runOpenshell); } catch { outcomes.push({ providerName, outcome: "inspection-failed" }); continue; @@ -677,7 +718,7 @@ export function cleanupManagedCloneProviderTransaction( continue; } try { - if (inspectProvider(provider.binding, runOpenshell).kind !== "missing") { + if (inspectProviderForCleanup(provider.binding, runOpenshell).kind !== "missing") { outcomes.push({ providerName, outcome: "delete-failed" }); continue; } diff --git a/src/lib/adapters/openshell/README.md b/src/lib/adapters/openshell/README.md index 574ba0fd678..a48c053bf78 100644 --- a/src/lib/adapters/openshell/README.md +++ b/src/lib/adapters/openshell/README.md @@ -41,6 +41,8 @@ Provider reads retain the complete config-key inventory so export can reject uns environment values. Configuration reads return revision metadata, not settings or credential values. Export compares two complete observations and can repeat that pair once when state changes. -Other provider CRUD, credential, profile, attachment, policy mutation, and lifecycle consumers -keep their existing adapters. Their migration remains with the linked capability issues. This -change does not replace their contracts or claim SDK qualification for their operations. +Managed rebuild recovery and snapshot-clone provider inspection, profile import, and creation use +`managed-provider-adapter.ts`, which binds the typed CLI adapter to the selected gateway. Provider +detachment, deletion, replacement cleanup, and other lifecycle operations keep their existing +adapters until the remaining #9806 migration slices land. This does not claim SDK qualification for +those operations. diff --git a/src/lib/adapters/openshell/managed-provider-adapter.ts b/src/lib/adapters/openshell/managed-provider-adapter.ts new file mode 100644 index 00000000000..b210de7fb2a --- /dev/null +++ b/src/lib/adapters/openshell/managed-provider-adapter.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OpenShellProviderAdapter } from "./provider-adapter"; +import { createCliOpenShellProviderAdapter, type RunProviderCommand } from "./provider-adapter-cli"; +import type { OpenShellGatewayTarget } from "./sandbox-observer"; + +export const managedProviderGatewayTarget: OpenShellGatewayTarget = { kind: "selected" }; + +/** Bind managed recovery consumers to one selected-gateway provider protocol owner. */ +export function createManagedProviderAdapter(run?: RunProviderCommand): OpenShellProviderAdapter { + return createCliOpenShellProviderAdapter(run ? { run } : {}); +} diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index 6eb19444171..c40ba9c261b 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -435,7 +435,9 @@ describe("CLI OpenShell provider adapter", () => { { env: { TAVILY_API_KEY: credentialValue }, ignoreError: true, + maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "pipe"], + suppressOutput: true, timeout: 30_000, }, ); diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 1929cd0fe35..2fa0dd92259 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -105,6 +105,11 @@ const NO_PROVIDER_ATTACHMENTS_RE = /^No providers attached to sandbox\b/mu; const PROVIDER_ATTACHMENT_HEADER_RE = /^NAME\s+TYPE\s+CREDENTIAL_KEYS\s+CONFIG_KEYS$/u; const PROVIDER_ATTACHMENT_ROW_RE = /^(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$/u; +/** Return whether a credential key satisfies the OpenShell provider CLI contract. */ +export function isValidOpenShellProviderCredentialName(value: string): boolean { + return ENV_NAME_PATTERN.test(value); +} + function success(value: T): OpenShellProviderResult { return { ok: true, value }; } @@ -399,7 +404,7 @@ function parseProfileCredentialKeys(output: string, expectedProfileId: string): 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; + if (typeof key !== "string" || !isValidOpenShellProviderCredentialName(key)) return null; keys.add(key); } } @@ -501,7 +506,8 @@ export function createCliOpenShellProviderAdapter( (!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, + (credential) => + !isValidOpenShellProviderCredentialName(credential.name) || credential.value.length === 0, ) ) { return failure({ @@ -519,7 +525,14 @@ export function createCliOpenShellProviderAdapter( const env = Object.fromEntries( request.credentials.map((credential) => [credential.name, credential.value]), ); - const result = invoke(args, request, request.credentials.length > 0 ? env : undefined); + const result = invoke( + args, + request, + request.credentials.length > 0 ? env : undefined, + 2, + true, + PROVIDER_GET_DIAGNOSTIC_LIMIT, + ); const error = commandError(result, Object.values(env)); if (request.fromExisting && error?.kind === "command") { return failure({ @@ -586,7 +599,8 @@ export function createCliOpenShellProviderAdapter( if ( !isValidCliOpenShellProviderIdentifier(request.providerName) || request.credentials.some( - (credential) => !ENV_NAME_PATTERN.test(credential.name) || credential.value.length === 0, + (credential) => + !isValidOpenShellProviderCredentialName(credential.name) || credential.value.length === 0, ) ) { return failure({ kind: "validation", message: "Provider update input is invalid." }); @@ -738,7 +752,7 @@ export function createCliOpenShellProviderAdapter( ]; if ( !isValidCliOpenShellProviderIdentifier(request.providerName) || - !ENV_NAME_PATTERN.test(request.credentialKey) || + !isValidOpenShellProviderCredentialName(request.credentialKey) || !request.strategy || materialKeys.length === 0 || new Set(materialKeys).size !== materialKeys.length || @@ -777,7 +791,7 @@ export function createCliOpenShellProviderAdapter( if (targetError) return failure(targetError); if ( !isValidCliOpenShellProviderIdentifier(request.providerName) || - !ENV_NAME_PATTERN.test(request.credentialKey) + !isValidOpenShellProviderCredentialName(request.credentialKey) ) { return failure({ kind: "validation", message: "Provider refresh status input is invalid." }); } diff --git a/test/credentials/rebuild-credential-preflight.test.ts b/test/credentials/rebuild-credential-preflight.test.ts index 4c01e1e540f..856f0e4e9e2 100644 --- a/test/credentials/rebuild-credential-preflight.test.ts +++ b/test/credentials/rebuild-credential-preflight.test.ts @@ -215,7 +215,11 @@ if (a[0] === "gateway" && a[1] === "select") process.exit(0); if (a[0] === "inference" && a[1] === "get") { process.stdout.write("Gateway inference:\\n Provider: ${provider}\\n Model: meta/llama-3.3-70b-instruct\\n"); process.exit(0); } if (a[0] === "inference" && a[1] === "set") process.exit(0); if (a[0] === "provider" && a[1] === "get") { - if (!${providerRegistered ? "true" : "false"}) process.exit(1); + if (!${providerRegistered ? "true" : "false"}) { + process.stderr.write("Error: provider '${provider}' not found\\n"); + process.exit(1); + } + process.stdout.write("Name: ${provider}\\nType: openai\\nCredential keys: ${credentialEnv}\\nConfig keys: OPENAI_BASE_URL\\n"); process.exit(0); } if (a[0] === "provider") process.exit(0); @@ -371,19 +375,31 @@ function registryHasSandbox(fixture: ReturnType): boolean } describe("atomic rebuild process contracts (#2273)", () => { - it("cancels interactive rebuild through stdin without entering preflight or backup", () => { - const fixture = createFixture({ providerRegistered: false }); + it( + "cancels interactive rebuild through stdin without entering preflight or backup", + testTimeoutOptions(30_000), + () => { + const fixture = createFixture({ providerRegistered: false }); + const providerGet = spawnSync( + process.execPath, + [path.join(fixture.tmpDir, "openshell"), "provider", "get", "nvidia-prod"], + { encoding: "utf-8", timeout: execTimeout(5_000) }, + ); - const result = runRebuild(fixture, {}, { yes: false, input: "n\n" }); - const output = `${result.stderr || ""}${result.stdout || ""}`; + expect(providerGet.status, providerGet.stderr).toBe(1); + expect(providerGet.stderr).toBe("Error: provider 'nvidia-prod' not found\n"); - expect(result.status, output).toBe(0); - expect(output).toContain("Proceed? [y/N]:"); - expect(output).toContain("Cancelled."); - expect(output).not.toContain("preflight failed"); - expect(output).not.toContain("Backing up sandbox state"); - expect(registryHasSandbox(fixture)).toBe(true); - }); + const result = runRebuild(fixture, {}, { yes: false, input: "n\n" }); + const output = `${result.stderr || ""}${result.stdout || ""}`; + + expect(result.status, output).toBe(0); + expect(output).toContain("Proceed? [y/N]:"); + expect(output).toContain("Cancelled."); + expect(output).not.toContain("preflight failed"); + expect(output).not.toContain("Backing up sandbox state"); + expect(registryHasSandbox(fixture)).toBe(true); + }, + ); it( "keeps a Ready DCode sandbox usable when its stored route returns 401 (#6195)", diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index e58aa89686f..0063020d42e 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -34,6 +34,7 @@ import { onboardCredentialEnv, onboardSession, openshellRuntime, + providerCommand, policies, policyGet, policyState, @@ -731,12 +732,12 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): return argv[0] === "provider" && argv[1] === "get" ? { status: 0, - stdout: - "Name: compatible-endpoint\nType: openai\nCredential keys: COMPATIBLE_API_KEY\nConfig keys: OPENAI_BASE_URL\n", + stdout: `Name: ${argv[2]}\nType: openai\nCredential keys: COMPATIBLE_API_KEY\nConfig keys: OPENAI_BASE_URL\n`, stderr: "", } : { status: 0, output: "" }; }); + providerCommand.setProviderCommandRuntimeHooksForTest({ runOpenshell: runOpenshellSpy }); const captureOpenshellSpy = vi .spyOn(openshellRuntime, "captureOpenshell") .mockImplementation((args: unknown, options?: unknown) => { diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index c180db8ca0b..b6f28104717 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -53,6 +53,7 @@ export const removedImmutabilityMigration = requireDist( "../../state/migrations/removed-immutability.js", ); export const openshellRuntime = requireDist("../../adapters/openshell/runtime.js"); +export const providerCommand = requireDist("../../adapters/openshell/provider-command.js"); export const policies = requireDist("../../policy/index.js"); export const policyState = requireDist("../../adapters/openshell/policy-state.js"); export const policyGet = requireDist("./policy-get.js"); @@ -168,6 +169,7 @@ export function installRebuildFlowTestHooks(options: RebuildFlowTestHookOptions }); afterEach(() => { vi.restoreAllMocks(); + providerCommand.setProviderCommandRuntimeHooksForTest({}); purgeRebuildModule(); for (const dir of harnessTempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/test/package-contract/cli/credentials-cli-command.test.ts b/test/package-contract/cli/credentials-cli-command.test.ts index 36195aa75eb..851e7ae23cf 100644 --- a/test/package-contract/cli/credentials-cli-command.test.ts +++ b/test/package-contract/cli/credentials-cli-command.test.ts @@ -467,8 +467,10 @@ describe("credentials oclif commands", () => { opts: { env: expect.any(Object), ignoreError: true, + maxBuffer: 64 * 1024, replaceEnv: true, stdio: ["ignore", "pipe", "pipe"], + suppressOutput: true, timeout: 30_000, }, }, diff --git a/test/runtime/gateway/gateway-state-reconcile-2276.test.ts b/test/runtime/gateway/gateway-state-reconcile-2276.test.ts index e420ea6c9b8..bf1cf172d6a 100644 --- a/test/runtime/gateway/gateway-state-reconcile-2276.test.ts +++ b/test/runtime/gateway/gateway-state-reconcile-2276.test.ts @@ -174,7 +174,10 @@ if (args[0] === "inference" && args[1] === "get") { process.exit(0); } -if (args[0] === "provider" && args[1] === "get") process.exit(0); +if (args[0] === "provider" && args[1] === "get") { + process.stdout.write("Name: nvidia-prod\\nType: nvidia\\nCredential keys: NVIDIA_INFERENCE_API_KEY\\nConfig keys: \\n"); + process.exit(0); +} // forward stop/start, provider delete, logs, etc. — no-op success process.exit(0);