diff --git a/frontend/server/skills/devenv.py b/frontend/server/skills/devenv.py index d9554635..af35910a 100644 --- a/frontend/server/skills/devenv.py +++ b/frontend/server/skills/devenv.py @@ -78,8 +78,8 @@ _sandbox_model_config, _validated_activities, ) -from veadk.cli.studio_sandbox_tools import studio_sandbox_agent_model_name from veadk.cli.studio_model_catalog import provider_allows_model +from veadk.cli.studio_sandbox_tools import studio_sandbox_agent_model_name from veadk.skills.skill import Skill from veadk.utils.cloud_provider import cloud_provider_from_env from veadk.utils.logger import get_logger @@ -2659,6 +2659,7 @@ def publish( "SKILL_PUBLISH_FAILED", "发布 Skill 失败,无法确认本次发布结果,请刷新 Skill 中心确认。", status_code=502, + original_error=error, ) from error except Exception as error: logger.error( @@ -2672,6 +2673,7 @@ def publish( "SKILL_PUBLISH_FAILED", "发布 Skill 失败,无法确认本次发布结果,请刷新 Skill 中心确认。", status_code=502, + original_error=error, ) from error def _publish_once( @@ -2762,13 +2764,17 @@ def report(phase: str, message: str) -> None: ), ) from agentkit.toolkit.cli.cli_skills_workflow import ( - _ensure_bucket_ready, _make_content_hashed_zip_copy, - _tos_upload, _wait_for_running_version, ) from agentkit.toolkit.config import GlobalConfigManager - from agentkit.toolkit.volcengine.services.tos_service import TOSService + + from .storage import ( + ensure_skill_publish_bucket, + resolve_skill_publish_credentials, + resolve_skill_publish_storage, + upload_skill_archive, + ) config = GlobalConfigManager().load() effective_region = ( @@ -2777,23 +2783,15 @@ def report(phase: str, message: str) -> None: and source_region in supported_regions else body.region or self._region ) - configured_bucket = ( - os.getenv("VEADK_SKILL_CREATOR_TOS_BUCKET") or config.tos.bucket or "" - ).strip() - bucket = configured_bucket or TOSService.generate_bucket_name() - prefix = ( - os.getenv("VEADK_SKILL_CREATOR_TOS_PREFIX") - or config.tos.prefix - or "agentkit/skills" - ).strip() - _ensure_bucket_ready( - bucket_name=bucket, - prefix=prefix, + storage = resolve_skill_publish_storage( region=effective_region, - auto_bucket=not bool(configured_bucket), - assume_yes=True, - assume_no=False, + config_bucket=config.tos.bucket or "", + config_prefix=config.tos.prefix or "", ) + credentials = resolve_skill_publish_credentials(provider=storage.provider) + bucket = storage.bucket + report("preparing", "正在准备发布存储") + ensure_skill_publish_bucket(storage, credentials) report("uploading", "正在上传 Skill 包") with tempfile.TemporaryDirectory(prefix="veadk-skill-publish-") as directory: archive_path = Path(directory) / f"{archive.name}.zip" @@ -2801,9 +2799,7 @@ def report(phase: str, message: str) -> None: hashed_path = _make_content_hashed_zip_copy( str(archive_path), archive.name, directory ) - tos_url = _tos_upload( - hashed_path, bucket, prefix, effective_region, verify_bucket=False - ) + tos_url = upload_skill_archive(hashed_path, storage, credentials) report("registering", "正在写入 AgentKit Skill") client = self._skills_client_factory(effective_region) effective_project = ( diff --git a/frontend/server/skills/repository.py b/frontend/server/skills/repository.py index 3e96a5c8..3a17888e 100644 --- a/frontend/server/skills/repository.py +++ b/frontend/server/skills/repository.py @@ -319,24 +319,25 @@ def publish_archive( ) -> dict[str, object]: from agentkit.sdk.skills import types as skills_types from agentkit.toolkit.cli.cli_skills_workflow import ( - _ensure_bucket_ready, _make_content_hashed_zip_copy, - _tos_upload, _wait_for_running_version, ) from agentkit.toolkit.config import GlobalConfigManager - from agentkit.toolkit.volcengine.services.tos_service import TOSService - client = self._client_factory(region) - existing = client.list_skills( - skills_types.ListSkillsRequest( - PageNumber=1, - PageSize=50, - Filter=skills_types.SkillFilter(Name=archive.name), - ProjectName=project_name, - ) + from .storage import ( + ensure_skill_publish_bucket, + resolve_skill_publish_credentials, + resolve_skill_publish_storage, + upload_skill_archive, ) - if existing.items: + + client = self._client_factory(region) + if self._space_has_skill_named( + client, + skills_types, + space_id=space_id, + name=archive.name, + ): raise SkillRepositoryError( "SKILL_NAME_CONFLICT", f"已存在同名 Skill“{archive.name}”,请重命名后上传,或使用优化功能覆盖。", @@ -344,39 +345,27 @@ def publish_archive( ) config = GlobalConfigManager().load() - configured_bucket = ( - os.getenv("VEADK_SKILL_CREATOR_TOS_BUCKET") or config.tos.bucket or "" - ).strip() - bucket = configured_bucket or TOSService.generate_bucket_name() - prefix = ( - os.getenv("VEADK_SKILL_CREATOR_TOS_PREFIX") - or config.tos.prefix - or "agentkit/skills" - ).strip() - _ensure_bucket_ready( - bucket_name=bucket, - prefix=prefix, + storage = resolve_skill_publish_storage( region=region, - auto_bucket=not bool(configured_bucket), - assume_yes=True, - assume_no=False, + config_bucket=config.tos.bucket or "", + config_prefix=config.tos.prefix or "", ) + credentials = resolve_skill_publish_credentials(provider=storage.provider) + ensure_skill_publish_bucket(storage, credentials) with tempfile.TemporaryDirectory(prefix="veadk-skill-upload-") as directory: archive_path = Path(directory) / f"{archive.name}.zip" archive_path.write_bytes(archive.content) hashed_path = _make_content_hashed_zip_copy( str(archive_path), archive.name, directory ) - tos_url = _tos_upload( - hashed_path, bucket, prefix, region, verify_bucket=False - ) + tos_url = upload_skill_archive(hashed_path, storage, credentials) created = client.create_skill( skills_types.CreateSkillRequest( Name=archive.name, Description=archive.description, TosUrl=tos_url, SkillSpaces=[space_id], - BucketName=bucket, + BucketName=storage.bucket, ProjectName=project_name, Tags=[skills_types.TagForSkill(Key="author", Value=author)], ) @@ -409,6 +398,53 @@ def publish_archive( "skillSpaceId": space_id, } + @staticmethod + def _space_has_skill_named( + client: Any, + skills_types: Any, + *, + space_id: str, + name: str, + ) -> bool: + page = 1 + page_size = 100 + expected = name.casefold() + while True: + response = client.list_skills_by_skill_space( + skills_types.ListSkillsBySkillSpaceRequest( + SkillSpaceId=space_id, + PageNumber=page, + PageSize=page_size, + ) + ) + items = list(getattr(response, "items", None) or []) + if any( + AgentKitSkillRepository._skill_relation_name(item).casefold() + == expected + for item in items + ): + return True + total_count = getattr(response, "total_count", None) + if total_count is not None: + try: + if page * page_size >= int(total_count): + return False + except (TypeError, ValueError): + pass + if len(items) < page_size: + return False + page += 1 + + @staticmethod + def _skill_relation_name(value: Any) -> str: + return str( + getattr(value, "skill_name", None) + or getattr(value, "skillName", None) + or getattr(value, "name", None) + or getattr(value, "Name", None) + or "" + ) + @staticmethod def _space_item(value: Any, region: str) -> dict[str, object]: tags = { diff --git a/frontend/server/skills/storage.py b/frontend/server/skills/storage.py new file mode 100644 index 00000000..187619db --- /dev/null +++ b/frontend/server/skills/storage.py @@ -0,0 +1,308 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""TOS storage helpers for Studio Skill publishing.""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +from frontend.server.storage import StudioProvider, StudioStorageConfig +from veadk.utils.cloud_provider import cloud_provider_from_env + +_IAM_CREDENTIAL_PATH = Path("/var/run/secrets/iam/credential") +_DEFAULT_SKILL_PREFIX = "agentkit/skills" + +SkillPublishBucketMode = Literal[ + "skill-env", + "studio-storage", + "config", + "auto-generated", +] + + +class SkillPublishStorageError(RuntimeError): + """Raised when Skill publishing storage cannot be resolved safely.""" + + +@dataclass(frozen=True) +class SkillPublishCredentials: + access_key: str + secret_key: str + session_token: str + source: str + + +@dataclass(frozen=True) +class SkillPublishStorage: + provider: StudioProvider + region: str + bucket: str + prefix: str + endpoint: str + bucket_mode: SkillPublishBucketMode + + @property + def auto_bucket(self) -> bool: + return self.bucket_mode == "auto-generated" + + +def _read_vefaas_iam_credentials() -> SkillPublishCredentials | None: + try: + with _IAM_CREDENTIAL_PATH.open(encoding="utf-8") as stream: + data = json.load(stream) + except (OSError, ValueError): + return None + access_key = str(data.get("access_key_id") or data.get("AccessKeyId") or "") + secret_key = str(data.get("secret_access_key") or data.get("SecretAccessKey") or "") + session_token = str(data.get("session_token") or data.get("SessionToken") or "") + if access_key and secret_key: + return SkillPublishCredentials( + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + source="iam-file", + ) + return None + + +def resolve_skill_publish_credentials( + *, + provider: StudioProvider | None = None, + source: Mapping[str, str] | None = None, +) -> SkillPublishCredentials: + resolved_provider = provider or cloud_provider_from_env() + environment = source if source is not None else os.environ + if resolved_provider == "byteplus": + access_key = str(environment.get("BYTEPLUS_ACCESS_KEY") or "") + secret_key = str(environment.get("BYTEPLUS_SECRET_KEY") or "") + session_token = str(environment.get("BYTEPLUS_SESSION_TOKEN") or "") + if access_key and secret_key: + return SkillPublishCredentials( + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + source="env", + ) + else: + access_key = str( + environment.get("VOLCENGINE_ACCESS_KEY") + or environment.get("VOLC_ACCESSKEY") + or "" + ) + secret_key = str( + environment.get("VOLCENGINE_SECRET_KEY") + or environment.get("VOLC_SECRETKEY") + or "" + ) + session_token = str( + environment.get("VOLCENGINE_SESSION_TOKEN") + or environment.get("VOLC_SESSIONTOKEN") + or "" + ) + if access_key and secret_key: + return SkillPublishCredentials( + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + source="env", + ) + + iam_credentials = _read_vefaas_iam_credentials() + if iam_credentials is not None: + return iam_credentials + + try: + from agentkit.platform.configuration import VolcConfiguration + + credentials = VolcConfiguration( + provider=resolved_provider + ).get_service_credentials("tos") + except Exception as error: + raise SkillPublishStorageError( + f"无法解析 Skill 发布所需的 TOS 凭证:{error}" + ) from error + access_key = str(getattr(credentials, "access_key", "") or "") + secret_key = str(getattr(credentials, "secret_key", "") or "") + if not access_key or not secret_key: + raise SkillPublishStorageError("Skill 发布所需的 TOS 凭证为空。") + return SkillPublishCredentials( + access_key=access_key, + secret_key=secret_key, + session_token=str(getattr(credentials, "session_token", "") or ""), + source=str(getattr(credentials, "source", "") or "sdk"), + ) + + +def resolve_skill_publish_storage( + *, + region: str, + config_bucket: str = "", + config_prefix: str = "", + source: Mapping[str, str] | None = None, +) -> SkillPublishStorage: + provider = cloud_provider_from_env() + environment = source if source is not None else os.environ + prefix = ( + str(environment.get("VEADK_SKILL_CREATOR_TOS_PREFIX") or "").strip() + or config_prefix.strip() + or _DEFAULT_SKILL_PREFIX + ) + skill_bucket = str(environment.get("VEADK_SKILL_CREATOR_TOS_BUCKET") or "").strip() + studio_storage = StudioStorageConfig.from_env(provider, environment) + if skill_bucket: + bucket = skill_bucket + bucket_mode: SkillPublishBucketMode = "skill-env" + elif studio_storage.bucket: + if studio_storage.region and studio_storage.region != region: + raise SkillPublishStorageError( + "Studio TOS 桶地域与 Skill 发布地域不一致:" + f"{studio_storage.bucket} 位于 {studio_storage.region}," + f"当前发布地域为 {region}。" + ) + bucket = studio_storage.bucket + bucket_mode = "studio-storage" + elif config_bucket.strip(): + bucket = config_bucket.strip() + bucket_mode = "config" + else: + from agentkit.toolkit.volcengine.services.tos_service import TOSService + + bucket = TOSService.generate_bucket_name() + bucket_mode = "auto-generated" + + endpoint_region = region.strip() + if not endpoint_region: + raise SkillPublishStorageError("Skill 发布地域为空,无法确定 TOS endpoint。") + domain = "bytepluses.com" if provider == "byteplus" else "volces.com" + return SkillPublishStorage( + provider=provider, + region=endpoint_region, + bucket=bucket, + prefix=prefix, + endpoint=f"tos-{endpoint_region}.{domain}", + bucket_mode=bucket_mode, + ) + + +def _create_tos_client( + storage: SkillPublishStorage, + credentials: SkillPublishCredentials, +) -> Any: + import tos + + return tos.TosClientV2( + ak=credentials.access_key, + sk=credentials.secret_key, + security_token=credentials.session_token, + endpoint=storage.endpoint, + region=storage.region, + ) + + +def _listed_buckets(client: Any) -> dict[str, str]: + try: + result = client.list_buckets() + except Exception as error: + raise SkillPublishStorageError( + f"无法读取当前账号的 TOS 桶,已阻止上传 Skill 包:{error}" + ) from error + return { + str(getattr(bucket, "name", "") or "").strip(): str( + getattr(bucket, "location", "") or "" + ).strip() + for bucket in (getattr(result, "buckets", None) or []) + if str(getattr(bucket, "name", "") or "").strip() + } + + +def ensure_skill_publish_bucket( + storage: SkillPublishStorage, + credentials: SkillPublishCredentials, +) -> None: + client = _create_tos_client(storage, credentials) + buckets = _listed_buckets(client) + existing_region = buckets.get(storage.bucket) + if existing_region: + if existing_region != storage.region: + raise SkillPublishStorageError( + f"TOS 桶 {storage.bucket} 已位于 {existing_region}," + f"不能用于发布地域 {storage.region}。" + ) + return + + try: + client.create_bucket(bucket=storage.bucket) + except Exception as error: + buckets = _listed_buckets(client) + if buckets.get(storage.bucket) == storage.region: + return + status_code = getattr(error, "status_code", None) + error_code = str(getattr(error, "code", "") or error) + if status_code == 409 or "BucketAlready" in error_code: + raise SkillPublishStorageError( + f"TOS 桶名 {storage.bucket} 已被其他账号占用,已阻止上传 Skill 包。" + ) from error + raise SkillPublishStorageError( + f"创建 Skill 发布 TOS 桶 {storage.bucket} 失败:{error}" + ) from error + + deadline = time.time() + 10 + while time.time() < deadline: + buckets = _listed_buckets(client) + if buckets.get(storage.bucket) == storage.region: + return + time.sleep(2) + raise SkillPublishStorageError( + f"创建 TOS 桶 {storage.bucket} 后无法确认归属,已阻止上传 Skill 包。" + ) + + +def upload_skill_archive( + zip_abs: str, + storage: SkillPublishStorage, + credentials: SkillPublishCredentials, +) -> str: + client = _create_tos_client(storage, credentials) + effective_prefix = storage.prefix.strip("/") or _DEFAULT_SKILL_PREFIX + key = f"{effective_prefix}/{Path(zip_abs).name}" + try: + client.put_object_from_file( + bucket=storage.bucket, + key=key, + file_path=zip_abs, + content_type="application/zip", + ) + except Exception as error: + raise SkillPublishStorageError( + f"上传 Skill 包到 TOS 失败:bucket={storage.bucket}, key={key}, error={error}" + ) from error + return f"https://{storage.bucket}.{storage.endpoint}/{key}" + + +__all__ = [ + "SkillPublishCredentials", + "SkillPublishStorage", + "SkillPublishStorageError", + "ensure_skill_publish_bucket", + "resolve_skill_publish_credentials", + "resolve_skill_publish_storage", + "upload_skill_archive", +] diff --git a/tests/cli/test_frontend_skill_creator.py b/tests/cli/test_frontend_skill_creator.py index 405f3758..35f66b08 100644 --- a/tests/cli/test_frontend_skill_creator.py +++ b/tests/cli/test_frontend_skill_creator.py @@ -86,7 +86,11 @@ def test_archive_metadata_requires_safe_single_matching_root() -> None: service._archive_metadata(unsafe.getvalue()) -def test_create_job_runs_fixed_models_in_independent_candidates() -> None: +def test_create_job_runs_fixed_models_in_independent_candidates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTKIT_CLOUD_PROVIDER", "volcengine") + monkeypatch.delenv("CLOUD_PROVIDER", raising=False) service = SkillCreatorService(tool_id="tool-id") calls: list[tuple[str, str]] = [] progress: list[dict[str, Any]] = [] diff --git a/tests/frontend/server/skills/test_skill_publish_storage.py b/tests/frontend/server/skills/test_skill_publish_storage.py new file mode 100644 index 00000000..4be62e15 --- /dev/null +++ b/tests/frontend/server/skills/test_skill_publish_storage.py @@ -0,0 +1,189 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from frontend.server.skills import storage + + +class _FakeTosClient: + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + self.created: list[str] = [] + self.uploads: list[dict[str, object]] = [] + + def list_buckets(self) -> SimpleNamespace: + return SimpleNamespace( + buckets=[ + SimpleNamespace( + name="veadk-studio-3001037806", + location="ap-southeast-1", + ) + ] + ) + + def create_bucket(self, *, bucket: str) -> None: + self.created.append(bucket) + + def put_object_from_file(self, **kwargs: object) -> None: + self.uploads.append(kwargs) + + +def test_studio_bucket_wins_without_generating_account_bucket( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTKIT_CLOUD_PROVIDER", "byteplus") + monkeypatch.setenv("VEADK_STUDIO_TOS_BUCKET", "veadk-studio-3001037806") + monkeypatch.setenv("VEADK_STUDIO_TOS_REGION", "ap-southeast-1") + + from agentkit.toolkit.volcengine.services.tos_service import TOSService + + monkeypatch.setattr( + TOSService, + "generate_bucket_name", + lambda: pytest.fail("Studio bucket must avoid generate_bucket_name()"), + ) + + result = storage.resolve_skill_publish_storage( + region="ap-southeast-1", + config_bucket="agentkit-config-bucket", + config_prefix="configured-prefix", + ) + + assert result.bucket == "veadk-studio-3001037806" + assert result.prefix == "configured-prefix" + assert result.bucket_mode == "studio-storage" + assert result.endpoint == "tos-ap-southeast-1.bytepluses.com" + + +def test_skill_bucket_env_still_overrides_studio_bucket( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTKIT_CLOUD_PROVIDER", "byteplus") + monkeypatch.setenv("VEADK_SKILL_CREATOR_TOS_BUCKET", "skill-upload-bucket") + monkeypatch.setenv("VEADK_STUDIO_TOS_BUCKET", "veadk-studio-3001037806") + monkeypatch.setenv("VEADK_STUDIO_TOS_REGION", "ap-southeast-1") + + result = storage.resolve_skill_publish_storage(region="ap-southeast-1") + + assert result.bucket == "skill-upload-bucket" + assert result.bucket_mode == "skill-env" + + +def test_studio_bucket_region_mismatch_fails_fast( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AGENTKIT_CLOUD_PROVIDER", "byteplus") + monkeypatch.setenv("VEADK_STUDIO_TOS_BUCKET", "veadk-studio-3001037806") + monkeypatch.setenv("VEADK_STUDIO_TOS_REGION", "ap-southeast-1") + + with pytest.raises(storage.SkillPublishStorageError, match="地域不一致"): + storage.resolve_skill_publish_storage(region="cn-beijing") + + +def test_auto_generated_bucket_is_last_resort(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTKIT_CLOUD_PROVIDER", "volcengine") + monkeypatch.delenv("VEADK_SKILL_CREATOR_TOS_BUCKET", raising=False) + monkeypatch.delenv("VEADK_STUDIO_TOS_BUCKET", raising=False) + monkeypatch.delenv("VEADK_STUDIO_TOS_REGION", raising=False) + + from agentkit.toolkit.volcengine.services.tos_service import TOSService + + monkeypatch.setattr( + TOSService, "generate_bucket_name", lambda: "agentkit-platform-1" + ) + + result = storage.resolve_skill_publish_storage(region="cn-beijing") + + assert result.bucket == "agentkit-platform-1" + assert result.bucket_mode == "auto-generated" + assert result.endpoint == "tos-cn-beijing.volces.com" + + +def test_byteplus_upload_uses_explicit_tos_client_with_session_token( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + monkeypatch.setenv("AGENTKIT_CLOUD_PROVIDER", "byteplus") + monkeypatch.setenv("BYTEPLUS_ACCESS_KEY", "ak") + monkeypatch.setenv("BYTEPLUS_SECRET_KEY", "sk") + monkeypatch.setenv("BYTEPLUS_SESSION_TOKEN", "token") + fake_client = _FakeTosClient() + + import tos + + def make_client(**kwargs: object) -> _FakeTosClient: + fake_client.kwargs = kwargs + return fake_client + + monkeypatch.setattr(tos, "TosClientV2", make_client) + publish_storage = storage.resolve_skill_publish_storage( + region="ap-southeast-1", + source={ + "VEADK_STUDIO_TOS_BUCKET": "veadk-studio-3001037806", + "VEADK_STUDIO_TOS_REGION": "ap-southeast-1", + }, + ) + credentials = storage.resolve_skill_publish_credentials(provider="byteplus") + archive_path = tmp_path / "demo.zip" + archive_path.write_bytes(b"zip") + + storage.ensure_skill_publish_bucket(publish_storage, credentials) + tos_url = storage.upload_skill_archive( + str(archive_path), + publish_storage, + credentials, + ) + + assert fake_client.kwargs == { + "ak": "ak", + "sk": "sk", + "security_token": "token", + "endpoint": "tos-ap-southeast-1.bytepluses.com", + "region": "ap-southeast-1", + } + assert fake_client.created == [] + assert fake_client.uploads[0]["bucket"] == "veadk-studio-3001037806" + assert fake_client.uploads[0]["key"] == "agentkit/skills/demo.zip" + assert tos_url == ( + "https://veadk-studio-3001037806.tos-ap-southeast-1.bytepluses.com/" + "agentkit/skills/demo.zip" + ) + + +def test_byteplus_credentials_can_read_iam_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + monkeypatch.setenv("AGENTKIT_CLOUD_PROVIDER", "byteplus") + monkeypatch.delenv("BYTEPLUS_ACCESS_KEY", raising=False) + monkeypatch.delenv("BYTEPLUS_SECRET_KEY", raising=False) + iam_file = tmp_path / "credential" + iam_file.write_text( + '{"access_key_id":"iam-ak","secret_access_key":"iam-sk",' + '"session_token":"iam-token"}', + encoding="utf-8", + ) + monkeypatch.setattr(storage, "_IAM_CREDENTIAL_PATH", iam_file) + + credentials = storage.resolve_skill_publish_credentials(provider="byteplus") + + assert credentials.access_key == "iam-ak" + assert credentials.secret_key == "iam-sk" + assert credentials.session_token == "iam-token" + assert credentials.source == "iam-file" diff --git a/tests/frontend/test_skills_server.py b/tests/frontend/test_skills_server.py index 1c1cd2d7..269d2974 100644 --- a/tests/frontend/test_skills_server.py +++ b/tests/frontend/test_skills_server.py @@ -17,8 +17,10 @@ import base64 import io import json +import sys import stat import zipfile +from types import ModuleType from types import SimpleNamespace import pytest @@ -35,7 +37,10 @@ UpdateSkillSpaceBody, ) from frontend.server.skills.prompts import decorate_intent -from frontend.server.skills.repository import AgentKitSkillRepository +from frontend.server.skills.repository import ( + AgentKitSkillRepository, + SkillRepositoryError, +) from frontend.server.skills.routes import _convert_error from frontend.server.skills.service import SkillService from veadk.cli.frontend_skill_creator import _sandbox_model_config @@ -301,6 +306,159 @@ def create_skill_space(self, request: object) -> SimpleNamespace: assert result["author"] == "person@example.com" +class _FakeSkillRequest: + def __init__(self, **kwargs: object) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + setattr(self, _pascal_to_snake(key), value) + + +def _pascal_to_snake(value: str) -> str: + result = [] + for index, char in enumerate(value): + if char.isupper() and index > 0: + result.append("_") + result.append(char.lower()) + return "".join(result) + + +class _FakeSkillClient: + def __init__(self, space_items: list[object]) -> None: + self.space_items = space_items + self.space_requests: list[object] = [] + self.create_requests: list[object] = [] + self.publish_requests: list[object] = [] + + def list_skills(self, request: object) -> SimpleNamespace: + del request + raise AssertionError( + "upload conflict checks must stay scoped to the target space" + ) + + def list_skills_by_skill_space(self, request: object) -> SimpleNamespace: + self.space_requests.append(request) + return SimpleNamespace( + items=self.space_items, + total_count=len(self.space_items), + ) + + def create_skill(self, request: object) -> SimpleNamespace: + self.create_requests.append(request) + return SimpleNamespace(id="skill-new") + + def publish_skill_to_skill_space(self, request: object) -> None: + self.publish_requests.append(request) + + +def _install_fake_agentkit_modules(monkeypatch: pytest.MonkeyPatch) -> None: + fake_types = SimpleNamespace( + ListSkillsBySkillSpaceRequest=_FakeSkillRequest, + CreateSkillRequest=_FakeSkillRequest, + PublishSkillToSkillSpaceRequest=_FakeSkillRequest, + SkillBasicInfo=_FakeSkillRequest, + TagForSkill=_FakeSkillRequest, + ) + agentkit = ModuleType("agentkit") + sdk = ModuleType("agentkit.sdk") + skills = ModuleType("agentkit.sdk.skills") + toolkit = ModuleType("agentkit.toolkit") + cli = ModuleType("agentkit.toolkit.cli") + workflow = ModuleType("agentkit.toolkit.cli.cli_skills_workflow") + config = ModuleType("agentkit.toolkit.config") + skills.types = fake_types # type: ignore[attr-defined] + workflow._make_content_hashed_zip_copy = ( # type: ignore[attr-defined] + lambda archive_path, _name, _directory: archive_path + ) + workflow._wait_for_running_version = ( # type: ignore[attr-defined] + lambda **_kwargs: SimpleNamespace(version="v1") + ) + + class GlobalConfigManager: + def load(self) -> SimpleNamespace: + return SimpleNamespace(tos=SimpleNamespace(bucket="", prefix="")) + + config.GlobalConfigManager = GlobalConfigManager # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "agentkit", agentkit) + monkeypatch.setitem(sys.modules, "agentkit.sdk", sdk) + monkeypatch.setitem(sys.modules, "agentkit.sdk.skills", skills) + monkeypatch.setitem(sys.modules, "agentkit.toolkit", toolkit) + monkeypatch.setitem(sys.modules, "agentkit.toolkit.cli", cli) + monkeypatch.setitem( + sys.modules, + "agentkit.toolkit.cli.cli_skills_workflow", + workflow, + ) + monkeypatch.setitem(sys.modules, "agentkit.toolkit.config", config) + + +def _stub_skill_publish_storage(monkeypatch: pytest.MonkeyPatch) -> None: + from frontend.server.skills import storage + + monkeypatch.setattr(storage, "ensure_skill_publish_bucket", lambda *_args: None) + monkeypatch.setattr( + storage, + "resolve_skill_publish_credentials", + lambda *, provider: SimpleNamespace(), + ) + monkeypatch.setattr( + storage, + "resolve_skill_publish_storage", + lambda **_kwargs: SimpleNamespace(provider="fake", bucket="skill-bucket"), + ) + monkeypatch.setattr( + storage, + "upload_skill_archive", + lambda *_args: "https://storage.invalid/skill.zip", + ) + + +def test_publish_archive_allows_same_name_in_other_space( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_agentkit_modules(monkeypatch) + _stub_skill_publish_storage(monkeypatch) + client = _FakeSkillClient(space_items=[]) + repository = AgentKitSkillRepository(lambda _region: client) + skill_archive = validate_skill_archive(archive({"SKILL.md": SKILL_MD.encode()})) + + result = repository.publish_archive( + region="cn-beijing", + project_name="default", + space_id="space-b", + archive=skill_archive, + author="person@example.com", + ) + + assert result["skillId"] == "skill-new" + assert client.space_requests[0].skill_space_id == "space-b" + assert client.create_requests[0].skill_spaces == ["space-b"] + assert client.publish_requests[0].skill_spaces == ["space-b"] + + +def test_publish_archive_rejects_same_name_in_target_space( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_agentkit_modules(monkeypatch) + _stub_skill_publish_storage(monkeypatch) + client = _FakeSkillClient(space_items=[SimpleNamespace(skill_name="example-skill")]) + repository = AgentKitSkillRepository(lambda _region: client) + skill_archive = validate_skill_archive(archive({"SKILL.md": SKILL_MD.encode()})) + + with pytest.raises(SkillRepositoryError) as raised: + repository.publish_archive( + region="cn-beijing", + project_name="default", + space_id="space-b", + archive=skill_archive, + author="person@example.com", + ) + + assert raised.value.code == "SKILL_NAME_CONFLICT" + assert client.space_requests[0].skill_space_id == "space-b" + assert client.create_requests == [] + assert client.publish_requests == [] + + def test_space_update_and_delete_use_the_selected_region() -> None: repository = FakeRepository() service = SkillService(repository) # type: ignore[arg-type] diff --git a/veadk/cli/frontend_skill_creator.py b/veadk/cli/frontend_skill_creator.py index 8392c293..5930ced9 100644 --- a/veadk/cli/frontend_skill_creator.py +++ b/veadk/cli/frontend_skill_creator.py @@ -29,13 +29,12 @@ import time import uuid import zipfile - from collections.abc import AsyncIterator, Callable from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path, PurePosixPath from typing import Any -import requests +import requests from agentkit.sdk.skills import types as skills_types from agentkit.sdk.skills.client import AgentkitSkillsClient from agentkit.sdk.tools import types as tools_types @@ -61,7 +60,6 @@ BYTEPLUS_STUDIO_AGENT_MODEL_NAME, ) - _MODELS = ( ("a", "doubao-seed-2-0-pro-260215", "豆包 Seed 2.0 Pro"), ("b", "deepseek-v4-flash-260425", "DeepSeek V4 Flash"), @@ -981,32 +979,26 @@ def publish( archive, _ = self.download(job_id, candidate_id, owner_id) name, description = self._archive_metadata(archive) from agentkit.toolkit.cli.cli_skills_workflow import ( - _ensure_bucket_ready, _make_content_hashed_zip_copy, - _tos_upload, _wait_for_running_version, ) from agentkit.toolkit.config import GlobalConfigManager - from agentkit.toolkit.volcengine.services.tos_service import TOSService + + from frontend.server.skills.storage import ( + ensure_skill_publish_bucket, + resolve_skill_publish_credentials, + resolve_skill_publish_storage, + upload_skill_archive, + ) config = GlobalConfigManager().load() - configured_bucket = ( - os.getenv("VEADK_SKILL_CREATOR_TOS_BUCKET") or config.tos.bucket or "" - ).strip() - bucket = configured_bucket or TOSService.generate_bucket_name() - prefix = ( - os.getenv("VEADK_SKILL_CREATOR_TOS_PREFIX") - or config.tos.prefix - or "agentkit/skills" - ).strip() - _ensure_bucket_ready( - bucket_name=bucket, - prefix=prefix, + storage = resolve_skill_publish_storage( region=self._region, - auto_bucket=not bool(configured_bucket), - assume_yes=True, - assume_no=False, + config_bucket=config.tos.bucket or "", + config_prefix=config.tos.prefix or "", ) + credentials = resolve_skill_publish_credentials(provider=storage.provider) + ensure_skill_publish_bucket(storage, credentials) with tempfile.TemporaryDirectory(prefix="veadk-skill-publish-") as temp_dir: archive_path = Path(temp_dir) / f"{name}.zip" @@ -1014,9 +1006,7 @@ def publish( hashed_path = _make_content_hashed_zip_copy( str(archive_path), name, temp_dir ) - tos_url = _tos_upload( - hashed_path, bucket, prefix, self._region, verify_bucket=False - ) + tos_url = upload_skill_archive(hashed_path, storage, credentials) client = AgentkitSkillsClient(region=self._region) effective_project = ( @@ -1046,7 +1036,7 @@ def publish( Description=description, TosUrl=tos_url, SkillSpaces=skill_space_ids or None, - BucketName=bucket, + BucketName=storage.bucket, ) ) else: @@ -1056,7 +1046,7 @@ def publish( Description=description, TosUrl=tos_url, SkillSpaces=skill_space_ids or None, - BucketName=bucket, + BucketName=storage.bucket, ProjectName=effective_project, ) )