diff --git a/docs/set-up/config-reference.mdx b/docs/set-up/config-reference.mdx index bc06f66504..a5d205a3ce 100644 --- a/docs/set-up/config-reference.mdx +++ b/docs/set-up/config-reference.mdx @@ -245,8 +245,8 @@ files: path: /data/files_storage # How many bytes to buffer before flushing to disk | default: 16777216 write_buffer_size: 16777216 - # Comma-separated list of external hosts the Files service is allowed to access. | default: 'https://api.ngc.nvidia.com,https://huggingface.co' - allowed_external_hosts: https://api.ngc.nvidia.com,https://huggingface.co + # Comma-separated list of external hosts the Files service is allowed to access. | default: 'https://api.ngc.nvidia.com,https://huggingface.co,https://api.github.com' + allowed_external_hosts: https://api.ngc.nvidia.com,https://huggingface.co,https://api.github.com # Allow users to explicitly create filesets with local storage config. Security-sensitive: enable only in trusted deployments. | default: False allow_user_local_storage: false # TTL for file locks in seconds (default 5 minutes) | default: 300 diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index e24058ffa3..c5bb4ea840 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -10203,6 +10203,7 @@ components: - $ref: '#/components/schemas/NGCStorageConfig' - $ref: '#/components/schemas/HuggingfaceStorageConfig' - $ref: '#/components/schemas/S3StorageConfig' + - $ref: '#/components/schemas/GithubStorageConfig' title: Storage description: The storage configuration for the fileset. If not provided, uses default storage. @@ -12530,6 +12531,7 @@ components: - $ref: '#/components/schemas/NGCStorageConfig' - $ref: '#/components/schemas/HuggingfaceStorageConfig' - $ref: '#/components/schemas/S3StorageConfig' + - $ref: '#/components/schemas/GithubStorageConfig' title: Storage metadata: $ref: '#/components/schemas/FilesetMetadata' @@ -13036,6 +13038,60 @@ components: with ``-`` for descending order.' + GithubStorageConfig: + properties: + read_chunk_size: + type: integer + title: Read Chunk Size + description: 'Chunk size in bytes for reading/streaming files. Larger chunks + reduce async overhead but increase memory per concurrent download. Default: + 1MB.' + default: 1048576 + type: + type: string + const: github + title: Type + default: github + owner: + type: string + title: Owner + description: GitHub repository owner (user or organization) + repo: + type: string + title: Repo + description: GitHub repository name + revision: + type: string + title: Revision + description: Branch, tag, or commit SHA. 'HEAD' resolves to the repository's + default branch. + default: HEAD + original_revision: + title: Original Revision + description: The original revision requested by the user before resolution + (e.g., 'main'). The 'revision' field contains the resolved commit SHA. + type: string + path: + type: string + title: Path + description: Optional directory within the repository. All paths are relative + to it. + default: '' + token_secret: + allOf: + - $ref: '#/components/schemas/SecretRef' + description: GitHub personal access token secret name, required for private + repositories + api_base_url: + type: string + title: Api Base Url + description: GitHub API base URL. Use for GitHub Enterprise instances. + default: https://api.github.com + type: object + required: + - owner + - repo + title: GithubStorageConfig GuardrailCheckRequest: properties: model: @@ -18907,6 +18963,7 @@ components: - ngc - huggingface - s3 + - github title: StorageConfigType type: string StringFilter: diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index e24058ffa3..c5bb4ea840 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -10203,6 +10203,7 @@ components: - $ref: '#/components/schemas/NGCStorageConfig' - $ref: '#/components/schemas/HuggingfaceStorageConfig' - $ref: '#/components/schemas/S3StorageConfig' + - $ref: '#/components/schemas/GithubStorageConfig' title: Storage description: The storage configuration for the fileset. If not provided, uses default storage. @@ -12530,6 +12531,7 @@ components: - $ref: '#/components/schemas/NGCStorageConfig' - $ref: '#/components/schemas/HuggingfaceStorageConfig' - $ref: '#/components/schemas/S3StorageConfig' + - $ref: '#/components/schemas/GithubStorageConfig' title: Storage metadata: $ref: '#/components/schemas/FilesetMetadata' @@ -13036,6 +13038,60 @@ components: with ``-`` for descending order.' + GithubStorageConfig: + properties: + read_chunk_size: + type: integer + title: Read Chunk Size + description: 'Chunk size in bytes for reading/streaming files. Larger chunks + reduce async overhead but increase memory per concurrent download. Default: + 1MB.' + default: 1048576 + type: + type: string + const: github + title: Type + default: github + owner: + type: string + title: Owner + description: GitHub repository owner (user or organization) + repo: + type: string + title: Repo + description: GitHub repository name + revision: + type: string + title: Revision + description: Branch, tag, or commit SHA. 'HEAD' resolves to the repository's + default branch. + default: HEAD + original_revision: + title: Original Revision + description: The original revision requested by the user before resolution + (e.g., 'main'). The 'revision' field contains the resolved commit SHA. + type: string + path: + type: string + title: Path + description: Optional directory within the repository. All paths are relative + to it. + default: '' + token_secret: + allOf: + - $ref: '#/components/schemas/SecretRef' + description: GitHub personal access token secret name, required for private + repositories + api_base_url: + type: string + title: Api Base Url + description: GitHub API base URL. Use for GitHub Enterprise instances. + default: https://api.github.com + type: object + required: + - owner + - repo + title: GithubStorageConfig GuardrailCheckRequest: properties: model: @@ -18907,6 +18963,7 @@ components: - ngc - huggingface - s3 + - github title: StorageConfigType type: string StringFilter: diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index e24058ffa3..c5bb4ea840 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -10203,6 +10203,7 @@ components: - $ref: '#/components/schemas/NGCStorageConfig' - $ref: '#/components/schemas/HuggingfaceStorageConfig' - $ref: '#/components/schemas/S3StorageConfig' + - $ref: '#/components/schemas/GithubStorageConfig' title: Storage description: The storage configuration for the fileset. If not provided, uses default storage. @@ -12530,6 +12531,7 @@ components: - $ref: '#/components/schemas/NGCStorageConfig' - $ref: '#/components/schemas/HuggingfaceStorageConfig' - $ref: '#/components/schemas/S3StorageConfig' + - $ref: '#/components/schemas/GithubStorageConfig' title: Storage metadata: $ref: '#/components/schemas/FilesetMetadata' @@ -13036,6 +13038,60 @@ components: with ``-`` for descending order.' + GithubStorageConfig: + properties: + read_chunk_size: + type: integer + title: Read Chunk Size + description: 'Chunk size in bytes for reading/streaming files. Larger chunks + reduce async overhead but increase memory per concurrent download. Default: + 1MB.' + default: 1048576 + type: + type: string + const: github + title: Type + default: github + owner: + type: string + title: Owner + description: GitHub repository owner (user or organization) + repo: + type: string + title: Repo + description: GitHub repository name + revision: + type: string + title: Revision + description: Branch, tag, or commit SHA. 'HEAD' resolves to the repository's + default branch. + default: HEAD + original_revision: + title: Original Revision + description: The original revision requested by the user before resolution + (e.g., 'main'). The 'revision' field contains the resolved commit SHA. + type: string + path: + type: string + title: Path + description: Optional directory within the repository. All paths are relative + to it. + default: '' + token_secret: + allOf: + - $ref: '#/components/schemas/SecretRef' + description: GitHub personal access token secret name, required for private + repositories + api_base_url: + type: string + title: Api Base Url + description: GitHub API base URL. Use for GitHub Enterprise instances. + default: https://api.github.com + type: object + required: + - owner + - repo + title: GithubStorageConfig GuardrailCheckRequest: properties: model: @@ -18907,6 +18963,7 @@ components: - ngc - huggingface - s3 + - github title: StorageConfigType type: string StringFilter: diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/files/storage_config.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/files/storage_config.py index 8a9601a3b3..cc6dccd186 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/files/storage_config.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/files/storage_config.py @@ -27,6 +27,7 @@ class StorageConfigType(StrEnum): NGC = "ngc" HUGGINGFACE = "huggingface" S3 = "s3" + GITHUB = "github" # AZURE_BLOB = "azure_blob" # GCS = "gcs" # HTTP = "http" @@ -146,6 +147,43 @@ def get_secret_references(self) -> dict[str, SecretRef]: return {"token": self.token_secret} if self.token_secret else {} +class GithubStorageConfig(BaseStorageConfig): + type: Literal[StorageConfigType.GITHUB] = StorageConfigType.GITHUB + owner: str = Field(description="GitHub repository owner (user or organization)") + repo: str = Field(description="GitHub repository name") + revision: str = Field( + default="HEAD", + description="Branch, tag, or commit SHA. 'HEAD' resolves to the repository's default branch.", + ) + original_revision: str | None = Field( + default=None, + description="The original revision requested by the user before resolution (e.g., 'main'). " + "The 'revision' field contains the resolved commit SHA.", + ) + path: str = Field( + default="", + description="Optional directory within the repository. All paths are relative to it.", + ) + + token_secret: SecretRef | None = Field( + default=None, + description="GitHub personal access token secret name, required for private repositories", + ) + + api_base_url: str = Field( + default="https://api.github.com", + description="GitHub API base URL. Use for GitHub Enterprise instances.", + ) + + @field_validator("path") + @classmethod + def strip_path_slashes(cls, v: str) -> str: + return v.strip("/") + + def get_secret_references(self) -> dict[str, SecretRef]: + return {"token": self.token_secret} if self.token_secret else {} + + class NGCStorageConfig(BaseStorageConfig): type: Literal[StorageConfigType.NGC] = StorageConfigType.NGC org: str = Field(description="NGC organization name") @@ -252,6 +290,6 @@ def copy_config(self, path: str) -> Self: return self.model_copy(deep=True, update={"prefix": new_prefix}) -StorageConfig = LocalStorageConfig | NGCStorageConfig | HuggingfaceStorageConfig | S3StorageConfig +StorageConfig = LocalStorageConfig | NGCStorageConfig | HuggingfaceStorageConfig | S3StorageConfig | GithubStorageConfig StorageConfigField = Annotated[StorageConfig, Field(discriminator="type")] diff --git a/packages/nmp_common/src/nmp/common/files/storage_config.py b/packages/nmp_common/src/nmp/common/files/storage_config.py index 67962954d2..384f578310 100644 --- a/packages/nmp_common/src/nmp/common/files/storage_config.py +++ b/packages/nmp_common/src/nmp/common/files/storage_config.py @@ -10,6 +10,7 @@ from nemo_platform_plugin.files.storage_config import DEFAULT_READ_CHUNK_SIZE as DEFAULT_READ_CHUNK_SIZE from nemo_platform_plugin.files.storage_config import BaseStorageConfig as BaseStorageConfig +from nemo_platform_plugin.files.storage_config import GithubStorageConfig as GithubStorageConfig from nemo_platform_plugin.files.storage_config import HuggingfaceStorageConfig as HuggingfaceStorageConfig from nemo_platform_plugin.files.storage_config import LocalStorageConfig as LocalStorageConfig from nemo_platform_plugin.files.storage_config import NGCStorageConfig as NGCStorageConfig diff --git a/services/core/files/src/nmp/core/files/app/backends/factory.py b/services/core/files/src/nmp/core/files/app/backends/factory.py index 05b38c521e..ac8b5295d4 100644 --- a/services/core/files/src/nmp/core/files/app/backends/factory.py +++ b/services/core/files/src/nmp/core/files/app/backends/factory.py @@ -14,6 +14,7 @@ import logging from nmp.common.files.storage_config import ( + GithubStorageConfig, HuggingfaceStorageConfig, LocalStorageConfig, NGCStorageConfig, @@ -49,5 +50,9 @@ def storage_impl_factory( from nmp.core.files.app.backends.s3 import S3StorageImpl return S3StorageImpl(config, secrets) + case GithubStorageConfig(): + from nmp.core.files.app.backends.github import GithubStorageImpl + + return GithubStorageImpl(config, secrets) case _: raise TypeError(f"Unsupported storage config type: {type(config).__name__}") diff --git a/services/core/files/src/nmp/core/files/app/backends/github.py b/services/core/files/src/nmp/core/files/app/backends/github.py new file mode 100644 index 0000000000..dde28c4a94 --- /dev/null +++ b/services/core/files/src/nmp/core/files/app/backends/github.py @@ -0,0 +1,192 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""GitHub storage backend for repositories served over the GitHub REST API.""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any + +import aiohttp +from nmp.common.files.storage_config import GithubStorageConfig as GithubStorageConfig +from nmp.core.files.app.backends.base import ( + ByteRange, + FileInfo, + StorageImpl, +) +from nmp.core.files.app.external_hosts import validate_external_host +from nmp.core.files.app.http_session import get_http_session +from nmp.core.files.exceptions import ( + NotFoundError, + StorageAccessError, + StorageBackendError, + StorageConfigError, + StorageUnavailableError, +) + +logger = logging.getLogger(__name__) + +JSON_MEDIA_TYPE = "application/vnd.github+json" +RAW_MEDIA_TYPE = "application/vnd.github.raw" + + +class GithubBackendError(StorageBackendError): + """Raised when there's issues talking to GitHub.""" + + +class GithubAccessError(StorageAccessError): + """Raised when access to a GitHub repository is denied (401, 403).""" + + +class GithubConfigError(StorageConfigError): + """Raised when GitHub storage config is invalid (repository or revision not found).""" + + +class GithubUnavailableError(StorageUnavailableError): + """Raised when GitHub is unavailable (5xx, 429).""" + + +def raise_for_github_status(status: int, subject: str, headers: dict[str, str] | None = None) -> None: + """Map a GitHub response status onto the storage exception hierarchy.""" + if status < 400: + return + + # A private repository is indistinguishable from a missing one without the right + # token, so 404 is reported as a config error rather than an access error. + if status == 404: + raise GithubConfigError(f"GitHub has no {subject}, or the token cannot see it") + if status in (401, 403): + if headers and headers.get("x-ratelimit-remaining") == "0": + raise GithubUnavailableError(f"GitHub rate limit exhausted while reading {subject}") + raise GithubAccessError(f"GitHub denied access to {subject}") + if status == 429 or status >= 500: + raise GithubUnavailableError(f"GitHub is unavailable ({status}) reading {subject}") + raise GithubBackendError(f"GitHub returned {status} reading {subject}") + + +@dataclass +class GithubStorageImpl(StorageImpl): + config: GithubStorageConfig + secrets: dict[str, str] + + @property + def _repo_slug(self) -> str: + return f"{self.config.owner}/{self.config.repo}" + + def _headers(self, accept: str) -> dict[str, str]: + headers = {"Accept": accept, "X-GitHub-Api-Version": "2022-11-28"} + token = self.secrets.get("token") + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + def _api_url(self, suffix: str) -> str: + return f"{self.config.api_base_url.rstrip('/')}/repos/{self._repo_slug}/{suffix}" + + def _repo_path(self, path: str) -> str: + return f"{self.config.path}/{path}" if self.config.path else path + + async def _get_json(self, url: str, subject: str) -> Any: + session = get_http_session() + try: + async with session.get(url, headers=self._headers(JSON_MEDIA_TYPE)) as response: + raise_for_github_status(subject=subject, status=response.status, headers=dict(response.headers)) + return await response.json() + except aiohttp.ClientError as exc: + raise GithubUnavailableError(f"Could not reach GitHub to read {subject}: {exc}") from exc + + async def resolve_config(self) -> GithubStorageConfig: + """Pin the revision to a commit SHA so the fileset cannot shift under a deployment.""" + commit = await self._get_json( + self._api_url(f"commits/{self.config.revision}"), + f"revision {self.config.revision} of {self._repo_slug}", + ) + sha = commit.get("sha") if isinstance(commit, dict) else None + if not isinstance(sha, str) or not sha: + raise GithubConfigError(f"GitHub returned no commit SHA for {self.config.revision} of {self._repo_slug}") + + return self.config.model_copy( + update={"revision": sha, "original_revision": self.config.original_revision or self.config.revision} + ) + + async def list_files(self, path: str | None = None) -> list[FileInfo]: + tree = await self._get_json( + self._api_url(f"git/trees/{self.config.revision}?recursive=1"), + f"{self._repo_slug} at {self.config.revision}", + ) + + if not isinstance(tree, dict) or not isinstance(tree.get("tree"), list): + raise GithubBackendError(f"GitHub returned no file list for {self._repo_slug}") + if tree.get("truncated") is True: + raise GithubConfigError( + f"{self._repo_slug} is too large for GitHub to list in one request; " + "point the fileset at a directory within it" + ) + + prefix = f"{self.config.path}/" if self.config.path else "" + wanted = f"{path.strip('/')}" if path else "" + + files: list[FileInfo] = [] + for entry in tree["tree"]: + if not isinstance(entry, dict) or entry.get("type") != "blob": + continue + entry_path = entry.get("path") + if not isinstance(entry_path, str) or (prefix and not entry_path.startswith(prefix)): + continue + + relative = entry_path[len(prefix) :] + if wanted and relative != wanted and not relative.startswith(f"{wanted}/"): + continue + size = entry.get("size") + files.append(FileInfo(path=relative, size=size if isinstance(size, int) else 0)) + + if wanted and not files: + raise NotFoundError(f"File not found for path: {path}") + return files + + async def download(self, path: str, byte_range: ByteRange | None) -> AsyncIterator[bytes]: + """Stream a file's bytes from the contents API, which serves private repos too.""" + url = self._api_url(f"contents/{self._repo_path(path)}?ref={self.config.revision}") + headers = self._headers(RAW_MEDIA_TYPE) + if byte_range is not None: + headers["Range"] = f"bytes={byte_range.start}-{byte_range.end}" + + async def _download() -> AsyncIterator[bytes]: + session = get_http_session() + try: + async with session.get(url, headers=headers) as response: + raise_for_github_status( + status=response.status, + subject=f"{path} in {self._repo_slug}", + headers=dict(response.headers), + ) + async for chunk in response.content.iter_chunked(self.config.read_chunk_size): + yield chunk + except aiohttp.ClientError as exc: + raise GithubUnavailableError(f"Could not download {path} from GitHub: {exc}") from exc + + return _download() + + async def validate_storage(self): + validate_external_host(self.config.api_base_url) + await self._get_json(self._api_url("").rstrip("/"), f"repository {self._repo_slug}") + + async def upload( + self, + path: str, + fstream: AsyncIterator[bytes], + content_length: int | None = None, + ) -> FileInfo: + raise NotImplementedError("GitHub upload is not implemented") + + async def delete(self, path: str) -> FileInfo: + raise NotImplementedError("GitHub delete is not implemented") + + async def get_cache_path_key(self, path: str | None = None) -> str: + prefix = f"cache/github/{self._repo_slug}/{self.config.revision}" + if self.config.path: + prefix = f"{prefix}/{self.config.path}" + return prefix if path is None else f"{prefix}/{path}" diff --git a/services/core/files/src/nmp/core/files/config.py b/services/core/files/src/nmp/core/files/config.py index 0dc1596a0e..99e022b4c5 100644 --- a/services/core/files/src/nmp/core/files/config.py +++ b/services/core/files/src/nmp/core/files/config.py @@ -28,7 +28,7 @@ class FilesConfig(create_service_config_class("files")): # type: ignore ) allowed_external_hosts: str = Field( - default="https://api.ngc.nvidia.com,https://huggingface.co", + default="https://api.ngc.nvidia.com,https://huggingface.co,https://api.github.com", description="Comma-separated list of external hosts the Files service is allowed to access.", ) diff --git a/services/core/files/tests/test_files_config.py b/services/core/files/tests/test_files_config.py index d2b3f54e1e..0e87440071 100644 --- a/services/core/files/tests/test_files_config.py +++ b/services/core/files/tests/test_files_config.py @@ -14,10 +14,13 @@ class TestFilesConfigAllowedExternalHosts: def test_allowed_external_hosts_default(self) -> None: """Test allowed_external_hosts default value and get_allowed_external_hosts() parsing.""" config = FilesConfig() - assert config.allowed_external_hosts == "https://api.ngc.nvidia.com,https://huggingface.co" + assert config.allowed_external_hosts == ( + "https://api.ngc.nvidia.com,https://huggingface.co,https://api.github.com" + ) assert config.get_allowed_external_hosts() == [ "https://api.ngc.nvidia.com", "https://huggingface.co", + "https://api.github.com", ] def test_allowed_external_hosts_from_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/services/core/files/tests/test_github_backend.py b/services/core/files/tests/test_github_backend.py new file mode 100644 index 0000000000..ccbc127d5a --- /dev/null +++ b/services/core/files/tests/test_github_backend.py @@ -0,0 +1,306 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the GitHub storage backend.""" + +from collections.abc import AsyncIterator, Callable +from unittest.mock import patch + +import pytest +from nmp.common.api.common import SecretRef +from nmp.core.files.app.backends.base import ByteRange +from nmp.core.files.app.backends.factory import storage_impl_factory +from nmp.core.files.app.backends.github import ( + GithubAccessError, + GithubBackendError, + GithubConfigError, + GithubStorageConfig, + GithubStorageImpl, + GithubUnavailableError, + raise_for_github_status, +) +from nmp.core.files.exceptions import NotFoundError + + +class _FakeContent: + def __init__(self, chunks: tuple[bytes, ...]): + self._chunks = chunks + + async def iter_chunked(self, _size: int) -> AsyncIterator[bytes]: + for chunk in self._chunks: + yield chunk + + +class _FakeResponse: + def __init__(self, status=200, json_body=None, chunks=(), headers=None): + self.status = status + self.headers = headers or {} + self.content = _FakeContent(chunks) + self._json_body = json_body + + async def json(self): + return self._json_body + + +class _FakeRequest: + def __init__(self, response: _FakeResponse): + self._response = response + + async def __aenter__(self) -> _FakeResponse: + return self._response + + async def __aexit__(self, *_exc) -> bool: + return False + + +class _FakeSession: + def __init__(self, handler: Callable[[str], _FakeResponse]): + self._handler = handler + self.requests: list[tuple[str, dict[str, str]]] = [] + + def get(self, url: str, headers: dict[str, str] | None = None) -> _FakeRequest: + self.requests.append((url, headers or {})) + return _FakeRequest(self._handler(url)) + + +def _session_for(handler: Callable[[str], _FakeResponse]) -> _FakeSession: + return _FakeSession(handler) + + +def _tree(*entries: dict, truncated: bool = False) -> dict: + return {"truncated": truncated, "tree": list(entries)} + + +def _blob(path: str, size: int = 1) -> dict: + return {"path": path, "type": "blob", "size": size} + + +def _config(**overrides) -> GithubStorageConfig: + return GithubStorageConfig(owner="acme", repo="agents", **{"revision": "main", **overrides}) + + +def _impl(config: GithubStorageConfig | None = None, secrets: dict[str, str] | None = None) -> GithubStorageImpl: + return GithubStorageImpl(config or _config(), secrets if secrets is not None else {}) + + +class TestRaiseForGithubStatus: + def test_success_statuses_do_not_raise(self): + raise_for_github_status(200, "repo") + raise_for_github_status(206, "repo") + + def test_missing_or_invisible_repository_is_a_config_error(self): + with pytest.raises(GithubConfigError, match="cannot see it"): + raise_for_github_status(404, "repository acme/agents") + + def test_forbidden_is_an_access_error(self): + with pytest.raises(GithubAccessError): + raise_for_github_status(403, "repository acme/agents") + + def test_exhausted_rate_limit_is_unavailable_not_access_denied(self): + with pytest.raises(GithubUnavailableError, match="rate limit"): + raise_for_github_status(403, "repository acme/agents", {"x-ratelimit-remaining": "0"}) + + @pytest.mark.parametrize("status", [429, 500, 502, 503]) + def test_server_side_failures_are_unavailable(self, status: int): + with pytest.raises(GithubUnavailableError): + raise_for_github_status(status, "repository acme/agents") + + def test_other_client_errors_are_backend_errors(self): + with pytest.raises(GithubBackendError, match="418"): + raise_for_github_status(418, "repository acme/agents") + + +class TestResolveConfig: + @pytest.mark.asyncio + async def test_pins_the_revision_to_a_commit_sha(self): + session = _session_for(lambda _url: _FakeResponse(json_body={"sha": "abc123"})) + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + resolved = await _impl().resolve_config() + + assert resolved.revision == "abc123" + assert resolved.original_revision == "main" + assert session.requests[0][0].endswith("/repos/acme/agents/commits/main") + + @pytest.mark.asyncio + async def test_keeps_the_first_original_revision_across_resolutions(self): + session = _session_for(lambda _url: _FakeResponse(json_body={"sha": "def456"})) + config = _config(revision="abc123", original_revision="main") + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + resolved = await GithubStorageImpl(config, {}).resolve_config() + + assert resolved.original_revision == "main" + + @pytest.mark.asyncio + async def test_rejects_a_response_carrying_no_sha(self): + session = _session_for(lambda _url: _FakeResponse(json_body={})) + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + with pytest.raises(GithubConfigError, match="no commit SHA"): + await _impl().resolve_config() + + +class TestListFiles: + @pytest.mark.asyncio + async def test_returns_blobs_and_skips_trees(self): + session = _session_for( + lambda _url: _FakeResponse( + json_body=_tree( + _blob("agent.yaml", 80), + {"path": "mcps", "type": "tree"}, + _blob("mcps/calculator.py", 20), + ) + ) + ) + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + files = await _impl().list_files() + + assert [(f.path, f.size) for f in files] == [("agent.yaml", 80), ("mcps/calculator.py", 20)] + + @pytest.mark.asyncio + async def test_strips_the_configured_directory_prefix(self): + session = _session_for( + lambda _url: _FakeResponse( + json_body=_tree( + _blob("agents/calc/agent.yaml"), + _blob("agents/other/agent.yaml"), + _blob("README.md"), + ) + ) + ) + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + files = await _impl(_config(path="agents/calc")).list_files() + + assert [f.path for f in files] == ["agent.yaml"] + + @pytest.mark.asyncio + async def test_filters_to_a_requested_subpath(self): + session = _session_for( + lambda _url: _FakeResponse(json_body=_tree(_blob("agent.yaml"), _blob("mcps/calculator.py"))) + ) + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + files = await _impl().list_files("mcps") + + assert [f.path for f in files] == ["mcps/calculator.py"] + + @pytest.mark.asyncio + async def test_raises_not_found_for_a_subpath_with_no_blobs(self): + session = _session_for(lambda _url: _FakeResponse(json_body=_tree(_blob("agent.yaml")))) + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + with pytest.raises(NotFoundError): + await _impl().list_files("nope") + + @pytest.mark.asyncio + async def test_refuses_a_tree_github_could_not_list_in_full(self): + session = _session_for(lambda _url: _FakeResponse(json_body=_tree(_blob("agent.yaml"), truncated=True))) + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + with pytest.raises(GithubConfigError, match="too large"): + await _impl().list_files() + + @pytest.mark.asyncio + async def test_maps_a_404_onto_a_config_error(self): + session = _session_for(lambda _url: _FakeResponse(status=404)) + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + with pytest.raises(GithubConfigError): + await _impl().list_files() + + +class TestDownload: + async def _collect(self, impl: GithubStorageImpl, session: _FakeSession, byte_range=None) -> bytes: + with patch("nmp.core.files.app.backends.github.get_http_session", return_value=session): + stream = await impl.download("agent.yaml", byte_range) + return b"".join([chunk async for chunk in stream]) + + @pytest.mark.asyncio + async def test_streams_file_contents_at_the_pinned_revision(self): + session = _session_for(lambda _url: _FakeResponse(chunks=(b"name: ", b"calc\n"))) + body = await self._collect(_impl(_config(revision="abc123")), session) + + assert body == b"name: calc\n" + url, headers = session.requests[0] + assert url.endswith("/repos/acme/agents/contents/agent.yaml?ref=abc123") + assert headers["Accept"] == "application/vnd.github.raw" + + @pytest.mark.asyncio + async def test_sends_the_token_for_a_private_repository(self): + session = _session_for(lambda _url: _FakeResponse(chunks=(b"x",))) + await self._collect(_impl(secrets={"token": "ghp_secret"}), session) + + assert session.requests[0][1]["Authorization"] == "Bearer ghp_secret" + + @pytest.mark.asyncio + async def test_omits_authorization_without_a_token(self): + session = _session_for(lambda _url: _FakeResponse(chunks=(b"x",))) + await self._collect(_impl(), session) + + assert "Authorization" not in session.requests[0][1] + + @pytest.mark.asyncio + async def test_prefixes_the_configured_directory(self): + session = _session_for(lambda _url: _FakeResponse(chunks=(b"x",))) + await self._collect(_impl(_config(path="agents/calc")), session) + + assert "/contents/agents/calc/agent.yaml?ref=" in session.requests[0][0] + + @pytest.mark.asyncio + async def test_forwards_a_byte_range(self): + session = _session_for(lambda _url: _FakeResponse(status=206, chunks=(b"me",))) + await self._collect(_impl(), session, ByteRange(start=2, end=3)) + + assert session.requests[0][1]["Range"] == "bytes=2-3" + + @pytest.mark.asyncio + async def test_maps_a_denied_download_onto_an_access_error(self): + session = _session_for(lambda _url: _FakeResponse(status=403)) + with pytest.raises(GithubAccessError): + await self._collect(_impl(), session) + + +class TestStorageContract: + @pytest.mark.asyncio + async def test_upload_and_delete_are_refused(self): + impl = _impl() + with pytest.raises(NotImplementedError): + await impl.upload("agent.yaml", iter(()), None) + with pytest.raises(NotImplementedError): + await impl.delete("agent.yaml") + + def test_the_platform_does_not_own_the_source_data(self): + assert _config().owns_storage_data is False + + @pytest.mark.asyncio + async def test_cache_key_is_scoped_to_the_revision(self): + impl = _impl(_config(revision="abc123")) + + assert await impl.get_cache_path_key() == "cache/github/acme/agents/abc123" + assert await impl.get_cache_path_key("agent.yaml") == "cache/github/acme/agents/abc123/agent.yaml" + + @pytest.mark.asyncio + async def test_cache_keys_of_two_revisions_do_not_collide(self): + first = await _impl(_config(revision="abc")).get_cache_path_key("agent.yaml") + second = await _impl(_config(revision="def")).get_cache_path_key("agent.yaml") + + assert first != second + + def test_the_factory_builds_the_github_backend(self): + impl = storage_impl_factory(_config(), {"token": "ghp_secret"}) + + assert isinstance(impl, GithubStorageImpl) + assert impl.secrets == {"token": "ghp_secret"} + + def test_config_declares_its_token_secret(self): + config = _config(token_secret=SecretRef("github-pat")) + + assert config.get_secret_references() == {"token": SecretRef("github-pat")} + + def test_config_without_a_token_declares_no_secret(self): + assert _config().get_secret_references() == {} + + def test_config_normalizes_a_slash_wrapped_path(self): + assert _config(path="/agents/calc/").path == "agents/calc" + + +class TestValidateStorage: + @pytest.mark.asyncio + async def test_rejects_a_host_outside_the_allowlist(self): + impl = _impl(_config(api_base_url="https://github.internal.example.com/api/v3")) + with pytest.raises(Exception, match="not"): + await impl.validate_storage() diff --git a/web/packages/studio/src/api/agents/agentSpecFileset.ts b/web/packages/studio/src/api/agents/agentSpecFileset.ts new file mode 100644 index 0000000000..7323be4c19 --- /dev/null +++ b/web/packages/studio/src/api/agents/agentSpecFileset.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isNotFoundError } from '@nemo/common/src/api/common/utils'; +import { agentsGetAgent } from '@nemo/sdk/generated/agents/api'; +import { filesDeleteFileset, filesRetrieveFileset } from '@nemo/sdk/generated/platform/api'; + +/** An agent of this name already exists; its spec fileset is not ours to take. */ +export class AgentSpecFilesetConflictError extends Error { + constructor(public readonly filesetName: string) { + super( + `An agent named "${filesetName.replace(/-spec$/, '')}" already owns the fileset "${filesetName}". Choose a different name.` + ); + } +} + +/** A spec fileset with no agent behind it — an abandoned upload, or an agent since deleted. */ +export class AgentSpecFilesetOrphanError extends Error { + constructor(public readonly filesetName: string) { + super( + `A fileset named "${filesetName}" already exists, but no agent owns it — an upload that did not finish, or an agent that was deleted, since deleting an agent leaves its fileset behind. Replacing it discards its current contents.` + ); + } +} + +export const claimFileset = async ( + workspace: string, + agentName: string, + filesetName: string, + replaceOrphanedFileset: boolean +): Promise => { + try { + await filesRetrieveFileset(workspace, filesetName); + } catch (error) { + // Only a 404 means the name is free; anything else leaves ownership unknown. + if (!isNotFoundError(error)) throw error; + return; + } + + if (await agentExists(workspace, agentName)) { + throw new AgentSpecFilesetConflictError(filesetName); + } + if (!replaceOrphanedFileset) { + throw new AgentSpecFilesetOrphanError(filesetName); + } + + await filesDeleteFileset(workspace, filesetName); +}; + +const agentExists = async (workspace: string, agentName: string): Promise => { + try { + await agentsGetAgent(workspace, agentName); + return true; + } catch (error) { + if (!isNotFoundError(error)) throw error; + return false; + } +}; + +export const rollbackFileset = async (workspace: string, filesetName: string): Promise => { + await filesDeleteFileset(workspace, filesetName).catch(() => undefined); +}; diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromGitHub.ts b/web/packages/studio/src/api/agents/useCreateAgentFromGitHub.ts new file mode 100644 index 0000000000..8c54c43b30 --- /dev/null +++ b/web/packages/studio/src/api/agents/useCreateAgentFromGitHub.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { agentsCreateAgent } from '@nemo/sdk/generated/agents/api'; +import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; +import { filesCreateFileset, filesDownloadFile } from '@nemo/sdk/generated/platform/api'; +import { claimFileset, rollbackFileset } from '@studio/api/agents/agentSpecFileset'; +import { + AGENT_CONFIG_FILENAME, + FABRIC_CONFIG_FORMAT, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import { + type GitHubAgentSource, + formatGitHubSource, + githubStorageConfig, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/github'; +import { + agentSpecFilesetName, + parseAgentConfig, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/utils'; +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; + +export interface CreateAgentFromGitHubParams { + workspace: string; + name: string; + source: GitHubAgentSource; + /** Workspace secret holding a personal access token. Omit for a public repository. */ + secretName?: string; + replaceOrphanedFileset?: boolean; +} + +/** + * The fileset reads the repository directly, so agent.yaml is fetched back through the files + * API rather than from GitHub — the token stays in the files service and never reaches here. + */ +export const createAgentFromGitHub = async ({ + workspace, + name, + source, + secretName, + replaceOrphanedFileset = false, +}: CreateAgentFromGitHubParams): Promise => { + const filesetName = agentSpecFilesetName(name); + + await claimFileset(workspace, name, filesetName, replaceOrphanedFileset); + + await filesCreateFileset(workspace, { + name: filesetName, + description: `Agent spec for ${name}, from ${formatGitHubSource(source)}`, + storage: githubStorageConfig(source, secretName), + }); + + try { + const config = parseAgentConfig(await readAgentConfig(workspace, filesetName, source)); + + return await agentsCreateAgent(workspace, { + name, + description: typeof config.description === 'string' ? config.description : '', + config, + config_format: FABRIC_CONFIG_FORMAT, + }); + } catch (error) { + await rollbackFileset(workspace, filesetName); + throw error; + } +}; + +const readAgentConfig = async ( + workspace: string, + filesetName: string, + source: GitHubAgentSource +): Promise => { + try { + const blob = await filesDownloadFile(workspace, filesetName, AGENT_CONFIG_FILENAME); + return await blob.text(); + } catch (error) { + throw new Error( + `Could not read ${AGENT_CONFIG_FILENAME} from ${formatGitHubSource(source)}. ` + + 'Check the branch and directory, and that the secret can read a private repository.', + { cause: error } + ); + } +}; + +export type UseCreateAgentFromGitHubOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export const useCreateAgentFromGitHub = (options?: UseCreateAgentFromGitHubOptions) => + useMutation({ ...options, mutationFn: createAgentFromGitHub }); diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts index 35fc2b6776..dde9ea0ce8 100644 --- a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.test.ts @@ -11,8 +11,8 @@ import { import { AgentSpecFilesetConflictError, AgentSpecFilesetOrphanError, - createAgentFromUpload, -} from '@studio/api/agents/useCreateAgentFromUpload'; +} from '@studio/api/agents/agentSpecFileset'; +import { createAgentFromUpload } from '@studio/api/agents/useCreateAgentFromUpload'; import type { UploadAgentEntry } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/type'; vi.mock('@nemo/sdk/generated/agents/api', async (importOriginal) => ({ diff --git a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts index bda13ced26..df86ec084a 100644 --- a/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts +++ b/web/packages/studio/src/api/agents/useCreateAgentFromUpload.ts @@ -1,15 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isNotFoundError } from '@nemo/common/src/api/common/utils'; -import { agentsCreateAgent, agentsGetAgent } from '@nemo/sdk/generated/agents/api'; +import { agentsCreateAgent } from '@nemo/sdk/generated/agents/api'; import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; -import { - filesCreateFileset, - filesDeleteFileset, - filesRetrieveFileset, - filesUploadFile, -} from '@nemo/sdk/generated/platform/api'; +import { filesCreateFileset, filesUploadFile } from '@nemo/sdk/generated/platform/api'; +import { claimFileset, rollbackFileset } from '@studio/api/agents/agentSpecFileset'; import { AGENT_CONFIG_FILENAME, FABRIC_CONFIG_FORMAT, @@ -28,24 +23,6 @@ export interface CreateAgentFromUploadParams { replaceOrphanedFileset?: boolean; } -/** An agent of this name already exists; its spec fileset is not ours to take. */ -export class AgentSpecFilesetConflictError extends Error { - constructor(public readonly filesetName: string) { - super( - `An agent named "${filesetName.replace(/-spec$/, '')}" already owns the fileset "${filesetName}". Choose a different name.` - ); - } -} - -/** A spec fileset with no agent behind it — an abandoned upload, or an agent since deleted. */ -export class AgentSpecFilesetOrphanError extends Error { - constructor(public readonly filesetName: string) { - super( - `A fileset named "${filesetName}" already exists, but no agent owns it — an upload that did not finish, or an agent that was deleted, since deleting an agent leaves its fileset behind. Replacing it discards its current contents.` - ); - } -} - // Files first: the fileset reserves the name, and a create-time validation that needs a // base_dir can only see files that are already uploaded. Creating it outside the try keeps // rollback to what this call created. @@ -78,45 +55,11 @@ export const createAgentFromUpload = async ({ config_format: FABRIC_CONFIG_FORMAT, }); } catch (error) { - await rollback(workspace, filesetName); + await rollbackFileset(workspace, filesetName); throw error; } }; -const claimFileset = async ( - workspace: string, - agentName: string, - filesetName: string, - replaceOrphanedFileset: boolean -): Promise => { - try { - await filesRetrieveFileset(workspace, filesetName); - } catch (error) { - // Only a 404 means the name is free; anything else leaves ownership unknown. - if (!isNotFoundError(error)) throw error; - return; - } - - if (await agentExists(workspace, agentName)) { - throw new AgentSpecFilesetConflictError(filesetName); - } - if (!replaceOrphanedFileset) { - throw new AgentSpecFilesetOrphanError(filesetName); - } - - await filesDeleteFileset(workspace, filesetName); -}; - -const agentExists = async (workspace: string, agentName: string): Promise => { - try { - await agentsGetAgent(workspace, agentName); - return true; - } catch (error) { - if (!isNotFoundError(error)) throw error; - return false; - } -}; - // One request per file, so a 500-file agent is 500 round trips. Run a bounded number at // once: unbounded Promise.all would queue them all against the browser's per-host limit // and lose the first error behind hundreds of in-flight requests. @@ -140,10 +83,6 @@ const uploadEntries = async ( ); }; -const rollback = async (workspace: string, filesetName: string): Promise => { - await filesDeleteFileset(workspace, filesetName).catch(() => undefined); -}; - export type UseCreateAgentFromUploadOptions = Omit< UseMutationOptions, 'mutationFn' diff --git a/web/packages/studio/src/components/DatasetsTable/columns.tsx b/web/packages/studio/src/components/DatasetsTable/columns.tsx index 5fdb507088..9477a028c6 100644 --- a/web/packages/studio/src/components/DatasetsTable/columns.tsx +++ b/web/packages/studio/src/components/DatasetsTable/columns.tsx @@ -87,6 +87,7 @@ export function makeDatasetsTableColumns({ { value: StorageConfigType.ngc, label: 'NGC' }, { value: StorageConfigType.huggingface, label: 'Hugging Face' }, { value: StorageConfigType.s3, label: 'S3' }, + { value: StorageConfigType.github, label: 'GitHub' }, ], }, }, diff --git a/web/packages/studio/src/components/DatasetsTable/helpers.ts b/web/packages/studio/src/components/DatasetsTable/helpers.ts index 4a7fa69c37..fc00da0f77 100644 --- a/web/packages/studio/src/components/DatasetsTable/helpers.ts +++ b/web/packages/studio/src/components/DatasetsTable/helpers.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + type GithubStorageConfig, type HuggingfaceStorageConfig, type LocalStorageConfig, type NGCStorageConfig, @@ -23,6 +24,8 @@ export function getStoragePath(storage: StorageConfig | undefined): string | nul team?: string; target?: string; repo_id?: string; + owner?: string; + repo?: string; bucket?: string; prefix?: string; }; @@ -36,6 +39,12 @@ export function getStoragePath(storage: StorageConfig | undefined): string | nul if (s.type === 'huggingface' && 'repo_id' in storage) { return (storage as HuggingfaceStorageConfig).repo_id; } + if (s.type === 'github' && 'owner' in storage && 'repo' in storage) { + const github = storage as GithubStorageConfig; + return github.path + ? `${github.owner}/${github.repo}/${github.path}` + : `${github.owner}/${github.repo}`; + } if (s.type === 's3' && 'bucket' in storage) { const s3 = storage as S3StorageConfig; return s3.prefix ? `${s3.bucket}/${s3.prefix}` : s3.bucket; diff --git a/web/packages/studio/src/components/DatasetsTable/types.ts b/web/packages/studio/src/components/DatasetsTable/types.ts index 4149ed790d..e8b168f0c3 100644 --- a/web/packages/studio/src/components/DatasetsTable/types.ts +++ b/web/packages/studio/src/components/DatasetsTable/types.ts @@ -5,6 +5,7 @@ import * as DataView from '@nemo/common/src/components/DataView/internal'; import { type FilesetOutput as Dataset, type FilesetPurpose, + type GithubStorageConfig, type HuggingfaceStorageConfig, type LocalStorageConfig, type NGCStorageConfig, @@ -18,7 +19,8 @@ export type StorageConfig = | LocalStorageConfig | NGCStorageConfig | HuggingfaceStorageConfig - | S3StorageConfig; + | S3StorageConfig + | GithubStorageConfig; export type DatasetWithId = Dataset & { id: string }; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts index b7970af3e6..6e9632ef47 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/const.ts @@ -42,4 +42,6 @@ export const uploadAgentFormSchema = z.object({ .trim() .min(1, 'Name is required') .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Use lowercase letters, numbers, and hyphens'), + repoUrl: z.string().trim().default(''), + secretKey: z.string().default(''), }); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/github.test.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/github.test.ts new file mode 100644 index 0000000000..b9075836ff --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/github.test.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + GitHubSourceError, + agentNameFromSource, + formatGitHubSource, + githubStorageConfig, + parseGitHubSource, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/github'; + +describe('parseGitHubSource', () => { + it.each([ + ['owner/repo', { owner: 'owner', repo: 'repo', ref: undefined, path: '' }], + ['github.com/owner/repo', { owner: 'owner', repo: 'repo', ref: undefined, path: '' }], + ['https://github.com/owner/repo', { owner: 'owner', repo: 'repo', ref: undefined, path: '' }], + [ + 'https://github.com/owner/repo.git', + { owner: 'owner', repo: 'repo', ref: undefined, path: '' }, + ], + ['git@github.com:owner/repo.git', { owner: 'owner', repo: 'repo', ref: undefined, path: '' }], + [ + 'https://github.com/owner/repo/tree/main/agents/calc', + { owner: 'owner', repo: 'repo', ref: 'main', path: 'agents/calc' }, + ], + [ + 'github.com/owner/repo@v1.2#agents/calc', + { owner: 'owner', repo: 'repo', ref: 'v1.2', path: 'agents/calc' }, + ], + ])('parses %s', (input, expected) => { + expect(parseGitHubSource(input)).toEqual(expected); + }); + + it('does not read the git@ userinfo as a ref', () => { + expect(parseGitHubSource('git@github.com:owner/repo').ref).toBeUndefined(); + }); + + it('rejects a host that is not GitHub', () => { + expect(() => parseGitHubSource('https://gitlab.com/owner/repo')).toThrow(GitHubSourceError); + }); + + it('rejects input that names no repository', () => { + expect(() => parseGitHubSource('owner')).toThrow(GitHubSourceError); + expect(() => parseGitHubSource(' ')).toThrow(GitHubSourceError); + }); +}); + +describe('formatGitHubSource', () => { + it('round-trips the spec form', () => { + expect(formatGitHubSource({ owner: 'o', repo: 'r', ref: 'main', path: 'a/b' })).toBe( + 'o/r@main#a/b' + ); + expect(formatGitHubSource({ owner: 'o', repo: 'r', path: '' })).toBe('o/r'); + }); +}); + +describe('githubStorageConfig', () => { + it('omits the revision and path when the source names neither', () => { + expect(githubStorageConfig({ owner: 'o', repo: 'r', path: '' })).toEqual({ + type: 'github', + owner: 'o', + repo: 'r', + }); + }); + + it('carries the revision, directory, and token secret', () => { + expect( + githubStorageConfig({ owner: 'o', repo: 'r', ref: 'v1', path: 'agents/calc' }, 'github-pat') + ).toEqual({ + type: 'github', + owner: 'o', + repo: 'r', + revision: 'v1', + path: 'agents/calc', + token_secret: 'github-pat', + }); + }); + + it('omits the token secret for a public repository', () => { + expect(githubStorageConfig({ owner: 'o', repo: 'r', path: '' })).not.toHaveProperty( + 'token_secret' + ); + }); +}); + +describe('agentNameFromSource', () => { + it('uses the repository when no directory is given', () => { + expect(agentNameFromSource({ owner: 'acme', repo: 'Calc-Agent', path: '' })).toBe('calc-agent'); + }); + + it('prefers the directory holding agent.yaml', () => { + expect(agentNameFromSource({ owner: 'acme', repo: 'agents', path: 'agents/Calc_Bot' })).toBe( + 'calc-bot' + ); + }); + + it('produces a name the form schema accepts', () => { + expect(agentNameFromSource({ owner: 'acme', repo: '__weird__', path: '' })).toBe('weird'); + }); +}); diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/github.ts b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/github.ts new file mode 100644 index 0000000000..4c9a3310ed --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/github.ts @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { GithubStorageConfig } from '@nemo/sdk/generated/platform/schema'; + +const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']); + +export class GitHubSourceError extends Error {} + +/** Mirrors the Experimentalist plugin's `[@][#]` agent spec. */ +export interface GitHubAgentSource { + owner: string; + repo: string; + /** Branch, tag, or commit. Undefined lets the files service resolve the default branch. */ + ref?: string; + /** Directory holding agent.yaml. Empty for the repository root. */ + path: string; +} + +const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, ''); + +const dropGitSuffix = (value: string): string => value.replace(/\.git$/, ''); + +/** Repository path segments, or undefined when this is not a GitHub locator. */ +const githubPathSegments = (locator: string): string[] | undefined => { + if (locator.includes('://')) { + let url: URL; + try { + url = new URL(locator); + } catch { + return undefined; + } + return GITHUB_HOSTS.has(url.hostname.toLowerCase()) + ? trimSlashes(url.pathname).split('/') + : undefined; + } + + // SCP form, `[user@]github.com:owner/repo`. + const colon = locator.indexOf(':'); + if (colon !== -1) { + const host = locator.slice(0, colon).split('@').pop() ?? ''; + if (!GITHUB_HOSTS.has(host.toLowerCase())) return undefined; + return trimSlashes(locator.slice(colon + 1)).split('/'); + } + + const bare = trimSlashes(locator); + return GITHUB_HOSTS.has(bare.split('/')[0]?.toLowerCase() ?? '') + ? bare.split('/').slice(1) + : bare.split('/'); +}; + +/** + * Accepts `owner/repo`, an HTTPS or SSH clone URL, and a `/tree//` browser URL, + * each optionally suffixed `@` and `#`. + * + * A branch containing a slash is indistinguishable from a nested path in a `/tree/` URL, so + * the first segment after `tree` is taken as the ref. Use `@` to be explicit. + */ +export const parseGitHubSource = (input: string): GitHubAgentSource => { + const trimmed = input.trim(); + if (!trimmed) throw new GitHubSourceError('Enter a GitHub repository URL.'); + + const hash = trimmed.indexOf('#'); + const fragmentPath = hash === -1 ? '' : trimSlashes(trimmed.slice(hash + 1)); + const locatorAndRef = hash === -1 ? trimmed : trimmed.slice(0, hash); + + // Only an `@` in the last segment marks a ref; earlier ones are the `git@host` userinfo. + const lastSlash = locatorAndRef.lastIndexOf('/'); + const refAt = locatorAndRef.indexOf('@', lastSlash + 1); + const explicitRef = refAt === -1 ? undefined : locatorAndRef.slice(refAt + 1) || undefined; + const locator = refAt === -1 ? locatorAndRef : locatorAndRef.slice(0, refAt); + + const segments = githubPathSegments(locator)?.filter(Boolean); + if (!segments || segments.length < 2) { + throw new GitHubSourceError( + `"${trimmed}" is not a GitHub repository. Use github.com/owner/repo, optionally with @branch and #sub/directory.` + ); + } + + const [owner, rawRepo, kind, ...rest] = segments; + const repo = dropGitSuffix(rawRepo ?? ''); + if (!owner || !repo) { + throw new GitHubSourceError(`"${trimmed}" is missing an owner or a repository name.`); + } + + // Browser URLs: /tree// and /blob//. + const browsed = kind === 'tree' || kind === 'blob'; + const urlRef = browsed ? rest[0] : undefined; + const urlPath = browsed ? rest.slice(1).join('/') : segments.slice(2).join('/'); + + return { + owner, + repo, + ref: explicitRef ?? urlRef, + path: fragmentPath || trimSlashes(urlPath), + }; +}; + +/** Human-readable `owner/repo[@ref][#path]`, for error text and the selection summary. */ +export const formatGitHubSource = ({ owner, repo, ref, path }: GitHubAgentSource): string => + `${owner}/${repo}${ref ? `@${ref}` : ''}${path ? `#${path}` : ''}`; + +/** + * The spec fileset reads the repository directly, so the token stays in the files service + * and is never fetched into the browser. + */ +export const githubStorageConfig = ( + source: GitHubAgentSource, + secretName?: string +): GithubStorageConfig => ({ + type: 'github', + owner: source.owner, + repo: source.repo, + ...(source.ref ? { revision: source.ref } : {}), + ...(source.path ? { path: source.path } : {}), + ...(secretName ? { token_secret: secretName } : {}), +}); + +/** A starting point for the agent name, which the user can still edit before submitting. */ +export const agentNameFromSource = (source: GitHubAgentSource): string => { + const candidate = source.path ? (source.path.split('/').pop() ?? source.repo) : source.repo; + return candidate + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, ''); +}; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx index 0681f0267c..b0401b55c2 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.test.tsx @@ -73,6 +73,8 @@ interface Scenario { const mockPlatform = ({ filesetExists = false, agentExists = false }: Scenario = {}) => { const uploaded: string[] = []; const created: { name?: string }[] = []; + const filesets: { storage?: unknown }[] = []; + const deleted: string[] = []; server.use( http.get(FILESET_URL, ({ params }) => @@ -85,8 +87,15 @@ const mockPlatform = ({ filesetExists = false, agentExists = false }: Scenario = ? HttpResponse.json({ name: params['name'], workspace }) : HttpResponse.json({ detail: 'not found' }, { status: 404 }) ), - http.delete(FILESET_URL, () => HttpResponse.json({ name: 'deleted' })), - http.post(FILESETS_URL, async ({ request }) => HttpResponse.json(await request.json())), + http.delete(FILESET_URL, ({ params }) => { + deleted.push(String(params['name'])); + return HttpResponse.json({ name: 'deleted' }); + }), + http.post(FILESETS_URL, async ({ request }) => { + const body = (await request.json()) as { storage?: unknown }; + filesets.push(body); + return HttpResponse.json(body); + }), http.put(UPLOAD_URL, ({ request }) => { uploaded.push(decodeURIComponent(new URL(request.url).pathname.split('/-/')[1] ?? '')); return HttpResponse.json({ path: 'ok' }); @@ -98,7 +107,7 @@ const mockPlatform = ({ filesetExists = false, agentExists = false }: Scenario = }) ); - return { uploaded, created }; + return { uploaded, created, filesets, deleted }; }; const renderModal = () => @@ -127,7 +136,7 @@ describe('UploadAgentModal', () => { const { uploaded, created } = mockPlatform(); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); pickDirectory(dialog); await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); @@ -143,7 +152,7 @@ describe('UploadAgentModal', () => { const { created } = mockPlatform({ filesetExists: true, agentExists: true }); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); pickDirectory(dialog); await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); @@ -159,7 +168,7 @@ describe('UploadAgentModal', () => { const { created } = mockPlatform({ filesetExists: true }); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); pickDirectory(dialog); await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); @@ -176,7 +185,7 @@ describe('UploadAgentModal', () => { mockPlatform(); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); pickDirectory(dialog, [makeFile('calc-agent/mcps/calculator.py', 'print(1)\n')]); expect(await within(dialog).findByText(/No agent\.yaml/)).toBeInTheDocument(); @@ -191,7 +200,7 @@ describe('UploadAgentModal', () => { ); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); pickDirectory(dialog, [gate.file]); pickDirectory(dialog, DEFAULT_FILES); await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); @@ -211,7 +220,7 @@ describe('UploadAgentModal', () => { ); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); pickDirectory(dialog); await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); expect(within(dialog).getByRole('button', { name: 'Create' })).toBeEnabled(); @@ -232,7 +241,7 @@ describe('UploadAgentModal', () => { mockPlatform({ filesetExists: true, agentExists: true }); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); pickDirectory(dialog); await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); await submit(dialog, user); @@ -249,7 +258,7 @@ describe('UploadAgentModal', () => { mockPlatform(); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); pickDirectory(dialog, [ makeFile('calc-agent/agent.yaml', FABRIC_YAML), new File([new Uint8Array([0xff, 0xfe, 0x00])], 'logo.bin'), @@ -263,7 +272,7 @@ describe('UploadAgentModal oversized pick', () => { it('rejects a directory far larger than an agent, naming the count', async () => { mockPlatform(); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); // A real accidental pick was 880k files; only length is read before the guard fires. fireEvent.change(within(dialog).getByTestId('agent-directory-input'), { @@ -275,6 +284,70 @@ describe('UploadAgentModal oversized pick', () => { }); }); +describe('UploadAgentModal GitHub import', () => { + const typeRepo = async ( + dialog: HTMLElement, + user: ReturnType, + spec = 'github.com/owner/repo' + ) => { + await user.type(within(dialog).getByRole('textbox', { name: 'Repository' }), spec); + await user.tab(); + }; + + it('backs the spec fileset with the repository instead of uploading files', async () => { + const user = userEvent.setup(); + const { uploaded, created, filesets } = mockPlatform(); + server.use(http.get(UPLOAD_URL, () => HttpResponse.text(FABRIC_YAML))); + + renderModal(); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); + await typeRepo(dialog, user, 'github.com/owner/repo@v2#agents/calc'); + await waitFor(() => expect(within(dialog).getByDisplayValue('calc')).toBeInTheDocument()); + await submit(dialog, user); + + await waitFor(() => expect(created).toHaveLength(1)); + expect(uploaded).toHaveLength(0); + expect(filesets[0]?.storage).toEqual({ + type: 'github', + owner: 'owner', + repo: 'repo', + revision: 'v2', + path: 'agents/calc', + }); + }); + + it('names the agent after the repository so the fileset name is settled up front', async () => { + const user = userEvent.setup(); + mockPlatform(); + server.use(http.get(UPLOAD_URL, () => HttpResponse.text(FABRIC_YAML))); + + renderModal(); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); + await typeRepo(dialog, user, 'github.com/owner/my-repo'); + + await waitFor(() => expect(within(dialog).getByDisplayValue('my-repo')).toBeInTheDocument()); + }); + + it('rolls the fileset back when the repository has no agent.yaml', async () => { + const user = userEvent.setup(); + const { created, deleted } = mockPlatform(); + server.use( + http.get(UPLOAD_URL, () => HttpResponse.json({ detail: 'not found' }, { status: 404 })) + ); + + renderModal(); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); + await typeRepo(dialog, user); + await waitFor(() => expect(within(dialog).getByDisplayValue('repo')).toBeInTheDocument()); + + await submit(dialog, user); + + expect(await within(dialog).findByText(/Could not read agent\.yaml/)).toBeInTheDocument(); + expect(created).toHaveLength(0); + await waitFor(() => expect(deleted).toContain('repo-spec')); + }); +}); + describe('UploadAgentModal folder drop', () => { const dirEntry = (name: string, fullPath: string, children: FileSystemEntry[]) => ({ @@ -307,7 +380,7 @@ describe('UploadAgentModal folder drop', () => { const { uploaded, created } = mockPlatform(); renderModal(); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: /Upload agent configuration/ }); const root = dirEntry('calc-agent', '/calc-agent', [ fileEntry('agent.yaml', '/calc-agent/agent.yaml', FABRIC_YAML), diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx index 6535590cf4..8fbf071610 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/UploadAgentModal/index.tsx @@ -14,14 +14,18 @@ import { UploadRoot, UploadTrigger, } from '@nvidia/foundations-react-core'; -import { - AgentSpecFilesetOrphanError, - useCreateAgentFromUpload, -} from '@studio/api/agents/useCreateAgentFromUpload'; +import { AgentSpecFilesetOrphanError } from '@studio/api/agents/agentSpecFileset'; +import { useCreateAgentFromGitHub } from '@studio/api/agents/useCreateAgentFromGitHub'; +import { useCreateAgentFromUpload } from '@studio/api/agents/useCreateAgentFromUpload'; import { AGENT_CONFIG_FILENAME, uploadAgentFormSchema, } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/const'; +import { + type GitHubAgentSource, + agentNameFromSource, + parseGitHubSource, +} from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/github'; import type { PickedFile, UploadAgentEntry, @@ -39,6 +43,8 @@ import { totalEntryBytes, validateAgentEntries, } from '@studio/routes/agents/AgentsListRoute/UploadAgentModal/utils'; +import { CreateSecretModal } from '@studio/routes/SecretsListRoute/CreateSecretModal'; +import { SecretSearchableSelect } from '@studio/routes/SecretsListRoute/SecretSearchableSelect'; import { getAgentDetailRoute } from '@studio/routes/utils'; import { useQueryClient } from '@tanstack/react-query'; import { @@ -65,9 +71,10 @@ export const UploadAgentModal: FC = ({ open, onClose, wor node?.setAttribute('webkitdirectory', ''); }, []); const [entries, setEntries] = useState([]); - const [directoryName, setDirectoryName] = useState(''); + const [sourceLabel, setSourceLabel] = useState(''); const [selectionError, setSelectionError] = useState(undefined); const [replaceArmedFor, setReplaceArmedFor] = useState(null); + const [isSecretModalOpen, setSecretModalOpen] = useState(false); const { mutateAsync: createAgent, @@ -83,6 +90,8 @@ export const UploadAgentModal: FC = ({ open, onClose, wor }, }); + const { mutateAsync: createAgentFromRepo, error: repoError } = useCreateAgentFromGitHub(); + const { control, setValue, @@ -91,7 +100,7 @@ export const UploadAgentModal: FC = ({ open, onClose, wor formState: { errors }, } = useForm({ resolver: zodResolver(uploadAgentFormSchema), - defaultValues: { name: '' }, + defaultValues: { name: '', repoUrl: '', secretKey: '' }, disabled: isPending, mode: 'onChange', }); @@ -101,20 +110,32 @@ export const UploadAgentModal: FC = ({ open, onClose, wor () => entries.length === 0 ? undefined - : `${directoryName} — ${entries.length} files, ${Math.max(1, Math.round(totalEntryBytes(entries) / 1000))} KB`, - [directoryName, entries] + : `${sourceLabel} — ${entries.length} files, ${Math.max(1, Math.round(totalEntryBytes(entries) / 1000))} KB`, + [sourceLabel, entries] ); const watchedName = useWatch({ control, name: 'name' }); + const watchedRepoUrl = useWatch({ control, name: 'repoUrl' }); + const watchedSecretKey = useWatch({ control, name: 'secretKey' }); + + // A repository is only a source once it parses; a half-typed URL must not enable submit. + const repoSource = useMemo((): GitHubAgentSource | undefined => { + if (!watchedRepoUrl?.trim()) return undefined; + try { + return parseGitHubSource(watchedRepoUrl); + } catch { + return undefined; + } + }, [watchedRepoUrl]); // Derived, not stored: an armed replace targets one fileset, so editing the name // disarms it in the same render rather than one render later. const replaceOrphan = replaceArmedFor !== null && replaceArmedFor === watchedName?.trim(); const resetAndClose = () => { resetMutation(); - resetForm({ name: '' }); + resetForm({ name: '', repoUrl: '', secretKey: '' }); setEntries([]); - setDirectoryName(''); + setSourceLabel(''); setSelectionError(undefined); setReplaceArmedFor(null); onClose(); @@ -132,10 +153,13 @@ export const UploadAgentModal: FC = ({ open, onClose, wor return () => selection !== selectionSeq.current; }; - // Both entry points land here so a drop is validated exactly like a pick. - const acceptPicked = async (picked: PickedFile[], superseded: () => boolean) => { - setDirectoryName(picked[0]?.relativePath.split('/')[0] ?? ''); - const collected = collectAgentEntries(picked); + // Picks, drops and repositories all land here so every source is validated the same way. + const acceptEntries = async ( + collected: UploadAgentEntry[], + label: string, + superseded: () => boolean + ) => { + setSourceLabel(label); const problem = validateAgentEntries(collected); if (problem) { @@ -171,11 +195,23 @@ export const UploadAgentModal: FC = ({ open, onClose, wor setEntries(collected); }; + const acceptPicked = (picked: PickedFile[], superseded: () => boolean) => + acceptEntries( + collectAgentEntries(picked), + picked[0]?.relativePath.split('/')[0] ?? '', + superseded + ); + + const onRepoUrlBlur = () => { + if (!repoSource || watchedName?.trim()) return; + setValue('name', agentNameFromSource(repoSource), { shouldValidate: true }); + }; + const rejectOversized = (count: number): boolean => { const oversized = tooManyPickedFiles(count); if (!oversized) return false; setEntries([]); - setDirectoryName(''); + setSourceLabel(''); setSelectionError(oversized); return true; }; @@ -217,9 +253,21 @@ export const UploadAgentModal: FC = ({ open, onClose, wor await acceptPicked(picked, superseded); }; + // A repository wins over a picked directory: the fileset can only have one source, and the + // repository is the one the user typed last. const onSubmit: SubmitHandler = async (formData) => { const name = formData.name.trim(); try { + if (repoSource) { + await createAgentFromRepo({ + workspace, + name, + source: repoSource, + secretName: formData.secretKey?.trim() || undefined, + replaceOrphanedFileset: replaceOrphan, + }); + return; + } await createAgent({ workspace, name, entries, replaceOrphanedFileset: replaceOrphan }); } catch (error) { // An orphaned fileset is recoverable, so the next submit replaces it. @@ -230,47 +278,90 @@ export const UploadAgentModal: FC = ({ open, onClose, wor // No fallback argument: getErrorMessage prefers one over a plain Error's own message. const errorMessage = selectionError ?? - (createError ? getErrorMessage(createError as Error) || 'Failed to create agent' : undefined); + ((createError ?? repoError) + ? getErrorMessage((createError ?? repoError) as Error) || 'Failed to create agent' + : undefined); return ( - - - Select agent config files - - - + + + Select agent config files + + + + + + {entriesSummary ? {entriesSummary} : null} + + Or import from a GitHub repository + + setSecretModalOpen(true)} + triggerPlaceholder="" + formFieldProps={{ + slotLabel: 'Access token secret', + slotInfo: + 'Required for a private repository. The token stays in the platform and is never sent to your browser.', + slotError: errors.secretKey?.message, + }} /> - - - {entriesSummary ? {entriesSummary} : null} - + + + + {isSecretModalOpen ? ( + setSecretModalOpen(false)} + onSecretCreated={(secretName) => { + setValue('secretKey', secretName, { shouldValidate: true }); + setSecretModalOpen(false); + }} /> - - + ) : null} + ); }; diff --git a/web/packages/studio/src/util/storageBackend.ts b/web/packages/studio/src/util/storageBackend.ts index cc85d6eb42..b8b7d4eb99 100644 --- a/web/packages/studio/src/util/storageBackend.ts +++ b/web/packages/studio/src/util/storageBackend.ts @@ -6,6 +6,7 @@ import type { FilesetOutput } from '@nemo/sdk/generated/platform/schema'; export type StorageBackend = NonNullable; const STORAGE_BACKEND_LABELS: Record = { + github: 'GitHub', huggingface: 'Hugging Face', ngc: 'NGC', s3: 'S3',