From dd264aec68c5722f21243d24afd3491bbc41e20b Mon Sep 17 00:00:00 2001 From: "hanzhi.421" Date: Thu, 2 Jul 2026 22:14:39 +0800 Subject: [PATCH] feat(sandbox): add CustomToolEnv create support --- agentkit/toolkit/cli/sandbox/README.md | 18 +++ agentkit/toolkit/cli/sandbox/cli_create.py | 73 ++++++++-- agentkit/toolkit/cli/sandbox/env_config.py | 135 +++++++++++++++++++ agentkit/toolkit/cli/sandbox/tool_resolve.py | 3 +- tests/toolkit/cli/test_cli_create_tool.py | 100 ++++++++++++++ 5 files changed, 314 insertions(+), 15 deletions(-) diff --git a/agentkit/toolkit/cli/sandbox/README.md b/agentkit/toolkit/cli/sandbox/README.md index 088bbdb..71c049d 100644 --- a/agentkit/toolkit/cli/sandbox/README.md +++ b/agentkit/toolkit/cli/sandbox/README.md @@ -53,8 +53,12 @@ agentkit sandbox create \ Options: - `--tool-type`: optional. Tool type to create; defaults to `CodeEnv`. + `CustomToolEnv` is a CLI-side convention for private images based on aio-sandbox. + It is sent to CreateTool as `ToolType: Private`. - `--tool-name`: optional. Tool name. If omitted, the CLI generates a name like `agentkit-codeenv-`. +- `--image-url`: optional custom image URL. Required when + `--tool-type CustomToolEnv`. - `--tos-bucket`: optional. TOS bucket to mount. If omitted, the tool is created without TOS mount configuration. - `--tos-mount`: optional. Local mount path for `--tos-bucket`; defaults to @@ -85,6 +89,20 @@ Options: The sandbox create request maps `--cpu` to `CpuMilli=` and `MemoryMb=`, so the default shape is 4 vCPU / 8 GiB. +When `--tool-type CustomToolEnv` is used, the CLI creates a private-image tool +without requiring a control-plane tool type change: + +```bash +agentkit sandbox create \ + --tool-type CustomToolEnv \ + --image-url registry.example.com/custom-image:latest +``` + +The CreateTool request is translated to `ToolType: Private`, +`Command: /opt/gem/run.sh`, and the environment variables matching the +aio-sandbox startup profile. The CLI stores the resulting tool locally under the +`CustomToolEnv` type for future sandbox resolution. + The tool injects the selected built-in provider's Volcengine Ark compatible endpoints into `OPENCODE_BASE_URL`, `CODEX_BASE_URL`, `MODEL_BASE_URL`, and `ANTHROPIC_BASE_URL`, and stores the selected provider in diff --git a/agentkit/toolkit/cli/sandbox/cli_create.py b/agentkit/toolkit/cli/sandbox/cli_create.py index 82a6287..22773f5 100644 --- a/agentkit/toolkit/cli/sandbox/cli_create.py +++ b/agentkit/toolkit/cli/sandbox/cli_create.py @@ -27,8 +27,13 @@ from agentkit.sdk.tools.client import AgentkitToolsClient from agentkit.sdk.tools import types as tools_types from agentkit.toolkit.cli.sandbox.env_config import ( + CUSTOM_TOOL_ENV_COMMAND, + CUSTOM_TOOL_ENV_OPENAPI_TOOL_TYPE, + CUSTOM_TOOL_ENV_PORT, + CUSTOM_TOOL_ENV_TOOL_TYPE, DEFAULT_CREATE_TOOL_TYPE, build_create_tool_envs, + build_custom_tool_envs, ) from agentkit.toolkit.cli.sandbox.model_config import ( ModelProviderType, @@ -102,9 +107,42 @@ def _build_create_tool_request( model_base_url_was_provided: Optional[bool] = None, role_name: Optional[str] = None, websearch_apikey: Optional[str] = None, + image_url: Optional[str] = None, ) -> tools_types.CreateToolRequest: resolved_tool_type = tool_type.strip() or DEFAULT_CREATE_TOOL_TYPE resolved_name = (name or "").strip() or _generate_tool_name(resolved_tool_type) + is_custom_tool_env = resolved_tool_type == CUSTOM_TOOL_ENV_TOOL_TYPE + if is_custom_tool_env and not (image_url or "").strip(): + error("--image-url is required when --tool-type CustomToolEnv") + openapi_tool_type = ( + CUSTOM_TOOL_ENV_OPENAPI_TOOL_TYPE + if is_custom_tool_env + else resolved_tool_type + ) + command = CUSTOM_TOOL_ENV_COMMAND if is_custom_tool_env else None + port = CUSTOM_TOOL_ENV_PORT if is_custom_tool_env else None + envs = ( + build_custom_tool_envs( + model_name=model_name, + model_api_key=model_api_key, + model_provider=model_provider, + model_base_url=model_base_url, + model_provider_was_provided=model_provider_was_provided, + model_base_url_was_provided=model_base_url_was_provided, + websearch_apikey=websearch_apikey, + ) + if is_custom_tool_env + else build_create_tool_envs( + tool_type=resolved_tool_type, + model_name=model_name, + model_api_key=model_api_key, + model_provider=model_provider, + model_base_url=model_base_url, + model_provider_was_provided=model_provider_was_provided, + model_base_url_was_provided=model_base_url_was_provided, + websearch_apikey=websearch_apikey, + ) + ) tos_mount_config = build_create_tool_tos_mount_config( tos_bucket, tos_region, @@ -116,7 +154,10 @@ def _build_create_tool_request( return tools_types.CreateToolRequest( Name=resolved_name, - ToolType=resolved_tool_type, + ToolType=openapi_tool_type, + Command=command, + ImageUrl=(image_url or "").strip() or None, + Port=port, CpuMilli=cpu_milli, MemoryMb=memory_mb, RoleName=role_name, @@ -131,16 +172,7 @@ def _build_create_tool_request( EnablePrivateNetwork=False, ), TosMountConfig=tos_mount_config, - Envs=build_create_tool_envs( - tool_type=resolved_tool_type, - model_name=model_name, - model_api_key=model_api_key, - model_provider=model_provider, - model_base_url=model_base_url, - model_provider_was_provided=model_provider_was_provided, - model_base_url_was_provided=model_base_url_was_provided, - websearch_apikey=websearch_apikey, - ), + Envs=envs, ) @@ -314,7 +346,9 @@ def create_tool( skill_role_name: Optional[str] = None, skill_role_name_provided: bool = False, websearch_apikey: Optional[str] = None, + image_url: Optional[str] = None, ) -> dict[str, object]: + is_custom_tool_env = tool_type.strip() == CUSTOM_TOOL_ENV_TOOL_TYPE resolved_model_base_url = normalize_model_base_url(model_base_url) raw_model_provider = ( model_provider.value @@ -353,6 +387,7 @@ def create_tool( model_base_url_was_provided=bool(resolved_model_base_url), role_name=resolved_role_name, websearch_apikey=resolved_websearch_apikey, + image_url=image_url, ) client = AgentkitToolsClient( region=region, @@ -364,11 +399,15 @@ def create_tool( final_tool = _wait_for_tool_ready(client, tool_id) return { "tool_id": tool_id, - "tool_type": final_tool.tool_type or request.tool_type, + "tool_type": ( + CUSTOM_TOOL_ENV_TOOL_TYPE + if is_custom_tool_env + else final_tool.tool_type or request.tool_type + ), "name": final_tool.name or request.name, "status": final_tool.status or TOOL_READY_STATUS, - "model_provider": resolved_model_provider, - "model_base_url": resolved_model_base_url, + "model_provider": None if is_custom_tool_env else resolved_model_provider, + "model_base_url": None if is_custom_tool_env else resolved_model_base_url, "role_name": resolved_role_name, "websearch_apikey_set": bool(resolved_websearch_apikey), } @@ -443,6 +482,11 @@ def create_command( "Use --disable-websearch-apikey in exec to disable it per session." ), ), + image_url: Optional[str] = typer.Option( + None, + "--image-url", + help="Custom image URL. Required when --tool-type CustomToolEnv.", + ), ) -> None: """Create an AgentKit Tool with optional TOS mount. @@ -468,6 +512,7 @@ def create_command( skill_role_name=skill_role_name, skill_role_name_provided=skill_role_name_provided, websearch_apikey=websearch_apikey, + image_url=image_url, ) save_tool_result(str(result["tool_type"]), result) except (typer.Abort, typer.Exit): diff --git a/agentkit/toolkit/cli/sandbox/env_config.py b/agentkit/toolkit/cli/sandbox/env_config.py index a415a58..496ffa9 100644 --- a/agentkit/toolkit/cli/sandbox/env_config.py +++ b/agentkit/toolkit/cli/sandbox/env_config.py @@ -50,6 +50,108 @@ ) DEFAULT_CREATE_TOOL_TYPE = "CodeEnv" +CUSTOM_TOOL_ENV_TOOL_TYPE = "CustomToolEnv" +CUSTOM_TOOL_ENV_OPENAPI_TOOL_TYPE = "Private" +CUSTOM_TOOL_ENV_COMMAND = "/opt/gem/run.sh" +CUSTOM_TOOL_ENV_PORT = 8080 +CUSTOM_TOOL_ENV_VARS = ( + ( + "PATH", + "/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:" + "/usr/sbin:/usr/bin:/sbin:/bin", + ), + ("DEBIAN_FRONTEND", "noninteractive"), + ("USER", "gem"), + ("USER_UID", "1000"), + ("USER_GID", "1000"), + ("DISPLAY", ":99.0"), + ("DISPLAY_WIDTH", "1280"), + ("DISPLAY_HEIGHT", "1024"), + ("DISPLAY_DEPTH", "24"), + ("XDG_RUNTIME_DIR", "/tmp/runtime-gem"), + ("BROWSER_EXECUTABLE_PATH", "/usr/local/bin/browser"), + ("BROWSER_REMOTE_DEBUGGING_PORT", "9222"), + ( + "BROWSER_COMMANDLINE_ARGS", + "--disable-backgrounding-occluded-windows " + "--disable-background-timer-throttling " + "--disable-blink-features=AutomationControlled " + "--disable-dev-shm-usage " + "--disable-external-intent-requests " + "--disable-features=IPH_DesktopCustomizeChrome,IsolateOrigins," + "site-per-proces,Translate " + "--disable-focus-on-load " + "--disable-gpu " + "--disable-infobars " + "--disable-popup-blocking " + "--disable-prompt-on-repost " + "--disable-renderer-backgrounding " + "--disable-site-isolation-trials " + "--disable-web-security " + "--disable-window-activation " + "--mute-audio " + "--no-default-browser-check " + "--no-first-run " + "--noerrdialogs " + "--remote-allow-origins=* " + "--remote-debugging-port=9222 " + "--suppress-message-center-popups " + "--start-maximized", + ), + ("BROWSER_EXTRA_ARGS", ""), + ("DNS_OVER_HTTPS_TEMPLATES", ""), + ("LOG_DIR", "/var/log/gem"), + ("JWT_PUBLIC_KEY", ""), + ("VNC_SERVER_PORT", "5900"), + ("WEBSOCKET_PROXY_PORT", "6080"), + ("GEM_SERVER_PORT", "8088"), + ("MCP_SERVER_PORT", "8089"), + ("PUBLIC_PORT", "8080"), + ("AUTH_BACKEND_PORT", "8081"), + ("WAIT_PORTS", "8091"), + ("WAIT_TIMEOUT", "300"), + ("WAIT_INTERVAL", "0.25"), + ("RUN_HOOK_INIT", ""), + ("RUN_HOOK_PRE_SERVICES", ""), + ("RUN_HOOK_POST_READY", ""), + ("RUN_HOOKS_STRICT", "false"), + ("SANDBOX_SRV_PORT", "8091"), + ("JUPYTER_LAB_PORT", "8888"), + ("CODE_SERVER_PORT", "8200"), + ("MCP_SERVER_BROWSER_PORT", "8100"), + ("TINYPROXY_PORT", "8118"), + ("MAX_SHELL_SESSIONS", "50"), + ("PYTHONPATH", ""), + ("LOG_TOOL_TRACE", "false"), + ("LANG", "en_US.UTF-8"), + ("LANGUAGE", "en_US:en"), + ("LC_ALL", "en_US.UTF-8"), + ("PUPPETEER_EXECUTABLE_PATH", "/usr/local/bin/browser"), + ("PUPPETEER_SKIP_CHROMIUM_DOWNLOAD", "true"), + ("BROWSER_NO_SANDBOX", ""), + ("BROWSER_LANG", "en-US"), + ( + "BROWSER_USER_AGENT", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/140.0.0.0 Safari/537.36", + ), + ("UV_TOOL_BIN_DIR", "/usr/local/bin/"), + ("UV_TOOL_DIR", "/usr/local/share/uv/tools"), + ("DISABLE_JUPYTER", "false"), + ("DISABLE_CODE_SERVER", "false"), + ("EXTRA_MCP_SERVERS", ""), + ("OTEL_SDK_DISABLED", "false"), + ( + "SRV_PYTHONPATH", + "/otel-auto-instrumentation-python/opentelemetry/instrumentation/" + "auto_instrumentation:/otel-auto-instrumentation-python", + ), + ("OTEL_PYTHON_DISABLED_INSTRUMENTATIONS", "redis"), + ("FAAS_SANDBOX_RUNTIME_INJECTION_ENABLE_SANDBOXD", "false"), + ("PYTHON_CODE_EXEC_VERSION", "python3"), + ("GO_PATH", "/usr/local/go"), +) DISABLED_SERVICE_ENV_KEYS = ( "DISABLE_JUPYTER", "DISABLE_CODE_SERVER", @@ -319,6 +421,39 @@ def build_create_tool_envs( return bundle.to_create_tool_envs() +def build_custom_tool_envs( + *, + model_name: Optional[str] = None, + model_api_key: Optional[str] = None, + model_provider: str | ModelProviderType | None = None, + model_base_url: Optional[str] = None, + model_provider_was_provided: Optional[bool] = None, + model_base_url_was_provided: Optional[bool] = None, + websearch_apikey: Optional[str] = None, +) -> list[tools_types.EnvsItemForCreateTool]: + """Build CreateTool.Envs for CustomToolEnv plus CodeEnv-only envs.""" + + envs = [ + tools_types.EnvsItemForCreateTool(Key=key, Value=value) + for key, value in CUSTOM_TOOL_ENV_VARS + ] + custom_tool_env_keys = {key for key, _value in CUSTOM_TOOL_ENV_VARS} + code_envs = build_create_tool_envs( + tool_type=DEFAULT_CREATE_TOOL_TYPE, + model_name=model_name, + model_api_key=model_api_key, + model_provider=model_provider, + model_base_url=model_base_url, + model_provider_was_provided=model_provider_was_provided, + model_base_url_was_provided=model_base_url_was_provided, + websearch_apikey=websearch_apikey, + ) + for env in code_envs or []: + if env.key not in custom_tool_env_keys: + envs.append(env) + return envs + + def build_exec_session_envs( *, model_name: Optional[str] = None, diff --git a/agentkit/toolkit/cli/sandbox/tool_resolve.py b/agentkit/toolkit/cli/sandbox/tool_resolve.py index 8b14069..e64393e 100644 --- a/agentkit/toolkit/cli/sandbox/tool_resolve.py +++ b/agentkit/toolkit/cli/sandbox/tool_resolve.py @@ -37,7 +37,7 @@ SANDBOX_TOOL_STORE_PATH = Path(".agentkit") / "sandbox" / "tools.json" DEFAULT_SANDBOX_TOOL_TYPE = "CodeEnv" -VALID_SANDBOX_TOOL_TYPES = ("CodeEnv", "SkillEnv") +VALID_SANDBOX_TOOL_TYPES = ("CodeEnv", "SkillEnv", "CustomToolEnv") READY_TOOL_STATUS = "Ready" TOOL_NOT_FOUND_ERROR_CODE = "InvalidResource.NotFound" @@ -45,6 +45,7 @@ class SandboxToolType(str, Enum): CODE_ENV = "CodeEnv" SKILL_ENV = "SkillEnv" + CUSTOM_TOOL_ENV = "CustomToolEnv" def normalize_tool_type(tool_type: str | SandboxToolType | None) -> str: diff --git a/tests/toolkit/cli/test_cli_create_tool.py b/tests/toolkit/cli/test_cli_create_tool.py index 883b387..47d3b72 100644 --- a/tests/toolkit/cli/test_cli_create_tool.py +++ b/tests/toolkit/cli/test_cli_create_tool.py @@ -572,6 +572,66 @@ def test_build_create_tool_request_skips_tos_mount_without_bucket(monkeypatch): assert request.tos_mount_config is None +def test_build_create_tool_request_translates_custom_tool_env_to_private(monkeypatch): + from agentkit.toolkit.cli.sandbox import cli_create + from agentkit.toolkit.cli.sandbox.env_config import ( + CUSTOM_TOOL_ENV_COMMAND, + CUSTOM_TOOL_ENV_VARS, + ) + + _reset_fake_tools_client() + monkeypatch.delenv("MODEL_API_KEY", raising=False) + monkeypatch.setattr(cli_create, "TOSService", _FakeTOSService) + + request = cli_create._build_create_tool_request( + tool_type="CustomToolEnv", + name="demo-tool", + tos_bucket=None, + tos_region="cn-beijing", + image_url="registry.example.com/custom-image:latest", + model_name="ignored-model", + **{"model_" + "api_key": _PLACEHOLDER_MODEL_VALUE}, + ) + + assert request.tool_type == "Private" + assert request.image_url == "registry.example.com/custom-image:latest" + assert request.command == CUSTOM_TOOL_ENV_COMMAND + assert request.port == 8080 + assert request.tos_mount_config is None + env_map = {item.key: item.value for item in request.envs} + assert [ + (item.key, item.value) + for item in request.envs[: len(CUSTOM_TOOL_ENV_VARS)] + ] == list(CUSTOM_TOOL_ENV_VARS) + env_keys = set(env_map) + assert "ABC" not in env_keys + assert env_map["OPENCODE_MODEL"] == "ignored-model" + assert env_map["CODEX_MODEL"] == "ignored-model" + assert env_map["ANTHROPIC_MODEL"] == "ignored-model" + assert env_map["CODEX_API_KEY"] == _PLACEHOLDER_MODEL_VALUE + assert env_map["CODEX_BASE_URL"] == "https://ark.cn-beijing.volces.com/api/v3" + assert "CODEX_CONFIG_TOML" in env_map + assert "CODEX_MODEL_CATALOG_JSON" in env_map + assert env_map["DISABLE_JUPYTER"] == "false" + assert env_map["DISABLE_CODE_SERVER"] == "false" + assert env_map["BROWSER_EXTRA_ARGS"] == "" + + +def test_build_create_tool_request_requires_image_url_for_custom_tool_env(monkeypatch): + from agentkit.toolkit.cli.sandbox import cli_create + + _reset_fake_tools_client() + monkeypatch.setattr(cli_create, "TOSService", _FakeTOSService) + + with pytest.raises(cli_create.typer.Exit): + cli_create._build_create_tool_request( + tool_type="CustomToolEnv", + name="demo-tool", + tos_bucket=None, + tos_region="cn-beijing", + ) + + def test_build_create_tool_request_derives_memory_from_cpu(monkeypatch): from agentkit.toolkit.cli.sandbox import cli_create @@ -590,6 +650,46 @@ def test_build_create_tool_request_derives_memory_from_cpu(monkeypatch): assert request.memory_mb == 16384 +def test_create_command_translates_custom_tool_env_and_caches_cli_type( + monkeypatch, + tool_store_path, +): + from agentkit.toolkit.cli.cli import app + from agentkit.toolkit.cli.sandbox import cli_create + + _reset_fake_tools_client() + monkeypatch.setattr(cli_create, "AgentkitToolsClient", _FakeToolsClient) + monkeypatch.setattr(cli_create, "TOSService", _FakeTOSService) + + result = runner.invoke( + app, + [ + "sandbox", + "create", + "--tool-type", + "CustomToolEnv", + "--tool-name", + "demo-aio", + "--image-url", + "registry.example.com/custom-image:latest", + ], + ) + + assert result.exit_code == 0 + request = _FakeToolsClient.last_request + assert request.tool_type == "Private" + assert request.command == "/opt/gem/run.sh" + assert request.image_url == "registry.example.com/custom-image:latest" + assert request.port == 8080 + tool_store = json.loads(tool_store_path.read_text(encoding="utf-8")) + assert tool_store["CustomToolEnv"] == { + "ToolId": "t-created", + "Name": "demo-tool", + "Status": "Ready", + "ToolType": "CustomToolEnv", + } + + def test_build_create_tool_request_adds_model_envs(monkeypatch): from agentkit.toolkit.cli.sandbox import cli_create