diff --git a/.github/workflows/publish-tag-to-pypi.yaml b/.github/workflows/publish-tag-to-pypi.yaml index 43825078c..89a782d6f 100644 --- a/.github/workflows/publish-tag-to-pypi.yaml +++ b/.github/workflows/publish-tag-to-pypi.yaml @@ -27,6 +27,38 @@ jobs: run: python3 -m pip install build --user --upgrade - name: Build wheel and source distribution run: python3 -m build + - name: Verify native AgentKit CLI archives stay outside Python distributions + run: | + python3 - <<'PY' + import tarfile + import zipfile + from pathlib import Path + + root = Path.cwd() + wheels = list((root / "dist").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit("Expected exactly one VeADK wheel") + with zipfile.ZipFile(wheels[0]) as archive: + names = archive.namelist() + if any("agentkit-linux-" in name or "agentkit-windows-" in name for name in names): + raise SystemExit("VeADK wheel must not embed an AgentKit CLI archive") + metadata_name = next( + name for name in names if name.endswith(".dist-info/METADATA") + ) + metadata = archive.read(metadata_name).decode() + if "volcengine-agentkit-cli-bin" in metadata: + raise SystemExit("VeADK wheel must not depend on an unpublished CLI companion") + + sdists = list((root / "dist").glob("*.tar.gz")) + if len(sdists) != 1: + raise SystemExit("Expected exactly one VeADK source distribution") + with tarfile.open(sdists[0], "r:gz") as archive: + if any( + "agentkit-linux-" in member.name or "agentkit-windows-" in member.name + for member in archive.getmembers() + ): + raise SystemExit("VeADK sdist must not embed an AgentKit CLI archive") + PY - name: Reject distributions over the PyPI file-size limit run: | oversized_file="$(find dist -type f -size +100M -print -quit)" diff --git a/frontend/README.md b/frontend/README.md index 6c0c24762..8244e1218 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -200,9 +200,13 @@ server that `veadk frontend` launches — no separate backend. variables: generated source retains only the `${ENV_NAME}` reference, while YAML and browser drafts preserve the corresponding environment value. Runtime updates restore only explicitly public environment values. Existing - MCP authentication is represented as “configured” without returning its old - value to the browser; leaving it unchanged preserves the server-side Runtime - value, while entering a replacement Token overrides it. Long descriptions and prompts + MCP authentication references are resolved against the server-managed Runtime + configuration and transiently restored to the masked editor: leaving the MCP + identity unchanged reuses the stored value without another environment-variable + input. Changing an authenticated MCP + URL requires the user to enter a replacement Token, explicitly confirm reuse + of the previous credential, or mark the new endpoint as unauthenticated; + Studio never silently replays a credential to a different endpoint. Long descriptions and prompts scroll within bounded editors, while the sidebar stays pinned to the viewport. On narrow desktop windows, the structure, configuration, and debug panels stack vertically instead of squeezing the form. The deployment page diff --git a/frontend/service/studio_release_server/builder.py b/frontend/service/studio_release_server/builder.py index 66b5737cb..2a0956eda 100644 --- a/frontend/service/studio_release_server/builder.py +++ b/frontend/service/studio_release_server/builder.py @@ -57,6 +57,7 @@ _GIT_CLONE_ATTEMPT_SECONDS = 30 _SPARSE_CHECKOUT_PATHS = ( "/pyproject.toml", + "/uv.lock", "/README.md", "/LICENSE", "/frontend/", diff --git a/frontend/service/studio_release_server/offline_runtime.py b/frontend/service/studio_release_server/offline_runtime.py new file mode 100644 index 000000000..82d8658bb --- /dev/null +++ b/frontend/service/studio_release_server/offline_runtime.py @@ -0,0 +1,373 @@ +# 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. + +"""Build the locked Linux wheelhouse consumed by VeFaaS Studio releases.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from collections.abc import Mapping, Sequence +from hashlib import sha256 +from pathlib import Path + +from packaging.markers import default_environment +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import ( + InvalidWheelFilename, + canonicalize_name, + parse_wheel_filename, +) +from packaging.version import InvalidVersion, Version + +STUDIO_RUNTIME_LOCK = "studio-runtime.lock" +STUDIO_RUNTIME_WHEELHOUSE = "wheelhouse" +_LINUX_PLATFORMS = ( + "manylinux_2_17_x86_64", + "manylinux2014_x86_64", + "manylinux_2_28_x86_64", + "linux_x86_64", +) +_PYTHON_VERSION = "3.12" +_PYTHON_ABI = "cp312" +_PIP_VERSION = "25.2" + + +def build_studio_offline_runtime( + source_root: Path, + package_dir: Path, + *, + veadk_wheel: Path, + dependency_sources: Sequence[Path], + environment: Mapping[str, str] | None = None, +) -> str: + """Bundle every locked Linux dependency and return offline requirements.""" + lock_source = source_root / "uv.lock" + if not lock_source.is_file(): + raise ValueError("Studio offline runtime requires uv.lock.") + uv = shutil.which("uv", path=(environment or os.environ).get("PATH")) + if uv is None: + raise ValueError("uv is required to build the Studio offline runtime.") + + package_dir.mkdir(parents=True, exist_ok=True) + wheelhouse = package_dir / STUDIO_RUNTIME_WHEELHOUSE + wheelhouse.mkdir() + runtime_lock = package_dir / STUDIO_RUNTIME_LOCK + build_environment = dict(environment or os.environ) + + with tempfile.TemporaryDirectory(prefix="veadk_studio_runtime_") as tmp: + workspace = Path(tmp) + exported_lock = workspace / STUDIO_RUNTIME_LOCK + _run( + [ + uv, + "export", + "--frozen", + "--no-dev", + "--no-emit-project", + "--no-hashes", + "--format", + "requirements-txt", + "--output-file", + str(exported_lock), + ], + cwd=source_root, + environment=build_environment, + failure="Could not export the locked Studio runtime.", + ) + if ( + not exported_lock.is_file() + or not exported_lock.read_text(encoding="utf-8").strip() + ): + raise ValueError("Studio runtime lock export is empty.") + _write_linux_runtime_lock(exported_lock, runtime_lock) + + pure_wheels = workspace / "pure-wheels" + pure_wheels.mkdir() + if dependency_sources: + pure_environment = dict(build_environment) + # crcmod intentionally falls back to its portable Python + # implementation when the optional C compiler is unavailable. + pure_environment["CC"] = "veadk-studio-no-native-compiler" + _run( + [ + uv, + "tool", + "run", + "--from", + f"pip=={_PIP_VERSION}", + "pip", + "wheel", + "--disable-pip-version-check", + "--no-deps", + "--wheel-dir", + str(pure_wheels), + *(str(path) for path in dependency_sources), + ], + cwd=source_root, + environment=pure_environment, + failure="Could not build portable Studio dependency wheels.", + ) + built_sources = sorted(pure_wheels.glob("*.whl")) + if len(built_sources) != len(dependency_sources) or any( + not path.name.endswith("-py3-none-any.whl") for path in built_sources + ): + raise ValueError( + "Studio source dependencies did not produce portable wheels." + ) + + command = [ + uv, + "tool", + "run", + "--from", + f"pip=={_PIP_VERSION}", + "pip", + "download", + "--disable-pip-version-check", + "--dest", + str(wheelhouse), + "--only-binary=:all:", + ] + for platform_name in _LINUX_PLATFORMS: + command.extend(("--platform", platform_name)) + command.extend( + ( + "--implementation", + "cp", + "--python-version", + _PYTHON_VERSION, + "--abi", + _PYTHON_ABI, + "--find-links", + str(pure_wheels), + "--requirement", + str(runtime_lock), + ) + ) + _run( + command, + cwd=source_root, + environment=build_environment, + failure="Could not download the locked Linux Studio wheelhouse.", + ) + staged_veadk = wheelhouse / veadk_wheel.name + shutil.move(str(veadk_wheel), staged_veadk) + if not staged_veadk.is_file(): + raise ValueError("Studio offline wheelhouse is incomplete.") + _pin_runtime_lock_to_wheelhouse(runtime_lock, wheelhouse, staged_veadk) + requirements = ( + "--no-index\n" + f"--find-links ./{STUDIO_RUNTIME_WHEELHOUSE}\n" + "--require-hashes\n" + f"-r ./{STUDIO_RUNTIME_LOCK}\n" + f"./{STUDIO_RUNTIME_WHEELHOUSE}/{staged_veadk.name} " + f"--hash=sha256:{_sha256(staged_veadk)}\n" + ) + _verify_offline_resolution( + package_dir, + staged_veadk, + uv=uv, + environment=build_environment, + ) + return requirements + + +def _write_linux_runtime_lock(exported_lock: Path, destination: Path) -> None: + """Evaluate uv markers for the VeFaaS Linux/x86_64 Python 3.12 target.""" + environment: dict[str, str] = { + key: str(value) for key, value in default_environment().items() + } + environment.update( + { + "implementation_name": "cpython", + "os_name": "posix", + "platform_machine": "x86_64", + "platform_python_implementation": "CPython", + "python_full_version": "3.12.0", + "python_version": _PYTHON_VERSION, + "sys_platform": "linux", + } + ) + selected: list[str] = [] + for raw_line in exported_lock.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + try: + requirement = Requirement(line) + except InvalidRequirement as error: + raise ValueError( + "Studio runtime lock contains an invalid requirement." + ) from error + if requirement.marker is not None and not requirement.marker.evaluate( + environment + ): + continue + selected.append(line.split(";", 1)[0].strip()) + if not selected: + raise ValueError("Studio Linux runtime lock is empty.") + destination.write_text("\n".join(selected) + "\n", encoding="utf-8") + + +def _pin_runtime_lock_to_wheelhouse( + runtime_lock: Path, + wheelhouse: Path, + veadk_wheel: Path, +) -> None: + """Replace the exported lock with hashes of the exact bundled wheels.""" + wheel_index: dict[tuple[str, Version], list[Path]] = {} + dependency_wheels: set[Path] = set() + for wheel in sorted(wheelhouse.glob("*.whl")): + if wheel == veadk_wheel: + continue + try: + name, version, _build, _tags = parse_wheel_filename(wheel.name) + except InvalidWheelFilename as error: + raise ValueError("Studio wheelhouse contains an invalid wheel.") from error + wheel_index.setdefault((canonicalize_name(name), version), []).append(wheel) + dependency_wheels.add(wheel) + + selected_wheels: set[Path] = set() + locked_lines: list[str] = [] + for raw_line in runtime_lock.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line: + continue + try: + requirement = Requirement(line) + except InvalidRequirement as error: + raise ValueError( + "Studio runtime lock contains an invalid requirement." + ) from error + specifiers = list(requirement.specifier) + if ( + requirement.url is not None + or len(specifiers) != 1 + or specifiers[0].operator != "==" + or "*" in specifiers[0].version + ): + raise ValueError("Studio runtime dependency is not exactly pinned.") + try: + version = Version(specifiers[0].version) + except InvalidVersion as error: + raise ValueError("Studio runtime dependency version is invalid.") from error + candidates = wheel_index.get( + (canonicalize_name(requirement.name), version), + [], + ) + if not candidates: + raise ValueError("Studio offline wheelhouse is incomplete.") + selected_wheels.update(candidates) + hashes = " ".join( + f"--hash=sha256:{_sha256(candidate)}" for candidate in candidates + ) + locked_lines.append(f"{line} {hashes}") + + if not locked_lines or selected_wheels != dependency_wheels: + raise ValueError("Studio offline wheelhouse does not match its runtime lock.") + runtime_lock.write_text("\n".join(locked_lines) + "\n", encoding="utf-8") + + +def _verify_offline_resolution( + package_dir: Path, + veadk_wheel: Path, + *, + uv: str, + environment: Mapping[str, str], +) -> None: + """Resolve the final bundle once with networking disabled before release.""" + with tempfile.TemporaryDirectory(prefix="veadk_studio_verify_") as tmp: + workspace = Path(tmp) + verification_requirements = workspace / "requirements.txt" + verification_requirements.write_text( + f"--find-links {(package_dir / STUDIO_RUNTIME_WHEELHOUSE).resolve().as_uri()}\n" + "--require-hashes\n" + f"-r {(package_dir / STUDIO_RUNTIME_LOCK).resolve()}\n" + f"{veadk_wheel.resolve().as_uri()} " + f"--hash=sha256:{_sha256(veadk_wheel)}\n", + encoding="utf-8", + ) + resolved = workspace / "resolved" + resolved.mkdir() + command = [ + uv, + "tool", + "run", + "--from", + f"pip=={_PIP_VERSION}", + "pip", + "download", + "--disable-pip-version-check", + "--no-index", + "--only-binary=:all:", + ] + for platform_name in _LINUX_PLATFORMS: + command.extend(("--platform", platform_name)) + command.extend( + ( + "--implementation", + "cp", + "--python-version", + _PYTHON_VERSION, + "--abi", + _PYTHON_ABI, + "--dest", + str(resolved), + "--requirement", + str(verification_requirements), + ) + ) + _run( + command, + cwd=package_dir, + environment=environment, + failure="Studio offline wheelhouse failed isolated resolution.", + ) + + +def _sha256(path: Path) -> str: + digest = sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _run( + command: list[str], + *, + cwd: Path, + environment: Mapping[str, str], + failure: str, +) -> None: + try: + subprocess.run( + command, + cwd=cwd, + env=dict(environment), + check=True, + stdout=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError as error: + raise ValueError(f"{failure} Exit code: {error.returncode}.") from error + + +__all__ = [ + "STUDIO_RUNTIME_LOCK", + "STUDIO_RUNTIME_WHEELHOUSE", + "build_studio_offline_runtime", +] diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py index dd7d60b70..8716ac273 100644 --- a/frontend/service/studio_release_server/publisher.py +++ b/frontend/service/studio_release_server/publisher.py @@ -21,6 +21,7 @@ import json import os import re +import shlex import shutil import subprocess import tempfile @@ -32,11 +33,31 @@ from typing import Any from zoneinfo import ZoneInfo +if __package__: + from .offline_runtime import build_studio_offline_runtime +else: + import importlib.util + + _offline_runtime_path = Path(__file__).with_name("offline_runtime.py") + _offline_runtime_spec = importlib.util.spec_from_file_location( + "veadk_studio_offline_runtime", + _offline_runtime_path, + ) + if _offline_runtime_spec is None or _offline_runtime_spec.loader is None: + raise RuntimeError("Studio offline runtime builder is unavailable.") + _offline_runtime = importlib.util.module_from_spec(_offline_runtime_spec) + _offline_runtime_spec.loader.exec_module(_offline_runtime) + build_studio_offline_runtime = _offline_runtime.build_studio_offline_runtime + _VERSION_PATTERN = re.compile(r"^\d{14}$") _GIT_SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") _SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") _MAX_STUDIO_BUNDLE_BYTES = 300 * 1024 * 1024 _MAX_STUDIO_RELEASES = 50 +_AGENTKIT_CLI_ARCHIVE = "agentkit-linux-x64.tar.gz" +_AGENTKIT_CLI_ARCHIVE_SHA256 = ( + "4e76e32c60473b5037c331a7c74bb99b1c23b62eb8ce26379d3a8c41af38a64e" +) class StudioPublisherError(ValueError): @@ -271,6 +292,7 @@ def _existing_releases(self) -> list[StudioReleaseManifest]: def _validate_source_checkout(source_root: Path) -> None: required = ( source_root / "pyproject.toml", + source_root / "uv.lock", source_root / "README.md", source_root / "LICENSE", source_root / "frontend" / "package.json", @@ -404,6 +426,69 @@ def validate_studio_wheel(wheel: Path, source_root: Path) -> None: ) +def validate_studio_agentkit_cli_archive(artifacts: list[Path]) -> Path: + """Require the exact pinned Linux/x64 native CLI archive.""" + + candidates = [path for path in artifacts if path.name == _AGENTKIT_CLI_ARCHIVE] + if len(candidates) != 1: + raise StudioPublisherError( + "The Studio release must contain the pinned AgentKit CLI archive." + ) + archive = candidates[0] + try: + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + except OSError as error: + raise StudioPublisherError( + "The Studio release AgentKit CLI archive is unavailable." + ) from error + if digest != _AGENTKIT_CLI_ARCHIVE_SHA256: + raise StudioPublisherError( + "The Studio release AgentKit CLI archive checksum is invalid." + ) + return archive + + +def validate_studio_bundle_dependencies(package_dir: Path) -> Path: + """Validate the local VeADK/CLI dependency pair in an extracted bundle.""" + requirements_path = package_dir / "requirements.txt" + try: + lines = requirements_path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError) as error: + raise StudioPublisherError( + "Studio release requirements are unavailable." + ) from error + local_wheels: list[Path] = [] + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + try: + tokens = shlex.split(line) + except ValueError as error: + raise StudioPublisherError( + "Studio release requirements contain invalid quoting." + ) from error + if not tokens or not tokens[0].endswith(".whl"): + continue + relative = tokens[0].removeprefix("./") + path = (package_dir / relative).resolve() + if not path.is_relative_to(package_dir.resolve()) or not path.is_file(): + raise StudioPublisherError( + "Studio release requirements reference a missing local wheel." + ) + local_wheels.append(path) + veadk_wheels = [ + wheel + for wheel in local_wheels + if wheel.name.startswith(("veadk_python-", "veadk-python-")) + ] + if len(veadk_wheels) != 1: + raise StudioPublisherError( + "The Studio release must contain exactly one local VeADK wheel." + ) + return validate_studio_agentkit_cli_archive(list(package_dir.iterdir())) + + def _build_local_requirements( source_root: Path, package_dir: Path, @@ -432,15 +517,26 @@ def _build_local_requirements( "Local source build did not produce one VeADK wheel." ) validate_studio_wheel(built_wheels[0], wheel_source) - dependencies: list[Path] = [] - for source in sorted(dependency_wheels.glob("*.whl")): - target = package_dir / source.name - shutil.copy2(source, target) - dependencies.append(target) - if not dependencies: - raise StudioPublisherError("Prepared Studio dependency wheels are missing.") + archive_source = dependency_wheels / _AGENTKIT_CLI_ARCHIVE + if archive_source.is_file(): + shutil.copy2(archive_source, package_dir / archive_source.name) + validate_studio_agentkit_cli_archive(list(package_dir.iterdir())) shutil.rmtree(wheel_source) - return "".join(f"./{path.name}\n" for path in (*dependencies, built_wheels[0])) + dependency_sources = sorted( + path + for path in dependency_wheels.glob("*.tar.gz") + if path.name != _AGENTKIT_CLI_ARCHIVE + ) + try: + return build_studio_offline_runtime( + source_root, + package_dir, + veadk_wheel=built_wheels[0], + dependency_sources=dependency_sources, + environment=env, + ) + except ValueError as error: + raise StudioPublisherError(str(error)) from error def _studio_run_script() -> str: @@ -452,7 +548,9 @@ def _studio_run_script() -> str: 'if [ -d "output" ]; then cd ./output/; fi\n' "HOST=0.0.0.0\n" "PORT=${_FAAS_RUNTIME_PORT:-8000}\n" - "export PYTHONPATH=$PYTHONPATH:./site-packages\n" + 'export PYTHONPATH="./site-packages${PYTHONPATH:+:$PYTHONPATH}"\n' + "python3 -m veadk.cli.studio_companion " + f'--archive "$ROOT_DIR/{_AGENTKIT_CLI_ARCHIVE}"\n' "exec python3 -m veadk.cli.cli studio " '--provider "${CLOUD_PROVIDER:-${AGENTKIT_CLOUD_PROVIDER:-volcengine}}" ' "--auth-mode frontend " diff --git a/frontend/service/studio_release_server/tos_store.py b/frontend/service/studio_release_server/tos_store.py index 026e44fab..9393c0bf2 100644 --- a/frontend/service/studio_release_server/tos_store.py +++ b/frontend/service/studio_release_server/tos_store.py @@ -299,31 +299,60 @@ def materialize( def _load_manifest(self, manifest: Path) -> tuple[tuple[str, str, str], ...]: payload = json.loads(manifest.read_text(encoding="utf-8")) raw_wheels = payload.get("wheels") if isinstance(payload, dict) else None - if not isinstance(raw_wheels, list) or not raw_wheels or len(raw_wheels) > 32: + raw_sources = payload.get("sources", []) if isinstance(payload, dict) else None + raw_artifacts = payload.get("artifacts") if isinstance(payload, dict) else None + if ( + not isinstance(raw_wheels, list) + or not raw_wheels + or len(raw_wheels) > 32 + or not isinstance(raw_sources, list) + or len(raw_sources) > 8 + or not isinstance(raw_artifacts, list) + or len(raw_artifacts) != 1 + ): raise ValueError("Studio dependency manifest is invalid.") - wheels: list[tuple[str, str, str]] = [] - for raw in raw_wheels: - if not isinstance(raw, dict): - raise ValueError("Studio dependency manifest is invalid.") - filename = raw.get("filename") - url = raw.get("url") - sha256 = raw.get("sha256") - if ( - not isinstance(filename, str) - or Path(filename).name != filename - or not filename.endswith(".whl") - or not isinstance(url, str) - or not url.startswith("https://") - or not isinstance(sha256, str) - or len(sha256) != 64 - ): - raise ValueError("Studio dependency manifest is invalid.") - try: - int(sha256, 16) - except ValueError as error: - raise ValueError("Studio dependency manifest is invalid.") from error - wheels.append((filename, url, sha256.lower())) - return tuple(wheels) + dependencies: list[tuple[str, str, str]] = [] + filenames: set[str] = set() + groups = ( + (raw_wheels, lambda name: name.endswith(".whl")), + ( + raw_sources, + lambda name: name.endswith(".tar.gz") + and name != "agentkit-linux-x64.tar.gz", + ), + (raw_artifacts, lambda name: name == "agentkit-linux-x64.tar.gz"), + ) + for items, valid_filename in groups: + for raw in items: + if not isinstance(raw, dict): + raise ValueError("Studio dependency manifest is invalid.") + filename = raw.get("filename") + url = raw.get("url") + sha256 = raw.get("sha256") + if ( + not isinstance(filename, str) + or Path(filename).name != filename + or filename in filenames + or not valid_filename(filename) + or not isinstance(url, str) + or not url.startswith("https://") + or not isinstance(sha256, str) + or len(sha256) != 64 + ): + raise ValueError("Studio dependency manifest is invalid.") + try: + int(sha256, 16) + except ValueError as error: + raise ValueError( + "Studio dependency manifest is invalid." + ) from error + if filename == "agentkit-linux-x64.tar.gz" and not url.startswith( + "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.14/" + ): + raise ValueError("Studio dependency manifest is invalid.") + filenames.add(filename) + dependencies.append((filename, url, sha256.lower())) + return tuple(dependencies) def _cache_key(self, filename: str, sha256: str) -> str: prefix = self._settings.job_prefix.strip().strip("/") diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 36d3a314f..819bd7f97 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -3396,6 +3396,12 @@ export async function deployAgentkitProject( url: string; value: string; }>; + mcpCredentialReuses?: Array<{ + agentName: string; + name: string; + url: string; + sourceAuthTokenEnv: string; + }>; sessionStorage?: "in-memory" | "persistent"; minInstance?: number; maxInstance?: number; @@ -3457,6 +3463,7 @@ export async function deployAgentkitProject( baseRuntimeVersion: opts?.baseRuntimeVersion, removeRuntimeEnvKeys: opts?.removeRuntimeEnvKeys, mcpSecretValues: opts?.mcpSecretValues, + mcpCredentialReuses: opts?.mcpCredentialReuses, sessionStorage: opts?.sessionStorage, minInstance: opts?.minInstance, maxInstance: opts?.maxInstance, diff --git a/frontend/src/create/CustomCreate.css b/frontend/src/create/CustomCreate.css index c5b0d4ecf..a8da4f158 100644 --- a/frontend/src/create/CustomCreate.css +++ b/frontend/src/create/CustomCreate.css @@ -3784,6 +3784,19 @@ outline: 2px solid hsl(var(--primary)); outline-offset: 1px; } +.cw-mcp-auth-state.is-warning { + align-items: flex-start; + border-color: hsl(var(--destructive) / 0.35); + background: hsl(var(--destructive) / 0.06); + color: hsl(var(--foreground)); +} +.cw-mcp-auth-actions { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} .cw-mcp-warning { display: flex; align-items: flex-start; diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx index 28e5ff73e..d84a42abd 100644 --- a/frontend/src/create/CustomCreate.tsx +++ b/frontend/src/create/CustomCreate.tsx @@ -91,13 +91,20 @@ import { import { localPickerMatches } from "./localPickerSearch"; import { draftToYaml } from "./configYaml"; import { + confirmMcpCredentialReuse, clearMcpConfiguredAuth, + deploymentMcpSecretValues, mcpAuthTokenInputValue, + mcpCredentialActionRequired, + mcpCredentialReuseValues, mcpUrlNeedsPathWarning, prepareMcpAuth, + removeMcpCredentialForChangedUrl, + replaceMcpCredentialForChangedUrl, removedConfiguredMcpEnvKeys, sourcePreservingMcpSecretValues, updateMcpAuthTokenInput, + updateMcpUrlInput, } from "./mcpAuth"; import { resolveMcpGatewayEnv } from "./mcpGatewayEnv"; import { @@ -1960,7 +1967,15 @@ function McpToolEditor({ className="cw-input" value={t.url ?? ""} placeholder="MCP 服务地址(StreamableHTTP)" - onChange={(e) => update(i, { url: e.target.value })} + onChange={(e) => + onChange( + tools.map((tool, index) => + index === i + ? updateMcpUrlInput(tool, e.target.value) + : tool, + ), + ) + } /> {mcpUrlNeedsPathWarning(t.url ?? "") && (

@@ -1973,9 +1988,10 @@ function McpToolEditor({ )} - {t.credentialConfigured && ( + {t.credentialUpdate === "pending" && ( +

+ + MCP 地址已变化,请重新填写 Key 或确认沿用原凭证。 + +
+ + + +
+
+ )} + {t.credentialUpdate === "reuse" && ( +
+ 发布时将沿用原凭证,并绑定到新的 MCP 地址。 + +
+ )} + {t.credentialConfigured && + !t.authToken && + !t.credentialUpdate && (
认证已配置,旧值不会显示在页面中。