Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/e2e-vitest-scenarios.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -982,11 +982,17 @@ jobs:

- name: Run credential migration live test
# Migrated from test/e2e/test-credential-migration.sh. This live test
# needs NVIDIA_INFERENCE_API_KEY only as the staged legacy credential value; it
# preserves the default NVIDIA provider/key migration path while
# pinning a lower-quota catalog model in the test fixture.
# stages the hosted inference credential through legacy credentials.json.
# CI uses the compatible-provider route so repository-scoped E2E
# credentials do not need an nvapi- prefix.
env:
NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
NEMOCLAW_E2E_USE_NVIDIA_SECRET_AS_COMPATIBLE: "1"
NEMOCLAW_PROVIDER: custom
NEMOCLAW_ENDPOINT_URL: https://inference-api.nvidia.com/v1
NEMOCLAW_MODEL: nvidia/nvidia/nemotron-3-super-v3
NEMOCLAW_COMPAT_MODEL: nvidia/nvidia/nemotron-3-super-v3
COMPATIBLE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
run: |
set -euo pipefail
npx vitest run --project e2e-scenarios-live \
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1544,11 +1544,19 @@ jobs:

- name: Run credential migration Vitest test
# Trusted-code boundary: this job runs the checked-out target ref with
# NVIDIA_INFERENCE_API_KEY because it validates live credential migration into the
# OpenShell gateway. Keep checkout credentials disabled, do not pass
# GITHUB_TOKEN, and rely on reviewed/maintainer-dispatched refs.
# the hosted inference credential because it validates live credential
# migration into the OpenShell gateway. CI uses the compatible-provider
# route so repository-scoped E2E credentials do not need an nvapi-
# prefix. Keep checkout credentials disabled, do not pass GITHUB_TOKEN,
# and rely on reviewed/maintainer-dispatched refs.
env:
NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
NEMOCLAW_E2E_USE_NVIDIA_SECRET_AS_COMPATIBLE: "1"
NEMOCLAW_PROVIDER: custom
NEMOCLAW_ENDPOINT_URL: https://inference-api.nvidia.com/v1
NEMOCLAW_MODEL: nvidia/nvidia/nemotron-3-super-v3
NEMOCLAW_COMPAT_MODEL: nvidia/nvidia/nemotron-3-super-v3
COMPATIBLE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/credential-migration
NEMOCLAW_RUN_E2E_SCENARIOS: "1"
NEMOCLAW_SANDBOX_NAME: "e2e-cred-migration"
Expand Down
79 changes: 79 additions & 0 deletions test/e2e-scenario/fixtures/hosted-inference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const COMPATIBLE_INFERENCE_FLAG = "NEMOCLAW_E2E_USE_NVIDIA_SECRET_AS_COMPATIBLE";
const DEFAULT_COMPATIBLE_BASE_URL = "https://inference-api.nvidia.com/v1";
const DEFAULT_COMPATIBLE_MODEL = "nvidia/nvidia/nemotron-3-super-v3";

export interface HostedInferenceSecrets {
required(name: string): string;
}

export interface HostedInferenceOptions {
nvidiaModel?: string;
}

export interface HostedInferenceConfig {
apiKey: string;
credentialEnv: "NVIDIA_INFERENCE_API_KEY" | "COMPATIBLE_API_KEY";
provider: "nvidia" | "compatible";
providerName: "nvidia-prod" | "compatible-endpoint";
env: NodeJS.ProcessEnv;
model: string;
endpointUrl: string;
contractLabel: string;
}

export function usingCiCompatibleInference(env: NodeJS.ProcessEnv = process.env): boolean {
return env[COMPATIBLE_INFERENCE_FLAG] === "1";
}

export function requireHostedInferenceConfig(
secrets: HostedInferenceSecrets,
env: NodeJS.ProcessEnv = process.env,
options: HostedInferenceOptions = {},
): HostedInferenceConfig {
if (usingCiCompatibleInference(env)) {
const apiKey = secrets.required("COMPATIBLE_API_KEY");
const endpointUrl = env.NEMOCLAW_ENDPOINT_URL || DEFAULT_COMPATIBLE_BASE_URL;
const model = env.NEMOCLAW_MODEL || env.NEMOCLAW_COMPAT_MODEL || DEFAULT_COMPATIBLE_MODEL;
return {
apiKey,
credentialEnv: "COMPATIBLE_API_KEY",
provider: "compatible",
providerName: "compatible-endpoint",
endpointUrl,
model,
env: {
NEMOCLAW_PROVIDER: "custom",
NEMOCLAW_ENDPOINT_URL: endpointUrl,
NEMOCLAW_MODEL: model,
NEMOCLAW_COMPAT_MODEL: model,
COMPATIBLE_API_KEY: apiKey,
},
contractLabel: "CI compatible inference credential is present",
};
}

const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY");
if (!apiKey.startsWith("nvapi-")) {
throw new Error(
`NVIDIA_INFERENCE_API_KEY must start with nvapi- unless ${COMPATIBLE_INFERENCE_FLAG}=1 is set`,
);
}

const model = options.nvidiaModel ?? env.NEMOCLAW_MODEL ?? "";
return {
apiKey,
credentialEnv: "NVIDIA_INFERENCE_API_KEY",
provider: "nvidia",
providerName: "nvidia-prod",
endpointUrl: DEFAULT_COMPATIBLE_BASE_URL,
model,
env: {
NVIDIA_INFERENCE_API_KEY: apiKey,
...(model ? { NEMOCLAW_MODEL: model } : {}),
},
contractLabel: "NVIDIA_INFERENCE_API_KEY is present and nvapi-prefixed",
};
}
53 changes: 30 additions & 23 deletions test/e2e-scenario/live/credential-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts";
import type { HostCliClient } from "../fixtures/clients/host.ts";
import { validateSandboxName } from "../fixtures/clients/sandbox.ts";
import { expect, test } from "../fixtures/e2e-test.ts";
import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts";
import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts";

// Migrated from test/e2e/test-credential-migration.sh. This is a focused live
Expand All @@ -17,10 +18,9 @@ import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts";
// a successful real onboard registers the migrated value with the OpenShell
// gateway, the plaintext file is removed after success, credentials list reads
// from the gateway, and secure unlink removes a planted symlink without touching
// its target. The live onboard intentionally follows the legacy default NVIDIA
// Endpoints path: NVIDIA_INFERENCE_API_KEY is present only in the legacy file, absent from
// the onboard child env, and must migrate into the nvidia-prod gateway provider.
// No registry, migration ledger, or shared helper is introduced.
// its target. By default the live onboard follows the legacy NVIDIA Endpoints
// path. When CI opts into the compatible-provider secret path, the same
// migration contract runs against COMPATIBLE_API_KEY and compatible-endpoint.

const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js");
Expand Down Expand Up @@ -107,7 +107,10 @@ async function cleanupCredentialMigrationState(host: HostCliClient, home: string
host.command("node", [CLI_ENTRYPOINT, SANDBOX_NAME, "destroy", "--yes"], {
artifactName: "cleanup-nemoclaw-destroy",
env,
redactionValues: [process.env.NVIDIA_INFERENCE_API_KEY ?? ""],
redactionValues: [
process.env.NVIDIA_INFERENCE_API_KEY ?? "",
process.env.COMPATIBLE_API_KEY ?? "",
],
timeoutMs: 120_000,
}),
);
Expand Down Expand Up @@ -138,15 +141,18 @@ runCredentialMigrationTest(
"credential migration stages legacy file into gateway and removes plaintext safely",
{ timeout: ONBOARD_TIMEOUT_MS + INSTALL_TIMEOUT_MS + 5 * 60_000 },
async ({ artifacts, cleanup, host, secrets, skip }) => {
// Use the existing nightly secret as the legacy NVIDIA credential. The
// onboard child env below deliberately does not receive NVIDIA_INFERENCE_API_KEY, so
// Use the existing nightly secret as the legacy provider credential. The
// onboard child env below deliberately does not receive that credential, so
// the only source is ~/.nemoclaw/credentials.json — matching the retired
// shell lane's migration contract.
const migratedCredentialValue = secrets.required("NVIDIA_INFERENCE_API_KEY");
expect(
migratedCredentialValue.startsWith("nvapi-"),
"NVIDIA_INFERENCE_API_KEY must start with nvapi-",
).toBe(true);
const hostedInference = requireHostedInferenceConfig(secrets, process.env, {
nvidiaModel: CREDENTIAL_MIGRATION_MODEL,
});
const migratedCredentialValue = hostedInference.apiKey;
const {
[hostedInference.credentialEnv]: _omittedCredential,
...hostedInferenceEnvWithoutCredential
} = hostedInference.env;
expect(fs.existsSync(CLI_ENTRYPOINT), "bin/nemoclaw.js missing").toBe(true);
expect(
fs.existsSync(DIST_CREDENTIAL_STORE),
Expand Down Expand Up @@ -183,8 +189,8 @@ runCredentialMigrationTest(
sandboxName: SANDBOX_NAME,
contracts: [
"legacy credentials.json stages allowlisted provider keys into onboard env",
"successful default NVIDIA Endpoints onboard registers the migrated value with OpenShell gateway",
"onboard keeps the default NVIDIA provider/key/endpoint/policy path while pinning a low-quota catalog model",
`successful onboard registers the migrated value with the ${hostedInference.providerName} OpenShell gateway provider`,
`onboard uses the ${hostedInference.provider} provider/key/endpoint/policy path`,
"successful onboard removes plaintext credentials.json",
"tampered non-credential keys do not become gateway providers",
"credentials list reads providers from the gateway, not disk",
Expand All @@ -201,7 +207,7 @@ runCredentialMigrationTest(
legacyFile,
JSON.stringify(
{
NVIDIA_INFERENCE_API_KEY: migratedCredentialValue,
[hostedInference.credentialEnv]: migratedCredentialValue,
OPENSHELL_GATEWAY: "evil-gw-from-tampered-file",
NODE_OPTIONS: "--require=/tmp/evil.js",
},
Expand All @@ -214,11 +220,9 @@ runCredentialMigrationTest(
const onboard = await host.command("node", [CLI_ENTRYPOINT, "onboard", "--non-interactive"], {
artifactName: "onboard-from-legacy-credentials",
env: testEnv(home, {
...hostedInferenceEnvWithoutCredential,
NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME,
NEMOCLAW_RECREATE_SANDBOX: "1",
// Keep the default NVIDIA provider/key/endpoint/policy path while
// avoiding the high-quota default Nemotron validation model.
NEMOCLAW_MODEL: CREDENTIAL_MIGRATION_MODEL,
}),
redactionValues: [migratedCredentialValue],
timeoutMs: ONBOARD_TIMEOUT_MS,
Expand Down Expand Up @@ -247,9 +251,10 @@ runCredentialMigrationTest(
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(line));
expect(providerNames, `expected migrated NVIDIA provider\n${providersText}`).toContain(
"nvidia-prod",
);
expect(
providerNames,
`expected migrated ${hostedInference.providerName} provider\n${providersText}`,
).toContain(hostedInference.providerName);
expect(providerNames).not.toContain("OPENSHELL_GATEWAY");
expect(providerNames).not.toContain("NODE_OPTIONS");

Expand Down Expand Up @@ -292,15 +297,17 @@ runCredentialMigrationTest(
await artifacts.writeJson("scenario-result.json", {
id: "credential-migration",
sandboxName: SANDBOX_NAME,
model: CREDENTIAL_MIGRATION_MODEL,
model: hostedInference.model || CREDENTIAL_MIGRATION_MODEL,
provider: hostedInference.providerName,
credentialEnv: hostedInference.credentialEnv,
providerNames,
assertions: {
onboardSucceeded: onboard.exitCode === 0,
migrationNoticeEmitted: onboardText.includes(
"Staged 1 legacy credential(s) for migration to the OpenShell gateway.",
),
legacyFileRemovedAfterOnboard: !fs.existsSync(legacyFile),
migratedNvidiaProviderRegistered: providerNames.includes("nvidia-prod"),
migratedProviderRegistered: providerNames.includes(hostedInference.providerName),
tamperedKeysExcluded:
!providerNames.includes("OPENSHELL_GATEWAY") && !providerNames.includes("NODE_OPTIONS"),
credentialsListReadsGateway: credentialsText.includes(
Expand Down
53 changes: 53 additions & 0 deletions test/e2e-scenario/support-tests/hosted-inference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts";

function secrets(values: Record<string, string | undefined>) {
return {
required: (name: string) => {
const value = values[name];
if (!value) throw new Error(`missing ${name}`);
return value;
},
};
}

describe("hosted inference E2E config", () => {
it("requires an nvapi-prefixed NVIDIA key by default", () => {
const cfg = requireHostedInferenceConfig(
secrets({ NVIDIA_INFERENCE_API_KEY: "nvapi-test-key" }),
{},
);

expect(cfg.provider).toBe("nvidia");
expect(cfg.credentialEnv).toBe("NVIDIA_INFERENCE_API_KEY");
expect(cfg.env.NVIDIA_INFERENCE_API_KEY).toBe("nvapi-test-key");
});

it("rejects a non-NVIDIA key unless the compatible-provider flag is set", () => {
expect(() =>
requireHostedInferenceConfig(secrets({ NVIDIA_INFERENCE_API_KEY: "sk-compatible-key" }), {}),
).toThrow(/must start with nvapi-/);
});

it("accepts a compatible-provider credential when CI enables the compatibility flag", () => {
const cfg = requireHostedInferenceConfig(
secrets({
COMPATIBLE_API_KEY: "sk-compatible-key",
}),
{ NEMOCLAW_E2E_USE_NVIDIA_SECRET_AS_COMPATIBLE: "1" },
);

expect(cfg.provider).toBe("compatible");
expect(cfg.credentialEnv).toBe("COMPATIBLE_API_KEY");
expect(cfg.env).toMatchObject({
NEMOCLAW_PROVIDER: "custom",
NEMOCLAW_ENDPOINT_URL: "https://inference-api.nvidia.com/v1",
NEMOCLAW_MODEL: "nvidia/nvidia/nemotron-3-super-v3",
COMPATIBLE_API_KEY: "sk-compatible-key",
});
});
});
Loading