diff --git a/README.md b/README.md index d91c85d..2c5f2ea 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,10 @@ The official Python SDK & CLI for [ModelScope Hub](https://modelscope.cn) — do ## News **v0.4.0** (2026-09-01) -- **Feature**: full MCP/Studios OpenAPI coverage: Studio lists, variables and configuration options; hosted MCP discovery; protected visibility and runtime metadata; read-only tokens can log in and rejected writes name the required tier +- **Feature**: complete OpenAPI coverage for Agent-IDP, MCP, and Studios — Agent Ed25519 identities, OIDC discovery/JWKS and signed JWT issuance (`HubApi`, `ms-hub agent-idp`); Studio lists, variables and configuration options; hosted MCP discovery; protected visibility and runtime metadata; read-only tokens can log in and rejected writes name the required tier. Agent private JWKs are only written to an explicitly requested owner-only file. - **Fix**: Studio compat calls no longer leak connection options or API tokens, or drop cover images; errors distinguish permission, quota and conflicts; anonymous Studio info and log pagination work - **Enhance**: MCP verb negotiation and Studio-owner listings adapt to endpoint behaviour -- **Quality**: the vendored OpenAPI spec and operation registry flag unimplemented published MCP/Studios endpoints +- **Quality**: the vendored OpenAPI spec and operation registry flag unimplemented published Agent-IDP, MCP, and Studios endpoints **v0.3.1** (2026-09-01) - **Refactor**: upload environment variables now state their units and semantics; deprecated names remain supported with warnings @@ -685,6 +685,32 @@ ms-hub agent upload -r user/my-agent --local-dir ./my-agent --dry-run +### `ms-hub agent-idp` + +Agent-IDP manages an Agent's **identity and signing key**, not its repository files. Use `ms-hub agent` for raw Agent repository transfer; use `ms-hub agent-idp` to register an Ed25519 public key, inspect OIDC metadata, and issue a short-lived JWT. + +```bash +# Private JWK storage is always explicit; the command prints only its public JWK. +ms-hub agent-idp keygen --private-key-out ./agent.jwk +ms-hub agent-idp create --agent-name my-agent --private-key-file ./agent.jwk +ms-hub agent-idp issue-token --agent-id agent_id:modelscope:agent_xxx --audience my-hub --private-key-file ./agent.jwk + +# These discovery endpoints are public and do not require login. +ms-hub agent-idp configuration +ms-hub agent-idp jwks +``` + +Protect the private JWK file: it is created with mode `0600` on POSIX, never copied into the SDK configuration or cache, and must not be committed. External key stores can pass a public JWK with `--public-jwk-file` for registration or rotation. + +```python +from modelscope_hub import HubApi, generate_agent_key_pair + +api = HubApi(token="ms-write-token") +private_jwk, public_jwk = generate_agent_key_pair() +identity = api.create_agent_identity({"agent_name": "my-agent", "public_key": public_jwk.to_dict()}) +token = api.issue_agent_token_with_private_key(private_jwk, agent_id=identity.agent_id, audience="my-hub") +``` + --- ## SDK API Overview @@ -742,6 +768,12 @@ api = HubApi(token="...", endpoint="https://modelscope.ai") | | `get_mcp_server(server_id)` | Get server details | | | `deploy_mcp_server(server_id)` | Deploy an MCP server | | | `undeploy_mcp_server(server_id)` | Undeploy an MCP server | +| **Agent-IDP** | `create_agent_identity(payload)` | Register an Agent Ed25519 public key | +| | `get_agent_identity(agent_id)` / `update_agent_identity(...)` / `delete_agent_identity(agent_id)` | Manage identity metadata | +| | `reset_agent_key_pair(agent_id, payload)` / `pause_agent(agent_id, paused=...)` | Rotate a key or control token issuance | +| | `list_user_agent_identities(...)` / `list_agent_token_records(...)` | List identities and non-sensitive issuance records | +| | `issue_agent_token_with_private_key(...)` | Locally sign and exchange a short-lived JWT | +| | `get_agent_id_configuration()` / `get_agent_id_jwks()` | Anonymous OIDC discovery and JWT verification keys | | **Cache** | `scan_cache(cache_dir)` | Inspect local cache | | | `clear_cache(cache_dir, ...)` | Free disk space | diff --git a/pyproject.toml b/pyproject.toml index 4ebd36d..afb9ff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "tqdm>=4.64.0", "filelock>=3.9", "urllib3>=1.26", + "cryptography>=41", ] [project.optional-dependencies] diff --git a/src/modelscope_hub/__init__.py b/src/modelscope_hub/__init__.py index 342d70d..46f2d95 100644 --- a/src/modelscope_hub/__init__.py +++ b/src/modelscope_hub/__init__.py @@ -10,6 +10,13 @@ from __future__ import annotations from ._download import ProgressCallback, TqdmCallback +from .agent_idp import ( + generate_agent_key_pair, + load_private_jwk, + public_jwk_from_private, + sign_agent_token_request, + write_private_jwk, +) from .api import HubApi from .config import HubConfig, get_default_config, set_default_config from .constants import License, RepoType, StudioVisibility, TokenScope, Visibility @@ -36,6 +43,12 @@ ValidationError, ) from .types import ( + AgentIdConfiguration, + AgentIdentity, + AgentIdentitySummary, + AgentJWK, + AgentToken, + AgentTokenRecord, CachedRepoInfo, CacheInfo, CacheVerification, @@ -65,7 +78,19 @@ # Progress callbacks "ProgressCallback", "TqdmCallback", + # Agent-IDP local key helpers + "generate_agent_key_pair", + "load_private_jwk", + "public_jwk_from_private", + "sign_agent_token_request", + "write_private_jwk", # Data classes + "AgentIdentity", + "AgentIdentitySummary", + "AgentIdConfiguration", + "AgentJWK", + "AgentToken", + "AgentTokenRecord", "CacheInfo", "CacheVerification", "CachedRepoInfo", diff --git a/src/modelscope_hub/_openapi.py b/src/modelscope_hub/_openapi.py index 30592f7..24ee5c8 100644 --- a/src/modelscope_hub/_openapi.py +++ b/src/modelscope_hub/_openapi.py @@ -45,9 +45,14 @@ raise_for_status, ) from .types import ( + CreateAgentIdentityPayload, CreateSkillPayload, CreateStudioPayload, DeployMcpServerPayload, + PauseAgentPayload, + ResetAgentKeyPairPayload, + TokenSignPayload, + UpdateAgentIdentityPayload, UpdateSkillSettingsPayload, UpdateStudioSettingsPayload, ) @@ -84,6 +89,10 @@ # ``GET /studios/{owner}/{repo}/logs/{type}`` caps page_size at 500. _STUDIO_LOG_MAX_PAGE_SIZE = 500 +# Agent-IDP declares these closed sets in its OpenAPI request schemas. +_AGENT_IDP_TOKEN_EXPIRIES: frozenset[int] = frozenset({300, 600, 1800, 3600}) +_AGENT_IDENTITY_STATUSES: frozenset[str] = frozenset({"active", "paused"}) + JSON = dict[str, Any] QueryParams = list[tuple[str, str]] Filters = Mapping[str, str | int | float | bool] | None @@ -123,6 +132,18 @@ def _as_wire_bool(value: bool | None) -> str | None: # deferred in that test. # --------------------------------------------------------------------------- OPERATION_REGISTRY: dict[str, tuple[str, TokenScope]] = { + # -- Agent-IDP ---------------------------------------------------------- + "createAgentIdentity": ("create_agent_identity", TokenScope.WRITE), + "getAgentIdentity": ("get_agent_identity", TokenScope.READ), + "updateAgentIdentity": ("update_agent_identity", TokenScope.WRITE), + "deleteAgentIdentity": ("delete_agent_identity", TokenScope.WRITE), + "resetAgentKeyPair": ("reset_agent_key_pair", TokenScope.WRITE), + "pauseAgent": ("pause_agent", TokenScope.WRITE), + "listAgentTokenRecords": ("list_agent_token_records", TokenScope.READ), + "listUserAgentIdentities": ("list_user_agent_identities", TokenScope.READ), + "issueAgentToken": ("issue_agent_token", TokenScope.READ), + "getAgentIdConfiguration": ("get_agent_id_configuration", TokenScope.READ), + "getAgentIdJWKS": ("get_agent_id_jwks", TokenScope.READ), # -- MCP ---------------------------------------------------------------- "listMcpServers": ("list_mcp_servers", TokenScope.READ), "listOperationalMcpServers": ("list_operational_mcp_servers", TokenScope.READ), @@ -1067,6 +1088,203 @@ def _flatten_mcp_list_params(body: Mapping[str, Any]) -> QueryParams: def _is_method_or_route_unsupported(exc: APIError) -> bool: return exc.status_code in (404, 405, 501) + # ================================================================== + # Agent-IDP + # ================================================================== + @staticmethod + def _validate_agent_idp_page(page: int, page_size: int) -> None: + """Validate the common Agent-IDP pagination contract (1-based, max 50).""" + if isinstance(page, bool) or not isinstance(page, int) or page < 1: + raise InvalidParameter("page must be an integer >= 1.") + if isinstance(page_size, bool) or not isinstance(page_size, int) or not 1 <= page_size <= 50: + raise InvalidParameter("page_size must be an integer between 1 and 50.") + + @staticmethod + def _validate_agent_public_jwk(value: Any) -> dict[str, Any]: + """Validate the public-only Ed25519 JWK accepted by registration routes.""" + if not isinstance(value, Mapping): + raise InvalidParameter("public_key must be an Ed25519 JWK object.") + key = dict(value) + if "d" in key: + raise InvalidParameter("public_key must not contain private JWK material ('d').") + required = {"kty", "crv", "x", "kid"} + missing = sorted(name for name in required if not isinstance(key.get(name), str) or not key[name]) + if missing: + raise InvalidParameter(f"public_key is missing required JWK field(s): {', '.join(missing)}.") + if key["kty"] != "OKP" or key["crv"] != "Ed25519": + raise InvalidParameter("public_key must use kty='OKP' and crv='Ed25519'.") + for field in ("alg", "use"): + if field in key and key[field] is not None and not isinstance(key[field], str): + raise InvalidParameter(f"public_key.{field} must be a string.") + return {field: value for field, value in key.items() if value is not None} + + @staticmethod + def _validate_agent_token_expiry(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value not in _AGENT_IDP_TOKEN_EXPIRIES: + allowed = ", ".join(str(item) for item in sorted(_AGENT_IDP_TOKEN_EXPIRIES)) + raise InvalidParameter(f"token_expire_time must be one of {allowed} seconds.") + return value + + def create_agent_identity(self, payload: CreateAgentIdentityPayload | Mapping[str, Any]) -> JSON: + """``POST /agent_ids`` — register an Agent-IDP public Ed25519 identity.""" + body = {key: value for key, value in dict(payload).items() if value is not None} + allowed = {"agent_name", "description", "public_key", "key_alg_type", "token_expire_time"} + unknown = sorted(set(body) - allowed) + if unknown: + raise InvalidParameter(f"Unsupported create Agent-IDP field(s): {', '.join(unknown)}.") + if not isinstance(body.get("agent_name"), str) or not body["agent_name"].strip(): + raise InvalidParameter("agent_name must be a non-empty string.") + body["public_key"] = self._validate_agent_public_jwk(body.get("public_key")) + if "key_alg_type" in body and body["key_alg_type"] != "Ed25519": + raise InvalidParameter("key_alg_type must be 'Ed25519'.") + if "token_expire_time" in body: + body["token_expire_time"] = self._validate_agent_token_expiry(body["token_expire_time"]) + return self._request("POST", "/agent_ids", json_body=body, required_scope=TokenScope.WRITE) + + def get_agent_identity(self, agent_id: str) -> JSON: + """``GET /agent_ids/{agent_id}`` — fetch one Agent-IDP identity.""" + if not agent_id: + raise InvalidParameter("agent_id must not be empty.") + return self._request("GET", f"/agent_ids/{agent_id}", required_scope=TokenScope.READ) + + def update_agent_identity( + self, + agent_id: str, + payload: UpdateAgentIdentityPayload | Mapping[str, Any], + ) -> JSON: + """``PATCH /agent_ids/{agent_id}`` — update mutable Agent-IDP metadata.""" + if not agent_id: + raise InvalidParameter("agent_id must not be empty.") + body = {key: value for key, value in dict(payload).items() if value is not None} + allowed = {"agent_name", "description", "token_expire_time"} + unknown = sorted(set(body) - allowed) + if unknown: + raise InvalidParameter(f"Unsupported Agent-IDP update field(s): {', '.join(unknown)}.") + if not body: + raise InvalidParameter("update_agent_identity requires at least one field.") + if "agent_name" in body and (not isinstance(body["agent_name"], str) or not body["agent_name"].strip()): + raise InvalidParameter("agent_name must be a non-empty string.") + if "description" in body and not isinstance(body["description"], str): + raise InvalidParameter("description must be a string.") + if "token_expire_time" in body: + body["token_expire_time"] = self._validate_agent_token_expiry(body["token_expire_time"]) + return self._request("PATCH", f"/agent_ids/{agent_id}", json_body=body, required_scope=TokenScope.WRITE) + + def delete_agent_identity(self, agent_id: str) -> JSON: + """``DELETE /agent_ids/{agent_id}`` — delete an Agent-IDP identity.""" + if not agent_id: + raise InvalidParameter("agent_id must not be empty.") + return self._request("DELETE", f"/agent_ids/{agent_id}", required_scope=TokenScope.WRITE) + + def reset_agent_key_pair( + self, + agent_id: str, + payload: ResetAgentKeyPairPayload | Mapping[str, Any], + ) -> JSON: + """``PUT /agent_ids/{agent_id}/key_pairs`` — replace an identity public key.""" + if not agent_id: + raise InvalidParameter("agent_id must not be empty.") + body = {key: value for key, value in dict(payload).items() if value is not None} + allowed = {"public_key", "key_alg_type"} + unknown = sorted(set(body) - allowed) + if unknown: + raise InvalidParameter(f"Unsupported Agent-IDP key-reset field(s): {', '.join(unknown)}.") + body["public_key"] = self._validate_agent_public_jwk(body.get("public_key")) + if "key_alg_type" in body and body["key_alg_type"] != "Ed25519": + raise InvalidParameter("key_alg_type must be 'Ed25519'.") + return self._request( + "PUT", + f"/agent_ids/{agent_id}/key_pairs", + json_body=body, + required_scope=TokenScope.WRITE, + ) + + def pause_agent(self, agent_id: str, payload: PauseAgentPayload | Mapping[str, Any]) -> JSON: + """``POST /agent_ids/{agent_id}/paused`` — enable or pause token issuance.""" + if not agent_id: + raise InvalidParameter("agent_id must not be empty.") + body = dict(payload) + if set(body) != {"paused"} or not isinstance(body.get("paused"), bool): + raise InvalidParameter("pause_agent requires exactly a boolean 'paused' field.") + return self._request( + "POST", + f"/agent_ids/{agent_id}/paused", + json_body=body, + required_scope=TokenScope.WRITE, + ) + + def list_agent_token_records(self, agent_id: str, *, page: int = 1, page_size: int = 20) -> JSON: + """``GET /agent_ids/{agent_id}/jwt_id_tokens`` — list issued-token records.""" + if not agent_id: + raise InvalidParameter("agent_id must not be empty.") + self._validate_agent_idp_page(page, page_size) + return self._request( + "GET", + f"/agent_ids/{agent_id}/jwt_id_tokens", + params=self._merge_params({"page": page, "page_size": page_size}), + required_scope=TokenScope.READ, + ) + + def list_user_agent_identities( + self, + username: str, + *, + status: str | None = None, + page: int = 1, + page_size: int = 20, + ) -> JSON: + """``GET /users/{username}/agent_ids`` — list a user's Agent-IDP identities.""" + if not username: + raise InvalidParameter("username must not be empty.") + if status is not None and status not in _AGENT_IDENTITY_STATUSES: + allowed = ", ".join(sorted(_AGENT_IDENTITY_STATUSES)) + raise InvalidParameter(f"status must be one of {allowed}.") + self._validate_agent_idp_page(page, page_size) + return self._request( + "GET", + f"/users/{username}/agent_ids", + params=self._merge_params({"status": status, "page": page, "page_size": page_size}), + required_scope=TokenScope.READ, + ) + + def issue_agent_token(self, payload: TokenSignPayload | Mapping[str, Any]) -> JSON: + """``POST /agent_id/token`` — exchange a locally signed request for a JWT. + + The signature itself authenticates this public endpoint. Explicit + anonymous transport ensures an ambient Hub token is never attached. + """ + body = dict(payload) + required = {"agent_id", "kid", "audience", "timestamp", "signature"} + if set(body) != required: + raise InvalidParameter("issue_agent_token requires agent_id, kid, audience, timestamp and signature.") + for field in ("agent_id", "kid", "audience", "signature"): + if not isinstance(body[field], str) or not body[field]: + raise InvalidParameter(f"{field} must be a non-empty string.") + if isinstance(body["timestamp"], bool) or not isinstance(body["timestamp"], int) or body["timestamp"] <= 0: + raise InvalidParameter("timestamp must be a positive Unix timestamp in seconds.") + return self._request("POST", "/agent_id/token", json_body=body, require_token=False, anonymous=True) + + def get_agent_id_configuration(self) -> JSON: + """Fetch anonymous Agent-IDP OIDC discovery metadata.""" + return self._request( + "GET", + "/agent_id/.well-known/agentid-configuration", + require_token=False, + anonymous=True, + ) + + def get_agent_id_jwks(self) -> JSON: + """Fetch the anonymous Agent-IDP JWT validation key set.""" + return self._request( + "GET", + "/agent_id/.well-known/agentid-jwks", + require_token=False, + anonymous=True, + ) + + # ================================================================== + # MCP + # ================================================================== def list_mcp_servers( self, *, diff --git a/src/modelscope_hub/agent_idp.py b/src/modelscope_hub/agent_idp.py new file mode 100644 index 0000000..e2ad5da --- /dev/null +++ b/src/modelscope_hub/agent_idp.py @@ -0,0 +1,206 @@ +"""Local Ed25519/JWK helpers for the Agent-IDP OpenAPI surface. + +This module deliberately owns no HTTP transport and never persists a key unless +an application explicitly calls :func:`write_private_jwk`. Agent repository +transfer lives in :mod:`modelscope_hub.agent` and is unrelated to Agent-IDP +identities, signing keys, OIDC discovery, or JWT issuance. +""" + +from __future__ import annotations + +import base64 +import json +import os +import secrets +import stat +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from .errors import InvalidParameter +from .types import AgentJWK, TokenSignPayload + +__all__ = [ + "generate_agent_key_pair", + "load_private_jwk", + "public_jwk_from_private", + "sign_agent_token_request", + "write_private_jwk", +] + + +_JWK_KEY_TYPE = "OKP" +_JWK_CURVE = "Ed25519" + + +def _encode_base64url(value: bytes) -> str: + """Encode bytes as unpadded base64url, the JWK wire representation.""" + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _decode_base64url(value: object, field: str) -> bytes: + """Decode an unpadded base64url JWK member without accepting aliases.""" + if not isinstance(value, str) or not value: + raise InvalidParameter(f"Agent private key field {field!r} must be a non-empty base64url string.") + try: + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + except (ValueError, UnicodeEncodeError) as exc: + raise InvalidParameter(f"Agent private key field {field!r} is not valid base64url.") from exc + if _encode_base64url(decoded) != value: + raise InvalidParameter(f"Agent private key field {field!r} must be unpadded base64url.") + return decoded + + +def _normalise_private_jwk(value: AgentJWK | Mapping[str, Any]) -> AgentJWK: + """Validate an Ed25519 private JWK and return a safe structured copy.""" + if isinstance(value, AgentJWK): + key = value + elif isinstance(value, Mapping): + key = AgentJWK.from_dict(value) + else: + raise InvalidParameter("Agent private key must be an Ed25519 JWK mapping.") + + if key.kty != _JWK_KEY_TYPE or key.crv != _JWK_CURVE: + raise InvalidParameter("Agent private key must use kty='OKP' and crv='Ed25519'.") + if not key.kid: + raise InvalidParameter("Agent private key must contain a non-empty 'kid'.") + public_bytes = _decode_base64url(key.x, "x") + private_bytes = _decode_base64url(key.d, "d") + if len(public_bytes) != 32 or len(private_bytes) != 32: + raise InvalidParameter("Agent Ed25519 JWK public and private values must each be 32 bytes.") + derived_public = ( + Ed25519PrivateKey.from_private_bytes(private_bytes) + .public_key() + .public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + ) + if derived_public != public_bytes: + raise InvalidParameter("Agent private JWK public and private values do not form an Ed25519 key pair.") + return AgentJWK( + kty=_JWK_KEY_TYPE, + crv=_JWK_CURVE, + x=key.x, + kid=key.kid, + alg=key.alg, + use=key.use, + d=key.d, + ) + + +def generate_agent_key_pair(kid: str | None = None) -> tuple[AgentJWK, AgentJWK]: + """Generate a local Ed25519 key pair as ``(private_jwk, public_jwk)``. + + The caller chooses whether and where to persist the private JWK. This helper + never writes files or returns the private material in a public JWK. + """ + if kid is not None and not kid.strip(): + raise InvalidParameter("Agent key id 'kid' must not be empty.") + private_key = Ed25519PrivateKey.generate() + private_bytes = private_key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + public_bytes = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + resolved_kid = kid.strip() if kid is not None else secrets.token_urlsafe(16) + private_jwk = AgentJWK( + kty=_JWK_KEY_TYPE, + crv=_JWK_CURVE, + x=_encode_base64url(public_bytes), + kid=resolved_kid, + alg="EdDSA", + use="sig", + d=_encode_base64url(private_bytes), + ) + return private_jwk, public_jwk_from_private(private_jwk) + + +def public_jwk_from_private(private_jwk: AgentJWK | Mapping[str, Any]) -> AgentJWK: + """Return the upload-safe public JWK corresponding to a private JWK.""" + key = _normalise_private_jwk(private_jwk) + return AgentJWK(kty=key.kty, crv=key.crv, x=key.x, kid=key.kid, alg=key.alg, use=key.use) + + +def write_private_jwk(path: str | Path, private_jwk: AgentJWK | Mapping[str, Any], *, overwrite: bool = False) -> Path: + """Write a validated private JWK with owner-only (``0600``) permissions. + + Existing files are never replaced unless *overwrite* is explicit. Symlinks + are rejected to keep a CLI invocation from overwriting an unexpected file. + """ + key = _normalise_private_jwk(private_jwk) + target = Path(path).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink(): + raise InvalidParameter("Refusing to write an Agent private key through a symbolic link.") + flags = os.O_WRONLY | os.O_CREAT | (os.O_TRUNC if overwrite else os.O_EXCL) + try: + descriptor = os.open(target, flags, 0o600) + except FileExistsError as exc: + raise InvalidParameter(f"Agent private key file already exists: {target}. Pass --force to replace it.") from exc + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + json.dump(key.to_dict(include_private=True), output, sort_keys=True) + output.write("\n") + os.chmod(target, 0o600) + except BaseException: + try: + target.unlink(missing_ok=True) + except OSError: + pass + raise + return target + + +def load_private_jwk(path: str | Path) -> AgentJWK: + """Load and strictly validate an owner-only Ed25519 private JWK file.""" + target = Path(path).expanduser() + try: + mode = target.stat().st_mode + except OSError as exc: + raise InvalidParameter(f"Unable to read Agent private key file: {target}.") from exc + if target.is_symlink() or not stat.S_ISREG(mode): + raise InvalidParameter("Agent private key path must be a regular, non-symbolic-link file.") + if os.name == "posix" and mode & 0o077: + raise InvalidParameter("Agent private key file must not be readable by group or other users (mode 0600).") + try: + raw = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise InvalidParameter(f"Agent private key file is not valid JSON: {target}.") from exc + if not isinstance(raw, Mapping): + raise InvalidParameter("Agent private key file must contain a JSON object.") + return _normalise_private_jwk(raw) + + +def sign_agent_token_request( + private_jwk: AgentJWK | Mapping[str, Any], + *, + agent_id: str, + audience: str, + timestamp: int, +) -> TokenSignPayload: + """Build the signed body required by anonymous ``POST /agent_id/token``.""" + if not isinstance(agent_id, str) or not agent_id: + raise InvalidParameter("agent_id must be a non-empty string.") + if not isinstance(audience, str) or not audience: + raise InvalidParameter("audience must be a non-empty string.") + if isinstance(timestamp, bool) or not isinstance(timestamp, int) or timestamp <= 0: + raise InvalidParameter("timestamp must be a positive Unix timestamp in seconds.") + key = _normalise_private_jwk(private_jwk) + message = f"{agent_id}|{key.kid}|{audience}|{timestamp}".encode("ascii") + private_bytes = _decode_base64url(key.d, "d") + signature = Ed25519PrivateKey.from_private_bytes(private_bytes).sign(message) + return { + "agent_id": agent_id, + "kid": key.kid, + "audience": audience, + "timestamp": timestamp, + "signature": _encode_base64url(signature), + } diff --git a/src/modelscope_hub/api.py b/src/modelscope_hub/api.py index fd22be0..5fb1996 100644 --- a/src/modelscope_hub/api.py +++ b/src/modelscope_hub/api.py @@ -23,6 +23,7 @@ from __future__ import annotations +import time from collections.abc import Iterable, Mapping from pathlib import Path from typing import Any, BinaryIO, TypeAlias @@ -38,6 +39,7 @@ from ._legacy_api import LegacyClient from ._openapi import OpenAPIClient from ._upload import UploadManager +from .agent_idp import sign_agent_token_request from .config import HubConfig, get_default_config from .constants import DEFAULT_ENDPOINT, RepoType, StudioVisibility, Visibility from .errors import ( @@ -49,7 +51,20 @@ NotSupportedError, PermissionDeniedError, ) -from .types import CacheInfo, CacheVerification, FileInfo, PagedResult, RepoInfo, UserInfo +from .types import ( + AgentIdConfiguration, + AgentIdentity, + AgentIdentitySummary, + AgentJWK, + AgentToken, + AgentTokenRecord, + CacheInfo, + CacheVerification, + FileInfo, + PagedResult, + RepoInfo, + UserInfo, +) from .utils.logger import get_logger __all__ = ["HubApi"] @@ -2266,6 +2281,109 @@ def undeploy_mcp_server(self, server_id: str) -> dict: """ return self.openapi.undeploy_mcp_server(server_id) + # ================================================================== + # Agent-IDP + # ================================================================== + def create_agent_identity(self, payload: Mapping[str, Any]) -> AgentIdentity: + """Register an Agent-IDP identity with an Ed25519 public JWK.""" + return AgentIdentity.from_dict(self.openapi.create_agent_identity(payload)) + + def get_agent_identity(self, agent_id: str) -> AgentIdentity: + """Return an Agent-IDP identity and its current public key.""" + return AgentIdentity.from_dict(self.openapi.get_agent_identity(agent_id)) + + def update_agent_identity(self, agent_id: str, payload: Mapping[str, Any]) -> AgentIdentity: + """Update an Agent-IDP identity's mutable metadata.""" + return AgentIdentity.from_dict(self.openapi.update_agent_identity(agent_id, payload)) + + def delete_agent_identity(self, agent_id: str) -> dict: + """Delete an Agent-IDP identity and prevent future token issuance.""" + result = self.openapi.delete_agent_identity(agent_id) + return result if isinstance(result, dict) else {} + + def reset_agent_key_pair(self, agent_id: str, payload: Mapping[str, Any]) -> AgentIdentity: + """Replace an Agent-IDP identity's registered Ed25519 public key.""" + return AgentIdentity.from_dict(self.openapi.reset_agent_key_pair(agent_id, payload)) + + def pause_agent(self, agent_id: str, *, paused: bool) -> dict: + """Pause or resume JWT issuance for an Agent-IDP identity.""" + result = self.openapi.pause_agent(agent_id, {"paused": paused}) + return result if isinstance(result, dict) else {} + + def list_agent_token_records( + self, + agent_id: str, + *, + page: int = 1, + page_size: int = 20, + ) -> PagedResult[AgentTokenRecord]: + """List one Agent-IDP identity's issued token records.""" + payload = self.openapi.list_agent_token_records(agent_id, page=page, page_size=page_size) + data = payload if isinstance(payload, Mapping) else {} + raw_records = data.get("token_records") + records: list[Any] = raw_records if isinstance(raw_records, list) else [] + return PagedResult( + items=[AgentTokenRecord.from_dict(item) for item in records if isinstance(item, Mapping)], + total_count=int(data.get("total_count") or 0), + page_number=int(data.get("page_number") or page), + page_size=int(data.get("page_size") or page_size), + collection_key="token_records", + ) + + def list_user_agent_identities( + self, + username: str, + *, + status: str | None = None, + page: int = 1, + page_size: int = 20, + ) -> PagedResult[AgentIdentitySummary]: + """List a user's Agent-IDP identities via the OpenAPI surface.""" + payload = self.openapi.list_user_agent_identities(username, status=status, page=page, page_size=page_size) + data = payload if isinstance(payload, Mapping) else {} + raw_identities = data.get("agent_identities") + identities: list[Any] = raw_identities if isinstance(raw_identities, list) else [] + return PagedResult( + items=[AgentIdentitySummary.from_dict(item) for item in identities if isinstance(item, Mapping)], + total_count=int(data.get("total_count") or 0), + page_number=int(data.get("page_number") or page), + page_size=int(data.get("page_size") or page_size), + collection_key="agent_identities", + ) + + def issue_agent_token(self, payload: Mapping[str, Any]) -> AgentToken: + """Exchange a locally signed Agent-IDP request for a short-lived JWT.""" + return AgentToken.from_dict(self.openapi.issue_agent_token(payload)) + + def issue_agent_token_with_private_key( + self, + private_jwk: AgentJWK | Mapping[str, Any], + *, + agent_id: str, + audience: str, + timestamp: int | None = None, + ) -> AgentToken: + """Locally sign and exchange an Agent-IDP token request without persisting a key.""" + signed = sign_agent_token_request( + private_jwk, + agent_id=agent_id, + audience=audience, + timestamp=int(time.time()) if timestamp is None else timestamp, + ) + return self.issue_agent_token(signed) + + def get_agent_id_configuration(self) -> AgentIdConfiguration: + """Fetch anonymous Agent-IDP OIDC discovery metadata.""" + return AgentIdConfiguration.from_dict(self.openapi.get_agent_id_configuration()) + + def get_agent_id_jwks(self) -> list[AgentJWK]: + """Fetch anonymous Agent-IDP JWT verification keys.""" + payload = self.openapi.get_agent_id_jwks() + keys = payload.get("keys") if isinstance(payload, Mapping) else None + if not isinstance(keys, list): + return [] + return [AgentJWK.from_dict(item) for item in keys if isinstance(item, Mapping)] + # ================================================================== # Cache # ================================================================== diff --git a/src/modelscope_hub/cli/agent.py b/src/modelscope_hub/cli/agent.py index 3f8fe33..e70748c 100644 --- a/src/modelscope_hub/cli/agent.py +++ b/src/modelscope_hub/cli/agent.py @@ -2,9 +2,10 @@ """``ms agent`` command -- low-level raw file transfer for agent repositories. This is the *slim* Hub CLI. It supports only ``download``/``upload``/``list`` -for raw file transfer to and from remote agent repositories, with no framework -awareness. Framework-aware operations (convert, watch/sync, status, backups, -restore, stop) live in **modelscope-agent** -- use ``ms-agent agent ...``. +for raw file transfer to and from remote agent repositories; Agent-IDP identity, +Ed25519-key, and token operations live in ``ms agent-idp``. Framework-aware +operations (convert, watch/sync, status, backups, restore, stop) live in +**modelscope-agent** -- use ``ms-agent agent ...``. """ from __future__ import annotations diff --git a/src/modelscope_hub/cli/agent_idp.py b/src/modelscope_hub/cli/agent_idp.py new file mode 100644 index 0000000..9ee112e --- /dev/null +++ b/src/modelscope_hub/cli/agent_idp.py @@ -0,0 +1,261 @@ +"""``ms agent-idp`` — manage Agent-IDP identities and signed token issuance. + +This command is intentionally separate from ``ms agent``: the latter transfers +raw files to Agent repositories, while this command manages Ed25519 identities, +OIDC discovery, and short-lived JWT issuance. +""" + +from __future__ import annotations + +import json +from argparse import Namespace +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ..agent_idp import ( + generate_agent_key_pair, + load_private_jwk, + public_jwk_from_private, + write_private_jwk, +) +from ..errors import InvalidParameter +from ..types import AgentIdConfiguration, AgentIdentity +from .base import CLICommand, SubParsers, info, make_api, render_table + +__all__ = ["AgentIdpCommand"] + + +_EXPIRY_CHOICES = (300, 600, 1800, 3600) +_STATUS_CHOICES = ("active", "paused") + + +def _read_public_jwk(path: str) -> dict[str, Any]: + """Read an upload-safe public JWK without accepting a private ``d`` member.""" + target = Path(path).expanduser() + try: + payload = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise InvalidParameter(f"Public JWK file is not valid JSON: {target}.") from exc + if not isinstance(payload, Mapping) or "d" in payload: + raise InvalidParameter("Public JWK file must be an object without private key material ('d').") + # The low-level client performs the complete protocol validation. Constructing + # the dataclass here keeps formatting and optional JWK fields consistent. + return {key: value for key, value in payload.items() if value is not None} + + +def _public_jwk_from_args(args: Namespace) -> dict[str, Any]: + if getattr(args, "private_key_file", None): + return public_jwk_from_private(load_private_jwk(args.private_key_file)).to_dict() + return _read_public_jwk(args.public_jwk_file) + + +def _identity_json(identity: AgentIdentity) -> dict[str, Any]: + """Render identity data while never serialising local private material.""" + result: dict[str, Any] = { + "agent_id": identity.agent_id, + "agent_name": identity.agent_name, + "description": identity.description, + "token_expire_time": identity.token_expire_time, + "principal": identity.principal, + "kid": identity.kid, + "status": identity.status, + "create_time": identity.create_time, + "update_time": identity.update_time, + } + if identity.public_key is not None: + result["public_key"] = identity.public_key.to_dict() + return {key: value for key, value in result.items() if value is not None and value != ""} + + +def _configuration_json(configuration: AgentIdConfiguration) -> dict[str, Any]: + return { + key: value + for key, value in { + "issuer": configuration.issuer, + "token_endpoint": configuration.token_endpoint, + "jwks_uri": configuration.jwks_uri, + "registration_endpoint": configuration.registration_endpoint, + "activity_endpoint": configuration.activity_endpoint, + "id_token_signing_alg_values_supported": configuration.id_token_signing_alg_values_supported, + }.items() + if value is not None + } + + +class AgentIdpCommand(CLICommand): + """Manage Agent-IDP identities, keys, OIDC discovery, and JWT issuance.""" + + name = "agent-idp" + + @staticmethod + def register(subparsers: SubParsers) -> None: + parser = subparsers.add_parser( + AgentIdpCommand.name, + help="Manage Agent-IDP identities, signing keys, and OIDC metadata.", + ) + parser.set_defaults(_command=AgentIdpCommand) + actions = parser.add_subparsers(dest="agent_idp_action", metavar="ACTION") + actions.required = True + + keygen = actions.add_parser("keygen", help="Generate an Ed25519 private JWK file (0600).") + keygen.add_argument("--private-key-out", required=True) + keygen.add_argument("--kid", default=None) + keygen.add_argument("--force", action="store_true", help="Replace an existing private-key file.") + + create = actions.add_parser("create", help="Register an Agent-IDP identity.") + create.add_argument("--agent-name", required=True) + create.add_argument("--description", default=None) + create.add_argument("--token-expire-time", type=int, choices=_EXPIRY_CHOICES, default=None) + AgentIdpCommand._add_key_source(create) + + get = actions.add_parser("get", help="Show one Agent-IDP identity.") + get.add_argument("agent_id") + + update = actions.add_parser("update", help="Update Agent-IDP identity metadata.") + update.add_argument("agent_id") + update.add_argument("--agent-name", default=None) + update.add_argument("--description", default=None) + update.add_argument("--token-expire-time", type=int, choices=_EXPIRY_CHOICES, default=None) + + delete = actions.add_parser("delete", help="Delete an Agent-IDP identity.") + delete.add_argument("agent_id") + delete.add_argument("--yes", action="store_true", help="Confirm permanent deletion.") + + reset = actions.add_parser("reset-key", help="Replace an Agent-IDP identity public key.") + reset.add_argument("agent_id") + AgentIdpCommand._add_key_source(reset) + + pause = actions.add_parser("pause", help="Pause or resume Agent-IDP token issuance.") + pause.add_argument("agent_id") + state = pause.add_mutually_exclusive_group(required=True) + state.add_argument("--paused", action="store_true", help="Pause token issuance.") + state.add_argument("--active", action="store_true", help="Resume token issuance.") + + listed = actions.add_parser("list", help="List a user's Agent-IDP identities.") + listed.add_argument("username") + listed.add_argument("--status", choices=_STATUS_CHOICES, default=None) + AgentIdpCommand._add_paging(listed) + + records = actions.add_parser("list-tokens", help="List non-sensitive issued-token records.") + records.add_argument("agent_id") + AgentIdpCommand._add_paging(records) + + issue = actions.add_parser("issue-token", help="Sign locally and exchange an Agent-IDP JWT request.") + issue.add_argument("--agent-id", required=True) + issue.add_argument("--audience", required=True) + issue.add_argument("--private-key-file", required=True) + issue.add_argument("--timestamp", type=int, default=None) + + actions.add_parser("configuration", help="Show anonymous Agent-IDP OIDC discovery metadata.") + actions.add_parser("jwks", help="Show anonymous Agent-IDP JWT verification keys.") + + @staticmethod + def _add_key_source(parser: Any) -> None: + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--private-key-file", default=None, help="Owner-only Ed25519 private JWK file.") + source.add_argument("--public-jwk-file", default=None, help="Public Ed25519 JWK file (for HSM-managed keys).") + + @staticmethod + def _add_paging(parser: Any) -> None: + parser.add_argument("--page", type=int, default=1) + parser.add_argument("--page-size", type=int, choices=range(1, 51), default=20) + + def execute(self) -> None: + action = self.args.agent_idp_action + if action == "keygen": + private_jwk, public_jwk = generate_agent_key_pair(self.args.kid) + write_private_jwk(self.args.private_key_out, private_jwk, overwrite=self.args.force) + info(json.dumps(public_jwk.to_dict(), ensure_ascii=False, sort_keys=True)) + return + + api = make_api(self.args) + if action == "create": + payload = { + "agent_name": self.args.agent_name, + "public_key": _public_jwk_from_args(self.args), + "description": self.args.description, + "token_expire_time": self.args.token_expire_time, + } + info(json.dumps(_identity_json(api.create_agent_identity(payload)), ensure_ascii=False, indent=2)) + elif action == "get": + info(json.dumps(_identity_json(api.get_agent_identity(self.args.agent_id)), ensure_ascii=False, indent=2)) + elif action == "update": + payload = { + key: value + for key, value in { + "agent_name": self.args.agent_name, + "description": self.args.description, + "token_expire_time": self.args.token_expire_time, + }.items() + if value is not None + } + identity = api.update_agent_identity(self.args.agent_id, payload) + info(json.dumps(_identity_json(identity), ensure_ascii=False, indent=2)) + elif action == "delete": + if not self.args.yes: + raise InvalidParameter("Deletion requires --yes.") + api.delete_agent_identity(self.args.agent_id) + info(f"Deleted Agent-IDP identity {self.args.agent_id}.") + elif action == "reset-key": + identity = api.reset_agent_key_pair( + self.args.agent_id, + {"public_key": _public_jwk_from_args(self.args)}, + ) + info(json.dumps(_identity_json(identity), ensure_ascii=False, indent=2)) + elif action == "pause": + api.pause_agent(self.args.agent_id, paused=self.args.paused) + info(f"Agent-IDP identity {self.args.agent_id} is now {'paused' if self.args.paused else 'active'}.") + elif action == "list": + identity_page = api.list_user_agent_identities( + self.args.username, + status=self.args.status, + page=self.args.page, + page_size=self.args.page_size, + ) + identity_rows = [ + ( + item.agent_id, + item.agent_name, + item.kid or "-", + item.status or "-", + item.token_expire_time or "-", + item.create_time or "-", + ) + for item in identity_page.items + ] + info(render_table(identity_rows, headers=["agent_id", "name", "kid", "status", "expiry", "created"])) + info( + f"page {identity_page.page_number} / total {identity_page.total_count} " + f"(page_size={identity_page.page_size})" + ) + elif action == "list-tokens": + token_page = api.list_agent_token_records( + self.args.agent_id, + page=self.args.page, + page_size=self.args.page_size, + ) + token_rows = [ + (item.token_id, item.audience, item.issued_at or "-", item.expire_at or "-", item.status or "-") + for item in token_page.items + ] + info(render_table(token_rows, headers=["token_id", "audience", "issued", "expires", "status"])) + info(f"page {token_page.page_number} / total {token_page.total_count} (page_size={token_page.page_size})") + elif action == "issue-token": + key = load_private_jwk(self.args.private_key_file) + token = api.issue_agent_token_with_private_key( + key, + agent_id=self.args.agent_id, + audience=self.args.audience, + timestamp=self.args.timestamp, + ) + # Deliberately the only stdout content: callers may pipe this exact + # credential to another process without parsing a status message. + print(token.access_token) + elif action == "configuration": + info(json.dumps(_configuration_json(api.get_agent_id_configuration()), ensure_ascii=False, indent=2)) + elif action == "jwks": + keys = [key.to_dict() for key in api.get_agent_id_jwks()] + info(json.dumps({"keys": keys}, ensure_ascii=False, indent=2)) + else: # pragma: no cover - argparse makes this defensive only + raise InvalidParameter(f"Unknown Agent-IDP action: {action}") diff --git a/src/modelscope_hub/cli/main.py b/src/modelscope_hub/cli/main.py index 88a6933..ce6528e 100644 --- a/src/modelscope_hub/cli/main.py +++ b/src/modelscope_hub/cli/main.py @@ -34,6 +34,7 @@ from ..constants import MODELSCOPE_ASCII from ..errors import HubError, InvalidParameter, NotSupportedError from .agent import AgentCommand +from .agent_idp import AgentIdpCommand from .base import CLICommand, error, info from .cache import CacheCommand, _CacheClear, _CacheScan from .deploy import DeployCommand, LogsCommand, SettingsCommand, StopCommand @@ -66,6 +67,7 @@ McpCommand, CacheCommand, AgentCommand, + AgentIdpCommand, ] # Plugin entry-point group name diff --git a/src/modelscope_hub/errors.py b/src/modelscope_hub/errors.py index 13d27b4..d3a3799 100644 --- a/src/modelscope_hub/errors.py +++ b/src/modelscope_hub/errors.py @@ -37,6 +37,9 @@ "session", "api_key", "apikey", + "signature", + "jwt", + "private_key", ) _SENSITIVE_QUERY_KEYS: frozenset[str] = frozenset( { @@ -53,6 +56,8 @@ "key", "authorization", "credentials", + "signature", + "jwt", } ) _SENSITIVE_BODY_KEYS: re.Pattern[str] = re.compile( diff --git a/src/modelscope_hub/types.py b/src/modelscope_hub/types.py index 6761649..4cf8316 100644 --- a/src/modelscope_hub/types.py +++ b/src/modelscope_hub/types.py @@ -147,6 +147,119 @@ def _as_str_or_none(value: Any | None) -> str | None: return str(value) +# --------------------------------------------------------------------------- +# Agent-IDP +# --------------------------------------------------------------------------- +@dataclass(slots=True) +class AgentJWK(_FromDictMixin): + """An Ed25519 JSON Web Key used by the Agent-IDP protocol. + + ``d`` exists only in local private-key material. Server responses and public + request payloads contain the other fields only; :meth:`to_dict` therefore + excludes it unless explicitly requested. + """ + + kty: str = "OKP" + crv: str = "Ed25519" + x: str = "" + kid: str = "" + alg: str | None = None + use: str | None = None + d: str | None = field(default=None, repr=False) + + def to_dict(self, *, include_private: bool = False) -> dict[str, str]: + """Return the public JWK, optionally including the private ``d`` value.""" + result = {"kty": self.kty, "crv": self.crv, "x": self.x, "kid": self.kid} + if self.alg is not None: + result["alg"] = self.alg + if self.use is not None: + result["use"] = self.use + if include_private and self.d is not None: + result["d"] = self.d + return result + + +@dataclass(slots=True) +class AgentIdentity(_FromDictMixin): + """A registered Agent-IDP identity returned by the OpenAPI service.""" + + agent_id: str = "" + agent_name: str = "" + description: str | None = None + token_expire_time: int | None = None + principal: dict[str, Any] | None = None + kid: str | None = None + public_key: AgentJWK | None = None + status: str | None = None + create_time: str | None = None + update_time: str | None = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any] | None) -> AgentIdentity: + if not isinstance(data, Mapping): + return cls() + raw_key = data.get("public_key") + principal = data.get("principal") + return cls( + agent_id=str(data.get("agent_id") or ""), + agent_name=str(data.get("agent_name") or ""), + description=data.get("description"), + token_expire_time=data.get("token_expire_time"), + principal=dict(principal) if isinstance(principal, Mapping) else None, + kid=data.get("kid"), + public_key=AgentJWK.from_dict(raw_key) if isinstance(raw_key, Mapping) else None, + status=data.get("status"), + create_time=data.get("create_time"), + update_time=data.get("update_time"), + ) + + +@dataclass(slots=True) +class AgentIdentitySummary(_FromDictMixin): + """The paginated, non-sensitive projection of an Agent-IDP identity.""" + + agent_id: str = "" + agent_name: str = "" + kid: str | None = None + status: str | None = None + token_expire_time: int | None = None + create_time: str | None = None + + +@dataclass(slots=True) +class AgentTokenRecord(_FromDictMixin): + """One issued Agent JWT record returned by the service.""" + + token_id: str = "" + audience: str = "" + issued_at: str | None = None + expire_at: str | None = None + status: str | None = None + jwt: str | None = field(default=None, repr=False) + + +@dataclass(slots=True) +class AgentToken(_FromDictMixin): + """A short-lived JWT issued by ``POST /agent_id/token``.""" + + access_token: str = field(default="", repr=False) + token_type: str = "Bearer" + expire_at: int | None = None + jti: str | None = None + + +@dataclass(slots=True) +class AgentIdConfiguration(_FromDictMixin): + """OIDC discovery metadata served by Agent-IDP.""" + + issuer: str | None = None + token_endpoint: str | None = None + jwks_uri: str | None = None + registration_endpoint: str | None = None + activity_endpoint: str | None = None + id_token_signing_alg_values_supported: str | None = None + + # --------------------------------------------------------------------------- # Repository # --------------------------------------------------------------------------- @@ -439,17 +552,82 @@ class DeployMcpServerPayload(TypedDict, total=False): env_info: dict[str, str] +class JWKPayload(TypedDict, total=False): + """Public or private Ed25519 JSON Web Key wire shape.""" + + kty: str + crv: str + x: str + kid: str + alg: str + use: str + d: str + + +class CreateAgentIdentityPayload(TypedDict, total=False): + """Payload for POST /agent_ids.""" + + agent_name: str + description: str + public_key: JWKPayload + key_alg_type: str + token_expire_time: int + + +class UpdateAgentIdentityPayload(TypedDict, total=False): + """Payload for PATCH /agent_ids/{agent_id}.""" + + agent_name: str + description: str + token_expire_time: int + + +class ResetAgentKeyPairPayload(TypedDict, total=False): + """Payload for PUT /agent_ids/{agent_id}/key_pairs.""" + + public_key: JWKPayload + key_alg_type: str + + +class PauseAgentPayload(TypedDict): + """Payload for POST /agent_ids/{agent_id}/paused.""" + + paused: bool + + +class TokenSignPayload(TypedDict): + """Signed request body for anonymous POST /agent_id/token.""" + + agent_id: str + kid: str + audience: str + timestamp: int + signature: str + + __all__ = [ + "AgentIdentity", + "AgentIdentitySummary", + "AgentIdConfiguration", + "AgentJWK", + "AgentToken", + "AgentTokenRecord", "CacheVerification", "CacheInfo", "CachedRepoInfo", "CommitInfo", + "CreateAgentIdentityPayload", "CreateSkillPayload", "CreateStudioPayload", "DeployMcpServerPayload", "FileInfo", + "JWKPayload", "PagedResult", + "PauseAgentPayload", "RepoInfo", + "ResetAgentKeyPairPayload", + "TokenSignPayload", + "UpdateAgentIdentityPayload", "UpdateSkillSettingsPayload", "UpdateStudioSettingsPayload", "UserInfo", diff --git a/tests/cli/test_agent_idp.py b/tests/cli/test_agent_idp.py new file mode 100644 index 0000000..36311be --- /dev/null +++ b/tests/cli/test_agent_idp.py @@ -0,0 +1,81 @@ +"""CLI tests for the Agent-IDP command, with no network access.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from modelscope_hub.agent_idp import generate_agent_key_pair, write_private_jwk +from modelscope_hub.cli.agent_idp import AgentIdpCommand +from modelscope_hub.errors import InvalidParameter +from modelscope_hub.types import AgentIdentity, AgentToken + + +def _execute(parser, arguments: list[str]) -> None: + args = parser.parse_args(["agent-idp", *arguments]) + assert args._command is AgentIdpCommand + AgentIdpCommand(args).execute() + + +def test_parser_registers_agent_idp(parser): + args = parser.parse_args(["agent-idp", "configuration"]) + assert args.agent_idp_action == "configuration" + assert args._command is AgentIdpCommand + + +def test_keygen_prints_only_public_jwk_and_writes_private_file(parser, tmp_path, capsys): + path = tmp_path / "agent.jwk" + _execute(parser, ["keygen", "--private-key-out", str(path), "--kid", "key-1"]) + public_jwk = json.loads(capsys.readouterr().out) + private_jwk = json.loads(path.read_text()) + assert public_jwk["kid"] == "key-1" + assert "d" not in public_jwk + assert private_jwk["d"] + + +def test_create_reads_public_key_without_private_material(parser, tmp_path): + public_path = tmp_path / "public.jwk" + public_path.write_text(json.dumps({"kty": "OKP", "crv": "Ed25519", "x": "x", "kid": "key-1"})) + api = MagicMock() + api.create_agent_identity.return_value = AgentIdentity(agent_id="agent-1", agent_name="builder") + with patch("modelscope_hub.cli.agent_idp.make_api", return_value=api): + _execute(parser, ["create", "--agent-name", "builder", "--public-jwk-file", str(public_path)]) + payload = api.create_agent_identity.call_args.args[0] + assert payload["public_key"] == {"kty": "OKP", "crv": "Ed25519", "x": "x", "kid": "key-1"} + + +def test_delete_requires_explicit_confirmation(parser): + with pytest.raises(InvalidParameter, match="--yes"): + _execute(parser, ["delete", "agent-1"]) + + +def test_issue_token_stdout_is_only_the_credential(parser, tmp_path, capsys): + private_jwk, _ = generate_agent_key_pair() + path = tmp_path / "agent.jwk" + write_private_jwk(path, private_jwk) + api = MagicMock() + api.issue_agent_token_with_private_key.return_value = AgentToken(access_token="issued.jwt") + with patch("modelscope_hub.cli.agent_idp.make_api", return_value=api): + _execute( + parser, + ["issue-token", "--agent-id", "agent-1", "--audience", "hub", "--private-key-file", str(path)], + ) + assert capsys.readouterr().out == "issued.jwt\n" + + +def test_public_oidc_commands_can_run_without_token(parser, capsys): + api = MagicMock() + api.get_agent_id_configuration.return_value = MagicMock( + issuer="https://issuer", + token_endpoint="https://issuer/token", + jwks_uri=None, + registration_endpoint=None, + activity_endpoint=None, + id_token_signing_alg_values_supported=None, + ) + with patch("modelscope_hub.cli.agent_idp.make_api", return_value=api): + _execute(parser, ["configuration"]) + assert "https://issuer" in capsys.readouterr().out + api.get_agent_id_configuration.assert_called_once_with() diff --git a/tests/test_agent_idp.py b/tests/test_agent_idp.py new file mode 100644 index 0000000..5a34b94 --- /dev/null +++ b/tests/test_agent_idp.py @@ -0,0 +1,147 @@ +"""Mock-based contract tests for the Agent-IDP OpenAPI client and facade.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from modelscope_hub._openapi import OpenAPIClient +from modelscope_hub.api import HubApi +from modelscope_hub.config import HubConfig +from modelscope_hub.errors import InvalidParameter +from modelscope_hub.types import AgentIdentity, AgentToken + + +def _response(data: object) -> MagicMock: + response = MagicMock(spec=requests.Response) + response.status_code = 200 + response.content = b"x" + response.headers = {} + response.request = MagicMock() + response.request.method = "GET" + response.request.path_url = "/test" + response.request.url = "https://modelscope.cn/openapi/v1/test" + response.url = response.request.url + response.json.return_value = {"success": True, "data": data} + return response + + +@pytest.fixture +def client() -> OpenAPIClient: + return OpenAPIClient(HubConfig(token="test-token", endpoint="https://modelscope.cn")) + + +@pytest.fixture +def public_jwk() -> dict[str, str]: + return {"kty": "OKP", "crv": "Ed25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "kid": "key-1"} + + +class TestAgentIdpOpenApi: + def test_create_identity_uses_openapi_payload(self, client, public_jwk): + with patch.object(client._session, "request", return_value=_response({"agent_id": "agent-1"})) as request: + client.create_agent_identity({"agent_name": "builder", "public_key": public_jwk, "token_expire_time": 600}) + call = request.call_args.kwargs + assert call["method"] == "POST" + assert call["url"].endswith("/openapi/v1/agent_ids") + assert call["json"]["public_key"] == public_jwk + assert call["headers"]["Authorization"] == "Bearer test-token" + + def test_update_and_key_reset_use_spec_methods(self, client, public_jwk): + with patch.object(client._session, "request", side_effect=[_response({}), _response({})]) as request: + client.update_agent_identity("agent-1", {"description": "updated"}) + client.reset_agent_key_pair("agent-1", {"public_key": public_jwk}) + update, reset = request.call_args_list + assert update.kwargs["method"] == "PATCH" + assert update.kwargs["url"].endswith("/agent_ids/agent-1") + assert reset.kwargs["method"] == "PUT" + assert reset.kwargs["url"].endswith("/agent_ids/agent-1/key_pairs") + + def test_delete_and_pause_use_spec_routes(self, client): + with patch.object(client._session, "request", side_effect=[_response({}), _response({})]) as request: + client.delete_agent_identity("agent-1") + client.pause_agent("agent-1", {"paused": True}) + delete, pause = request.call_args_list + assert delete.kwargs["method"] == "DELETE" + assert delete.kwargs["url"].endswith("/agent_ids/agent-1") + assert pause.kwargs["method"] == "POST" + assert pause.kwargs["url"].endswith("/agent_ids/agent-1/paused") + assert pause.kwargs["json"] == {"paused": True} + + def test_list_routes_use_spec_page_fields(self, client): + with patch.object(client._session, "request", side_effect=[_response({}), _response({})]) as request: + client.list_agent_token_records("agent-1", page=2, page_size=10) + client.list_user_agent_identities("alice", status="paused", page=3, page_size=20) + tokens, identities = request.call_args_list + assert tokens.kwargs["url"].endswith("/agent_ids/agent-1/jwt_id_tokens") + assert dict(tokens.kwargs["params"]) == {"page": "2", "page_size": "10"} + assert identities.kwargs["url"].endswith("/users/alice/agent_ids") + assert dict(identities.kwargs["params"]) == {"status": "paused", "page": "3", "page_size": "20"} + + @pytest.mark.parametrize(("page", "page_size"), [(0, 20), (1, 0), (1, 51)]) + def test_paging_is_validated(self, client, page, page_size): + with pytest.raises(InvalidParameter): + client.list_agent_token_records("agent-1", page=page, page_size=page_size) + + def test_update_and_public_jwk_are_strict(self, client, public_jwk): + with pytest.raises(InvalidParameter, match="at least one"): + client.update_agent_identity("agent-1", {}) + with pytest.raises(InvalidParameter, match="private"): + client.create_agent_identity({"agent_name": "builder", "public_key": {**public_jwk, "d": "not-allowed"}}) + with pytest.raises(InvalidParameter, match="status"): + client.list_user_agent_identities("alice", status="retired") + + def test_public_operations_never_attach_ambient_credentials(self, client): + with patch.object( + client._session, + "request", + side_effect=[_response({"access_token": "jwt"}), _response({}), _response({"keys": []})], + ) as request: + client.issue_agent_token( + { + "agent_id": "agent-1", + "kid": "key-1", + "audience": "hub", + "timestamp": 1, + "signature": "signature", + } + ) + client.get_agent_id_configuration() + client.get_agent_id_jwks() + for call in request.call_args_list: + assert "Authorization" not in call.kwargs["headers"] + assert call.kwargs["cookies"] == {} + assert request.call_args_list[0].kwargs["method"] == "POST" + assert request.call_args_list[0].kwargs["url"].endswith("/agent_id/token") + assert request.call_args_list[1].kwargs["url"].endswith("/agent_id/.well-known/agentid-configuration") + assert request.call_args_list[2].kwargs["url"].endswith("/agent_id/.well-known/agentid-jwks") + + +class TestAgentIdpFacade: + def test_facade_converts_identity_and_pagination(self): + api = HubApi(token="test-token") + api._openapi = MagicMock() + api._openapi.create_agent_identity.return_value = {"agent_id": "agent-1", "agent_name": "builder"} + api._openapi.list_user_agent_identities.return_value = { + "agent_identities": [{"agent_id": "agent-1", "agent_name": "builder"}], + "total_count": 1, + "page_number": 1, + "page_size": 20, + } + created = api.create_agent_identity({"agent_name": "builder", "public_key": {}}) + page = api.list_user_agent_identities("alice") + assert isinstance(created, AgentIdentity) + assert created.agent_id == "agent-1" + assert page.total_count == 1 + assert page.items[0].agent_name == "builder" + + def test_facade_converts_issued_token(self): + api = HubApi(token="test-token") + api._openapi = MagicMock() + api._openapi.issue_agent_token.return_value = {"access_token": "jwt", "token_type": "Bearer", "expire_at": 10} + token = api.issue_agent_token( + {"agent_id": "agent-1", "kid": "key-1", "audience": "hub", "timestamp": 1, "signature": "sig"} + ) + assert isinstance(token, AgentToken) + assert token.access_token == "jwt" diff --git a/tests/test_agent_idp_keys.py b/tests/test_agent_idp_keys.py new file mode 100644 index 0000000..ec5bd2f --- /dev/null +++ b/tests/test_agent_idp_keys.py @@ -0,0 +1,75 @@ +"""Security-sensitive local Ed25519/JWK tests for Agent-IDP.""" + +from __future__ import annotations + +import base64 +import os +import stat + +import pytest +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from modelscope_hub.agent_idp import ( + generate_agent_key_pair, + load_private_jwk, + public_jwk_from_private, + sign_agent_token_request, + write_private_jwk, +) +from modelscope_hub.errors import InvalidParameter + + +def _decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def test_generated_jwks_round_trip_and_sign(): + private_jwk, public_jwk = generate_agent_key_pair("key-1") + assert private_jwk.d + assert "d" not in public_jwk.to_dict() + assert private_jwk.x == public_jwk.x + assert "=" not in private_jwk.x + + signed = sign_agent_token_request(private_jwk, agent_id="agent-1", audience="hub", timestamp=100) + message = b"agent-1|key-1|hub|100" + Ed25519PublicKey.from_public_bytes(_decode(public_jwk.x)).verify(_decode(signed["signature"]), message) + with pytest.raises(InvalidSignature): + Ed25519PublicKey.from_public_bytes(_decode(public_jwk.x)).verify(_decode(signed["signature"]), b"wrong") + + +def test_private_file_is_owner_only_and_not_overwritten(tmp_path): + private_jwk, _ = generate_agent_key_pair() + destination = tmp_path / "agent.jwk" + write_private_jwk(destination, private_jwk) + assert stat.S_IMODE(destination.stat().st_mode) == 0o600 + loaded = load_private_jwk(destination) + assert loaded.to_dict(include_private=True) == private_jwk.to_dict(include_private=True) + with pytest.raises(InvalidParameter, match="already exists"): + write_private_jwk(destination, private_jwk) + + +def test_private_file_rejects_insecure_permissions_on_posix(tmp_path): + if os.name != "posix": + pytest.skip("POSIX permission enforcement only") + private_jwk, _ = generate_agent_key_pair() + destination = tmp_path / "agent.jwk" + write_private_jwk(destination, private_jwk) + destination.chmod(0o644) + with pytest.raises(InvalidParameter, match="0600"): + load_private_jwk(destination) + + +def test_mismatched_key_material_is_rejected_without_leaking_private_value(): + private_jwk, _ = generate_agent_key_pair() + invalid = private_jwk.to_dict(include_private=True) + invalid["x"] = "A" * len(invalid["x"]) + with pytest.raises(InvalidParameter) as raised: + public_jwk_from_private(invalid) + assert private_jwk.d not in str(raised.value) + + +def test_signing_rejects_invalid_timestamp(): + private_jwk, _ = generate_agent_key_pair() + with pytest.raises(InvalidParameter, match="timestamp"): + sign_agent_token_request(private_jwk, agent_id="agent-1", audience="hub", timestamp=0) diff --git a/tests/test_openapi_coverage.py b/tests/test_openapi_coverage.py index ce2cead..344f87b 100644 --- a/tests/test_openapi_coverage.py +++ b/tests/test_openapi_coverage.py @@ -24,7 +24,7 @@ _HTTP_METHODS = frozenset({"get", "post", "put", "delete", "patch", "head", "options"}) # Tags the SDK implements end to end. -_COVERED_TAGS = frozenset({"MCP", "Studios"}) +_COVERED_TAGS = frozenset({"Agent-IDP", "MCP", "Studios"}) # Tags not implemented yet. Remove an entry as its tag lands; until then the # guard would otherwise fail on operations nobody has promised. @@ -36,7 +36,6 @@ "Skills", "Files", "Collections", - "Agent-IDP", "Galleries", "Magicube", }